mirror of https://github.com/espruino/BangleApps
New app: folderlaunch: Folder-based launcher
parent
01d4cea940
commit
be872e62dd
|
@ -0,0 +1 @@
|
|||
0.01: New app!
|
|
@ -0,0 +1,38 @@
|
|||
# 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.
|
||||
|
||||
The first time you launch an app, you will be asked if you want to fast load it, if the feature is enabled. Fast loading saves time by skipping much of the initialization between apps. Instead, the launcher just undoes the changes it made to the watch's state. However, fast loading can only be done between two apps that support widgets, so the decision to fast load has to be made for each app. The options are "Yes", "Not now", and "Never", so choose "Yes" if the app uses widgets, "Not now" if you don't remember, and "Never" if you know for sure it doesn't use widgets.
|
||||
|
||||
## 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.
|
||||
|
||||
* Prompt for fast launch: If yes, when launching an app that does not yet have a setting saved, ask whether it should be fast loaded. If no, already saved settings are still applied, but apps that have not been assigned a setting will be slow loaded. The default is yes.
|
||||
|
||||
* 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,235 @@
|
|||
{
|
||||
var loader_1 = 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_1.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 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 app_2 = config_1.apps[entry.id];
|
||||
var infoFile_1 = storage_1.readJSON(entry.id + '.info', false);
|
||||
if (app_2.fast)
|
||||
Bangle.load(infoFile_1.src);
|
||||
else if (config_1.fastNag && !app_2.nagged)
|
||||
E.showPrompt('Would you like to fast load?', {
|
||||
title: infoFile_1.name,
|
||||
buttons: {
|
||||
"Yes": 0,
|
||||
"Not now": 1,
|
||||
"Never": 2
|
||||
}
|
||||
}).then(function (value) {
|
||||
switch (value) {
|
||||
case 0:
|
||||
app_2.nagged = true;
|
||||
app_2.fast = true;
|
||||
loader_1.cleanAndSave(config_1);
|
||||
Bangle.load(infoFile_1.src);
|
||||
break;
|
||||
case 1:
|
||||
load(infoFile_1.src);
|
||||
break;
|
||||
default:
|
||||
app_2.nagged = true;
|
||||
loader_1.cleanAndSave(config_1);
|
||||
load(infoFile_1.src);
|
||||
break;
|
||||
}
|
||||
});
|
||||
else
|
||||
load(infoFile_1.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,299 @@
|
|||
{
|
||||
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: AppInfoFile = 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 app = config.apps[entry.id]!;
|
||||
let infoFile = storage.readJSON(entry.id + '.info', false);
|
||||
if (app.fast) Bangle.load(infoFile.src);
|
||||
else if (config.fastNag && !app.nagged)
|
||||
E.showPrompt(/*LANG*/ 'Would you like to fast load?', {
|
||||
title: infoFile.name,
|
||||
buttons: {
|
||||
"Yes": 0,
|
||||
"Not now": 1,
|
||||
"Never": 2
|
||||
}
|
||||
}).then((value: number) => {
|
||||
switch (value) {
|
||||
case 0:
|
||||
app.nagged = true;
|
||||
app.fast = true;
|
||||
loader.cleanAndSave(config);
|
||||
Bangle.load(infoFile.src);
|
||||
break;
|
||||
case 1:
|
||||
load(infoFile.src);
|
||||
break;
|
||||
default:
|
||||
app.nagged = true;
|
||||
loader.cleanAndSave(config);
|
||||
load(infoFile.src);
|
||||
break;
|
||||
}
|
||||
});
|
||||
else 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,107 @@
|
|||
var storage = require("Storage");
|
||||
var SETTINGS_FILE = "folderlaunch.json";
|
||||
var DEFAULT_CONFIG = {
|
||||
showClocks: false,
|
||||
showLaunchers: false,
|
||||
hidden: [],
|
||||
display: {
|
||||
rows: 2,
|
||||
icon: true,
|
||||
font: 12
|
||||
},
|
||||
fastNag: true,
|
||||
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: [],
|
||||
fast: false,
|
||||
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,158 @@
|
|||
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
|
||||
},
|
||||
fastNag: true,
|
||||
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: AppInfoFile = storage.readJSON(a, false);
|
||||
let bJson: AppInfoFile = 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: AppInfoFile = 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: [],
|
||||
fast: false,
|
||||
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,257 @@
|
|||
(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;
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
'Prompt for fast launch': {
|
||||
value: config.fastNag,
|
||||
format: function (value) { return (value ? 'Yes' : 'No'); },
|
||||
onchange: function (value) {
|
||||
config.fastNag = value;
|
||||
changed = true;
|
||||
}
|
||||
},
|
||||
'Timeout': {
|
||||
value: config.timeout,
|
||||
format: function (value) { return "".concat(value / 1000, " sec"); },
|
||||
min: 0,
|
||||
step: 1000,
|
||||
onchange: function (value) {
|
||||
config.timeout = value;
|
||||
changed = true;
|
||||
}
|
||||
},
|
||||
'Folder management': function () {
|
||||
showFolderMenu([]);
|
||||
}
|
||||
});
|
||||
};
|
||||
showMainMenu();
|
||||
});
|
|
@ -0,0 +1,272 @@
|
|||
(function (back: Function) {
|
||||
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: AppInfoFile = 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): AppInfoFile => {
|
||||
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;
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
'Prompt for fast launch': {
|
||||
value: config.fastNag,
|
||||
format: value => (value ? 'Yes' : 'No'),
|
||||
onchange: value => {
|
||||
config.fastNag = 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();
|
||||
});
|
|
@ -0,0 +1,48 @@
|
|||
type AppInfoFile = { // Contents of a .info file
|
||||
id: string,
|
||||
name: string,
|
||||
type?: string,
|
||||
src?: string,
|
||||
icon: string,
|
||||
version: string,
|
||||
tags: string,
|
||||
files: string,
|
||||
data: string,
|
||||
sortorder?: number
|
||||
};
|
||||
|
||||
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
|
||||
},
|
||||
fastNag: boolean, // Ask whether new apps should be fast-loaded the first time they are launched
|
||||
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
|
||||
}
|
Loading…
Reference in New Issue