feat: move Settings out of Files into its own searchable app with categories

This commit is contained in:
daniel committed 2026-10-24 13:26:47 +02:00
1 parent d3e49a925d
commit 8de98f3ab3
33 files changed
+1183 -93

No files matched your search

+9 -4
View File
@@ -49,7 +49,7 @@ BINDIR := bin
PROGRAMS := $(notdir $(wildcard src/*))
# Programs with custom Makefiles (built separately).
CUSTOM_BUILDS := 2048 files fetch wiki wikipedia weather imageviewer fontpreview spreadsheet wordprocessor pdfviewer disks devexplorer installer audio music video bluetooth network display capabilities keyboard sshserver terminal syslog procmgr powermgr calculator charmap desktop login shell paint tcc lua screenshot texteditor mandelbrot printers timezone printd printctl dialogs libloader crashpad sshd rtlsdr sdr
CUSTOM_BUILDS := 2048 files settings fetch wiki wikipedia weather imageviewer fontpreview spreadsheet wordprocessor pdfviewer disks devexplorer installer audio music video bluetooth network display capabilities keyboard sshserver terminal syslog procmgr powermgr calculator charmap desktop login shell paint tcc lua screenshot texteditor mandelbrot printers timezone printd printctl dialogs libloader crashpad sshd rtlsdr sdr
SYSTEM_PROGRAMS := $(filter-out $(CUSTOM_BUILDS),$(PROGRAMS))
# Build targets: system programs go to bin/os/, apps go to bin/apps/<name>/.
@@ -96,9 +96,9 @@ WPDIR := data/wallpapers
WPSRC := $(wildcard $(WPDIR)/*.jpg)
WPDST := $(patsubst $(WPDIR)/%,$(BINDIR)/os/wallpapers/%,$(WPSRC))
.PHONY: all clean 2048 files fetch wiki wikipedia weather imageviewer fontpreview spreadsheet wordprocessor pdfviewer disks devexplorer installer audio music video bluetooth network display capabilities keyboard sshserver sshd terminal syslog procmgr powermgr calculator charmap login desktop shell paint tcc lua screenshot texteditor mandelbrot printers timezone printd printctl dialogs icons fonts configs osdata bearssl libc tls libjpeg libjpegwrite install-apps libloader crashpad rtlsdr sdr check-syscalls gen-syscalls rust
.PHONY: all clean 2048 files settings fetch wiki wikipedia weather imageviewer fontpreview spreadsheet wordprocessor pdfviewer disks devexplorer installer audio music video bluetooth network display capabilities keyboard sshserver sshd terminal syslog procmgr powermgr calculator charmap login desktop shell paint tcc lua screenshot texteditor mandelbrot printers timezone printd printctl dialogs icons fonts configs osdata bearssl libc tls libjpeg libjpegwrite install-apps libloader crashpad rtlsdr sdr check-syscalls gen-syscalls rust
all: bearssl libc libjpeg libjpegwrite tls libloader rtlsdr sdr devkit $(TARGETS) files fetch wiki wikipedia weather imageviewer fontpreview spreadsheet wordprocessor pdfviewer disks devexplorer installer audio music video bluetooth network display capabilities sshserver terminal syslog procmgr powermgr calculator charmap 2048 paint tcc lua screenshot texteditor mandelbrot printers timezone printd printctl dialogs login desktop shell sshd icons fonts install-apps crashpad rust $(MANDST) $(WWWDST) $(CA_CERTS) $(CONFIGDST) $(OSDATADST) $(FWDST) $(LICDST) $(WPDST)
all: bearssl libc libjpeg libjpegwrite tls libloader rtlsdr sdr devkit $(TARGETS) files settings fetch wiki wikipedia weather imageviewer fontpreview spreadsheet wordprocessor pdfviewer disks devexplorer installer audio music video bluetooth network display capabilities sshserver terminal syslog procmgr powermgr calculator charmap 2048 paint tcc lua screenshot texteditor mandelbrot printers timezone printd printctl dialogs login desktop shell sshd icons fonts install-apps crashpad rust $(MANDST) $(WWWDST) $(CA_CERTS) $(CONFIGDST) $(OSDATADST) $(FWDST) $(LICDST) $(WPDST)
# Build BearSSL static library (cross-compiled for freestanding x86_64).
BEARSSL_INCLUDES := -isystem $(shell cd .. && pwd)/kernel/freestnd-c-hdrs/x86_64/include -isystem $(abspath include/libc)
@@ -233,6 +233,10 @@ capabilities: libc
keyboard: libc
$(MAKE) -C src/keyboard
# Build the Settings app (depends on libc, bearssl for user.h).
settings: libc bearssl
$(MAKE) -C src/settings
# Build SSH Server settings applet.
sshserver: bearssl libc
$(MAKE) -C src/sshserver
@@ -395,7 +399,7 @@ crashpad: libc
$(MAKE) -C src/crashpad
# Install app bundles (manifests, icons, data files) into bin/apps/<name>/.
install-apps: 2048 files paint spreadsheet wordprocessor weather wikipedia imageviewer fontpreview pdfviewer disks devexplorer installer audio music video bluetooth network display capabilities keyboard sshserver terminal syslog procmgr powermgr calculator charmap screenshot texteditor mandelbrot printers timezone crashpad
install-apps: 2048 files settings paint spreadsheet wordprocessor weather wikipedia imageviewer fontpreview pdfviewer disks devexplorer installer audio music video bluetooth network display capabilities keyboard sshserver terminal syslog procmgr powermgr calculator charmap screenshot texteditor mandelbrot printers timezone crashpad
../scripts/install_apps.sh
# Copy man pages into bin/os/man/ so mkramdisk.sh picks them up.
@@ -547,6 +551,7 @@ clean:
$(MAKE) -C src/bluetooth clean
$(MAKE) -C src/network clean
$(MAKE) -C src/keyboard clean
$(MAKE) -C src/settings clean
$(MAKE) -C src/terminal clean
$(MAKE) -C src/syslog clean
$(MAKE) -C src/procmgr clean
+74 -3
View File
@@ -1,7 +1,9 @@
/*
* app_catalog.hpp
* The app catalog shared by the desktop and Files: installed app manifests
* (0:/apps/<dir>/manifest.toml) plus the desktop's built-in settings pages
* (0:/apps/<dir>/manifest.toml) plus the desktop's built-in settings pages,
* and the category, description and search keywords the Settings app
* shows for each settings page
* Copyright (c) 2026 Daniel Hammer
*/
@@ -17,24 +19,61 @@
namespace gui {
// Settings app categories, in display order. A manifest may name any other
// [settings] category; the Settings app lists those after these, by name.
inline constexpr const char* kSettingsCategories[] = {
"Personalization", "Network", "Devices", "Time & Language",
"Accounts", "Privacy & Security", "System",
};
inline constexpr const char* kSettingsDefaultCategory = "System";
// Built-in settings pages the desktop implements itself. Other processes
// list them by id and open them with a WIN_REQ_LAUNCH_ITEM request.
struct CatalogBuiltinPage {
const char* id;
const char* label;
const char* icon_path;
const char* category;
const char* description;
const char* keywords;
};
inline constexpr CatalogBuiltinPage kCatalogDesktopSettings = {
"desktop-settings", "Desktop Settings", "0:/os/icons/preferences-desktop-apps.svg"
"desktop-settings", "Desktop Settings", "0:/os/icons/preferences-desktop-apps.svg",
"Personalization", "Wallpaper, accent color, panel and clock",
"background wallpaper image accent color colour theme appearance panel "
"shadows clock 24-hour scale"
};
inline constexpr CatalogBuiltinPage kCatalogUserManagement = {
"user-management", "User Management", "0:/os/icons/user-home.svg"
"user-management", "User Management", "0:/os/icons/user-home.svg",
"Accounts", "Add, remove and edit user accounts",
"users accounts password login admin administrator role display name"
};
inline constexpr const CatalogBuiltinPage* kCatalogBuiltinPages[] = {
&kCatalogDesktopSettings, &kCatalogUserManagement,
};
inline const CatalogBuiltinPage* app_catalog_builtin_page(const char* id) {
for (const CatalogBuiltinPage* page : kCatalogBuiltinPages) {
if (id && montauk::streq(page->id, id)) return page;
}
return nullptr;
}
// Copy a built-in page's Settings app metadata onto its catalog item.
inline void app_catalog_apply_page_info(DesktopItem* item, const CatalogBuiltinPage* page) {
if (!item || !page) return;
montauk::strncpy(item->settings_category, page->category, sizeof(item->settings_category));
montauk::strncpy(item->description, page->description, sizeof(item->description));
montauk::strncpy(item->keywords, page->keywords, sizeof(item->keywords));
}
// True if the Settings app lists the item: every settings-section item, plus
// any other app whose manifest names a [settings] category.
inline bool app_catalog_in_settings(const DesktopItem& item) {
return item.section == DESKTOP_ITEM_SECTION_SETTINGS || item.settings_category[0];
}
// out = a + b + c (+ d + e), truncated to out_max.
inline void app_catalog_join(char* out, int out_max, const char* a, const char* b,
const char* c, const char* d = "", const char* e = "") {
@@ -137,6 +176,11 @@ inline void app_catalog_add_manifest_apps(DesktopState* ds) {
bool launch_with_home = doc.get_bool("launch.pass_home_dir", true);
const char* section_name = doc.get_string("desktop.section", "apps");
bool admin_only = doc.get_bool("desktop.admin_only", false);
// How the Settings app files and finds the item (see
// app_catalog_in_settings).
const char* settings_category = doc.get_string("settings.category", "");
const char* description = doc.get_string("settings.description", "");
const char* keywords = doc.get_string("settings.keywords", "");
if (id[0] == '\0' || binary[0] == '\0') {
montauk::print("app catalog: skipping app manifest with missing id or binary\n");
doc.destroy();
@@ -183,6 +227,12 @@ inline void app_catalog_add_manifest_apps(DesktopState* ds) {
item->launch_kind = DESKTOP_ITEM_LAUNCH_EXECUTABLE;
item->builtin_id = -1;
item->section = section;
if (!settings_category[0] && section == DESKTOP_ITEM_SECTION_SETTINGS)
settings_category = kSettingsDefaultCategory;
montauk::strncpy(item->settings_category, settings_category,
sizeof(item->settings_category));
montauk::strncpy(item->description, description, sizeof(item->description));
montauk::strncpy(item->keywords, keywords, sizeof(item->keywords));
// Build full binary path: 0:/apps/<dir>/<binary>
app_catalog_join(item->binary_path, sizeof(item->binary_path),
@@ -225,6 +275,27 @@ inline void app_catalog_add_manifest_apps(DesktopState* ds) {
}
}
// The built-in settings pages as catalog items, for processes other than the
// desktop (which builds its own from its registry). icon_size 0 skips
// loading icons.
inline void app_catalog_add_builtin_pages(DesktopState* ds, int icon_size) {
for (const CatalogBuiltinPage* page : kCatalogBuiltinPages) {
DesktopItem* item = app_catalog_next_slot(ds);
if (!item) return;
montauk::memset(item, 0, sizeof(*item));
montauk::strncpy(item->id, page->id, sizeof(item->id));
montauk::strncpy(item->name, page->label, sizeof(item->name));
montauk::strncpy(item->icon_path, page->icon_path, sizeof(item->icon_path));
if (icon_size > 0)
item->icon = svg_load(page->icon_path, icon_size, icon_size, colors::ICON_COLOR);
item->section = DESKTOP_ITEM_SECTION_SETTINGS;
item->launch_kind = DESKTOP_ITEM_LAUNCH_BUILTIN;
item->builtin_id = -1;
app_catalog_apply_page_info(item, page);
ds->desktop_item_count++;
}
}
inline int app_catalog_compare_ci(const char* a, const char* b) {
while (*a && *b) {
char ca = (*a >= 'A' && *a <= 'Z') ? (*a + 32) : *a;
+6 -2
View File
@@ -49,6 +49,12 @@ struct DesktopItem {
// File extensions the app opens (manifest files.types): lowercase, no dot,
// space-separated with a leading and trailing space, e.g. " jpg jpeg ".
char file_types[160];
// Settings app metadata (manifest [settings]): the category it is filed
// under ("" = not listed unless in the settings section), a one-line
// description and extra search words.
char settings_category[24];
char description[64];
char keywords[128];
};
enum LauncherItemKind : uint8_t {
@@ -225,8 +231,6 @@ struct DesktopState {
SvgIcon icon_apps;
SvgIcon icon_apps_lg;
SvgIcon icon_system_configuration_menu;
SvgIcon icon_system_configuration;
SvgIcon icon_system_configuration_lg;
// Special user folder icons (16x16 and 48x48)
static constexpr int SPECIAL_FOLDER_COUNT = gui::SPECIAL_FOLDER_COUNT;
+6
View File
@@ -6,3 +6,9 @@ icon = "pavucontrol.svg"
[menu]
category = "Hardware"
visible = true
# Where the Settings app lists this page and what its search matches.
[settings]
category = "Devices"
description = "Volume, output device and app levels"
keywords = "sound audio volume mute speakers headphones output mixer hda"
+6
View File
@@ -6,3 +6,9 @@ icon = "bluetooth.svg"
[menu]
category = "Hardware"
visible = true
# Where the Settings app lists this page and what its search matches.
[settings]
category = "Devices"
description = "Pair and connect Bluetooth devices"
keywords = "bluetooth pair pairing headphones wireless devices mac address adapter"
+6
View File
@@ -11,3 +11,9 @@ visible = false
[desktop]
section = "settings"
admin_only = true
# Where the Settings app lists this page and what its search matches.
[settings]
category = "Privacy & Security"
description = "Program permissions and consent"
keywords = "capabilities permissions privacy security consent grants access allow deny"
@@ -49,8 +49,12 @@ void open_filemanager_path(DesktopState* ds, const char* path) {
spawn_app(ds, FILES_APP_PATH, path);
}
// ============================================================================
// Settings (0:/apps/settings/settings.elf, its own process)
// ============================================================================
void open_system_configuration(DesktopState* ds) {
spawn_app(ds, FILES_APP_PATH, "virtual://settings");
spawn_app(ds, "0:/apps/settings/settings.elf");
}
// Icons the launcher and app menu show for home folders and the Settings
+1 -1
View File
@@ -216,7 +216,7 @@ static void desktop_build_launcher_items(DesktopState* ds) {
: item->category;
launcher_add_item(ds, LAUNCHER_ITEM_EXTERNAL_APP,
item->name, category,
icon, i, nullptr, nullptr);
icon, i, nullptr, item->keywords);
}
launcher_add_item(ds, LAUNCHER_ITEM_COMMAND_REBOOT,
+2
View File
@@ -34,6 +34,8 @@ static void desktop_add_builtin_catalog_items(DesktopState* ds) {
item->section = DESKTOP_ITEM_SECTION_SETTINGS;
item->launch_kind = DESKTOP_ITEM_LAUNCH_BUILTIN;
item->builtin_id = (int)builtins[i].id;
gui::app_catalog_apply_page_info(
item, gui::app_catalog_builtin_page(builtins[i].system_configuration_id));
ds->desktop_item_count++;
}
}
+6
View File
@@ -11,3 +11,9 @@ visible = false
[desktop]
section = "settings"
admin_only = false
# Where the Settings app lists this page and what its search matches.
[settings]
category = "Devices"
description = "Resolution, display modes and brightness"
keywords = "screen monitor resolution refresh rate mode edid brightness backlight graphics gpu intel vblank"
+1 -1
View File
@@ -476,7 +476,7 @@ void filemanager_open_ctx_menu(FileManagerState* fm, int local_x, int local_y, i
fm->ctx_items[fm->ctx_item_count++] = CTX_PROPERTIES;
} else if ((filemanager_is_computer_view(fm)
&& (tt == FM_ENTRY_DRIVE || tt == FM_ENTRY_HOME || tt == FM_ENTRY_APPS_ROOT
|| tt == FM_ENTRY_SPECIAL_DIR || tt == FM_ENTRY_SYSTEM_CONFIGURATION_ROOT))
|| tt == FM_ENTRY_SPECIAL_DIR))
|| (filemanager_is_virtual_view(fm) && tt == FM_ENTRY_VIRTUAL_ITEM)) {
// Drive, Home, Apps shortcut, or app entry: only Open
fm->ctx_items[fm->ctx_item_count++] = CTX_OPEN;
-14
View File
@@ -9,9 +9,6 @@
namespace filemanager {
static constexpr const char* FM_PATH_APPS = "virtual://apps";
static constexpr const char* FM_PATH_SETTINGS = "virtual://settings";
static constexpr const char* FM_PATH_LEGACY_SYSTEM_CONFIGURATION =
"virtual://system-configuration";
static bool filemanager_open_virtual_path(FileManagerState* fm, const char* path) {
if (!fm || !path) return false;
@@ -20,11 +17,6 @@ static bool filemanager_open_virtual_path(FileManagerState* fm, const char* path
filemanager_read_virtual_view(fm, FM_VIRTUAL_VIEW_APPS);
return true;
}
if (montauk::streq(path, FM_PATH_SETTINGS)
|| montauk::streq(path, FM_PATH_LEGACY_SYSTEM_CONFIGURATION)) {
filemanager_read_virtual_view(fm, FM_VIRTUAL_VIEW_SYSTEM_CONFIGURATION);
return true;
}
return false;
}
@@ -101,12 +93,6 @@ void ensure_filemanager_icons_loaded(DesktopState* ds) {
ds->icon_trash_full_lg = svg_load("0:/os/icons/user-trash-full.svg", 48, 48, defColor);
ds->icon_apps = svg_load("0:/os/icons/folder-blue-development.svg", 16, 16, defColor);
ds->icon_apps_lg = svg_load("0:/os/icons/folder-blue-development.svg", 48, 48, defColor);
ds->icon_system_configuration_menu =
svg_load("0:/os/icons/preferences-system.svg", 20, 20, defColor);
ds->icon_system_configuration =
svg_load("0:/os/icons/preferences-system.svg", 16, 16, defColor);
ds->icon_system_configuration_lg =
svg_load("0:/os/icons/preferences-system.svg", 48, 48, defColor);
for (int sf = 0; sf < filemanager::SF_COUNT; sf++) {
char icon_path[128];
+2 -6
View File
@@ -27,14 +27,12 @@ enum FileManagerEntryType : int {
FM_ENTRY_APPS_ROOT = 5,
FM_ENTRY_VIRTUAL_ITEM = 6,
FM_ENTRY_SPECIAL_DIR = 7,
FM_ENTRY_SYSTEM_CONFIGURATION_ROOT = 8,
FM_ENTRY_TRASH = 9,
};
enum FileManagerVirtualViewKind : uint8_t {
FM_VIRTUAL_VIEW_NONE = 0,
FM_VIRTUAL_VIEW_APPS,
FM_VIRTUAL_VIEW_SYSTEM_CONFIGURATION,
};
struct FileManagerLocation {
@@ -361,7 +359,6 @@ inline bool filemanager_is_computer_view(const FileManagerState* fm) {
inline const char* filemanager_virtual_view_label(FileManagerVirtualViewKind view) {
switch (view) {
case FM_VIRTUAL_VIEW_APPS: return "Apps";
case FM_VIRTUAL_VIEW_SYSTEM_CONFIGURATION: return "Settings";
default: return "Computer";
}
}
@@ -457,7 +454,6 @@ void filemanager_set_sort(FileManagerState* fm, FileManagerSortKey key);
void filemanager_free_app_icons(FileManagerState* fm);
void filemanager_read_virtual_view(FileManagerState* fm, FileManagerVirtualViewKind view);
void filemanager_read_apps(FileManagerState* fm);
void filemanager_read_system_configuration(FileManagerState* fm);
// Map a raw syscall result (kernel StatusCodes are negative) to a userspace
// montauk::StatusCodes. Non-negative results (handles, counts) are Success.
@@ -758,6 +754,6 @@ void filemanager_on_poll(Window* win);
// Loads the icons Files draws beyond the host's base set (app.cpp).
void ensure_filemanager_icons_loaded(DesktopState* ds);
// Open a Files window on `path` (a folder, virtual://apps or
// virtual://settings), or on Computer when path is null.
// Open a Files window on `path` (a folder or virtual://apps), or on
// Computer when path is null.
void open_filemanager_path(DesktopState* ds, const char* path);
-13
View File
@@ -231,9 +231,6 @@ void filemanager_read_drives(FileManagerState* fm) {
}
}
// Settings entry
filemanager_add_root_entry(fm, "Settings", FM_ENTRY_SYSTEM_CONFIGURATION_ROOT, -1);
// Trash; drive_indices carries "has items" so the icon shows full/empty.
if (ds && ds->home_dir[0] != '\0') {
filemanager_add_root_entry(fm, "Trash", FM_ENTRY_TRASH,
@@ -505,9 +502,6 @@ void filemanager_free_app_icons(FileManagerState* fm) {
void filemanager_read_virtual_view(FileManagerState* fm, FileManagerVirtualViewKind view) {
switch (view) {
case FM_VIRTUAL_VIEW_APPS: filemanager_read_apps(fm); break;
case FM_VIRTUAL_VIEW_SYSTEM_CONFIGURATION:
filemanager_read_system_configuration(fm);
break;
default: filemanager_read_drives(fm); break;
}
}
@@ -519,13 +513,6 @@ void filemanager_read_apps(FileManagerState* fm) {
filemanager_reset_list_state(fm);
}
void filemanager_read_system_configuration(FileManagerState* fm) {
filemanager_begin_virtual_view(fm, FM_VIRTUAL_VIEW_SYSTEM_CONFIGURATION);
filemanager_add_catalog_section(fm, DESKTOP_ITEM_SECTION_SETTINGS);
filemanager_reset_list_state(fm);
}
// Read ALL direct children of `dir` into a freshly-allocated array of basenames
// (each up to 255 chars, directory entries keep their trailing '/'). Pages the
// directory fully so there is no fixed entry cap. Returns the child count and
+1 -4
View File
@@ -253,11 +253,8 @@ void filemanager_type_label(const FileManagerState* fm, int idx, char* out, int
if (!out || out_max <= 0) return;
const char* fixed = nullptr;
switch (fm->entry_types[idx]) {
case FM_ENTRY_VIRTUAL_ITEM:
fixed = fm->virtual_view == FM_VIRTUAL_VIEW_SYSTEM_CONFIGURATION ? "Setting" : "App";
break;
case FM_ENTRY_VIRTUAL_ITEM: fixed = "App"; break;
case FM_ENTRY_APPS_ROOT: fixed = "Apps"; break;
case FM_ENTRY_SYSTEM_CONFIGURATION_ROOT: fixed = "Settings"; break;
case FM_ENTRY_HOME: fixed = "Home"; break;
case FM_ENTRY_DRIVE: fixed = "Drive"; break;
case FM_ENTRY_TRASH: fixed = "Trash"; break;
+4 -17
View File
@@ -148,22 +148,9 @@ int files_host::find_item_by_binary(DesktopState* ds, const char* path) {
return -1;
}
// Settings pages the desktop draws itself, then every installed app.
// Every installed app (Apps view, default apps and "Open With"). Settings
// pages live in the Settings app, not here.
static void host_load_catalog(DesktopState* ds) {
Color def = colors::ICON_COLOR;
for (const CatalogBuiltinPage* page : kCatalogBuiltinPages) {
DesktopItem* item = app_catalog_next_slot(ds);
if (!item) return;
montauk::memset(item, 0, sizeof(*item));
montauk::strncpy(item->id, page->id, sizeof(item->id));
montauk::strncpy(item->name, page->label, sizeof(item->name));
montauk::strncpy(item->icon_path, page->icon_path, sizeof(item->icon_path));
item->icon = svg_load(page->icon_path, 20, 20, def);
item->section = DESKTOP_ITEM_SECTION_SETTINGS;
item->launch_kind = DESKTOP_ITEM_LAUNCH_BUILTIN;
item->builtin_id = -1;
ds->desktop_item_count++;
}
app_catalog_add_manifest_apps(ds);
}
@@ -402,8 +389,8 @@ static bool starts_with(const char* s, const char* prefix) {
}
extern "C" void _start() {
// Argument: a folder path, virtual://apps or virtual://settings, or one
// of the dialog-only modes (--transfer, --open-with).
// Argument: a folder path or virtual://apps, or one of the dialog-only
// modes (--transfer, --open-with).
char path[512] = {};
montauk::getargs(path, sizeof(path));
-7
View File
@@ -254,13 +254,6 @@ void filemanager_open_entry(FileManagerState* fm, int idx) {
return;
}
if (filemanager_is_computer_view(fm)
&& fm->entry_types[idx] == FM_ENTRY_SYSTEM_CONFIGURATION_ROOT) {
filemanager_read_virtual_view(fm, FM_VIRTUAL_VIEW_SYSTEM_CONFIGURATION);
filemanager_push_history(fm);
return;
}
if (filemanager_is_virtual_view(fm) && fm->entry_types[idx] == FM_ENTRY_VIRTUAL_ITEM) {
DesktopState* ds = fm->desktop;
const FileManagerVirtualEntry& entry = fm->virtual_entries[idx];
+2 -12
View File
@@ -126,11 +126,7 @@ static const char* props_type_label(const FileManagerState* fm, int idx) {
case FM_ENTRY_HOME: return "Home Folder";
case FM_ENTRY_APPS_ROOT: return "Apps Folder";
case FM_ENTRY_SPECIAL_DIR: return "Folder";
case FM_ENTRY_SYSTEM_CONFIGURATION_ROOT: return "Settings";
case FM_ENTRY_VIRTUAL_ITEM:
if (fm->virtual_view == FM_VIRTUAL_VIEW_SYSTEM_CONFIGURATION)
return "Setting";
return "Application";
case FM_ENTRY_VIRTUAL_ITEM: return "Application";
case FM_ENTRY_FILE:
default:
return fm->is_dir[idx] ? "Folder" : "File";
@@ -175,8 +171,6 @@ static void props_build_path(const FileManagerState* fm, int idx, char* out, int
props_drive_path(fm->drive_indices[idx], out, out_len);
} else if (type == FM_ENTRY_APPS_ROOT) {
props_copy(out, out_len, "virtual://apps");
} else if (type == FM_ENTRY_SYSTEM_CONFIGURATION_ROOT) {
props_copy(out, out_len, "virtual://settings");
}
return;
}
@@ -218,8 +212,7 @@ static void props_choose_icon(FilePropertiesDialogState* st,
int type = fm->entry_types[idx];
st->fallback_is_dir = fm->is_dir[idx] || type == FM_ENTRY_DIR ||
type == FM_ENTRY_SPECIAL_DIR || type == FM_ENTRY_HOME ||
type == FM_ENTRY_APPS_ROOT || type == FM_ENTRY_DRIVE ||
type == FM_ENTRY_SYSTEM_CONFIGURATION_ROOT;
type == FM_ENTRY_APPS_ROOT || type == FM_ENTRY_DRIVE;
int sfi = -1;
if (type == FM_ENTRY_SPECIAL_DIR) sfi = fm->drive_indices[idx];
@@ -234,9 +227,6 @@ static void props_choose_icon(FilePropertiesDialogState* st,
props_load_owned_icon(
st, ds->desktop_items[entry.desktop_item_index].icon_path);
}
} else if (ds && type == FM_ENTRY_SYSTEM_CONFIGURATION_ROOT &&
ds->icon_system_configuration_lg.pixels) {
props_set_shared_icon(st, &ds->icon_system_configuration_lg);
} else if (ds && type == FM_ENTRY_APPS_ROOT && ds->icon_apps_lg.pixels) {
props_set_shared_icon(st, &ds->icon_apps_lg);
} else if (ds && type == FM_ENTRY_HOME && ds->icon_home_folder_lg.pixels) {
-6
View File
@@ -417,9 +417,6 @@ void filemanager_on_draw(Window* win, Framebuffer& fb) {
&& i < fm->virtual_entry_count
&& fm->virtual_entries[i].icon_lg.pixels) {
c.icon(icon_x, icon_y, fm->virtual_entries[i].icon_lg);
} else if (ds && fm->entry_types[i] == FM_ENTRY_SYSTEM_CONFIGURATION_ROOT
&& ds->icon_system_configuration_lg.pixels) {
c.icon(icon_x, icon_y, ds->icon_system_configuration_lg);
} else if (ds && fm->entry_types[i] == FM_ENTRY_APPS_ROOT && ds->icon_apps_lg.pixels) {
c.icon(icon_x, icon_y, ds->icon_apps_lg);
} else if (ds && fm->entry_types[i] == FM_ENTRY_HOME && ds->icon_home_folder_lg.pixels) {
@@ -555,9 +552,6 @@ void filemanager_on_draw(Window* win, Framebuffer& fb) {
&& i < fm->virtual_entry_count
&& fm->virtual_entries[i].icon_sm.pixels) {
c.icon(ico_x, ico_y, fm->virtual_entries[i].icon_sm);
} else if (ds && fm->entry_types[i] == FM_ENTRY_SYSTEM_CONFIGURATION_ROOT
&& ds->icon_system_configuration.pixels) {
c.icon(ico_x, ico_y, ds->icon_system_configuration);
} else if (ds && fm->entry_types[i] == FM_ENTRY_APPS_ROOT && ds->icon_apps.pixels) {
c.icon(ico_x, ico_y, ds->icon_apps);
} else if (ds && fm->entry_types[i] == FM_ENTRY_HOME && ds->icon_home_folder.pixels) {
+6
View File
@@ -11,3 +11,9 @@ visible = false
[desktop]
section = "settings"
admin_only = false
# Where the Settings app lists this page and what its search matches.
[settings]
category = "Time & Language"
description = "Keyboard layouts and key map"
keywords = "keyboard layout language input keys keymap dead keys czech norwegian"
+6
View File
@@ -11,3 +11,9 @@ visible = false
[desktop]
section = "settings"
admin_only = false
# Where the Settings app lists this page and what its search matches.
[settings]
category = "Network"
description = "Wired and Wi-Fi connections, IP and DNS"
keywords = "internet ethernet wifi wi-fi wireless wlan ip address dhcp dns gateway subnet adapter"
+6
View File
@@ -9,3 +9,9 @@ visible = true
[launch]
pass_home_dir = false
# Where the Settings app lists this page and what its search matches.
[settings]
category = "System"
description = "CPU power, frequency and temperature"
keywords = "power cpu frequency governor thermal temperature hwp energy performance battery"
+6
View File
@@ -11,3 +11,9 @@ visible = true
[desktop]
section = "settings"
admin_only = false
# Where the Settings app lists this page and what its search matches.
[settings]
category = "Devices"
description = "Printers, print queue and test page"
keywords = "printer printing print queue spooler ipp test page"
+47
View File
@@ -0,0 +1,47 @@
# Makefile for the Settings app on MontaukOS
MAKEFLAGS += -rR
.SUFFIXES:
TOOLCHAIN_PREFIX := $(shell cd ../../.. && pwd)/toolchain/local/bin/x86_64-montauk-
ifeq ($(wildcard $(TOOLCHAIN_PREFIX)gcc),)
CXX = $(error x86_64-montauk toolchain not found. Run ./toolchain/build-montauk-toolchain.sh first)
else
CXX := $(TOOLCHAIN_PREFIX)g++
endif
PROG_INC := ../../include
LINK_LD := ../../link.ld
BINDIR := ../../bin
OBJDIR := obj
LIBDIR := ../../lib
BEARSSL := ../../lib/bearssl
CXXFLAGS := -std=gnu++20 -g -O2 -pipe -Wall -Wextra -Wno-unused-parameter \
-Wno-unused-function -ffreestanding -fno-stack-protector -fno-stack-check \
-fno-rtti -fno-exceptions -ffunction-sections -fdata-sections -msse -msse2 \
-MMD -MP -I $(PROG_INC) -isystem $(PROG_INC)/libc \
-I $(BEARSSL)/inc
LDFLAGS := -nostdlib -Wl,--gc-sections -T $(LINK_LD)
SRCS := main.cpp stb_truetype_impl.cpp
OBJS := $(addprefix $(OBJDIR)/,$(SRCS:.cpp=.o))
TARGET := $(BINDIR)/apps/settings/settings.elf
# bearssl: montauk/user.h (account records) needs its headers and hashes.
LIBS := $(BEARSSL)/libbearssl.a $(LIBDIR)/libc/liblibc.a
.PHONY: all clean
all: $(TARGET)
$(TARGET): $(OBJS) $(LINK_LD) Makefile $(LIBS)
mkdir -p $(BINDIR)/apps/settings
$(CXX) $(CXXFLAGS) $(LDFLAGS) $(OBJS) $(LIBS) -o $@
$(OBJDIR)/%.o: %.cpp Makefile
mkdir -p $(dir $@)
$(CXX) $(CXXFLAGS) -c $< -o $@
-include $(OBJS:.o=.d)
clean:
rm -rf $(OBJDIR) $(TARGET)
+923
View File
@@ -0,0 +1,923 @@
/*
* main.cpp
* MontaukOS Settings: every settings page in one window, filed by
* category and searchable by name, description and keywords. Pages are
* catalog items (gui/app_catalog.hpp); opening one asks the desktop to
* launch it.
* Copyright (c) 2026 Daniel Hammer
*/
#include <montauk/syscall.h>
#include <montauk/string.h>
#include <montauk/heap.h>
#include <montauk/user.h>
#include <montauk/utf8.h>
#include <gui/mtk.hpp>
#include <gui/mtk/settings.hpp>
#include <gui/standalone.hpp>
#include <gui/app_catalog.hpp>
#include <gui/transfer.hpp>
extern "C" {
#include <stdio.h>
}
using namespace gui;
static constexpr int WIN_W = 860;
static constexpr int WIN_H = 580;
static constexpr int SIDEBAR_W = 232;
static constexpr int SEARCH_H = 32;
static constexpr int SEARCH_ICON_X = 9; // magnifier inset
static constexpr int SEARCH_TEXT_X = 31; // text inset, after the magnifier
static constexpr int SEARCH_RADIUS = 4; // matches the sidebar rows
static constexpr int CLEAR_SIZE = 16;
static constexpr int CAT_TOP = 62;
static constexpr int CAT_ROW_H = 34;
static constexpr int PAD = 24;
static constexpr int HEADER_H = 86;
static constexpr int GROUP_H = 36;
static constexpr int TILE_H = 68;
static constexpr int TILE_GAP = 10;
static constexpr int TILE_MIN_W = 280;
static constexpr int TILE_ICON = 32;
static constexpr int TITLE_SIZE = 24;
static constexpr int SCROLL_STEP = 48;
static constexpr int MAX_ENTRIES = 128;
static constexpr int MAX_CATEGORIES = 16;
static constexpr int MAX_PLACED = MAX_ENTRIES + MAX_CATEGORIES;
static constexpr int CAT_ALL = -1;
static constexpr int CAT_NONE = -2;
static constexpr Color SIDEBAR_BG = Color::from_rgb(0xF6, 0xF6, 0xF6);
static constexpr Color SIDEBAR_HOVER = Color::from_rgb(0xEA, 0xEA, 0xEA);
static constexpr Color SYMBOLIC = Color::from_rgb(0x5C, 0x61, 0x6C);
static constexpr Color SEARCH_BG = Color::from_rgb(0xE6, 0xE6, 0xE6);
static constexpr Color SEARCH_HOVER = Color::from_rgb(0xDF, 0xDF, 0xDF);
static constexpr Color CLEAR_BG = Color::from_rgb(0xA8, 0xA8, 0xA8);
static constexpr Color CLEAR_HOVER = Color::from_rgb(0x88, 0x88, 0x88);
// One settings page.
struct Entry {
int item; // index into g_ds->desktop_items
int category; // index into g_categories
SvgIcon icon; // TILE_ICON px
};
struct Category {
char name[24];
SvgIcon icon; // 16 px symbolic
int count;
};
// A laid-out group heading or tile. Rects are in content coordinates: x is
// window x, y is relative to the top of the scrolled pane (which starts with
// the title header, so tiles begin at HEADER_H).
enum PlacedKind : uint8_t { PLACED_HEADING, PLACED_TILE };
struct Placed {
PlacedKind kind;
int ref; // category index (heading) or entry index (tile)
Rect r;
};
static WsWindow g_win;
static DesktopState* g_ds = nullptr;
static Color g_accent = colors::ACCENT;
static Entry g_entries[MAX_ENTRIES];
static int g_entry_count = 0;
static Category g_categories[MAX_CATEGORIES];
static int g_category_count = 0;
static SvgIcon g_icon_all;
static SvgIcon g_icon_search;
static SvgIcon g_icon_clear;
// View
static int g_category = CAT_ALL;
static char g_query[64] = {};
static mtk::TextInputState g_search = {};
// Keyboard focus: the search field, the tiles (arrows move between them), or
// neither. Typing with neither focuses the search field.
static bool g_search_focus = false;
static bool g_grid_focus = false;
static int g_scroll = 0;
static int g_selected = -1; // index into g_placed (a tile), keyboard focus
static int g_hover_tile = -1; // index into g_placed
static int g_hover_cat = CAT_NONE;
static int g_mouse_x = -1;
static int g_mouse_y = -1;
static Placed g_placed[MAX_PLACED];
static int g_placed_count = 0;
static int g_content_h = 0;
static int g_result_count = 0;
static mtk::Theme app_theme() { return mtk::make_theme(g_accent); }
// ==== Text helpers ====
static char fold(char c) { return (c >= 'A' && c <= 'Z') ? c + 32 : c; }
// Case-insensitive (ASCII) search for needle[0..nlen) in haystack.
static int find_folded(const char* haystack, const char* needle, int nlen) {
if (nlen <= 0) return 0;
for (int i = 0; haystack[i]; i++) {
int j = 0;
while (j < nlen && haystack[i + j] && fold(haystack[i + j]) == fold(needle[j])) j++;
if (j == nlen) return i;
}
return -1;
}
static bool is_word_start(const char* s, int i) {
if (i == 0) return true;
char p = s[i - 1];
return p == ' ' || p == '-' || p == '&' || p == '/' || p == ',';
}
// src cut to fit max_w pixels, with a trailing "..." when cut.
static void fit_text(const char* src, char* out, int out_len, int max_w,
TrueTypeFont* font = nullptr, int size = 0) {
auto width = [&](const char* s) {
return font ? gui::text_width(font, s, size) : text_width(s);
};
montauk::strncpy(out, src ? src : "", out_len);
if (out_len <= 4 || width(out) <= max_w) return;
int ellipsis_w = width("...");
int len = montauk::slen(out);
while (len > 0) {
out[len] = '\0';
if (width(out) + ellipsis_w <= max_w) break;
len = montauk::utf8::prev_boundary(out, len);
}
if (len > out_len - 4) len = montauk::utf8::floor_boundary(out, len, out_len - 4);
if (len < 0) len = 0;
montauk::strcpy(out + len, "...");
}
static void draw_bold(Canvas& c, int x, int y, const char* s, Color color, int size) {
TrueTypeFont* font = fonts::system_bold ? fonts::system_bold : fonts::system_font;
gui::draw_text(c, font, x, y, s, color, size);
}
// ==== Catalog ====
static const DesktopItem& entry_item(int e) { return g_ds->desktop_items[g_entries[e].item]; }
static const char* category_icon_path(const char* name) {
struct { const char* name; const char* icon; } map[] = {
{ "Personalization", "org.gnome.Settings-appearance-symbolic.svg" },
{ "Network", "org.gnome.Settings-network-symbolic.svg" },
{ "Devices", "computer-symbolic.svg" },
{ "Time & Language", "preferences-system-time-symbolic.svg" },
{ "Accounts", "system-users-symbolic.svg" },
{ "Privacy & Security", "security-high-symbolic.svg" },
{ "System", "preferences-system-symbolic.svg" },
};
for (auto& m : map)
if (montauk::streq(m.name, name)) return m.icon;
return "applications-system-symbolic.svg";
}
static SvgIcon load_symbolic(const char* file) {
char path[128];
snprintf(path, sizeof(path), "0:/os/icons/%s", file);
return svg_load(path, 16, 16, SYMBOLIC);
}
static int find_category(const char* name) {
for (int i = 0; i < g_category_count; i++)
if (app_catalog_compare_ci(g_categories[i].name, name) == 0) return i;
return -1;
}
static int add_category(const char* name) {
if (g_category_count >= MAX_CATEGORIES) return -1;
Category& cat = g_categories[g_category_count];
montauk::strncpy(cat.name, name, sizeof(cat.name));
cat.count = 0;
cat.icon = {};
return g_category_count++;
}
static void load_identity() {
char user[64] = {};
if (montauk::getuser(user, sizeof(user)) <= 0 || !user[0]) montauk::strcpy(user, "default");
montauk::strncpy(g_ds->current_user, user, sizeof(g_ds->current_user));
// UI affordance only (admin_only pages); the kernel enforces what each
// page may actually do, and the desktop refuses to launch them anyway.
montauk::user::UserInfo users[16];
int count = montauk::user::load_users(users, 16);
for (int i = 0; i < count; i++) {
if (montauk::streq(users[i].username, user)) {
g_ds->is_admin = montauk::streq(users[i].role, "admin");
break;
}
}
}
static void load_catalog() {
app_catalog_add_builtin_pages(g_ds, 0);
app_catalog_add_manifest_apps(g_ds);
// The fixed categories first, in their display order; any others a
// manifest names follow, by name.
for (const char* name : kSettingsCategories) add_category(name);
int fixed = g_category_count;
for (int i = 0; i < g_ds->desktop_item_count && g_entry_count < MAX_ENTRIES; i++) {
DesktopItem& item = g_ds->desktop_items[i];
// The catalog loads small icons for every app; Settings draws its own.
if (item.icon.pixels) svg_free(item.icon);
if (!app_catalog_in_settings(item)) continue;
if (item.admin_only && !g_ds->is_admin) continue;
const char* cat_name = item.settings_category[0] ? item.settings_category
: kSettingsDefaultCategory;
int cat = find_category(cat_name);
if (cat < 0) cat = add_category(cat_name);
if (cat < 0) cat = find_category(kSettingsDefaultCategory);
Entry& e = g_entries[g_entry_count++];
e.item = i;
e.category = cat;
e.icon = item.icon_path[0]
? svg_load(item.icon_path, TILE_ICON, TILE_ICON, colors::ICON_COLOR)
: SvgIcon{};
g_categories[cat].count++;
}
// Sort the extra categories by name, keeping each entry's index right.
for (int i = fixed + 1; i < g_category_count; i++) {
for (int j = i; j > fixed &&
app_catalog_compare_ci(g_categories[j].name, g_categories[j - 1].name) < 0; j--) {
Category tmp = g_categories[j];
g_categories[j] = g_categories[j - 1];
g_categories[j - 1] = tmp;
for (int e = 0; e < g_entry_count; e++) {
if (g_entries[e].category == j) g_entries[e].category = j - 1;
else if (g_entries[e].category == j - 1) g_entries[e].category = j;
}
}
}
// Entries in display order: by category, then by name.
for (int i = 1; i < g_entry_count; i++) {
Entry tmp = g_entries[i];
int j = i - 1;
while (j >= 0) {
const Entry& prev = g_entries[j];
int by_cat = prev.category - tmp.category;
int by_name = app_catalog_compare_ci(g_ds->desktop_items[prev.item].name,
g_ds->desktop_items[tmp.item].name);
if (by_cat < 0 || (by_cat == 0 && by_name <= 0)) break;
g_entries[j + 1] = g_entries[j];
j--;
}
g_entries[j + 1] = tmp;
}
for (int i = 0; i < g_category_count; i++) {
if (g_categories[i].count > 0)
g_categories[i].icon = load_symbolic(category_icon_path(g_categories[i].name));
}
g_icon_all = load_symbolic("view-app-grid-symbolic.svg");
g_icon_search = load_symbolic("system-search-symbolic.svg");
g_icon_clear = svg_load("0:/os/icons/window-close-symbolic.svg", 14, 14, colors::WHITE);
}
// ==== Search ====
// A query of only spaces is no search.
static bool searching() {
for (const char* p = g_query; *p; p++)
if (*p != ' ') return true;
return false;
}
// How well entry e matches the query, lower is better, or -1 for no match.
// Every space-separated term must appear in the name, category, description
// or keywords; names that start with (or have a word starting with) the
// query rank first.
static int match_score(int e) {
const DesktopItem& item = entry_item(e);
const char* cat = g_categories[g_entries[e].category].name;
const char* p = g_query;
int first_len = -1;
while (*p) {
while (*p == ' ') p++;
const char* term = p;
while (*p && *p != ' ') p++;
int len = (int)(p - term);
if (len == 0) continue;
if (first_len < 0) first_len = len;
if (find_folded(item.name, term, len) < 0 && find_folded(cat, term, len) < 0 &&
find_folded(item.description, term, len) < 0 &&
find_folded(item.keywords, term, len) < 0)
return -1;
}
if (first_len < 0) return -1;
const char* first = g_query;
while (*first == ' ') first++;
int at = find_folded(item.name, first, first_len);
if (at == 0) return 0;
for (int i = 0; at >= 0 && item.name[i]; i++) {
if (is_word_start(item.name, i) && find_folded(item.name + i, first, first_len) == 0)
return 1;
}
if (at > 0) return 2;
return 3;
}
// ==== Layout ====
// The scrolled pane right of the sidebar, less the side padding.
static Rect body_rect() {
return {SIDEBAR_W + PAD, 0, g_win.width - SIDEBAR_W - PAD * 2, g_win.height};
}
static Rect search_rect() {
return {12, 14, SIDEBAR_W - 24, SEARCH_H};
}
// The query's area, as mtk text input expects it (text starts at x + 8).
static Rect search_text_rect() {
Rect sr = search_rect();
int x = sr.x + SEARCH_TEXT_X - 8;
int right = sr.x + sr.w - 8 - CLEAR_SIZE - 4;
return {x, sr.y, right - x, sr.h};
}
static Rect clear_button_rect() {
Rect sr = search_rect();
return {sr.x + sr.w - 8 - CLEAR_SIZE, sr.y + (sr.h - CLEAR_SIZE) / 2, CLEAR_SIZE, CLEAR_SIZE};
}
// Sidebar rows: "All Settings", then each category that has pages.
static int sidebar_row_count() {
int n = 1;
for (int i = 0; i < g_category_count; i++)
if (g_categories[i].count > 0) n++;
return n;
}
static int sidebar_row_category(int row) {
if (row == 0) return CAT_ALL;
int seen = 0;
for (int i = 0; i < g_category_count; i++) {
if (g_categories[i].count == 0) continue;
if (++seen == row) return i;
}
return CAT_NONE;
}
static Rect sidebar_row_rect(int row) {
return {6, CAT_TOP + row * CAT_ROW_H, SIDEBAR_W - 13, CAT_ROW_H - 2};
}
static int sidebar_category_at(int x, int y) {
for (int row = 0; row < sidebar_row_count(); row++)
if (sidebar_row_rect(row).contains(x, y)) return sidebar_row_category(row);
return CAT_NONE;
}
static void relayout() {
// Which entries show, in order.
int order[MAX_ENTRIES];
int count = 0;
if (searching()) {
int scores[MAX_ENTRIES];
for (int e = 0; e < g_entry_count; e++) {
int s = match_score(e);
if (s < 0) continue;
// Insertion by score; entries are already in category/name order.
int j = count++;
while (j > 0 && scores[j - 1] > s) {
order[j] = order[j - 1];
scores[j] = scores[j - 1];
j--;
}
order[j] = e;
scores[j] = s;
}
} else {
for (int e = 0; e < g_entry_count; e++)
if (g_category == CAT_ALL || g_entries[e].category == g_category) order[count++] = e;
}
g_result_count = count;
Rect body = body_rect();
int cols = (body.w + TILE_GAP) / (TILE_MIN_W + TILE_GAP);
if (cols < 1) cols = 1;
int tile_w = (body.w - (cols - 1) * TILE_GAP) / cols;
bool grouped = !searching() && g_category == CAT_ALL;
g_placed_count = 0;
int y = HEADER_H, col = 0, prev_cat = -1;
for (int i = 0; i < count && g_placed_count < MAX_PLACED; i++) {
int e = order[i];
if (grouped && g_entries[e].category != prev_cat) {
if (col != 0) { y += TILE_H + TILE_GAP; col = 0; }
prev_cat = g_entries[e].category;
g_placed[g_placed_count++] = {PLACED_HEADING, prev_cat, {body.x, y, body.w, GROUP_H}};
y += GROUP_H;
if (g_placed_count >= MAX_PLACED) break;
}
Rect r = {body.x + col * (tile_w + TILE_GAP), y, tile_w, TILE_H};
g_placed[g_placed_count++] = {PLACED_TILE, e, r};
if (++col == cols) { col = 0; y += TILE_H + TILE_GAP; }
}
if (col != 0) y += TILE_H + TILE_GAP;
g_content_h = y + PAD;
int max_scroll = g_content_h - body.h;
if (max_scroll < 0) max_scroll = 0;
if (g_scroll > max_scroll) g_scroll = max_scroll;
if (g_scroll < 0) g_scroll = 0;
if (g_selected >= g_placed_count || (g_selected >= 0 && g_placed[g_selected].kind != PLACED_TILE))
g_selected = -1;
if (g_hover_tile >= g_placed_count) g_hover_tile = -1;
}
static Rect placed_on_screen(int i) {
Rect r = g_placed[i].r;
r.y -= g_scroll;
return r;
}
static int tile_at(int x, int y) {
for (int i = 0; i < g_placed_count; i++)
if (g_placed[i].kind == PLACED_TILE && placed_on_screen(i).contains(x, y)) return i;
return -1;
}
static int first_tile() {
for (int i = 0; i < g_placed_count; i++)
if (g_placed[i].kind == PLACED_TILE) return i;
return -1;
}
static void scroll_into_view(int i) {
if (i < 0) return;
Rect body = body_rect();
const Rect& r = g_placed[i].r;
// A group's first tile brings its heading along, and the first group
// the title header.
int top = r.y;
if (i > 0 && g_placed[i - 1].kind == PLACED_HEADING) top = g_placed[i - 1].r.y;
if (i == first_tile()) top = 0;
if (top < g_scroll) g_scroll = top;
else if (r.y + r.h + TILE_GAP > g_scroll + body.h) g_scroll = r.y + r.h + TILE_GAP - body.h;
relayout();
}
// ==== Actions ====
static void open_entry(int e) {
if (e < 0 || e >= g_entry_count) return;
gui::win_request_launch_item(g_win.id, entry_item(e).id);
}
static void select_category(int cat) {
g_category = cat;
g_query[0] = '\0';
mtk::text_input_reset(g_search);
g_scroll = 0;
g_selected = -1;
relayout();
}
static void query_changed() {
g_scroll = 0;
relayout();
// Enter opens the best match.
g_selected = searching() ? first_tile() : -1;
}
// Next tile in direction (dx, dy) from the selected one, by screen position.
static int neighbour_tile(int dx, int dy) {
if (g_selected < 0) return first_tile();
const Rect& cur = g_placed[g_selected].r;
int best = -1;
int best_primary = 0, best_secondary = 0;
for (int i = 0; i < g_placed_count; i++) {
if (i == g_selected || g_placed[i].kind != PLACED_TILE) continue;
const Rect& r = g_placed[i].r;
int primary, secondary;
if (dy != 0) {
primary = dy > 0 ? r.y - cur.y : cur.y - r.y;
secondary = r.x > cur.x ? r.x - cur.x : cur.x - r.x;
} else {
if (r.y != cur.y) continue;
primary = dx > 0 ? r.x - cur.x : cur.x - r.x;
secondary = 0;
}
if (primary <= 0) continue;
if (best < 0 || primary < best_primary ||
(primary == best_primary && secondary < best_secondary)) {
best = i;
best_primary = primary;
best_secondary = secondary;
}
}
return best >= 0 ? best : g_selected;
}
// ==== Rendering ====
// A filled field with a leading magnifier that only shows the accent ring and
// caret while focused, and a clear button once there is a query.
static void draw_search(Canvas& c, const mtk::Theme& theme) {
Rect sr = search_rect();
int fh = system_font_height();
if (g_search_focus) {
mtk::fill_round_rect_aa(c, sr, SEARCH_RADIUS, g_accent);
mtk::fill_round_rect_aa(c, {sr.x + 1, sr.y + 1, sr.w - 2, sr.h - 2},
SEARCH_RADIUS - 1, colors::WHITE);
} else {
mtk::fill_round_rect_aa(c, sr, SEARCH_RADIUS,
sr.contains(g_mouse_x, g_mouse_y) ? SEARCH_HOVER : SEARCH_BG);
}
if (g_icon_search.pixels)
c.icon(sr.x + SEARCH_ICON_X, sr.y + (sr.h - g_icon_search.height) / 2, g_icon_search);
Rect tr = search_text_rect();
int tx = tr.x + 8;
int ty = sr.y + (sr.h - fh) / 2;
int len = mtk::text_input_len(g_query);
if (g_search_focus && mtk::text_input_has_selection(g_search, g_query, sizeof(g_query))) {
int sx = tx + mtk::text_input_prefix_width(
g_query, mtk::text_input_selection_start(g_search, len), false);
int ex = tx + mtk::text_input_prefix_width(
g_query, mtk::text_input_selection_end(g_search, len), false);
if (ex > tr.x + tr.w) ex = tr.x + tr.w;
if (ex > sx) c.fill_rect(sx, sr.y + 6, ex - sx, sr.h - 12, theme.text_selection);
}
if (g_query[0]) c.text(tx, ty, g_query, theme.text);
else c.text(tx, ty, "Search settings", theme.text_muted);
if (g_search_focus) {
int cx = tx + mtk::text_input_prefix_width(
g_query, mtk::text_input_clamp_cursor(g_search.cursor, len), false);
c.fill_rect(cx - 1, sr.y + 8, 2, sr.h - 16, g_accent);
}
if (g_query[0]) {
Rect cb = clear_button_rect();
mtk::fill_round_rect_aa(c, cb, CLEAR_SIZE / 2,
cb.contains(g_mouse_x, g_mouse_y) ? CLEAR_HOVER : CLEAR_BG);
if (g_icon_clear.pixels)
c.icon(cb.x + (cb.w - g_icon_clear.width) / 2,
cb.y + (cb.h - g_icon_clear.height) / 2, g_icon_clear);
}
}
static void draw_sidebar(Canvas& c, const mtk::Theme& theme) {
c.fill_rect(0, 0, SIDEBAR_W, g_win.height, SIDEBAR_BG);
c.vline(SIDEBAR_W - 1, 0, g_win.height, theme.border);
draw_search(c, theme);
int fh = system_font_height();
int rows = sidebar_row_count();
for (int row = 0; row < rows; row++) {
int cat = sidebar_row_category(row);
Rect r = sidebar_row_rect(row);
bool current = !searching() && cat == g_category;
if (current)
c.fill_rounded_rect(r.x, r.y, r.w, r.h, 4, mtk::accent_hover_tint(g_accent));
else if (cat == g_hover_cat)
c.fill_rounded_rect(r.x, r.y, r.w, r.h, 4, SIDEBAR_HOVER);
const SvgIcon& icon = cat == CAT_ALL ? g_icon_all : g_categories[cat].icon;
if (icon.pixels) c.icon(r.x + 10, r.y + (r.h - icon.height) / 2, icon);
const char* label = cat == CAT_ALL ? "All Settings" : g_categories[cat].name;
int count = cat == CAT_ALL ? g_entry_count : g_categories[cat].count;
char count_text[8];
snprintf(count_text, sizeof(count_text), "%d", count);
int count_w = text_width(count_text);
char fit[48];
fit_text(label, fit, sizeof(fit), r.w - 34 - count_w - 16);
int ty = r.y + (r.h - fh) / 2;
c.text(r.x + 34, ty, fit, theme.text);
c.text(r.x + r.w - 10 - count_w, ty, count_text, theme.text_muted);
}
}
static void draw_tile(Canvas& c, int i, const mtk::Theme& theme) {
Rect r = placed_on_screen(i);
if (r.y + r.h <= 0 || r.y >= g_win.height) return;
int e = g_placed[i].ref;
const DesktopItem& item = entry_item(e);
bool hovered = i == g_hover_tile;
bool selected = i == g_selected && (g_grid_focus || searching());
mtk::draw_rounded_frame(c, r, theme.radius_md + 2,
hovered ? theme.surface_hover : theme.surface,
selected ? theme.accent : theme.border);
if (selected)
mtk::draw_rounded_frame(c, {r.x + 1, r.y + 1, r.w - 2, r.h - 2}, theme.radius_md + 1,
hovered ? theme.surface_hover : theme.surface, theme.accent);
const SvgIcon& icon = g_entries[e].icon;
if (icon.pixels) c.icon(r.x + 16, r.y + (r.h - icon.height) / 2, icon);
int fh = system_font_height();
int tx = r.x + 16 + TILE_ICON + 14;
int text_w = r.w - (tx - r.x) - 14;
int name_y = r.y + (r.h - fh * 2 - 2) / 2;
// Name, then the description (led by the category when the results mix
// categories).
char name[48];
fit_text(item.name, name, sizeof(name), text_w,
fonts::system_bold ? fonts::system_bold : fonts::system_font, fonts::UI_SIZE);
draw_bold(c, tx, name_y, name, theme.text, fonts::UI_SIZE);
char line[128];
if (searching())
snprintf(line, sizeof(line), "%s%s%s", g_categories[g_entries[e].category].name,
item.description[0] ? " - " : "", item.description);
else
montauk::strncpy(line, item.description[0] ? item.description : "Open settings",
sizeof(line));
char desc[128];
fit_text(line, desc, sizeof(desc), text_w);
c.text(tx, name_y + fh + 2, desc, theme.text_subtle);
}
static void draw_body(Canvas& c, const mtk::Theme& theme) {
int fh = system_font_height();
for (int i = 0; i < g_placed_count; i++) {
if (g_placed[i].kind == PLACED_TILE) {
draw_tile(c, i, theme);
continue;
}
Rect r = placed_on_screen(i);
if (r.y + r.h <= 0 || r.y >= g_win.height) continue;
const Category& cat = g_categories[g_placed[i].ref];
int ty = r.y + r.h - fh - 10;
if (cat.icon.pixels) c.icon(r.x, ty + (fh - cat.icon.height) / 2, cat.icon);
c.text(r.x + 24, ty, cat.name, theme.text_subtle);
}
if (g_result_count == 0) {
Rect body = body_rect();
const char* msg = searching() ? "Try another word, such as \"display\" or \"wifi\"."
: "Nothing to show here.";
c.text(body.x + (body.w - text_width(msg)) / 2, HEADER_H + 48 - g_scroll, msg,
theme.text_muted);
}
Rect body = body_rect();
if (mtk::scrollbar_needed(g_content_h, body.h)) {
Rect view = {SIDEBAR_W, 0, g_win.width - SIDEBAR_W, body.h};
Rect track = mtk::scrollbar_track_rect(view);
Rect thumb = mtk::scrollbar_thumb_rect(track, g_content_h, body.h, g_scroll);
mtk::draw_scrollbar(c, track, thumb, false, false, theme);
}
}
static void draw_header(Canvas& c, const mtk::Theme& theme) {
// Part of the scrolled pane: it scrolls away with the tiles.
int x0 = SIDEBAR_W;
int y0 = 20 - g_scroll;
if (y0 + HEADER_H <= 0) return;
char title[96];
char subtitle[128];
if (searching()) {
montauk::strcpy(title, "Search Results");
if (g_result_count == 0)
snprintf(subtitle, sizeof(subtitle), "No settings match \"%s\"", g_query);
else
snprintf(subtitle, sizeof(subtitle), "%d %s for \"%s\" - press Enter to open the first",
g_result_count, g_result_count == 1 ? "result" : "results", g_query);
} else if (g_category == CAT_ALL) {
montauk::strcpy(title, "All Settings");
int cats = 0;
for (int i = 0; i < g_category_count; i++)
if (g_categories[i].count > 0) cats++;
snprintf(subtitle, sizeof(subtitle), "%d settings in %d categories",
g_entry_count, cats);
} else {
montauk::strncpy(title, g_categories[g_category].name, sizeof(title));
int n = g_categories[g_category].count;
snprintf(subtitle, sizeof(subtitle), "%d %s", n, n == 1 ? "setting" : "settings");
}
int tx = x0 + PAD;
int max_w = g_win.width - tx - PAD;
char fit[128];
TrueTypeFont* bold = fonts::system_bold ? fonts::system_bold : fonts::system_font;
fit_text(title, fit, sizeof(fit), max_w, bold, TITLE_SIZE);
draw_bold(c, tx, y0, fit, theme.text, TITLE_SIZE);
fit_text(subtitle, fit, sizeof(fit), max_w);
c.text(tx, y0 + gui::text_height(bold, TITLE_SIZE) + 6, fit, theme.text_subtle);
}
static void render() {
mtk::StandaloneHost host(&g_win);
Canvas c = host.canvas();
mtk::Theme theme = app_theme();
c.fill(theme.window_bg);
draw_header(c, theme);
draw_body(c, theme);
draw_sidebar(c, theme);
if (g_search.context.open)
mtk::draw_text_input_context_menu(
c, g_search, theme, mtk::text_input_has_selection(g_search, g_query, sizeof(g_query)));
host.present();
}
// ==== Input ====
// The query stops growing once it fills the field (the field does not scroll).
static bool query_fits() {
return text_width(g_query) <= search_text_rect().w - 8 - 2;
}
static void focus_search(bool select_all) {
g_search_focus = true;
g_grid_focus = false;
if (select_all) {
g_search.selection_anchor = 0;
g_search.cursor = mtk::text_input_len(g_query);
}
}
static void clear_query() {
g_query[0] = '\0';
mtk::text_input_reset(g_search);
query_changed();
}
// True if the key changed the query.
static bool edit_query(const montauk::abi::KeyEvent& key) {
char before[sizeof(g_query)];
montauk::memcpy(before, g_query, sizeof(g_query));
mtk::TextInputState saved = g_search;
int r = mtk::text_input_key(g_search, g_query, sizeof(g_query), key);
if (!(r & mtk::TEXT_INPUT_CHANGED)) return false;
if (!query_fits()) {
montauk::memcpy(g_query, before, sizeof(g_query));
g_search = saved;
return false;
}
query_changed();
return true;
}
static void handle_key(const montauk::abi::KeyEvent& key) {
if (!key.pressed) return;
uint8_t nav = montauk::nav_scancode(key);
if (key.scancode == 0x01) { // Escape
if (g_search.context.open) mtk::context_menu_close(g_search.context);
else if (g_grid_focus) focus_search(false);
else if (g_query[0]) clear_query();
else if (g_search_focus) g_search_focus = false;
else g_win.closed = true;
return;
}
if (key.scancode == 0x1C) { // Enter
if (g_selected >= 0) open_entry(g_placed[g_selected].ref);
return;
}
if (key.ctrl && (key.ascii == 'f' || key.ascii == 'F')) {
focus_search(true);
return;
}
if (key.scancode == 0x0F) { // Tab
if (g_grid_focus) {
focus_search(false);
return;
}
g_grid_focus = true;
g_search_focus = false;
if (g_selected < 0) g_selected = first_tile();
scroll_into_view(g_selected);
return;
}
if (g_grid_focus) {
int next = -1;
if (nav == 0x48) next = neighbour_tile(0, -1);
else if (nav == 0x50) next = neighbour_tile(0, 1);
else if (nav == 0x4B) next = neighbour_tile(-1, 0);
else if (nav == 0x4D) next = neighbour_tile(1, 0);
if (next >= 0 || nav == 0x48 || nav == 0x50 || nav == 0x4B || nav == 0x4D) {
// Up from the top row returns to the search field.
if (nav == 0x48 && next == g_selected) focus_search(false);
else g_selected = next;
scroll_into_view(g_selected);
return;
}
// Anything else types into the search field.
g_grid_focus = false;
} else if (nav == 0x50) { // Down: into the results
g_grid_focus = true;
g_search_focus = false;
if (g_selected < 0) g_selected = first_tile();
scroll_into_view(g_selected);
return;
}
// Typing anywhere searches.
if (edit_query(key)) g_search_focus = true;
}
static void handle_mouse(const montauk::abi::WinEvent& ev) {
g_mouse_x = ev.mouse.x;
g_mouse_y = ev.mouse.y;
uint8_t buttons = ev.mouse.buttons, prev = ev.mouse.prev_buttons;
bool pressed = (buttons & 1) && !(prev & 1);
// The search field (and its Cut/Copy/Paste menu) sees the event first.
Rect sr = search_rect();
Rect tr = search_text_rect();
bool in_search = sr.contains(g_mouse_x, g_mouse_y);
bool in_text = tr.contains(g_mouse_x, g_mouse_y);
if (pressed && g_query[0] && !g_search.context.open &&
clear_button_rect().contains(g_mouse_x, g_mouse_y)) {
clear_query();
focus_search(false);
return;
}
if (g_search.context.open || g_search.dragging || in_text) {
char before[sizeof(g_query)];
montauk::memcpy(before, g_query, sizeof(g_query));
int r = mtk::text_input_handle_mouse(g_search, tr, g_query, sizeof(g_query),
g_mouse_x, g_mouse_y, buttons, prev,
g_win.width, g_win.height, g_search_focus);
if ((r & mtk::TEXT_INPUT_CONSUMED) && in_text) focus_search(false);
if (r & mtk::TEXT_INPUT_CHANGED) {
if (!query_fits()) {
montauk::memcpy(g_query, before, sizeof(g_query));
mtk::text_input_reset(g_search, mtk::text_input_len(g_query));
}
query_changed();
}
if (r != mtk::TEXT_INPUT_NONE) return;
}
// The magnifier and padding focus the field too, caret at the end.
if (pressed && in_search) {
focus_search(false);
mtk::text_input_reset(g_search, mtk::text_input_len(g_query));
return;
}
if (ev.mouse.scroll != 0 && g_mouse_x >= SIDEBAR_W) {
g_scroll -= ev.mouse.scroll * SCROLL_STEP;
relayout();
}
g_hover_tile = tile_at(g_mouse_x, g_mouse_y);
g_hover_cat = g_mouse_x < SIDEBAR_W ? sidebar_category_at(g_mouse_x, g_mouse_y) : CAT_NONE;
if (!pressed) return;
// A click anywhere else takes focus off the field.
g_search_focus = false;
if (g_hover_cat != CAT_NONE) {
select_category(g_hover_cat);
g_grid_focus = false;
} else if (g_hover_tile >= 0) {
g_selected = g_hover_tile;
open_entry(g_placed[g_hover_tile].ref);
}
}
extern "C" void _start() {
if (!fonts::init()) montauk::exit(1);
g_accent = mtk::load_system_accent();
g_ds = (DesktopState*)montauk::malloc(sizeof(DesktopState));
if (!g_ds) montauk::exit(1);
montauk::memset(g_ds, 0, sizeof(DesktopState));
load_identity();
load_catalog();
if (!g_win.create("Settings", WIN_W, WIN_H)) montauk::exit(1);
relayout();
render();
while (g_win.id >= 0 && !g_win.closed) {
montauk::abi::WinEvent ev;
int result = g_win.poll(&ev);
if (result < 0) break;
if (result == 0) {
montauk::sleep_ms(16);
continue;
}
if (ev.type == montauk::abi::WIN_EVENT_CLOSE) break;
if (ev.type == montauk::abi::WIN_EVENT_MOUSE) handle_mouse(ev);
else if (ev.type == montauk::abi::WIN_EVENT_KEY) handle_key(ev.key);
else if (ev.type == montauk::abi::WIN_EVENT_RESIZE) relayout();
render();
}
g_win.destroy();
montauk::exit(0);
}
+18
View File
@@ -0,0 +1,18 @@
[app]
id = "settings"
name = "Settings"
binary = "settings.elf"
icon = "preferences-system.svg"
# The desktop lists Settings itself (the app menu footer entry and the
# desktop context menu start this binary), so the manifest stays out of the
# menus and out of its own list.
[menu]
category = "System"
visible = false
[desktop]
section = "hidden"
[launch]
pass_home_dir = false
@@ -0,0 +1,2 @@
#define STB_TRUETYPE_IMPLEMENTATION
#include <gui/stb_truetype.h>
+6
View File
@@ -11,3 +11,9 @@ visible = false
[desktop]
section = "settings"
admin_only = false
# Where the Settings app lists this page and what its search matches.
[settings]
category = "Network"
description = "Remote login over SSH"
keywords = "ssh sshd remote login shell server users access"
+6
View File
@@ -11,3 +11,9 @@ visible = false
[desktop]
section = "settings"
admin_only = false
# Where the Settings app lists this page and what its search matches.
[settings]
category = "Time & Language"
description = "Time zone and network time sync"
keywords = "clock date time zone timezone ntp sync country region"