New app: folderlaunch: Folder-based launcher

pull/2752/head
Bruce Blore 2023-05-12 15:52:45 -07:00
parent 01d4cea940
commit be872e62dd
14 changed files with 1464 additions and 0 deletions

View File

@ -0,0 +1 @@
0.01: New app!

View File

@ -0,0 +1,38 @@
# Folder launcher
Launcher that allows you to put your apps into folders
![](screenshot1.png)
![](screenshot2.png)
## 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.

235
apps/folderlaunch/app.js Normal file
View File

@ -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();
}

299
apps/folderlaunch/app.ts Normal file
View File

@ -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();
}

View File

@ -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);
}
};

View File

@ -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);
}
}

View File

@ -0,0 +1 @@
require("heatshrink").decompress(atob("mEwwMA///wAJCAoPAAongAonwAon4Aon8Aon+Aon/AooA/AH4A/AFgA="))

BIN
apps/folderlaunch/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 234 B

View File

@ -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

View File

@ -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();
});

View File

@ -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();
});

48
apps/folderlaunch/types.d.ts vendored Normal file
View File

@ -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
}