mirror of https://github.com/espruino/BangleApps
Merge branch 'master' of github.com:espruino/BangleApps
commit
9287cd44a8
|
@ -0,0 +1 @@
|
|||
0.01: New app!
|
|
@ -0,0 +1,34 @@
|
|||
# Folder launcher
|
||||
|
||||
Launcher that allows you to put your apps into folders
|
||||
|
||||

|
||||

|
||||
|
||||
## Launcher UI
|
||||
|
||||
The apps and folders will be presented in a grid layout, configurable in size. Tapping on a folder will open the folder. Folders can contain both apps and more folders. Tapping on an app will launch the app. If there is more than one page, there will be a scroll bar on the right side to indicate how far through the list you have scrolled. Folders will be displayed before apps, in the order that they were added. Apps will honor their sort order, if it exists.
|
||||
|
||||
Swiping up and down will scroll. Swiping from the left, using the back button, or pressing BTN1 will take you up a level to the folder containing the current one, or exit the launcher if you are at the top level.
|
||||
|
||||
## Settings menu
|
||||
|
||||
* Show clocks / Show launcher: Whether clock and launcher apps are displayed in the UI to be launched. The default is no.
|
||||
|
||||
* Hidden apps: Displays the list of installed apps, enabling them to be manually hidden. (Or unhidden, if hidden from here.) This may be convenient for apps that you have some other shortcut to access, or apps that are only shortcuts to an infrequently used settings menu. By default, no apps are hidden.
|
||||
|
||||
* Display:
|
||||
* Rows: The side length of the square grid. Lowest value is 1, no upper limit. The default is 2, but 3 is also convenient.
|
||||
* Show icons?: Whether app and folder icons are displayed. The default is yes.
|
||||
* Font size: How much height of each grid cell to allocate for the app or folder name. If size zero is selected, there will be no title for apps and folders will use a size of 12. (This is important because it is not possible to distinguish folders solely by icon.) The default is 12.
|
||||
|
||||
To prevent the launcher from becoming unusable, if neither icons nor text are enabled in the settings menu, text will still be drawn.
|
||||
|
||||
* Timeout: If the launcher is left idle for too long, return to the clock. This is convenient if you often accidentally open the launcher without noticing. At zero seconds, the timeout is disabled. The default is 30 seconds.
|
||||
|
||||
* Folder management: Open the folder management menu for the root folder. (The folder first displayed when opening the launcher.) The folder management menu contains the following:
|
||||
* New subfolder: Open the keyboard to enter the name for a new subfolder to be created. If left blank or given the name of an existing subfolder, no folder is created. If a subfolder is created, open the folder management menu for the new folder.
|
||||
* Move app here: Display a list of apps. Selecting one moves it into the folder.
|
||||
* One menu entry for each subfolder, which opens the folder management menu for that subfolder.
|
||||
* View apps: Only present if this folder contains apps, Display a menu of all apps in the folder. This is for information only, tapping the apps does nothing.
|
||||
* Delete folder: Only present if not viewing the root folder. Delete the current folder and move all apps into the parent folder.
|
|
@ -0,0 +1,205 @@
|
|||
{
|
||||
var loader = require('folderlaunch-configLoad.js');
|
||||
var storage_1 = require('Storage');
|
||||
var FOLDER_ICON_1 = require("heatshrink").decompress(atob("mEwwMA///wAJCAoPAAongAonwAon4Aon8Aon+Aon/AooA/AH4A/AFgA="));
|
||||
var config_1 = loader.getConfig();
|
||||
var timeout_1;
|
||||
var resetTimeout_1 = function () {
|
||||
if (timeout_1) {
|
||||
clearTimeout(timeout_1);
|
||||
}
|
||||
if (config_1.timeout != 0) {
|
||||
timeout_1 = setTimeout(function () {
|
||||
Bangle.showClock();
|
||||
}, config_1.timeout);
|
||||
}
|
||||
};
|
||||
var folderPath_1 = [];
|
||||
var getFolder_1 = function (folderPath) {
|
||||
var result = config_1.rootFolder;
|
||||
for (var _i = 0, folderPath_2 = folderPath; _i < folderPath_2.length; _i++) {
|
||||
var folderName = folderPath_2[_i];
|
||||
result = result.folders[folderName];
|
||||
}
|
||||
nPages_1 = Math.ceil((result.apps.length + Object.keys(result.folders).length) / (config_1.display.rows * config_1.display.rows));
|
||||
return result;
|
||||
};
|
||||
var folder_1 = getFolder_1(folderPath_1);
|
||||
var getFontSize_1 = function (length, maxWidth, minSize, maxSize) {
|
||||
var size = Math.floor(maxWidth / length);
|
||||
size *= (20 / 12);
|
||||
if (size < minSize)
|
||||
return minSize;
|
||||
else if (size > maxSize)
|
||||
return maxSize;
|
||||
else
|
||||
return Math.floor(size);
|
||||
};
|
||||
var grid_1 = [];
|
||||
for (var x = 0; x < config_1.display.rows; x++) {
|
||||
grid_1.push([]);
|
||||
for (var y = 0; y < config_1.display.rows; y++) {
|
||||
grid_1[x].push({
|
||||
type: 'empty',
|
||||
id: ''
|
||||
});
|
||||
}
|
||||
}
|
||||
var render_1 = function () {
|
||||
var gridSize = config_1.display.rows * config_1.display.rows;
|
||||
var startIndex = page_1 * gridSize;
|
||||
for (var i = 0; i < gridSize; i++) {
|
||||
var y = Math.floor(i / config_1.display.rows);
|
||||
var x = i % config_1.display.rows;
|
||||
var folderIndex = startIndex + i;
|
||||
var appIndex = folderIndex - Object.keys(folder_1.folders).length;
|
||||
if (folderIndex < Object.keys(folder_1.folders).length) {
|
||||
grid_1[x][y].type = 'folder';
|
||||
grid_1[x][y].id = Object.keys(folder_1.folders)[folderIndex];
|
||||
}
|
||||
else if (appIndex < folder_1.apps.length) {
|
||||
grid_1[x][y].type = 'app';
|
||||
grid_1[x][y].id = folder_1.apps[appIndex];
|
||||
}
|
||||
else
|
||||
grid_1[x][y].type = 'empty';
|
||||
}
|
||||
var squareSize = (g.getHeight() - 24) / config_1.display.rows;
|
||||
if (!config_1.display.icon && !config_1.display.font)
|
||||
config_1.display.font = 12;
|
||||
g.clearRect(0, 24, g.getWidth(), g.getHeight())
|
||||
.reset()
|
||||
.setFontAlign(0, -1);
|
||||
var empty = true;
|
||||
for (var x = 0; x < config_1.display.rows; x++) {
|
||||
for (var y = 0; y < config_1.display.rows; y++) {
|
||||
var entry = grid_1[x][y];
|
||||
var icon = void 0;
|
||||
var text = void 0;
|
||||
var fontSize = void 0;
|
||||
switch (entry.type) {
|
||||
case 'app':
|
||||
var app_1 = storage_1.readJSON(entry.id + '.info', false);
|
||||
icon = storage_1.read(app_1.icon);
|
||||
text = app_1.name;
|
||||
empty = false;
|
||||
fontSize = config_1.display.font;
|
||||
break;
|
||||
case 'folder':
|
||||
icon = FOLDER_ICON_1;
|
||||
text = entry.id;
|
||||
empty = false;
|
||||
fontSize = config_1.display.font ? config_1.display.font : 12;
|
||||
break;
|
||||
default:
|
||||
continue;
|
||||
}
|
||||
var iconSize = config_1.display.icon ? Math.max(0, squareSize - fontSize) : 0;
|
||||
var iconScale = iconSize / 48;
|
||||
var posX = 12 + (x * squareSize);
|
||||
var posY = 24 + (y * squareSize);
|
||||
if (config_1.display.icon && iconSize != 0)
|
||||
try {
|
||||
g.drawImage(icon, posX + (squareSize - iconSize) / 2, posY, { scale: iconScale });
|
||||
}
|
||||
catch (error) {
|
||||
console.log("Failed to draw icon for ".concat(text, ": ").concat(error));
|
||||
console.log(icon);
|
||||
}
|
||||
if (fontSize)
|
||||
g.setFont('Vector', getFontSize_1(text.length, squareSize, 6, squareSize - iconSize))
|
||||
.drawString(text, posX + (squareSize / 2), posY + iconSize);
|
||||
}
|
||||
}
|
||||
if (empty)
|
||||
E.showMessage('Folder is empty. Swipe from left, back button, or BTN1 to go back.');
|
||||
if (nPages_1 > 1) {
|
||||
var barSize = (g.getHeight() - 24) / nPages_1;
|
||||
var barTop = 24 + (page_1 * barSize);
|
||||
g.fillRect(g.getWidth() - 8, barTop, g.getWidth() - 4, barTop + barSize);
|
||||
}
|
||||
};
|
||||
var onTouch = function (_button, xy) {
|
||||
var x = Math.floor((xy.x - 12) / ((g.getWidth() - 24) / config_1.display.rows));
|
||||
if (x < 0)
|
||||
x = 0;
|
||||
else if (x >= config_1.display.rows)
|
||||
x = config_1.display.rows - 1;
|
||||
var y = Math.floor((xy.y - 24) / ((g.getHeight() - 24) / config_1.display.rows));
|
||||
if (y < 0)
|
||||
y = 0;
|
||||
else if (y >= config_1.display.rows)
|
||||
y = config_1.display.rows - 1;
|
||||
var entry = grid_1[x][y];
|
||||
switch (entry.type) {
|
||||
case "app":
|
||||
Bangle.buzz();
|
||||
var infoFile = storage_1.readJSON(entry.id + '.info', false);
|
||||
load(infoFile.src);
|
||||
break;
|
||||
case "folder":
|
||||
Bangle.buzz();
|
||||
resetTimeout_1();
|
||||
page_1 = 0;
|
||||
folderPath_1.push(entry.id);
|
||||
folder_1 = getFolder_1(folderPath_1);
|
||||
render_1();
|
||||
break;
|
||||
default:
|
||||
resetTimeout_1();
|
||||
break;
|
||||
}
|
||||
};
|
||||
var page_1 = 0;
|
||||
var nPages_1;
|
||||
var onSwipe = function (lr, ud) {
|
||||
if (lr == 1 && ud == 0) {
|
||||
onBackButton_1();
|
||||
return;
|
||||
}
|
||||
else if (ud == 1) {
|
||||
resetTimeout_1();
|
||||
if (page_1 == 0) {
|
||||
Bangle.buzz(200);
|
||||
return;
|
||||
}
|
||||
else
|
||||
page_1--;
|
||||
}
|
||||
else if (ud == -1) {
|
||||
resetTimeout_1();
|
||||
if (page_1 == nPages_1 - 1) {
|
||||
Bangle.buzz(200);
|
||||
return;
|
||||
}
|
||||
else
|
||||
page_1++;
|
||||
}
|
||||
render_1();
|
||||
};
|
||||
var onBackButton_1 = function () {
|
||||
Bangle.buzz();
|
||||
if (folderPath_1.length == 0)
|
||||
Bangle.showClock();
|
||||
else {
|
||||
folderPath_1.pop();
|
||||
folder_1 = getFolder_1(folderPath_1);
|
||||
resetTimeout_1();
|
||||
page_1 = 0;
|
||||
render_1();
|
||||
}
|
||||
};
|
||||
Bangle.loadWidgets();
|
||||
Bangle.drawWidgets();
|
||||
Bangle.setUI({
|
||||
mode: 'custom',
|
||||
back: onBackButton_1,
|
||||
btn: onBackButton_1,
|
||||
swipe: onSwipe,
|
||||
touch: onTouch,
|
||||
remove: function () { if (timeout_1)
|
||||
clearTimeout(timeout_1); }
|
||||
});
|
||||
resetTimeout_1();
|
||||
render_1();
|
||||
}
|
|
@ -0,0 +1,271 @@
|
|||
{
|
||||
const loader = require('folderlaunch-configLoad.js')
|
||||
const storage = require('Storage')
|
||||
|
||||
const FOLDER_ICON = require("heatshrink").decompress(atob("mEwwMA///wAJCAoPAAongAonwAon4Aon8Aon+Aon/AooA/AH4A/AFgA="))
|
||||
|
||||
let config: Config = loader.getConfig();
|
||||
|
||||
let timeout: any;
|
||||
/**
|
||||
* If a timeout to return to the clock is set, reset it.
|
||||
*/
|
||||
let resetTimeout = function () {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
|
||||
if (config.timeout != 0) {
|
||||
timeout = setTimeout(() => {
|
||||
Bangle.showClock();
|
||||
}, config.timeout);
|
||||
}
|
||||
}
|
||||
|
||||
let folderPath: Array<string> = [];
|
||||
/**
|
||||
* Get the folder at the provided path
|
||||
*
|
||||
* @param folderPath a path for the desired folder
|
||||
* @return the folder that was found
|
||||
*/
|
||||
let getFolder = function (folderPath: Array<string>): Folder {
|
||||
let result: Folder = config.rootFolder;
|
||||
for (let folderName of folderPath)
|
||||
result = result.folders[folderName]!;
|
||||
nPages = Math.ceil((result.apps.length + Object.keys(result.folders).length) / (config.display.rows * config.display.rows));
|
||||
return result;
|
||||
}
|
||||
let folder: Folder = getFolder(folderPath);
|
||||
|
||||
/**
|
||||
* Determine the font size needed to fit a string of the given length widthin maxWidth number of pixels, clamped between minSize and maxSize
|
||||
*
|
||||
* @param length the number of characters of the string
|
||||
* @param maxWidth the maximum allowable width
|
||||
* @param minSize the minimum acceptable font size
|
||||
* @param maxSize the maximum acceptable font size
|
||||
* @return the calculated font size
|
||||
*/
|
||||
let getFontSize = function (length: number, maxWidth: number, minSize: number, maxSize: number): number {
|
||||
let size = Math.floor(maxWidth / length); //Number of pixels of width available to character
|
||||
size *= (20 / 12); //Convert to height, assuming 20 pixels of height for every 12 of width
|
||||
|
||||
// Clamp to within range
|
||||
if (size < minSize) return minSize;
|
||||
else if (size > maxSize) return maxSize;
|
||||
else return Math.floor(size);
|
||||
}
|
||||
|
||||
// grid[x][y] = id of app at column x row y, or undefined if no app displayed there
|
||||
let grid: Array<Array<GridEntry>> = [];
|
||||
for (let x = 0; x < config.display.rows; x++) {
|
||||
grid.push([]);
|
||||
for (let y = 0; y < config.display.rows; y++) {
|
||||
grid[x]!.push({
|
||||
type: 'empty',
|
||||
id: ''
|
||||
});
|
||||
}
|
||||
}
|
||||
let render = function () {
|
||||
let gridSize: number = config.display.rows * config.display.rows;
|
||||
let startIndex: number = page * gridSize; // Start at this position in the folders
|
||||
|
||||
// Populate the grid
|
||||
for (let i = 0; i < gridSize; i++) {
|
||||
// Calculate coordinates
|
||||
let y = Math.floor(i / config.display.rows);
|
||||
let x = i % config.display.rows;
|
||||
|
||||
// Try to place a folder
|
||||
let folderIndex = startIndex + i;
|
||||
let appIndex = folderIndex - Object.keys(folder.folders).length;
|
||||
if (folderIndex < Object.keys(folder.folders).length) {
|
||||
grid[x]![y]!.type = 'folder';
|
||||
grid[x]![y]!.id = Object.keys(folder.folders)[folderIndex];
|
||||
}
|
||||
|
||||
// If that fails, try to place an app
|
||||
else if (appIndex < folder.apps.length) {
|
||||
grid[x]![y]!.type = 'app';
|
||||
grid[x]![y]!.id = folder.apps[appIndex]!;
|
||||
}
|
||||
|
||||
// If that also fails, make the space empty
|
||||
else grid[x]![y]!.type = 'empty';
|
||||
}
|
||||
|
||||
// Prepare to draw the grid
|
||||
let squareSize: number = (g.getHeight() - 24) / config.display.rows;
|
||||
if (!config.display.icon && !config.display.font) config.display.font = 12; // Fallback in case user disabled both icon and text
|
||||
g.clearRect(0, 24, g.getWidth(), g.getHeight())
|
||||
.reset()
|
||||
.setFontAlign(0, -1);
|
||||
|
||||
// Actually draw the grid
|
||||
let empty = true; // Set to empty upon drawing something, so we can know whether to draw a nice message rather than leaving the screen completely blank
|
||||
for (let x = 0; x < config.display.rows; x++) {
|
||||
for (let y = 0; y < config.display.rows; y++) {
|
||||
let entry: GridEntry = grid[x]![y]!;
|
||||
let icon: string | ArrayBuffer;
|
||||
let text: string;
|
||||
let fontSize: number;
|
||||
|
||||
// Get the icon and text, skip if the space is empty. Always draw text for folders even if disabled
|
||||
switch (entry.type) {
|
||||
case 'app':
|
||||
let app: AppInfo = storage.readJSON(entry.id + '.info', false);
|
||||
icon = storage.read(app.icon!)!;
|
||||
text = app.name;
|
||||
empty = false;
|
||||
fontSize = config.display.font;
|
||||
break;
|
||||
case 'folder':
|
||||
icon = FOLDER_ICON;
|
||||
text = entry.id;
|
||||
empty = false;
|
||||
fontSize = config.display.font ? config.display.font : 12;
|
||||
break;
|
||||
default:
|
||||
continue;
|
||||
}
|
||||
|
||||
// Calculate position and icon size
|
||||
let iconSize = config.display.icon ? Math.max(0, squareSize - fontSize) : 0; // If icon is disabled, stay at zero. Otherwise, subtract font size from square
|
||||
let iconScale: number = iconSize / 48;
|
||||
let posX = 12 + (x * squareSize);
|
||||
let posY = 24 + (y * squareSize);
|
||||
|
||||
// Draw the icon
|
||||
if (config.display.icon && iconSize != 0)
|
||||
try {
|
||||
g.drawImage(icon, posX + (squareSize - iconSize) / 2, posY, { scale: iconScale });
|
||||
} catch (error) {
|
||||
console.log(`Failed to draw icon for ${text}: ${error}`);
|
||||
console.log(icon);
|
||||
}
|
||||
|
||||
// Draw the text
|
||||
if (fontSize)
|
||||
g.setFont('Vector', getFontSize(text.length, squareSize, 6, squareSize - iconSize))
|
||||
.drawString(text, posX + (squareSize / 2), posY + iconSize);
|
||||
}
|
||||
}
|
||||
|
||||
// Draw a nice message if there is nothing to see, so the user doesn't think the app is broken
|
||||
if (empty) E.showMessage(/*LANG*/'Folder is empty. Swipe from left, back button, or BTN1 to go back.');
|
||||
|
||||
// Draw a scroll bar if necessary
|
||||
if (nPages > 1) { // Avoid divide-by-zero and pointless scroll bars
|
||||
let barSize = (g.getHeight() - 24) / nPages;
|
||||
let barTop = 24 + (page * barSize);
|
||||
g.fillRect(
|
||||
g.getWidth() - 8, barTop,
|
||||
g.getWidth() - 4, barTop + barSize);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a touch
|
||||
*
|
||||
* @param _button 1 for left half, 2 for right half
|
||||
* @param xy postion on screen
|
||||
*/
|
||||
let onTouch = function (_button: number, xy: { x: number, y: number } | undefined) {
|
||||
// Determine which grid cell was tapped
|
||||
let x: number = Math.floor((xy!.x - 12) / ((g.getWidth() - 24) / config.display.rows));
|
||||
if (x < 0) x = 0;
|
||||
else if (x >= config.display.rows) x = config.display.rows - 1;
|
||||
let y: number = Math.floor((xy!.y - 24) / ((g.getHeight() - 24) / config.display.rows));
|
||||
if (y < 0) y = 0;
|
||||
else if (y >= config.display.rows) y = config.display.rows - 1;
|
||||
|
||||
// Handle the grid cell
|
||||
let entry: GridEntry = grid[x]![y]!;
|
||||
switch (entry.type) {
|
||||
case "app":
|
||||
Bangle.buzz();
|
||||
let infoFile = storage.readJSON(entry.id + '.info', false);
|
||||
load(infoFile.src);
|
||||
break;
|
||||
case "folder":
|
||||
Bangle.buzz();
|
||||
resetTimeout();
|
||||
page = 0;
|
||||
folderPath.push(entry.id);
|
||||
folder = getFolder(folderPath);
|
||||
render();
|
||||
break;
|
||||
default:
|
||||
resetTimeout();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let page: number = 0;
|
||||
let nPages: number; // Set when setting folder
|
||||
|
||||
/**
|
||||
* Handle a swipe
|
||||
*
|
||||
* A swipe from left is treated as the back button. Up and down swipes change pages
|
||||
*
|
||||
* @param lr -1 if left, 0 if pure up/down, 1 if right
|
||||
* @param ud -1 if up, 0 if pure left/right, 1 if down
|
||||
*/
|
||||
let onSwipe = function (lr: -1 | 0 | 1 | undefined, ud: -1 | 0 | 1 | undefined) {
|
||||
if (lr == 1 && ud == 0) {
|
||||
onBackButton();
|
||||
return;
|
||||
} else if (ud == 1) {
|
||||
resetTimeout();
|
||||
if (page == 0) {
|
||||
Bangle.buzz(200);
|
||||
return;
|
||||
} else page--;
|
||||
} else if (ud == -1) {
|
||||
resetTimeout();
|
||||
if (page == nPages - 1) {
|
||||
Bangle.buzz(200);
|
||||
return;
|
||||
} else page++;
|
||||
}
|
||||
|
||||
// If we reached this point, the page number has been changed and is valid.
|
||||
render();
|
||||
}
|
||||
|
||||
/**
|
||||
* Go back up a level. If already at the root folder, exit the launcher
|
||||
*/
|
||||
let onBackButton = () => {
|
||||
Bangle.buzz();
|
||||
if (folderPath.length == 0)
|
||||
Bangle.showClock();
|
||||
else {
|
||||
folderPath.pop();
|
||||
folder = getFolder(folderPath);
|
||||
resetTimeout();
|
||||
page = 0;
|
||||
render();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Bangle.loadWidgets();
|
||||
Bangle.drawWidgets();
|
||||
|
||||
Bangle.setUI({
|
||||
mode: 'custom',
|
||||
back: onBackButton,
|
||||
btn: onBackButton,
|
||||
swipe: onSwipe,
|
||||
touch: onTouch,
|
||||
remove: () => { if (timeout) clearTimeout(timeout); }
|
||||
});
|
||||
|
||||
resetTimeout();
|
||||
render();
|
||||
|
||||
}
|
|
@ -0,0 +1,105 @@
|
|||
var storage = require("Storage");
|
||||
var SETTINGS_FILE = "folderlaunch.json";
|
||||
var DEFAULT_CONFIG = {
|
||||
showClocks: false,
|
||||
showLaunchers: false,
|
||||
hidden: [],
|
||||
display: {
|
||||
rows: 2,
|
||||
icon: true,
|
||||
font: 12
|
||||
},
|
||||
timeout: 30000,
|
||||
rootFolder: {
|
||||
folders: {},
|
||||
apps: []
|
||||
},
|
||||
apps: {},
|
||||
hash: 0
|
||||
};
|
||||
function clearFolder(folder) {
|
||||
for (var childName in folder.folders)
|
||||
folder.folders[childName] = clearFolder(folder.folders[childName]);
|
||||
folder.apps = [];
|
||||
return folder;
|
||||
}
|
||||
function cleanAndSave(config) {
|
||||
var infoFiles = storage.list(/\.info$/);
|
||||
var installedAppIds = [];
|
||||
for (var _i = 0, infoFiles_1 = infoFiles; _i < infoFiles_1.length; _i++) {
|
||||
var infoFile = infoFiles_1[_i];
|
||||
installedAppIds.push(storage.readJSON(infoFile, true).id);
|
||||
}
|
||||
var toRemove = [];
|
||||
for (var appId in config.apps)
|
||||
if (!installedAppIds.includes(appId))
|
||||
toRemove.push(appId);
|
||||
for (var _a = 0, toRemove_1 = toRemove; _a < toRemove_1.length; _a++) {
|
||||
var appId = toRemove_1[_a];
|
||||
delete config.apps[appId];
|
||||
}
|
||||
storage.writeJSON(SETTINGS_FILE, config);
|
||||
return config;
|
||||
}
|
||||
var infoFileSorter = function (a, b) {
|
||||
var aJson = storage.readJSON(a, false);
|
||||
var bJson = storage.readJSON(b, false);
|
||||
var n = (0 | aJson.sortorder) - (0 | bJson.sortorder);
|
||||
if (n)
|
||||
return n;
|
||||
if (aJson.name < bJson.name)
|
||||
return -1;
|
||||
if (aJson.name > bJson.name)
|
||||
return 1;
|
||||
return 0;
|
||||
};
|
||||
module.exports = {
|
||||
cleanAndSave: cleanAndSave,
|
||||
infoFileSorter: infoFileSorter,
|
||||
getConfig: function () {
|
||||
var config = storage.readJSON(SETTINGS_FILE, true) || DEFAULT_CONFIG;
|
||||
if (config.hash == storage.hash(/\.info$/)) {
|
||||
return config;
|
||||
}
|
||||
E.showMessage('Rebuilding cache...');
|
||||
config.rootFolder = clearFolder(config.rootFolder);
|
||||
var infoFiles = storage.list(/\.info$/);
|
||||
infoFiles.sort(infoFileSorter);
|
||||
for (var _i = 0, infoFiles_2 = infoFiles; _i < infoFiles_2.length; _i++) {
|
||||
var infoFile = infoFiles_2[_i];
|
||||
var app_1 = storage.readJSON(infoFile, false);
|
||||
if ((!config.showClocks && app_1.type == 'clock') ||
|
||||
(!config.showLaunchers && app_1.type == 'launch') ||
|
||||
(app_1.type == 'widget') ||
|
||||
(!app_1.src)) {
|
||||
if (Object.keys(config.hidden).includes(app_1.id))
|
||||
delete config.apps[app_1.id];
|
||||
continue;
|
||||
}
|
||||
if (!config.apps.hasOwnProperty(app_1.id)) {
|
||||
config.apps[app_1.id] = {
|
||||
folder: [],
|
||||
nagged: false
|
||||
};
|
||||
}
|
||||
if (config.hidden.includes(app_1.id))
|
||||
continue;
|
||||
var curFolder = config.rootFolder;
|
||||
var depth = 0;
|
||||
for (var _a = 0, _b = config.apps[app_1.id].folder; _a < _b.length; _a++) {
|
||||
var folderName = _b[_a];
|
||||
if (curFolder.folders.hasOwnProperty(folderName)) {
|
||||
curFolder = curFolder.folders[folderName];
|
||||
depth++;
|
||||
}
|
||||
else {
|
||||
config.apps[app_1.id].folder = config.apps[app_1.id].folder.slice(0, depth);
|
||||
break;
|
||||
}
|
||||
}
|
||||
curFolder.apps.push(app_1.id);
|
||||
}
|
||||
config.hash = storage.hash(/\.info$/);
|
||||
return cleanAndSave(config);
|
||||
}
|
||||
};
|
|
@ -0,0 +1,156 @@
|
|||
const storage = require("Storage");
|
||||
|
||||
const SETTINGS_FILE: string = "folderlaunch.json";
|
||||
|
||||
const DEFAULT_CONFIG: Config = {
|
||||
showClocks: false,
|
||||
showLaunchers: false,
|
||||
hidden: [],
|
||||
display: {
|
||||
rows: 2,
|
||||
icon: true,
|
||||
font: 12
|
||||
},
|
||||
timeout: 30000,
|
||||
rootFolder: {
|
||||
folders: {},
|
||||
apps: []
|
||||
},
|
||||
apps: {},
|
||||
hash: 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively remove all apps from a folder
|
||||
*
|
||||
* @param folder the folder to clean
|
||||
* @return the folder with all apps removed
|
||||
*/
|
||||
function clearFolder(folder: Folder): Folder {
|
||||
for (let childName in folder.folders)
|
||||
folder.folders[childName] = clearFolder(folder.folders[childName]!);
|
||||
folder.apps = [];
|
||||
return folder;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean and save the configuration.
|
||||
*
|
||||
* Assume that:
|
||||
* - All installed apps have an appInfo entry
|
||||
* - References to nonexistent folders have been removed from appInfo
|
||||
* And therefore we do not need to do this ourselves.
|
||||
* Note: It is not a real problem if the assumptions are not true. If this was called by getConfig, the assumptions are already taken care of. If this was called somewhere else, they will be taken care of the next time getConfig is called.
|
||||
*
|
||||
* Perform the following cleanup:
|
||||
* - Remove appInfo entries for nonexistent apps, to prevent irrelevant data invisible to the user from accumulating
|
||||
*
|
||||
* @param config the configuration to be cleaned
|
||||
* @return the cleaned configuration
|
||||
*/
|
||||
function cleanAndSave(config: Config): Config {
|
||||
// Get the list of installed apps
|
||||
let infoFiles: Array<string> = storage.list(/\.info$/);
|
||||
let installedAppIds: Array<string> = [];
|
||||
for (let infoFile of infoFiles)
|
||||
installedAppIds.push(storage.readJSON(infoFile, true).id);
|
||||
|
||||
// Remove nonexistent apps from appInfo
|
||||
let toRemove: Array<string> = [];
|
||||
for (let appId in config.apps)
|
||||
if (!installedAppIds.includes(appId))
|
||||
toRemove.push(appId);
|
||||
for (let appId of toRemove)
|
||||
delete config.apps[appId];
|
||||
|
||||
// Save and return
|
||||
storage.writeJSON(SETTINGS_FILE, config);
|
||||
return config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Comparator function to sort a list of app info files.
|
||||
* Copied and slightly modified (mainly to port to Typescript) from dtlaunch.
|
||||
*
|
||||
* @param a the first
|
||||
* @param b the second
|
||||
* @return negative if a should go first, positive if b should go first, zero if equivalent.
|
||||
*/
|
||||
let infoFileSorter = (a: string, b: string): number => {
|
||||
let aJson: AppInfo = storage.readJSON(a, false);
|
||||
let bJson: AppInfo = storage.readJSON(b, false);
|
||||
var n = (0 | aJson.sortorder!) - (0 | bJson.sortorder!);
|
||||
if (n) return n; // do sortorder first
|
||||
if (aJson.name < bJson.name) return -1;
|
||||
if (aJson.name > bJson.name) return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
export = {
|
||||
cleanAndSave: cleanAndSave,
|
||||
infoFileSorter: infoFileSorter,
|
||||
|
||||
/**
|
||||
* Get the configuration for the launcher. Perform a cleanup if any new apps were installed or any apps refer to nonexistent folders.
|
||||
*
|
||||
* @param keepHidden if true, don't exclude apps that would otherwise be hidden
|
||||
* @return the loaded configuration
|
||||
*/
|
||||
getConfig: (): Config => {
|
||||
let config = storage.readJSON(SETTINGS_FILE, true) || DEFAULT_CONFIG;
|
||||
|
||||
// We only need to load data from the filesystem if there is a change
|
||||
if (config.hash == storage.hash(/\.info$/)) {
|
||||
return config;
|
||||
}
|
||||
|
||||
E.showMessage(/*LANG*/'Rebuilding cache...')
|
||||
config.rootFolder = clearFolder(config.rootFolder);
|
||||
let infoFiles: Array<string> = storage.list(/\.info$/);
|
||||
infoFiles.sort(infoFileSorter);
|
||||
|
||||
for (let infoFile of infoFiles) {
|
||||
let app: AppInfo = storage.readJSON(infoFile, false);
|
||||
|
||||
// If the app is to be hidden by policy, exclude it completely
|
||||
if (
|
||||
(!config.showClocks && app.type == 'clock') ||
|
||||
(!config.showLaunchers && app.type == 'launch') ||
|
||||
(app.type == 'widget') ||
|
||||
(!app.src)
|
||||
) {
|
||||
if (Object.keys(config.hidden).includes(app.id)) delete config.apps[app.id];
|
||||
continue;
|
||||
}
|
||||
|
||||
// Creates the apps entry if it doesn't exist yet.
|
||||
if (!config.apps.hasOwnProperty(app.id)) {
|
||||
config.apps[app.id] = {
|
||||
folder: [],
|
||||
nagged: false
|
||||
};
|
||||
}
|
||||
|
||||
// If the app is manually hidden, don't put it in a folder but still keep information about it
|
||||
if (config.hidden.includes(app.id)) continue;
|
||||
|
||||
// Place apps in folders, deleting references to folders that no longer exist
|
||||
// Note: Relies on curFolder secretly being a reference rather than a copy
|
||||
let curFolder: Folder = config.rootFolder;
|
||||
let depth = 0;
|
||||
for (let folderName of config.apps[app.id].folder) {
|
||||
if (curFolder.folders.hasOwnProperty(folderName)) {
|
||||
curFolder = curFolder.folders[folderName]!;
|
||||
depth++;
|
||||
} else {
|
||||
config.apps[app.id].folder = config.apps[app.id].folder.slice(0, depth);
|
||||
break;
|
||||
}
|
||||
}
|
||||
curFolder.apps.push(app.id);
|
||||
}
|
||||
config.hash = storage.hash(/\.info$/);
|
||||
|
||||
return cleanAndSave(config);
|
||||
}
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
require("heatshrink").decompress(atob("mEwwMA///wAJCAoPAAongAonwAon4Aon8Aon+Aon/AooA/AH4A/AFgA="))
|
Binary file not shown.
After Width: | Height: | Size: 234 B |
|
@ -0,0 +1,48 @@
|
|||
{
|
||||
"id": "folderlaunch",
|
||||
"name": "Folder launcher",
|
||||
"version": "0.01",
|
||||
"description": "Launcher that allows you to put your apps into folders",
|
||||
"icon": "icon.png",
|
||||
"type": "launch",
|
||||
"tags": "tool,system,launcher",
|
||||
"supports": [
|
||||
"BANGLEJS2"
|
||||
],
|
||||
"readme": "README.md",
|
||||
"storage": [
|
||||
{
|
||||
"name": "folderlaunch.app.js",
|
||||
"url": "app.js"
|
||||
},
|
||||
{
|
||||
"name": "folderlaunch.settings.js",
|
||||
"url": "settings.js"
|
||||
},
|
||||
{
|
||||
"name": "folderlaunch-configLoad.js",
|
||||
"url": "configLoad.js"
|
||||
},
|
||||
{
|
||||
"name": "folderlaunch.img",
|
||||
"url": "icon.js",
|
||||
"evaluate": true
|
||||
}
|
||||
],
|
||||
"data": [
|
||||
{
|
||||
"name": "folderlaunch.json"
|
||||
}
|
||||
],
|
||||
"dependencies": {
|
||||
"textinput": "type"
|
||||
},
|
||||
"screenshots": [
|
||||
{
|
||||
"url": "screenshot1.png"
|
||||
},
|
||||
{
|
||||
"url": "screenshot2.png"
|
||||
}
|
||||
]
|
||||
}
|
Binary file not shown.
After Width: | Height: | Size: 4.7 KiB |
Binary file not shown.
After Width: | Height: | Size: 3.8 KiB |
|
@ -0,0 +1,248 @@
|
|||
(function (back) {
|
||||
var loader = require('folderlaunch-configLoad.js');
|
||||
var storage = require('Storage');
|
||||
var textinput = require('textinput');
|
||||
var config = loader.getConfig();
|
||||
var changed = false;
|
||||
var hiddenAppsMenu = function () {
|
||||
var menu = {
|
||||
'': {
|
||||
'title': 'Hide?',
|
||||
'back': showMainMenu
|
||||
}
|
||||
};
|
||||
var onchange = function (value, appId) {
|
||||
if (value && !config.hidden.includes(appId))
|
||||
config.hidden.push(appId);
|
||||
else if (!value && config.hidden.includes(appId))
|
||||
config.hidden = config.hidden.filter(function (item) { return item != appId; });
|
||||
changed = true;
|
||||
};
|
||||
onchange;
|
||||
for (var app_1 in config.apps) {
|
||||
var appInfo = storage.readJSON(app_1 + '.info', false);
|
||||
menu[appInfo.name] = {
|
||||
value: config.hidden.includes(app_1),
|
||||
format: function (value) { return (value ? 'Yes' : 'No'); },
|
||||
onchange: eval("(value) => { onchange(value, \"".concat(app_1, "\"); }"))
|
||||
};
|
||||
}
|
||||
E.showMenu(menu);
|
||||
};
|
||||
var getAppInfo = function (id) {
|
||||
return storage.readJSON(id + '.info', false);
|
||||
};
|
||||
var showFolderMenu = function (path) {
|
||||
var folder = config.rootFolder;
|
||||
for (var _i = 0, path_1 = path; _i < path_1.length; _i++) {
|
||||
var folderName = path_1[_i];
|
||||
try {
|
||||
folder = folder.folders[folderName];
|
||||
}
|
||||
catch (_a) {
|
||||
E.showAlert('BUG: Nonexistent folder ' + path);
|
||||
}
|
||||
}
|
||||
var back = function () {
|
||||
if (path.length) {
|
||||
path.pop();
|
||||
showFolderMenu(path);
|
||||
}
|
||||
else
|
||||
showMainMenu();
|
||||
};
|
||||
var menu = {
|
||||
'': {
|
||||
'title': path.length ? path[path.length - 1] : 'Root folder',
|
||||
'back': back
|
||||
},
|
||||
'New subfolder': function () {
|
||||
textinput.input({ text: '' }).then(function (result) {
|
||||
if (result && !Object.keys(folder.folders).includes(result)) {
|
||||
folder.folders[result] = {
|
||||
folders: {},
|
||||
apps: []
|
||||
};
|
||||
changed = true;
|
||||
path.push(result);
|
||||
showFolderMenu(path);
|
||||
}
|
||||
else {
|
||||
E.showAlert('No folder created').then(function () {
|
||||
showFolderMenu(path);
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
'Move app here': function () {
|
||||
var menu = {
|
||||
'': {
|
||||
'title': 'Select app',
|
||||
'back': function () {
|
||||
showFolderMenu(path);
|
||||
}
|
||||
}
|
||||
};
|
||||
var mover = function (appId) {
|
||||
var folder = config.rootFolder;
|
||||
for (var _i = 0, _a = config.apps[appId].folder; _i < _a.length; _i++) {
|
||||
var folderName = _a[_i];
|
||||
folder = folder.folders[folderName];
|
||||
}
|
||||
folder.apps = folder.apps.filter(function (item) { return item != appId; });
|
||||
config.apps[appId].folder = path.slice();
|
||||
folder = config.rootFolder;
|
||||
for (var _b = 0, path_2 = path; _b < path_2.length; _b++) {
|
||||
var folderName = path_2[_b];
|
||||
folder = folder.folders[folderName];
|
||||
}
|
||||
folder.apps.push(appId);
|
||||
changed = true;
|
||||
showFolderMenu(path);
|
||||
};
|
||||
mover;
|
||||
E.showMessage('Loading apps...');
|
||||
for (var _i = 0, _a = Object.keys(config.apps)
|
||||
.filter(function (item) { return !folder.apps.includes(item); })
|
||||
.map(function (item) { return item + '.info'; })
|
||||
.sort(loader.infoFileSorter)
|
||||
.map(function (item) { return item.split('.info')[0]; }); _i < _a.length; _i++) {
|
||||
var appId = _a[_i];
|
||||
menu[getAppInfo(appId).name] = eval("() => { mover(\"".concat(appId, "\"); }"));
|
||||
}
|
||||
E.showMenu(menu);
|
||||
}
|
||||
};
|
||||
var switchToFolder = function (subfolder) {
|
||||
path.push(subfolder);
|
||||
showFolderMenu(path);
|
||||
};
|
||||
switchToFolder;
|
||||
for (var _b = 0, _c = Object.keys(folder.folders); _b < _c.length; _b++) {
|
||||
var subfolder = _c[_b];
|
||||
menu[subfolder] = eval("() => { switchToFolder(\"".concat(subfolder, "\") }"));
|
||||
}
|
||||
if (folder.apps.length)
|
||||
menu['View apps'] = function () {
|
||||
var menu = {
|
||||
'': {
|
||||
'title': path[path.length - 1],
|
||||
'back': function () { showFolderMenu(path); }
|
||||
}
|
||||
};
|
||||
for (var _i = 0, _a = folder.apps; _i < _a.length; _i++) {
|
||||
var appId = _a[_i];
|
||||
menu[storage.readJSON(appId + '.info', false).name] = function () { };
|
||||
}
|
||||
E.showMenu(menu);
|
||||
};
|
||||
if (path.length)
|
||||
menu['Delete folder'] = function () {
|
||||
var apps = folder.apps;
|
||||
var subfolders = folder.folders;
|
||||
var toDelete = path.pop();
|
||||
folder = config.rootFolder;
|
||||
for (var _i = 0, path_3 = path; _i < path_3.length; _i++) {
|
||||
var folderName = path_3[_i];
|
||||
folder = folder.folders[folderName];
|
||||
}
|
||||
for (var _a = 0, apps_1 = apps; _a < apps_1.length; _a++) {
|
||||
var appId = apps_1[_a];
|
||||
config.apps[appId].folder.pop();
|
||||
folder.apps.push(appId);
|
||||
}
|
||||
for (var _b = 0, _c = Object.keys(subfolders); _b < _c.length; _b++) {
|
||||
var subfolder = _c[_b];
|
||||
folder.folders[subfolder] = subfolders[subfolder];
|
||||
}
|
||||
delete folder.folders[toDelete];
|
||||
changed = true;
|
||||
showFolderMenu(path);
|
||||
};
|
||||
E.showMenu(menu);
|
||||
};
|
||||
var save = function () {
|
||||
if (changed) {
|
||||
E.showMessage('Saving...');
|
||||
config.hash = 0;
|
||||
loader.cleanAndSave(config);
|
||||
changed = false;
|
||||
}
|
||||
};
|
||||
E.on('kill', save);
|
||||
var showMainMenu = function () {
|
||||
E.showMenu({
|
||||
'': {
|
||||
'title': 'Folder launcher',
|
||||
'back': function () {
|
||||
save();
|
||||
back();
|
||||
}
|
||||
},
|
||||
'Show clocks': {
|
||||
value: config.showClocks,
|
||||
format: function (value) { return (value ? 'Yes' : 'No'); },
|
||||
onchange: function (value) {
|
||||
config.showClocks = value;
|
||||
changed = true;
|
||||
}
|
||||
},
|
||||
'Show launchers': {
|
||||
value: config.showLaunchers,
|
||||
format: function (value) { return (value ? 'Yes' : 'No'); },
|
||||
onchange: function (value) {
|
||||
config.showLaunchers = value;
|
||||
changed = true;
|
||||
}
|
||||
},
|
||||
'Hidden apps': hiddenAppsMenu,
|
||||
'Display': function () {
|
||||
E.showMenu({
|
||||
'': {
|
||||
'title': 'Display',
|
||||
'back': showMainMenu
|
||||
},
|
||||
'Rows': {
|
||||
value: config.display.rows,
|
||||
min: 1,
|
||||
onchange: function (value) {
|
||||
config.display.rows = value;
|
||||
changed = true;
|
||||
}
|
||||
},
|
||||
'Show icons?': {
|
||||
value: config.display.icon,
|
||||
format: function (value) { return (value ? 'Yes' : 'No'); },
|
||||
onchange: function (value) {
|
||||
config.display.icon = value;
|
||||
changed = true;
|
||||
}
|
||||
},
|
||||
'Font size': {
|
||||
value: config.display.font,
|
||||
min: 0,
|
||||
format: function (value) { return (value ? value : 'Icon only'); },
|
||||
onchange: function (value) {
|
||||
config.display.font = value;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
'Timeout': {
|
||||
value: config.timeout,
|
||||
format: function (value) { return value ? "".concat(value / 1000, " sec") : 'None'; },
|
||||
min: 0,
|
||||
step: 1000,
|
||||
onchange: function (value) {
|
||||
config.timeout = value;
|
||||
changed = true;
|
||||
}
|
||||
},
|
||||
'Folder management': function () {
|
||||
showFolderMenu([]);
|
||||
}
|
||||
});
|
||||
};
|
||||
showMainMenu();
|
||||
});
|
|
@ -0,0 +1,264 @@
|
|||
(function (back) {
|
||||
const loader = require('folderlaunch-configLoad.js');
|
||||
const storage = require('Storage');
|
||||
const textinput = require('textinput');
|
||||
|
||||
let config: Config = loader.getConfig();
|
||||
let changed: boolean = false;
|
||||
|
||||
let hiddenAppsMenu = () => {
|
||||
let menu: Menu = {
|
||||
'': {
|
||||
'title': 'Hide?',
|
||||
'back': showMainMenu
|
||||
}
|
||||
}
|
||||
|
||||
let onchange = (value: boolean, appId: string) => {
|
||||
if (value && !config.hidden.includes(appId)) // Hiding, not already hidden
|
||||
config.hidden.push(appId);
|
||||
else if (!value && config.hidden.includes(appId)) // Unhiding, already hidden
|
||||
config.hidden = config.hidden.filter(item => item != appId)
|
||||
changed = true;
|
||||
}
|
||||
onchange // Do nothing, but stop typescript from yelling at me for this function being unused. It gets used by eval. I know eval is evil, but the menus are a bit limited.
|
||||
|
||||
for (let app in config.apps) {
|
||||
let appInfo: AppInfo = storage.readJSON(app + '.info', false);
|
||||
menu[appInfo.name] = {
|
||||
value: config.hidden.includes(app),
|
||||
format: (value: boolean) => (value ? 'Yes' : 'No'),
|
||||
onchange: eval(`(value) => { onchange(value, "${app}"); }`)
|
||||
}
|
||||
}
|
||||
|
||||
E.showMenu(menu);
|
||||
};
|
||||
|
||||
let getAppInfo = (id: string): AppInfo => {
|
||||
return storage.readJSON(id + '.info', false);
|
||||
}
|
||||
|
||||
let showFolderMenu = (path: Array<string>) => {
|
||||
let folder: Folder = config.rootFolder;
|
||||
for (let folderName of path)
|
||||
try {
|
||||
folder = folder.folders[folderName]!;
|
||||
} catch {
|
||||
E.showAlert(/*LANG*/'BUG: Nonexistent folder ' + path);
|
||||
}
|
||||
|
||||
let back = () => {
|
||||
if (path.length) {
|
||||
path.pop();
|
||||
showFolderMenu(path);
|
||||
} else showMainMenu();
|
||||
};
|
||||
|
||||
let menu: Menu = {
|
||||
'': {
|
||||
'title': path.length ? path[path.length - 1]! : /*LANG*/ 'Root folder',
|
||||
'back': back
|
||||
},
|
||||
/*LANG*/'New subfolder': () => {
|
||||
textinput.input({ text: '' }).then((result: string) => {
|
||||
if (result && !Object.keys(folder.folders).includes(result)) {
|
||||
folder.folders[result] = {
|
||||
folders: {},
|
||||
apps: []
|
||||
};
|
||||
changed = true;
|
||||
path.push(result);
|
||||
showFolderMenu(path);
|
||||
} else {
|
||||
E.showAlert(/*LANG*/'No folder created').then(() => {
|
||||
showFolderMenu(path);
|
||||
})
|
||||
}
|
||||
});
|
||||
},
|
||||
/*LANG*/'Move app here': () => {
|
||||
let menu: Menu = {
|
||||
'': {
|
||||
'title': /*LANG*/'Select app',
|
||||
'back': () => {
|
||||
showFolderMenu(path);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let mover = (appId: string) => {
|
||||
// Delete app from old folder
|
||||
let folder: Folder = config.rootFolder;
|
||||
for (let folderName of config.apps[appId]!.folder)
|
||||
folder = folder.folders[folderName]!;
|
||||
folder.apps = folder.apps.filter((item: string) => item != appId);
|
||||
|
||||
// Change folder in app info, .slice is to force a copy rather than a reference
|
||||
config.apps[appId]!.folder = path.slice();
|
||||
|
||||
// Place app in new folder (here)
|
||||
folder = config.rootFolder;
|
||||
for (let folderName of path)
|
||||
folder = folder.folders[folderName]!;
|
||||
folder.apps.push(appId);
|
||||
|
||||
// Mark changed and refresh menu
|
||||
changed = true;
|
||||
showFolderMenu(path);
|
||||
};
|
||||
mover;
|
||||
|
||||
E.showMessage(/*LANG*/'Loading apps...');
|
||||
for (
|
||||
let appId of Object.keys(config.apps) // All known apps
|
||||
.filter(item => !folder.apps.includes(item)) // Filter out ones already in this folder
|
||||
.map(item => item + '.info') // Convert to .info files
|
||||
.sort(loader.infoFileSorter) // Sort the info files using infoFileSorter
|
||||
.map(item => item.split('.info')[0]) // Back to app ids
|
||||
) {
|
||||
menu[getAppInfo(appId).name] = eval(`() => { mover("${appId}"); }`);
|
||||
}
|
||||
|
||||
E.showMenu(menu);
|
||||
}
|
||||
};
|
||||
|
||||
let switchToFolder = (subfolder: string) => {
|
||||
path.push(subfolder);
|
||||
showFolderMenu(path);
|
||||
};
|
||||
switchToFolder;
|
||||
|
||||
for (let subfolder of Object.keys(folder.folders)) {
|
||||
menu[subfolder] = eval(`() => { switchToFolder("${subfolder}") }`);
|
||||
}
|
||||
|
||||
if (folder.apps.length) menu[/*LANG*/'View apps'] = () => {
|
||||
let menu: Menu = {
|
||||
'': {
|
||||
'title': path[path.length - 1]!,
|
||||
'back': () => { showFolderMenu(path); }
|
||||
}
|
||||
}
|
||||
for (let appId of folder.apps) {
|
||||
menu[storage.readJSON(appId + '.info', false).name] = () => { };
|
||||
}
|
||||
E.showMenu(menu);
|
||||
}
|
||||
|
||||
if (path.length) menu[/*LANG*/'Delete folder'] = () => {
|
||||
// Cache apps for changing the folder reference
|
||||
let apps: Array<string> = folder.apps;
|
||||
let subfolders = folder.folders;
|
||||
|
||||
// Move up to the parent folder
|
||||
let toDelete: string = path.pop()!;
|
||||
folder = config.rootFolder;
|
||||
for (let folderName of path)
|
||||
folder = folder.folders[folderName]!;
|
||||
|
||||
// Move all apps and folders to the parent folder, then delete this one
|
||||
for (let appId of apps) {
|
||||
config.apps[appId]!.folder.pop();
|
||||
folder.apps.push(appId);
|
||||
}
|
||||
for (let subfolder of Object.keys(subfolders))
|
||||
folder.folders[subfolder] = subfolders[subfolder]!;
|
||||
delete folder.folders[toDelete];
|
||||
|
||||
// Mark as modified and go back
|
||||
changed = true;
|
||||
showFolderMenu(path);
|
||||
}
|
||||
|
||||
E.showMenu(menu);
|
||||
};
|
||||
|
||||
let save = () => {
|
||||
if (changed) {
|
||||
E.showMessage(/*LANG*/'Saving...');
|
||||
config.hash = 0; // Invalidate the cache so changes to hidden apps or folders actually get reflected
|
||||
loader.cleanAndSave(config);
|
||||
changed = false; // So we don't do it again on exit
|
||||
}
|
||||
};
|
||||
|
||||
E.on('kill', save);
|
||||
|
||||
let showMainMenu = () => {
|
||||
E.showMenu({
|
||||
'': {
|
||||
'title': 'Folder launcher',
|
||||
'back': () => {
|
||||
save();
|
||||
back();
|
||||
}
|
||||
},
|
||||
'Show clocks': {
|
||||
value: config.showClocks,
|
||||
format: value => (value ? 'Yes' : 'No'),
|
||||
onchange: value => {
|
||||
config.showClocks = value;
|
||||
changed = true;
|
||||
}
|
||||
},
|
||||
'Show launchers': {
|
||||
value: config.showLaunchers,
|
||||
format: value => (value ? 'Yes' : 'No'),
|
||||
onchange: value => {
|
||||
config.showLaunchers = value;
|
||||
changed = true;
|
||||
}
|
||||
},
|
||||
'Hidden apps': hiddenAppsMenu,
|
||||
'Display': () => {
|
||||
E.showMenu({
|
||||
'': {
|
||||
'title': 'Display',
|
||||
'back': showMainMenu
|
||||
},
|
||||
'Rows': {
|
||||
value: config.display.rows,
|
||||
min: 1,
|
||||
onchange: value => {
|
||||
config.display.rows = value;
|
||||
changed = true;
|
||||
}
|
||||
},
|
||||
'Show icons?': {
|
||||
value: config.display.icon,
|
||||
format: value => (value ? 'Yes' : 'No'),
|
||||
onchange: value => {
|
||||
config.display.icon = value;
|
||||
changed = true;
|
||||
}
|
||||
},
|
||||
'Font size': {
|
||||
value: config.display.font as any,
|
||||
min: 0,
|
||||
format: (value: any) => (value ? value : 'Icon only'),
|
||||
onchange: (value: any) => {
|
||||
config.display.font = value;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
'Timeout': {
|
||||
value: config.timeout,
|
||||
format: value => value ? `${value / 1000} sec` : 'None',
|
||||
min: 0,
|
||||
step: 1000,
|
||||
onchange: value => {
|
||||
config.timeout = value;
|
||||
changed = true;
|
||||
}
|
||||
},
|
||||
'Folder management': () => {
|
||||
showFolderMenu([]);
|
||||
}
|
||||
});
|
||||
};
|
||||
showMainMenu();
|
||||
} satisfies SettingsFunc);
|
|
@ -0,0 +1,34 @@
|
|||
type Folder = {
|
||||
folders: { // name: folder pairs of all nested folders
|
||||
[key: string]: Folder
|
||||
},
|
||||
apps: Array<string> // List of ids of all apps in this folder
|
||||
};
|
||||
|
||||
type FolderList = Array<string>;
|
||||
|
||||
type Config = {
|
||||
showClocks: boolean, // Whether clocks are shown
|
||||
showLaunchers: boolean, // Whether launchers are shown
|
||||
hidden: Array<String>, // IDs of apps to explicitly hide
|
||||
display: {
|
||||
rows: number, // Display an X by X grid of apps
|
||||
icon: boolean, // Whether to show icons
|
||||
font: number // Which font to use for the name, or false to not show the name
|
||||
},
|
||||
timeout: number, // How many ms before returning to the clock, or zero to never return
|
||||
rootFolder: Folder, // The top level folder, first displayed when opened
|
||||
apps: { // Saved info for each app
|
||||
[key: string]: {
|
||||
folder: FolderList, // Folder path
|
||||
fast: boolean, // Whether the app should be fast launched
|
||||
nagged: boolean // Whether the app's fast launch setting was configured
|
||||
}
|
||||
},
|
||||
hash: number // Hash of .info files
|
||||
};
|
||||
|
||||
type GridEntry = { // An entry in the grid displayed on-screen
|
||||
type: 'app' | 'folder' | 'empty', // Which type of item is in this space
|
||||
id: string // The id of that item
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
0.01: New widget - music control via a swipe
|
||||
0.02: Improve interactivity - avoid responding to swipes when a menu or
|
||||
launcher is active.
|
|
@ -13,6 +13,7 @@ Swipe down to enable - note the icon changes from blue to orange, indicating it'
|
|||
|
||||
All other watch interaction is disabled for 3 seconds, to prevent clashing taps/drags - this period is extended as you continue to alter the volume, play/pause and jump between tracks.
|
||||
|
||||
Requires espruino firmware > 2v17 to avoid event handler clashes.
|
||||
|
||||
# Setup / Technical details
|
||||
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
"id": "widhid",
|
||||
"name": "Bluetooth Music Swipe Control Widget",
|
||||
"shortName": "BLE Swipe Widget",
|
||||
"version": "0.01",
|
||||
"version": "0.02",
|
||||
"description": "Based on Swipe Bluetooth Music Controls (based on Bluetooth Music Controls). Swipe down to enable, then swipe up/down for volume, left/right for previous and next and tap for play/pause. Enable HID in settings, pair with your phone/computer, then use this widget to control music from your watch!",
|
||||
"icon": "icon.png",
|
||||
"readme": "README.md",
|
||||
|
|
|
@ -11,16 +11,13 @@
|
|||
var activeTimeout;
|
||||
var waitForRelease = true;
|
||||
var onSwipe = (function (_lr, ud) {
|
||||
if (Bangle.CLKINFO_FOCUS)
|
||||
return;
|
||||
if (!activeTimeout && ud > 0) {
|
||||
if (ud > 0 && !activeTimeout && !Bangle.CLKINFO_FOCUS) {
|
||||
listen();
|
||||
Bangle.buzz(20);
|
||||
}
|
||||
});
|
||||
var onDrag = (function (e) {
|
||||
if (Bangle.CLKINFO_FOCUS)
|
||||
return;
|
||||
E.stopEventPropagation && E.stopEventPropagation();
|
||||
if (e.b === 0) {
|
||||
var wasDragging = dragging;
|
||||
dragging = false;
|
||||
|
@ -82,9 +79,9 @@
|
|||
var listen = function () {
|
||||
var wasActive = !!activeTimeout;
|
||||
if (!wasActive) {
|
||||
suspendOthers();
|
||||
waitForRelease = true;
|
||||
Bangle.on("drag", onDrag);
|
||||
Bangle["#ondrag"] = [onDrag].concat(Bangle["#ondrag"].filter(function (f) { return f !== onDrag; }));
|
||||
redraw();
|
||||
}
|
||||
if (activeTimeout)
|
||||
|
@ -92,7 +89,6 @@
|
|||
activeTimeout = setTimeout(function () {
|
||||
activeTimeout = undefined;
|
||||
Bangle.removeListener("drag", onDrag);
|
||||
resumeOthers();
|
||||
redraw();
|
||||
}, 3000);
|
||||
};
|
||||
|
@ -131,46 +127,4 @@
|
|||
var toggle = function () { return sendHid(0x10); };
|
||||
var up = function () { return sendHid(0x40); };
|
||||
var down = function () { return sendHid(0x80); };
|
||||
var touchEvents = {
|
||||
tap: null,
|
||||
gesture: null,
|
||||
aiGesture: null,
|
||||
swipe: null,
|
||||
touch: null,
|
||||
drag: null,
|
||||
stroke: null,
|
||||
};
|
||||
var suspendOthers = function () {
|
||||
for (var event in touchEvents) {
|
||||
var handlers = Bangle["#on".concat(event)];
|
||||
if (!handlers)
|
||||
continue;
|
||||
var newEvents = void 0;
|
||||
if (handlers instanceof Array)
|
||||
newEvents = handlers.slice();
|
||||
else
|
||||
newEvents = [handlers];
|
||||
for (var _i = 0, newEvents_1 = newEvents; _i < newEvents_1.length; _i++) {
|
||||
var handler = newEvents_1[_i];
|
||||
Bangle.removeListener(event, handler);
|
||||
}
|
||||
touchEvents[event] = newEvents;
|
||||
}
|
||||
};
|
||||
var resumeOthers = function () {
|
||||
for (var event in touchEvents) {
|
||||
var handlers = touchEvents[event];
|
||||
touchEvents[event] = null;
|
||||
if (handlers)
|
||||
for (var _i = 0, handlers_1 = handlers; _i < handlers_1.length; _i++) {
|
||||
var handler = handlers_1[_i];
|
||||
try {
|
||||
Bangle.on(event, handler);
|
||||
}
|
||||
catch (e) {
|
||||
console.log("couldn't restore \"".concat(event, "\" handler:"), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
})();
|
||||
|
|
|
@ -14,16 +14,16 @@
|
|||
let waitForRelease = true;
|
||||
|
||||
const onSwipe = ((_lr, ud) => {
|
||||
if((Bangle as BangleExt).CLKINFO_FOCUS) return;
|
||||
|
||||
if(!activeTimeout && ud! > 0){
|
||||
// do these checks in order of cheapness
|
||||
if(ud! > 0 && !activeTimeout && !(Bangle as BangleExt).CLKINFO_FOCUS){
|
||||
listen();
|
||||
Bangle.buzz(20);
|
||||
}
|
||||
}) satisfies SwipeCallback;
|
||||
|
||||
const onDrag = (e => {
|
||||
if((Bangle as BangleExt).CLKINFO_FOCUS) return;
|
||||
// Espruino/35c8cb9be11
|
||||
(E as any).stopEventPropagation && (E as any).stopEventPropagation();
|
||||
|
||||
if(e.b === 0){
|
||||
// released
|
||||
|
@ -81,9 +81,14 @@
|
|||
const listen = () => {
|
||||
const wasActive = !!activeTimeout;
|
||||
if(!wasActive){
|
||||
suspendOthers();
|
||||
waitForRelease = true; // wait for first touch up before accepting gestures
|
||||
|
||||
Bangle.on("drag", onDrag);
|
||||
// move our drag to the start of the event listener array
|
||||
(Bangle as any)["#ondrag"] = [onDrag].concat(
|
||||
(Bangle as any)["#ondrag"].filter((f: unknown) => f !== onDrag)
|
||||
);
|
||||
|
||||
redraw();
|
||||
}
|
||||
|
||||
|
@ -92,7 +97,6 @@
|
|||
activeTimeout = undefined;
|
||||
|
||||
Bangle.removeListener("drag", onDrag);
|
||||
resumeOthers();
|
||||
|
||||
redraw();
|
||||
}, 3000);
|
||||
|
@ -147,53 +151,4 @@
|
|||
const toggle = () => /*DEBUG ? console.log("toggle") : */ sendHid(0x10);
|
||||
const up = () => /*DEBUG ? console.log("up") : */ sendHid(0x40);
|
||||
const down = () => /*DEBUG ? console.log("down") : */ sendHid(0x80);
|
||||
|
||||
// similarly to the lightswitch app, we tangle with the listener arrays to
|
||||
// disable event handlers
|
||||
type Handler = () => void;
|
||||
const touchEvents: {
|
||||
[key: string]: null | Handler[]
|
||||
} = {
|
||||
tap: null,
|
||||
gesture: null,
|
||||
aiGesture: null,
|
||||
swipe: null,
|
||||
touch: null,
|
||||
drag: null,
|
||||
stroke: null,
|
||||
};
|
||||
|
||||
const suspendOthers = () => {
|
||||
for(const event in touchEvents){
|
||||
const handlers: Handler[] | Handler | undefined
|
||||
= (Bangle as any)[`#on${event}`];
|
||||
|
||||
if(!handlers) continue;
|
||||
|
||||
let newEvents;
|
||||
if(handlers instanceof Array)
|
||||
newEvents = handlers.slice();
|
||||
else
|
||||
newEvents = [handlers /* single fn */];
|
||||
|
||||
for(const handler of newEvents)
|
||||
Bangle.removeListener(event, handler);
|
||||
|
||||
touchEvents[event] = newEvents;
|
||||
}
|
||||
};
|
||||
const resumeOthers = () => {
|
||||
for(const event in touchEvents){
|
||||
const handlers = touchEvents[event];
|
||||
touchEvents[event] = null;
|
||||
|
||||
if(handlers)
|
||||
for(const handler of handlers)
|
||||
try{
|
||||
Bangle.on(event as any, handler);
|
||||
}catch(e){
|
||||
console.log(`couldn't restore "${event}" handler:`, e);
|
||||
}
|
||||
}
|
||||
};
|
||||
})()
|
||||
|
|
|
@ -1,12 +1,17 @@
|
|||
type AppInfo = {
|
||||
src: string,
|
||||
img: string,
|
||||
icon: string,
|
||||
id: string,
|
||||
src?: string,
|
||||
img?: string,
|
||||
icon?: string,
|
||||
name: string,
|
||||
type: AppType,
|
||||
type?: AppType,
|
||||
version?: string,
|
||||
tags?: string,
|
||||
files: string,
|
||||
data?: string,
|
||||
sortorder?: number,
|
||||
};
|
||||
|
||||
type AppType = "app" | "clock" | "widget" | "module" | "bootloader" |
|
||||
"settings" | "clkinfo" | "RAM" | "launch" | "textinput" | "scheduler" |
|
||||
"notify" | "locale";
|
||||
"settings" | "clkinfo" | "RAM" | "launch" | "textinput" | "scheduler" |
|
||||
"notify" | "locale";
|
||||
|
|
Loading…
Reference in New Issue