refactor: split Files and Settings apps into multiple files

This commit is contained in:
2026-04-01 23:08:25 +02:00
parent 045f3ee0a5
commit b140825121
20 changed files with 3578 additions and 3449 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,178 @@
/*
* actions.cpp
* Clipboard, rename, delete, and context-menu actions for the embedded desktop file manager
* Copyright (c) 2026 Daniel Hammer
*/
#include "filemanager_internal.hpp"
namespace filemanager {
void filemanager_do_copy(FileManagerState* fm) {
if (fm->selected < 0 || fm->selected >= fm->entry_count) return;
if (fm->at_drives_root || fm->entry_types[fm->selected] == 3) return;
filemanager_build_fullpath(fm->clipboard_path, 256,
fm->current_path, fm->entry_names[fm->selected]);
fm->clipboard_has_data = true;
fm->clipboard_is_cut = false;
}
void filemanager_do_cut(FileManagerState* fm) {
if (fm->selected < 0 || fm->selected >= fm->entry_count) return;
if (fm->at_drives_root || fm->entry_types[fm->selected] == 3) return;
filemanager_build_fullpath(fm->clipboard_path, 256,
fm->current_path, fm->entry_names[fm->selected]);
fm->clipboard_has_data = true;
fm->clipboard_is_cut = true;
}
void filemanager_do_paste(FileManagerState* fm) {
if (!fm->clipboard_has_data || fm->at_drives_root) return;
const char* basename = path_basename(fm->clipboard_path);
char dst[512];
filemanager_build_fullpath(dst, 512, fm->current_path, basename);
// Check if source is a directory by trying to readdir it
const char* probe[1];
int probe_count = montauk::readdir(fm->clipboard_path, probe, 1);
bool src_is_dir = (probe_count >= 0);
bool ok;
if (src_is_dir)
ok = filemanager_copy_dir_recursive(fm->clipboard_path, dst);
else
ok = filemanager_copy_file(fm->clipboard_path, dst);
if (ok && fm->clipboard_is_cut) {
if (src_is_dir)
filemanager_delete_recursive(fm->clipboard_path);
else
montauk::fdelete(fm->clipboard_path);
fm->clipboard_has_data = false;
}
filemanager_read_dir(fm);
}
void filemanager_start_rename(FileManagerState* fm) {
if (fm->selected < 0 || fm->selected >= fm->entry_count) return;
if (fm->at_drives_root || fm->entry_types[fm->selected] == 3) return;
fm->rename_active = true;
fm->rename_idx = fm->selected;
montauk::strcpy(fm->rename_buf, fm->entry_names[fm->selected]);
fm->rename_len = montauk::slen(fm->rename_buf);
fm->rename_cursor = fm->rename_len;
}
void filemanager_finish_rename(FileManagerState* fm) {
if (!fm->rename_active) return;
fm->rename_active = false;
// If name unchanged, skip
if (montauk::streq(fm->rename_buf, fm->entry_names[fm->rename_idx])) return;
if (fm->rename_len == 0) return;
char old_path[512], new_path[512];
filemanager_build_fullpath(old_path, 512,
fm->current_path, fm->entry_names[fm->rename_idx]);
filemanager_build_fullpath(new_path, 512,
fm->current_path, fm->rename_buf);
if (fm->is_dir[fm->rename_idx]) {
// Directory rename: copy recursively then delete old
if (filemanager_copy_dir_recursive(old_path, new_path))
filemanager_delete_recursive(old_path);
} else {
// File rename: copy then delete old
if (filemanager_copy_file(old_path, new_path))
montauk::fdelete(old_path);
}
filemanager_read_dir(fm);
}
void filemanager_cancel_rename(FileManagerState* fm) {
fm->rename_active = false;
}
void filemanager_new_folder(FileManagerState* fm) {
if (fm->at_drives_root) return;
// Find a unique name
char name[64] = "New Folder";
char path[512];
filemanager_build_fullpath(path, 512, fm->current_path, name);
int attempt = 1;
// Try creating; if it fails, append a number
if (montauk::fmkdir(path) != 0) {
for (attempt = 2; attempt <= 99; attempt++) {
snprintf(name, 64, "New Folder %d", attempt);
filemanager_build_fullpath(path, 512, fm->current_path, name);
if (montauk::fmkdir(path) == 0) break;
}
}
filemanager_read_dir(fm);
// Select the new folder and start rename
for (int i = 0; i < fm->entry_count; i++) {
if (montauk::streq(fm->entry_names[i], name)) {
fm->selected = i;
filemanager_start_rename(fm);
break;
}
}
}
void filemanager_delete_selected(FileManagerState* fm) {
if (fm->selected < 0 || fm->selected >= fm->entry_count) return;
if (fm->at_drives_root || fm->entry_types[fm->selected] == 3) return;
char fullpath[512];
filemanager_build_fullpath(fullpath, 512,
fm->current_path, fm->entry_names[fm->selected]);
if (fm->is_dir[fm->selected])
filemanager_delete_recursive(fullpath);
else
montauk::fdelete(fullpath);
filemanager_read_dir(fm);
}
void filemanager_open_ctx_menu(FileManagerState* fm, int local_x, int local_y, int target_idx) {
fm->ctx_open = true;
fm->ctx_x = local_x;
fm->ctx_y = local_y;
fm->ctx_target_idx = target_idx;
fm->ctx_hover = -1;
fm->ctx_item_count = 0;
if (target_idx >= 0 && target_idx < fm->entry_count) {
int tt = fm->entry_types[target_idx];
if ((fm->at_drives_root && (tt == 3 || tt == 4 || tt == 5 || tt == 7)) ||
(fm->at_apps_view && tt == 6)) {
// Drive, Home, Apps shortcut, or app entry: only Open
fm->ctx_items[fm->ctx_item_count++] = CTX_OPEN;
} else {
fm->ctx_items[fm->ctx_item_count++] = CTX_OPEN;
fm->ctx_items[fm->ctx_item_count++] = CTX_COPY;
fm->ctx_items[fm->ctx_item_count++] = CTX_CUT;
fm->ctx_items[fm->ctx_item_count++] = CTX_RENAME;
fm->ctx_items[fm->ctx_item_count++] = CTX_DELETE;
}
} else {
// Background click
if (fm->clipboard_has_data)
fm->ctx_items[fm->ctx_item_count++] = CTX_PASTE;
fm->ctx_items[fm->ctx_item_count++] = CTX_NEW_FOLDER;
}
}
void filemanager_close_ctx_menu(FileManagerState* fm) {
fm->ctx_open = false;
}
} // namespace filemanager
@@ -0,0 +1,83 @@
/*
* app.cpp
* File manager launcher and desktop integration entry points
* Copyright (c) 2026 Daniel Hammer
*/
#include "filemanager_internal.hpp"
namespace filemanager {
static void open_filemanager_internal(DesktopState* ds, const char* initial_path) {
int idx = desktop_create_window(ds, "Files", 150, 120, 560, 420);
if (idx < 0) return;
Window* win = &ds->windows[idx];
FileManagerState* fm = (FileManagerState*)montauk::malloc(sizeof(FileManagerState));
montauk::memset(fm, 0, sizeof(FileManagerState));
fm->selected = -1;
fm->last_click_item = -1;
fm->history_pos = -1;
fm->history_count = 0;
fm->desktop = ds;
fm->grid_view = true;
fm->scrollbar.init(0, 0, FM_SCROLLBAR_W, 100);
ensure_filemanager_icons_loaded(ds);
if (initial_path && initial_path[0] != '\0') {
int probe_fd = montauk::open(initial_path);
if (probe_fd >= 0) {
montauk::close(probe_fd);
montauk::strncpy(fm->current_path, initial_path, sizeof(fm->current_path));
filemanager_read_dir(fm);
} else {
filemanager_read_drives(fm);
}
} else {
filemanager_read_drives(fm);
}
filemanager_push_history(fm);
win->app_data = fm;
win->on_draw = filemanager_on_draw;
win->on_mouse = filemanager_on_mouse;
win->on_key = filemanager_on_key;
win->on_close = filemanager_on_close;
}
} // namespace filemanager
void ensure_filemanager_icons_loaded(DesktopState* ds) {
if (!ds || ds->icon_drive.pixels) return;
Color defColor = colors::ICON_COLOR;
ds->icon_drive = svg_load("0:/icons/drive-harddisk.svg", 16, 16, defColor);
ds->icon_drive_lg = svg_load("0:/icons/drive-harddisk.svg", 48, 48, defColor);
ds->icon_home_folder = svg_load("0:/icons/folder-blue-home.svg", 16, 16, defColor);
ds->icon_home_folder_lg = svg_load("0:/icons/folder-blue-home.svg", 48, 48, defColor);
ds->icon_apps = svg_load("0:/icons/folder-blue-development.svg", 16, 16, defColor);
ds->icon_apps_lg = svg_load("0:/icons/folder-blue-development.svg", 48, 48, defColor);
for (int sf = 0; sf < filemanager::SF_COUNT; sf++) {
char icon_path[128];
snprintf(icon_path, 128, "0:/icons/%s", filemanager::sf_icons[sf]);
ds->icon_special_folder[sf] = svg_load(icon_path, 16, 16, defColor);
ds->icon_special_folder_lg[sf] = svg_load(icon_path, 48, 48, defColor);
}
ds->icon_delete = svg_load("0:/icons/trash-empty.svg", 16, 16, defColor);
ds->icon_copy = svg_load("0:/icons/edit-copy.svg", 16, 16, defColor);
ds->icon_cut = svg_load("0:/icons/edit-cut.svg", 16, 16, defColor);
ds->icon_paste = svg_load("0:/icons/edit-paste.svg", 16, 16, defColor);
ds->icon_rename = svg_load("0:/icons/edit-rename.svg", 16, 16, defColor);
ds->icon_folder_new = svg_load("0:/icons/folder-new.svg", 16, 16, defColor);
}
void open_filemanager(DesktopState* ds) {
filemanager::open_filemanager_internal(ds, nullptr);
}
void open_filemanager_path(DesktopState* ds, const char* path) {
filemanager::open_filemanager_internal(ds, path);
}
@@ -0,0 +1,395 @@
/*
* events.cpp
* Mouse and keyboard handling for the embedded desktop file manager
* Copyright (c) 2026 Daniel Hammer
*/
#include "filemanager_internal.hpp"
namespace filemanager {
void filemanager_on_mouse(Window* win, MouseEvent& ev) {
FileManagerState* fm = (FileManagerState*)win->app_data;
if (!fm) return;
Rect cr = win->content_rect();
int local_x = ev.x - cr.x;
int local_y = ev.y - cr.y;
int cw = cr.w;
// Scrollbar interaction
MouseEvent local_ev = ev;
local_ev.x = local_x;
local_ev.y = local_y;
fm->scrollbar.handle_mouse(local_ev);
// ---- Context menu interaction ----
if (fm->ctx_open) {
int cmx = fm->ctx_x;
int cmy = fm->ctx_y;
int cmh = fm->ctx_item_count * CTX_ITEM_H + 8;
if (cmx + CTX_MENU_W > cw) cmx = cw - CTX_MENU_W;
if (cmy + cmh > win->content_h) cmy = win->content_h - cmh;
if (cmx < 0) cmx = 0;
if (cmy < 0) cmy = 0;
// Update hover
if (local_x >= cmx && local_x < cmx + CTX_MENU_W &&
local_y >= cmy + 4 && local_y < cmy + 4 + fm->ctx_item_count * CTX_ITEM_H) {
fm->ctx_hover = (local_y - cmy - 4) / CTX_ITEM_H;
} else {
fm->ctx_hover = -1;
}
if (ev.left_pressed()) {
if (fm->ctx_hover >= 0 && fm->ctx_hover < fm->ctx_item_count) {
int action = fm->ctx_items[fm->ctx_hover];
int target = fm->ctx_target_idx;
filemanager_close_ctx_menu(fm);
// Select target if needed
if (target >= 0 && target < fm->entry_count)
fm->selected = target;
switch (action) {
case CTX_OPEN: filemanager_open_entry(fm, target); break;
case CTX_COPY: filemanager_do_copy(fm); break;
case CTX_CUT: filemanager_do_cut(fm); break;
case CTX_PASTE: filemanager_do_paste(fm); break;
case CTX_RENAME: filemanager_start_rename(fm); break;
case CTX_DELETE: filemanager_delete_selected(fm); break;
case CTX_NEW_FOLDER: filemanager_new_folder(fm); break;
}
} else {
filemanager_close_ctx_menu(fm);
}
return;
}
if (ev.right_pressed()) {
filemanager_close_ctx_menu(fm);
return;
}
return;
}
// ---- Cancel rename on click outside rename area ----
if (fm->rename_active && ev.left_pressed()) {
filemanager_finish_rename(fm);
}
if (ev.left_pressed()) {
// Click on path bar area
if (local_y >= FM_TOOLBAR_H && local_y < FM_TOOLBAR_H + FM_PATHBAR_H) {
if (!fm->pathbar_editing)
filemanager_start_pathbar(fm);
return;
}
// Clicking anywhere else while pathbar is editing commits the path
if (fm->pathbar_editing) {
filemanager_commit_pathbar(fm);
}
// Toolbar button clicks
if (local_y < FM_TOOLBAR_H) {
// Navigation buttons
if (local_x >= 4 && local_x < 28) filemanager_go_back(fm);
else if (local_x >= 32 && local_x < 56) filemanager_go_forward(fm);
else if (local_x >= 60 && local_x < 84) filemanager_go_up(fm);
else if (local_x >= 88 && local_x < 112) filemanager_go_home(fm);
else if (local_x >= 120 && local_x < 144) {
fm->grid_view = !fm->grid_view;
fm->scrollbar.scroll_offset = 0;
}
// Action buttons
else if (local_x >= 160 && local_x < 184) filemanager_do_copy(fm);
else if (local_x >= 188 && local_x < 212) filemanager_do_cut(fm);
else if (local_x >= 216 && local_x < 240) filemanager_do_paste(fm);
else if (local_x >= 244 && local_x < 268) filemanager_start_rename(fm);
else if (local_x >= 272 && local_x < 296) filemanager_new_folder(fm);
else if (local_x >= 300 && local_x < 324) filemanager_delete_selected(fm);
return;
}
// File clicks (grid vs list)
if (fm->grid_view) {
int list_y = FM_TOOLBAR_H + FM_PATHBAR_H;
if (local_y >= list_y && local_x < cw - FM_SCROLLBAR_W) {
int cols = (cw - FM_SCROLLBAR_W) / FM_GRID_CELL_W;
if (cols < 1) cols = 1;
int col = local_x / FM_GRID_CELL_W;
int row = (local_y - list_y + fm->scrollbar.scroll_offset) / FM_GRID_CELL_H;
int clicked_idx = row * cols + col;
if (clicked_idx >= 0 && clicked_idx < fm->entry_count && col < cols) {
uint64_t now = montauk::get_milliseconds();
if (fm->last_click_item == clicked_idx &&
(now - fm->last_click_time) < 400) {
filemanager_open_entry(fm, clicked_idx);
fm->last_click_item = -1;
fm->last_click_time = 0;
} else {
fm->selected = clicked_idx;
fm->last_click_item = clicked_idx;
fm->last_click_time = now;
}
} else {
fm->selected = -1;
}
}
} else {
// List view clicks
int list_y = FM_TOOLBAR_H + FM_PATHBAR_H + FM_HEADER_H;
if (local_y >= list_y && local_x < cw - FM_SCROLLBAR_W) {
int rel_y = local_y - list_y + fm->scrollbar.scroll_offset;
int clicked_idx = rel_y / FM_ITEM_H;
if (clicked_idx >= 0 && clicked_idx < fm->entry_count) {
uint64_t now = montauk::get_milliseconds();
// Double-click detection
if (fm->last_click_item == clicked_idx &&
(now - fm->last_click_time) < 400) {
filemanager_open_entry(fm, clicked_idx);
fm->last_click_item = -1;
fm->last_click_time = 0;
} else {
fm->selected = clicked_idx;
fm->last_click_item = clicked_idx;
fm->last_click_time = now;
}
} else {
fm->selected = -1;
}
}
}
}
// ---- Right-click: open context menu ----
if (ev.right_pressed()) {
filemanager_cancel_rename(fm);
// Determine what was right-clicked
int target_idx = -1;
if (local_y >= FM_TOOLBAR_H + FM_PATHBAR_H) {
if (fm->grid_view) {
int list_y = FM_TOOLBAR_H + FM_PATHBAR_H;
if (local_x < cw - FM_SCROLLBAR_W) {
int cols = (cw - FM_SCROLLBAR_W) / FM_GRID_CELL_W;
if (cols < 1) cols = 1;
int col = local_x / FM_GRID_CELL_W;
int row = (local_y - list_y + fm->scrollbar.scroll_offset) / FM_GRID_CELL_H;
int idx = row * cols + col;
if (idx >= 0 && idx < fm->entry_count && col < cols) {
target_idx = idx;
fm->selected = idx;
}
}
} else {
int list_y = FM_TOOLBAR_H + FM_PATHBAR_H + FM_HEADER_H;
if (local_y >= list_y && local_x < cw - FM_SCROLLBAR_W) {
int rel_y = local_y - list_y + fm->scrollbar.scroll_offset;
int idx = rel_y / FM_ITEM_H;
if (idx >= 0 && idx < fm->entry_count) {
target_idx = idx;
fm->selected = idx;
}
}
}
}
filemanager_open_ctx_menu(fm, local_x, local_y, target_idx);
}
// Scroll handling
if (ev.scroll != 0) {
int list_y_start = fm->grid_view
? FM_TOOLBAR_H + FM_PATHBAR_H
: FM_TOOLBAR_H + FM_PATHBAR_H + FM_HEADER_H;
int scroll_step = fm->grid_view ? FM_GRID_CELL_H : FM_ITEM_H;
if (local_y >= list_y_start) {
fm->scrollbar.scroll_offset -= ev.scroll * scroll_step;
int ms = fm->scrollbar.max_scroll();
if (fm->scrollbar.scroll_offset < 0) fm->scrollbar.scroll_offset = 0;
if (fm->scrollbar.scroll_offset > ms) fm->scrollbar.scroll_offset = ms;
}
}
}
void filemanager_on_key(Window* win, const Montauk::KeyEvent& key) {
FileManagerState* fm = (FileManagerState*)win->app_data;
if (!fm || !key.pressed) return;
// ---- Path bar editing key handling ----
if (fm->pathbar_editing) {
if (key.ascii == '\n' || key.ascii == '\r') {
filemanager_commit_pathbar(fm);
} else if (key.scancode == 0x01) {
filemanager_cancel_pathbar(fm);
} else if (key.ascii == '\b' || key.scancode == 0x0E) {
if (fm->pathbar_cursor > 0) {
for (int i = fm->pathbar_cursor - 1; i < fm->pathbar_len - 1; i++)
fm->pathbar_buf[i] = fm->pathbar_buf[i + 1];
fm->pathbar_len--;
fm->pathbar_cursor--;
fm->pathbar_buf[fm->pathbar_len] = '\0';
}
} else if (key.scancode == 0x53) {
if (fm->pathbar_cursor < fm->pathbar_len) {
for (int i = fm->pathbar_cursor; i < fm->pathbar_len - 1; i++)
fm->pathbar_buf[i] = fm->pathbar_buf[i + 1];
fm->pathbar_len--;
fm->pathbar_buf[fm->pathbar_len] = '\0';
}
} else if (key.scancode == 0x4B) {
if (fm->pathbar_cursor > 0) fm->pathbar_cursor--;
} else if (key.scancode == 0x4D) {
if (fm->pathbar_cursor < fm->pathbar_len) fm->pathbar_cursor++;
} else if (key.scancode == 0x47) {
fm->pathbar_cursor = 0;
} else if (key.scancode == 0x4F) {
fm->pathbar_cursor = fm->pathbar_len;
} else if (key.ascii >= 32 && key.ascii < 127 && fm->pathbar_len < 254) {
for (int i = fm->pathbar_len; i > fm->pathbar_cursor; i--)
fm->pathbar_buf[i] = fm->pathbar_buf[i - 1];
fm->pathbar_buf[fm->pathbar_cursor] = key.ascii;
fm->pathbar_cursor++;
fm->pathbar_len++;
fm->pathbar_buf[fm->pathbar_len] = '\0';
}
return;
}
// ---- Rename mode key handling ----
if (fm->rename_active) {
if (key.ascii == '\n' || key.ascii == '\r') {
filemanager_finish_rename(fm);
} else if (key.scancode == 0x01) {
// Escape
filemanager_cancel_rename(fm);
} else if (key.ascii == '\b' || key.scancode == 0x0E) {
// Backspace
if (fm->rename_cursor > 0) {
for (int i = fm->rename_cursor - 1; i < fm->rename_len - 1; i++)
fm->rename_buf[i] = fm->rename_buf[i + 1];
fm->rename_len--;
fm->rename_cursor--;
fm->rename_buf[fm->rename_len] = '\0';
}
} else if (key.scancode == 0x53) {
// Delete key
if (fm->rename_cursor < fm->rename_len) {
for (int i = fm->rename_cursor; i < fm->rename_len - 1; i++)
fm->rename_buf[i] = fm->rename_buf[i + 1];
fm->rename_len--;
fm->rename_buf[fm->rename_len] = '\0';
}
} else if (key.scancode == 0x4B) {
// Left arrow
if (fm->rename_cursor > 0) fm->rename_cursor--;
} else if (key.scancode == 0x4D) {
// Right arrow
if (fm->rename_cursor < fm->rename_len) fm->rename_cursor++;
} else if (key.scancode == 0x47) {
// Home
fm->rename_cursor = 0;
} else if (key.scancode == 0x4F) {
// End
fm->rename_cursor = fm->rename_len;
} else if (key.ascii >= 32 && key.ascii < 127) {
// Printable character (reject / and other FS-unsafe chars)
if (key.ascii != '/' && key.ascii != '\\' && fm->rename_len < 62) {
for (int i = fm->rename_len; i > fm->rename_cursor; i--)
fm->rename_buf[i] = fm->rename_buf[i - 1];
fm->rename_buf[fm->rename_cursor] = key.ascii;
fm->rename_cursor++;
fm->rename_len++;
fm->rename_buf[fm->rename_len] = '\0';
}
}
return;
}
// ---- Context menu: Escape to close ----
if (fm->ctx_open && key.scancode == 0x01) {
filemanager_close_ctx_menu(fm);
return;
}
// ---- Keyboard shortcuts ----
if (key.ctrl) {
if (key.ascii == 'c' || key.ascii == 'C') {
filemanager_do_copy(fm);
return;
}
if (key.ascii == 'x' || key.ascii == 'X') {
filemanager_do_cut(fm);
return;
}
if (key.ascii == 'v' || key.ascii == 'V') {
filemanager_do_paste(fm);
return;
}
}
// F2 = rename
if (key.scancode == 0x3C) {
filemanager_start_rename(fm);
return;
}
if (key.ascii == '\b' || key.scancode == 0x0E) {
filemanager_go_up(fm);
} else if (key.scancode == 0x48) {
// Up arrow
if (fm->grid_view) {
Rect cr_r = win->content_rect();
int cols = (cr_r.w - FM_SCROLLBAR_W) / FM_GRID_CELL_W;
if (cols < 1) cols = 1;
if (fm->selected >= cols) fm->selected -= cols;
} else {
if (fm->selected > 0) fm->selected--;
}
} else if (key.scancode == 0x50) {
// Down arrow
if (fm->grid_view) {
Rect cr_r = win->content_rect();
int cols = (cr_r.w - FM_SCROLLBAR_W) / FM_GRID_CELL_W;
if (cols < 1) cols = 1;
if (fm->selected + cols < fm->entry_count) fm->selected += cols;
} else {
if (fm->selected < fm->entry_count - 1) fm->selected++;
}
} else if (key.scancode == 0x4B && !key.alt && fm->grid_view) {
// Left arrow (grid view only)
if (fm->selected > 0) fm->selected--;
} else if (key.scancode == 0x4D && !key.alt && fm->grid_view) {
// Right arrow (grid view only)
if (fm->selected < fm->entry_count - 1) fm->selected++;
} else if (key.ascii == '\n' || key.ascii == '\r') {
if (fm->selected >= 0 && fm->selected < fm->entry_count)
filemanager_open_entry(fm, fm->selected);
} else if (key.scancode == 0x53) {
// Delete key
filemanager_delete_selected(fm);
} else if (key.alt && key.scancode == 0x4B) {
// Alt+Left: go back
filemanager_go_back(fm);
} else if (key.alt && key.scancode == 0x4D) {
// Alt+Right: go forward
filemanager_go_forward(fm);
}
}
void filemanager_on_close(Window* win) {
if (win->app_data) {
FileManagerState* fm = (FileManagerState*)win->app_data;
filemanager_free_app_icons(fm);
montauk::mfree(win->app_data);
win->app_data = nullptr;
}
}
} // namespace filemanager
@@ -0,0 +1,160 @@
/*
* filemanager_internal.hpp
* Shared declarations for the embedded desktop file manager
* Copyright (c) 2026 Daniel Hammer
*/
#pragma once
#include "../apps_common.hpp"
namespace filemanager {
inline constexpr int FM_MAX_DRIVES = 16;
struct FileManagerState {
char current_path[256];
char history[16][256];
int history_pos;
int history_count;
char entry_names[64][64];
int entry_types[64]; // 0=file, 1=dir, 2=exec, 3=drive, 4=home, 5=apps, 6=app, 7=special_dir
int entry_sizes[64];
int entry_count;
int selected;
int scroll_offset;
bool is_dir[64];
int last_click_item;
uint64_t last_click_time;
Scrollbar scrollbar;
DesktopState* desktop;
bool grid_view;
bool at_drives_root;
int drive_indices[FM_MAX_DRIVES]; // which drive number each entry maps to
// Clipboard
char clipboard_path[256];
bool clipboard_has_data;
bool clipboard_is_cut;
// Context menu
bool ctx_open;
int ctx_x, ctx_y;
int ctx_target_idx; // -1 = background
int ctx_items[8];
int ctx_item_count;
int ctx_hover;
// Rename
bool rename_active;
int rename_idx;
char rename_buf[64];
int rename_cursor;
int rename_len;
// Path bar editing
bool pathbar_editing;
char pathbar_buf[256];
int pathbar_cursor;
int pathbar_len;
// Apps view
bool at_apps_view;
int app_map[16]; // entry index -> external_apps[] index
SvgIcon app_icons_lg[16]; // 48x48 icons for grid view
SvgIcon app_icons_sm[16]; // 16x16 icons for list view
int app_icon_count;
};
inline constexpr int FM_TOOLBAR_H = 32;
inline constexpr int FM_PATHBAR_H = 24;
inline constexpr int FM_HEADER_H = 20;
inline constexpr int FM_ITEM_H = 24;
inline constexpr int FM_SCROLLBAR_W = 12;
inline constexpr int FM_GRID_CELL_W = 80;
inline constexpr int FM_GRID_CELL_H = 80;
inline constexpr int FM_GRID_ICON = 48;
inline constexpr int FM_GRID_PAD = 4;
// Special user folders: name, icon filename, index into DesktopState arrays
inline constexpr int SF_COUNT = DesktopState::SPECIAL_FOLDER_COUNT;
inline constexpr const char* sf_names[SF_COUNT] = {
"Documents", "Desktop", "Music", "Videos", "Pictures", "Downloads"
};
inline constexpr const char* sf_icons[SF_COUNT] = {
"folder-blue-documents.svg", "folder-blue-desktop.svg",
"folder-blue-music.svg", "folder-blue-videos.svg",
"folder-blue-pictures.svg", "folder-blue-downloads.svg"
};
// Context menu action codes
inline constexpr int CTX_OPEN = 0;
inline constexpr int CTX_COPY = 1;
inline constexpr int CTX_CUT = 2;
inline constexpr int CTX_PASTE = 3;
inline constexpr int CTX_RENAME = 4;
inline constexpr int CTX_DELETE = 5;
inline constexpr int CTX_NEW_FOLDER = 6;
inline constexpr int CTX_MENU_W = 140;
inline constexpr int CTX_ITEM_H = 24;
int special_folder_index(const char* name);
const char* ctx_label(int action);
bool str_ends_with(const char* s, const char* suffix);
bool is_image_file(const char* name);
bool is_font_file(const char* name);
bool is_pdf_file(const char* name);
bool is_spreadsheet_file(const char* name);
bool is_wordprocessor_file(const char* name);
bool is_audio_file(const char* name);
bool is_video_file(const char* name);
int detect_file_type(const char* name, bool is_dir);
void filemanager_build_fullpath(char* out, int out_max, const char* dir, const char* name);
const char* path_basename(const char* path);
void filemanager_read_drives(FileManagerState* fm);
void filemanager_read_dir(FileManagerState* fm);
void filemanager_free_app_icons(FileManagerState* fm);
void filemanager_read_apps(FileManagerState* fm);
bool filemanager_delete_recursive(const char* path);
bool filemanager_copy_file(const char* src, const char* dst);
bool filemanager_copy_dir_recursive(const char* src, const char* dst);
void filemanager_do_copy(FileManagerState* fm);
void filemanager_do_cut(FileManagerState* fm);
void filemanager_do_paste(FileManagerState* fm);
void filemanager_start_rename(FileManagerState* fm);
void filemanager_finish_rename(FileManagerState* fm);
void filemanager_cancel_rename(FileManagerState* fm);
void filemanager_new_folder(FileManagerState* fm);
void filemanager_delete_selected(FileManagerState* fm);
void filemanager_open_ctx_menu(FileManagerState* fm, int local_x, int local_y, int target_idx);
void filemanager_close_ctx_menu(FileManagerState* fm);
void filemanager_push_history(FileManagerState* fm);
void filemanager_navigate(FileManagerState* fm, const char* name);
void filemanager_go_up(FileManagerState* fm);
void filemanager_navigate_to_history(FileManagerState* fm);
void filemanager_go_back(FileManagerState* fm);
void filemanager_go_forward(FileManagerState* fm);
void filemanager_go_home(FileManagerState* fm);
void filemanager_start_pathbar(FileManagerState* fm);
void filemanager_cancel_pathbar(FileManagerState* fm);
void filemanager_commit_pathbar(FileManagerState* fm);
void filemanager_open_entry(FileManagerState* fm, int idx);
void filemanager_draw_header(Canvas& c, FileManagerState* fm, Color toolbar_color, Color btn_bg);
void filemanager_on_draw(Window* win, Framebuffer& fb);
void filemanager_on_mouse(Window* win, MouseEvent& ev);
void filemanager_on_key(Window* win, const Montauk::KeyEvent& key);
void filemanager_on_close(Window* win);
} // namespace filemanager
@@ -0,0 +1,469 @@
/*
* filesystem.cpp
* Filesystem enumeration and copy/delete helpers for the embedded desktop file manager
* Copyright (c) 2026 Daniel Hammer
*/
#include "filemanager_internal.hpp"
#include <montauk/toml.h>
namespace filemanager {
void filemanager_read_drives(FileManagerState* fm) {
fm->entry_count = 0;
fm->at_drives_root = true;
fm->current_path[0] = '\0';
// Home folder entry (if we have a valid home directory)
DesktopState* ds = fm->desktop;
if (ds && ds->home_dir[0] != '\0') {
int i = fm->entry_count;
montauk::strncpy(fm->entry_names[i], "Home", 63);
fm->entry_types[i] = 4; // home
fm->entry_sizes[i] = 0;
fm->is_dir[i] = true;
fm->drive_indices[i] = -1;
fm->entry_count++;
}
// Apps entry
{
int i = fm->entry_count;
montauk::strncpy(fm->entry_names[i], "Apps", 63);
fm->entry_types[i] = 5; // apps
fm->entry_sizes[i] = 0;
fm->is_dir[i] = true;
fm->drive_indices[i] = -1;
fm->entry_count++;
}
// Special user folders (Documents, Desktop, Music, etc.) - only if they exist
if (ds && ds->home_dir[0] != '\0') {
for (int sf = 0; sf < SF_COUNT && fm->entry_count < 64; sf++) {
char probe_path[256];
montauk::strcpy(probe_path, ds->home_dir);
int plen = montauk::slen(probe_path);
if (plen > 0 && probe_path[plen - 1] != '/')
str_append(probe_path, "/", 256);
str_append(probe_path, sf_names[sf], 256);
// Probe: try open to check if the directory exists
int probe_fd = montauk::open(probe_path);
if (probe_fd >= 0) {
montauk::close(probe_fd);
int i = fm->entry_count;
montauk::strncpy(fm->entry_names[i], sf_names[sf], 63);
fm->entry_types[i] = 7; // special_dir
fm->entry_sizes[i] = 0;
fm->is_dir[i] = true;
fm->drive_indices[i] = sf; // special folder index
fm->entry_count++;
}
}
}
// Drive entries
int drives[FM_MAX_DRIVES];
int driveCount = montauk::drivelist(drives, FM_MAX_DRIVES);
for (int di = 0; di < driveCount; di++) {
int d = drives[di];
int i = fm->entry_count;
char probe[8];
if (d < 10) {
probe[0] = '0' + d;
probe[1] = ':';
probe[2] = '/';
probe[3] = '\0';
} else {
probe[0] = '1';
probe[1] = '0' + (d - 10);
probe[2] = ':';
probe[3] = '/';
probe[4] = '\0';
}
char label[64];
montauk::strcpy(label, "Drive ");
str_append(label, probe, 64);
montauk::strncpy(fm->entry_names[i], label, 63);
fm->entry_types[i] = 3; // drive
fm->entry_sizes[i] = 0;
fm->is_dir[i] = true;
fm->drive_indices[i] = d;
fm->entry_count++;
if (fm->entry_count >= 64) break;
}
fm->selected = -1;
fm->scroll_offset = 0;
fm->scrollbar.scroll_offset = 0;
fm->last_click_item = -1;
fm->last_click_time = 0;
}
void filemanager_read_dir(FileManagerState* fm) {
fm->at_drives_root = false;
if (fm->at_apps_view) {
fm->at_apps_view = false;
filemanager_free_app_icons(fm);
}
const char* names[64];
fm->entry_count = montauk::readdir(fm->current_path, names, 64);
if (fm->entry_count < 0) fm->entry_count = 0;
// readdir returns full paths from the VFS (e.g. "man/fetch.1" instead
// of just "fetch.1"). Compute the prefix to strip so we get basenames.
const char* after_drive = fm->current_path;
for (int k = 0; after_drive[k]; k++) {
if (after_drive[k] == ':' && after_drive[k + 1] == '/') {
after_drive += k + 2;
break;
}
}
char prefix[256] = {0};
int prefix_len = 0;
if (after_drive[0] != '\0') {
montauk::strcpy(prefix, after_drive);
prefix_len = montauk::slen(prefix);
if (prefix_len > 0 && prefix[prefix_len - 1] != '/') {
prefix[prefix_len++] = '/';
prefix[prefix_len] = '\0';
}
}
for (int i = 0; i < fm->entry_count; i++) {
const char* raw = names[i];
// Strip directory prefix if it matches
if (prefix_len > 0) {
bool match = true;
for (int k = 0; k < prefix_len; k++) {
if (raw[k] != prefix[k]) { match = false; break; }
}
if (match) raw += prefix_len;
}
montauk::strncpy(fm->entry_names[i], raw, 63);
int len = montauk::slen(fm->entry_names[i]);
// Detect directory
if (len > 0 && fm->entry_names[i][len - 1] == '/') {
fm->is_dir[i] = true;
fm->entry_names[i][len - 1] = '\0';
} else {
fm->is_dir[i] = false;
}
fm->entry_types[i] = detect_file_type(fm->entry_names[i], fm->is_dir[i]);
// Get file size
fm->entry_sizes[i] = 0;
if (!fm->is_dir[i]) {
char fullpath[512];
montauk::strcpy(fullpath, fm->current_path);
int plen = montauk::slen(fullpath);
if (plen > 0 && fullpath[plen - 1] != '/') {
str_append(fullpath, "/", 512);
}
str_append(fullpath, fm->entry_names[i], 512);
int fd = montauk::open(fullpath);
if (fd >= 0) {
fm->entry_sizes[i] = (int)montauk::getsize(fd);
montauk::close(fd);
}
}
}
// Sort: directories first, then alphabetical (case-insensitive)
for (int i = 1; i < fm->entry_count; i++) {
char tmp_name[64];
int tmp_type = fm->entry_types[i];
int tmp_size = fm->entry_sizes[i];
bool tmp_isdir = fm->is_dir[i];
montauk::strcpy(tmp_name, fm->entry_names[i]);
int j = i - 1;
while (j >= 0) {
bool swap = false;
if (tmp_isdir && !fm->is_dir[j]) {
swap = true;
} else if (tmp_isdir == fm->is_dir[j]) {
if (str_compare_ci(tmp_name, fm->entry_names[j]) < 0) {
swap = true;
}
}
if (!swap) break;
montauk::strcpy(fm->entry_names[j + 1], fm->entry_names[j]);
fm->entry_types[j + 1] = fm->entry_types[j];
fm->entry_sizes[j + 1] = fm->entry_sizes[j];
fm->is_dir[j + 1] = fm->is_dir[j];
j--;
}
montauk::strcpy(fm->entry_names[j + 1], tmp_name);
fm->entry_types[j + 1] = tmp_type;
fm->entry_sizes[j + 1] = tmp_size;
fm->is_dir[j + 1] = tmp_isdir;
}
fm->selected = -1;
fm->scroll_offset = 0;
fm->scrollbar.scroll_offset = 0;
fm->last_click_item = -1;
fm->last_click_time = 0;
}
void filemanager_free_app_icons(FileManagerState* fm) {
for (int i = 0; i < fm->app_icon_count; i++) {
if (fm->app_icons_lg[i].pixels) {
montauk::mfree(fm->app_icons_lg[i].pixels);
fm->app_icons_lg[i].pixels = nullptr;
}
if (fm->app_icons_sm[i].pixels) {
montauk::mfree(fm->app_icons_sm[i].pixels);
fm->app_icons_sm[i].pixels = nullptr;
}
}
fm->app_icon_count = 0;
}
void filemanager_read_apps(FileManagerState* fm) {
filemanager_free_app_icons(fm);
fm->at_drives_root = false;
fm->at_apps_view = true;
fm->entry_count = 0;
DesktopState* ds = fm->desktop;
if (!ds) return;
// Scan manifests directly (like desktop_scan_apps but load icons at FM sizes)
montauk::fmkdir("0:/apps");
const char* entries[32];
int count = montauk::readdir("0:/apps", entries, 32);
// Extract basenames from readdir results (strip "apps/" prefix)
for (int i = 0; i < count && fm->entry_count < 16; i++) {
const char* raw = entries[i];
// Strip "apps/" prefix
if (raw[0] == 'a' && raw[1] == 'p' && raw[2] == 'p' && raw[3] == 's' && raw[4] == '/')
raw += 5;
char dirname[64];
montauk::strncpy(dirname, raw, 63);
int dlen = montauk::slen(dirname);
if (dlen > 0 && dirname[dlen - 1] == '/') dirname[dlen - 1] = '\0';
if (dirname[0] == '\0') continue;
// Open manifest
char manifest_path[128];
snprintf(manifest_path, 128, "0:/apps/%s/manifest.toml", dirname);
int fh = montauk::open(manifest_path);
if (fh < 0) continue;
uint64_t sz = montauk::getsize(fh);
if (sz == 0 || sz > 4096) { montauk::close(fh); continue; }
char* text = (char*)montauk::malloc(sz + 1);
montauk::read(fh, (uint8_t*)text, 0, sz);
montauk::close(fh);
text[sz] = '\0';
auto doc = montauk::toml::parse(text);
montauk::mfree(text);
const char* name = doc.get_string("app.name", "Unknown");
const char* binary = doc.get_string("app.binary", "");
const char* icon_file = doc.get_string("app.icon", "");
int idx = fm->entry_count;
montauk::strncpy(fm->entry_names[idx], name, 63);
fm->entry_types[idx] = 6; // app entry
fm->entry_sizes[idx] = 0;
fm->is_dir[idx] = false;
// Store binary path in drive_indices as an app_map index
fm->app_map[idx] = -1;
// Find matching external_app by binary path
char bin_path[128];
snprintf(bin_path, 128, "0:/apps/%s/%s", dirname, binary);
for (int x = 0; x < ds->external_app_count; x++) {
if (montauk::streq(ds->external_apps[x].binary_path, bin_path)) {
fm->app_map[idx] = x;
break;
}
}
// Load icons at file manager sizes
fm->app_icons_lg[idx] = {};
fm->app_icons_sm[idx] = {};
if (icon_file[0]) {
char icon_path[128];
snprintf(icon_path, 128, "0:/apps/%s/%s", dirname, icon_file);
Color defColor = colors::ICON_COLOR;
fm->app_icons_lg[idx] = svg_load(icon_path, 48, 48, defColor);
fm->app_icons_sm[idx] = svg_load(icon_path, 16, 16, defColor);
}
doc.destroy();
fm->entry_count++;
fm->app_icon_count = fm->entry_count;
}
fm->selected = -1;
fm->scroll_offset = 0;
fm->scrollbar.scroll_offset = 0;
fm->last_click_item = -1;
fm->last_click_time = 0;
}
bool filemanager_delete_recursive(const char* path) {
// Try deleting as a file (or empty directory) first
if (montauk::fdelete(path) == 0) return true;
// If that failed, it may be a non-empty directory -- enumerate and delete children
const char* names[64];
int count = montauk::readdir(path, names, 64);
if (count < 0) return false;
// Compute the prefix to strip (readdir returns paths relative to drive root)
const char* after_drive = path;
for (int k = 0; after_drive[k]; k++) {
if (after_drive[k] == ':' && after_drive[k + 1] == '/') {
after_drive += k + 2;
break;
}
}
char prefix[256] = {0};
int prefix_len = 0;
if (after_drive[0] != '\0') {
montauk::strcpy(prefix, after_drive);
prefix_len = montauk::slen(prefix);
if (prefix_len > 0 && prefix[prefix_len - 1] != '/') {
prefix[prefix_len++] = '/';
prefix[prefix_len] = '\0';
}
}
for (int i = 0; i < count; i++) {
const char* raw = names[i];
if (prefix_len > 0) {
bool match = true;
for (int k = 0; k < prefix_len; k++) {
if (raw[k] != prefix[k]) { match = false; break; }
}
if (match) raw += prefix_len;
}
char child[512];
montauk::strcpy(child, path);
int plen = montauk::slen(child);
if (plen > 0 && child[plen - 1] != '/')
str_append(child, "/", 512);
str_append(child, raw, 512);
// Strip trailing slash if present (directory marker)
int clen = montauk::slen(child);
if (clen > 0 && child[clen - 1] == '/') child[clen - 1] = '\0';
filemanager_delete_recursive(child);
}
// Now the directory should be empty -- delete it
return montauk::fdelete(path) == 0;
}
bool filemanager_copy_file(const char* src, const char* dst) {
int sfd = montauk::open(src);
if (sfd < 0) return false;
int size = (int)montauk::getsize(sfd);
// Create destination
int dfd = montauk::fcreate(dst);
if (dfd < 0) {
montauk::close(sfd);
return false;
}
if (size > 0) {
// Cap at 4 MB to avoid exhausting memory
if (size > 4 * 1024 * 1024) {
montauk::close(sfd);
montauk::close(dfd);
montauk::fdelete(dst);
return false;
}
uint8_t* buf = (uint8_t*)montauk::malloc(size);
if (!buf) {
montauk::close(sfd);
montauk::close(dfd);
montauk::fdelete(dst);
return false;
}
montauk::read(sfd, buf, 0, size);
montauk::fwrite(dfd, buf, 0, size);
montauk::mfree(buf);
}
montauk::close(sfd);
montauk::close(dfd);
return true;
}
// Recursive directory copy
bool filemanager_copy_dir_recursive(const char* src, const char* dst) {
montauk::fmkdir(dst);
const char* names[64];
int count = montauk::readdir(src, names, 64);
if (count < 0) return false;
// Compute prefix to strip
const char* after_drive = src;
for (int k = 0; after_drive[k]; k++) {
if (after_drive[k] == ':' && after_drive[k + 1] == '/') {
after_drive += k + 2;
break;
}
}
char prefix[256] = {0};
int prefix_len = 0;
if (after_drive[0] != '\0') {
montauk::strcpy(prefix, after_drive);
prefix_len = montauk::slen(prefix);
if (prefix_len > 0 && prefix[prefix_len - 1] != '/') {
prefix[prefix_len++] = '/';
prefix[prefix_len] = '\0';
}
}
for (int i = 0; i < count; i++) {
const char* raw = names[i];
if (prefix_len > 0) {
bool match = true;
for (int k = 0; k < prefix_len; k++) {
if (raw[k] != prefix[k]) { match = false; break; }
}
if (match) raw += prefix_len;
}
bool child_is_dir = false;
char basename[64];
montauk::strncpy(basename, raw, 63);
int blen = montauk::slen(basename);
if (blen > 0 && basename[blen - 1] == '/') {
child_is_dir = true;
basename[blen - 1] = '\0';
}
char src_child[512], dst_child[512];
filemanager_build_fullpath(src_child, 512, src, basename);
filemanager_build_fullpath(dst_child, 512, dst, basename);
if (child_is_dir)
filemanager_copy_dir_recursive(src_child, dst_child);
else
filemanager_copy_file(src_child, dst_child);
}
return true;
}
} // namespace filemanager
@@ -0,0 +1,95 @@
/*
* helpers.cpp
* Shared helpers for the embedded desktop file manager
* Copyright (c) 2026 Daniel Hammer
*/
#include "filemanager_internal.hpp"
namespace filemanager {
int special_folder_index(const char* name) {
for (int i = 0; i < SF_COUNT; i++) {
if (montauk::streq(name, sf_names[i])) return i;
}
return -1;
}
const char* ctx_label(int action) {
switch (action) {
case CTX_OPEN: return "Open";
case CTX_COPY: return "Copy";
case CTX_CUT: return "Cut";
case CTX_PASTE: return "Paste";
case CTX_RENAME: return "Rename";
case CTX_DELETE: return "Delete";
case CTX_NEW_FOLDER: return "New Folder";
default: return "?";
}
}
bool str_ends_with(const char* s, const char* suffix) {
int slen = montauk::slen(s);
int suflen = montauk::slen(suffix);
if (suflen > slen) return false;
for (int i = 0; i < suflen; i++) {
char sc = s[slen - suflen + i];
char ec = suffix[i];
if (sc >= 'A' && sc <= 'Z') sc += 32;
if (ec >= 'A' && ec <= 'Z') ec += 32;
if (sc != ec) return false;
}
return true;
}
bool is_image_file(const char* name) {
return str_ends_with(name, ".jpg") || str_ends_with(name, ".jpeg");
}
bool is_font_file(const char* name) {
return str_ends_with(name, ".ttf");
}
bool is_pdf_file(const char* name) {
return str_ends_with(name, ".pdf");
}
bool is_spreadsheet_file(const char* name) {
return str_ends_with(name, ".mss");
}
bool is_wordprocessor_file(const char* name) {
return str_ends_with(name, ".mwp");
}
bool is_audio_file(const char* name) {
return str_ends_with(name, ".mp3") || str_ends_with(name, ".wav");
}
bool is_video_file(const char* name) {
return str_ends_with(name, ".avi");
}
int detect_file_type(const char* name, bool is_dir) {
if (is_dir) return 1;
if (str_ends_with(name, ".elf")) return 2;
return 0;
}
void filemanager_build_fullpath(char* out, int out_max, const char* dir, const char* name) {
montauk::strcpy(out, dir);
int plen = montauk::slen(out);
if (plen > 0 && out[plen - 1] != '/') str_append(out, "/", out_max);
str_append(out, name, out_max);
}
// Extract basename from a path (pointer into the path string)
const char* path_basename(const char* path) {
const char* last = path;
for (const char* p = path; *p; p++) {
if (*p == '/' && *(p + 1)) last = p + 1;
}
return last;
}
} // namespace filemanager
@@ -0,0 +1,233 @@
/*
* navigation.cpp
* Navigation and file opening logic for the embedded desktop file manager
* Copyright (c) 2026 Daniel Hammer
*/
#include "filemanager_internal.hpp"
namespace filemanager {
void filemanager_push_history(FileManagerState* fm) {
// Don't push if same as current position
if (fm->history_count > 0 && fm->history_pos >= 0) {
if (montauk::streq(fm->history[fm->history_pos], fm->current_path)) return;
}
fm->history_pos++;
if (fm->history_pos >= 16) fm->history_pos = 15;
montauk::strcpy(fm->history[fm->history_pos], fm->current_path);
fm->history_count = fm->history_pos + 1;
}
void filemanager_navigate(FileManagerState* fm, const char* name) {
int path_len = montauk::slen(fm->current_path);
if (path_len > 0 && fm->current_path[path_len - 1] != '/') {
str_append(fm->current_path, "/", 256);
}
str_append(fm->current_path, name, 256);
filemanager_push_history(fm);
filemanager_read_dir(fm);
}
void filemanager_go_up(FileManagerState* fm) {
if (fm->at_drives_root) return;
// From apps view, go back to Computer
if (fm->at_apps_view) {
fm->at_apps_view = false;
filemanager_free_app_icons(fm);
filemanager_read_drives(fm);
filemanager_push_history(fm);
return;
}
int len = montauk::slen(fm->current_path);
// At drive root (e.g. "0:/" or "10:/") -- go to drives view
bool is_drive_root = false;
if (len >= 3 && fm->current_path[len - 1] == '/') {
// Check if path is just "N:/" or "NN:/"
int colon = -1;
for (int i = 0; i < len; i++) {
if (fm->current_path[i] == ':') { colon = i; break; }
}
if (colon >= 0 && colon + 2 == len) is_drive_root = true;
}
if (is_drive_root) {
filemanager_read_drives(fm);
filemanager_push_history(fm);
return;
}
if (len > 0 && fm->current_path[len - 1] == '/') {
fm->current_path[len - 1] = '\0';
len--;
}
int last_slash = -1;
for (int i = len - 1; i >= 0; i--) {
if (fm->current_path[i] == '/') { last_slash = i; break; }
}
if (last_slash >= 0) {
// Keep the slash only if it's the root slash (right after "N:")
if (last_slash > 0 && fm->current_path[last_slash - 1] == ':') {
fm->current_path[last_slash + 1] = '\0';
} else {
fm->current_path[last_slash] = '\0';
}
}
filemanager_push_history(fm);
filemanager_read_dir(fm);
}
void filemanager_navigate_to_history(FileManagerState* fm) {
montauk::strcpy(fm->current_path, fm->history[fm->history_pos]);
if (fm->current_path[0] == '\0') {
filemanager_read_drives(fm);
} else {
filemanager_read_dir(fm);
}
}
void filemanager_go_back(FileManagerState* fm) {
if (fm->history_pos <= 0) return;
fm->history_pos--;
filemanager_navigate_to_history(fm);
}
void filemanager_go_forward(FileManagerState* fm) {
if (fm->history_pos >= fm->history_count - 1) return;
fm->history_pos++;
filemanager_navigate_to_history(fm);
}
void filemanager_go_home(FileManagerState* fm) {
if (fm->at_apps_view) {
fm->at_apps_view = false;
filemanager_free_app_icons(fm);
}
filemanager_read_drives(fm);
filemanager_push_history(fm);
}
void filemanager_start_pathbar(FileManagerState* fm) {
fm->pathbar_editing = true;
if (fm->at_drives_root) {
fm->pathbar_buf[0] = '\0';
fm->pathbar_len = 0;
} else {
montauk::strcpy(fm->pathbar_buf, fm->current_path);
fm->pathbar_len = montauk::slen(fm->pathbar_buf);
}
fm->pathbar_cursor = fm->pathbar_len;
}
void filemanager_cancel_pathbar(FileManagerState* fm) {
fm->pathbar_editing = false;
}
void filemanager_commit_pathbar(FileManagerState* fm) {
fm->pathbar_editing = false;
if (fm->pathbar_len == 0) {
filemanager_read_drives(fm);
filemanager_push_history(fm);
return;
}
montauk::strcpy(fm->current_path, fm->pathbar_buf);
filemanager_push_history(fm);
filemanager_read_dir(fm);
}
void filemanager_open_entry(FileManagerState* fm, int idx) {
if (idx < 0 || idx >= fm->entry_count) return;
// Special user folder in Computer view
if (fm->at_drives_root && fm->entry_types[idx] == 7) {
DesktopState* ds = fm->desktop;
if (ds && ds->home_dir[0] != '\0') {
montauk::strcpy(fm->current_path, ds->home_dir);
int plen = montauk::slen(fm->current_path);
if (plen > 0 && fm->current_path[plen - 1] != '/')
str_append(fm->current_path, "/", 256);
str_append(fm->current_path, fm->entry_names[idx], 256);
filemanager_push_history(fm);
filemanager_read_dir(fm);
}
return;
}
// Apps shortcut in Computer view
if (fm->at_drives_root && fm->entry_types[idx] == 5) {
filemanager_read_apps(fm);
return;
}
// App entry in apps view - launch it
if (fm->at_apps_view && fm->entry_types[idx] == 6) {
DesktopState* ds = fm->desktop;
if (ds && fm->app_map[idx] >= 0 && fm->app_map[idx] < ds->external_app_count) {
const ExternalApp& app = ds->external_apps[fm->app_map[idx]];
if (app.launch_with_home) {
montauk::spawn(app.binary_path, ds->home_dir);
} else {
montauk::spawn(app.binary_path);
}
}
return;
}
if (fm->at_drives_root && fm->entry_types[idx] == 4) {
// Home folder
DesktopState* ds = fm->desktop;
if (ds && ds->home_dir[0] != '\0') {
montauk::strcpy(fm->current_path, ds->home_dir);
filemanager_push_history(fm);
filemanager_read_dir(fm);
}
return;
}
if (fm->at_drives_root && fm->entry_types[idx] == 3) {
int d = fm->drive_indices[idx];
char dpath[8];
if (d < 10) {
dpath[0] = '0' + d; dpath[1] = ':'; dpath[2] = '/'; dpath[3] = '\0';
} else {
dpath[0] = '1'; dpath[1] = '0' + (d - 10); dpath[2] = ':'; dpath[3] = '/'; dpath[4] = '\0';
}
montauk::strcpy(fm->current_path, dpath);
filemanager_push_history(fm);
filemanager_read_dir(fm);
return;
}
if (fm->is_dir[idx]) {
filemanager_navigate(fm, fm->entry_names[idx]);
return;
}
// Open file with appropriate app
char fullpath[512];
filemanager_build_fullpath(fullpath, 512, fm->current_path, fm->entry_names[idx]);
if (is_image_file(fm->entry_names[idx])) {
montauk::spawn("0:/apps/imageviewer/imageviewer.elf", fullpath);
} else if (is_font_file(fm->entry_names[idx])) {
montauk::spawn("0:/apps/fontpreview/fontpreview.elf", fullpath);
} else if (is_pdf_file(fm->entry_names[idx])) {
montauk::spawn("0:/apps/pdfviewer/pdfviewer.elf", fullpath);
} else if (is_spreadsheet_file(fm->entry_names[idx])) {
montauk::spawn("0:/apps/spreadsheet/spreadsheet.elf", fullpath);
} else if (is_wordprocessor_file(fm->entry_names[idx])) {
montauk::spawn("0:/apps/wordprocessor/wordprocessor.elf", fullpath);
} else if (is_audio_file(fm->entry_names[idx])) {
montauk::spawn("0:/apps/music/music.elf", fullpath);
} else if (is_video_file(fm->entry_names[idx])) {
montauk::spawn("0:/apps/video/video.elf", fullpath);
} else if (str_ends_with(fm->entry_names[idx], ".elf")) {
montauk::spawn(fullpath);
} else {
montauk::spawn("0:/apps/texteditor/texteditor.elf", fullpath);
}
}
} // namespace filemanager
@@ -0,0 +1,421 @@
/*
* render.cpp
* Drawing code for the embedded desktop file manager
* Copyright (c) 2026 Daniel Hammer
*/
#include "filemanager_internal.hpp"
namespace filemanager {
void filemanager_draw_header(Canvas& c, FileManagerState* fm, Color toolbar_color, Color btn_bg) {
DesktopState* ds = fm->desktop;
// ---- Toolbar (32px) ----
c.fill_rect(0, 0, c.w, FM_TOOLBAR_H, toolbar_color);
// Navigation buttons: Back, Forward, Up, Home
struct ToolBtn { int x; SvgIcon* icon; };
ToolBtn nav_btns[4] = {
{ 4, ds ? &ds->icon_go_back : nullptr },
{ 32, ds ? &ds->icon_go_forward : nullptr },
{ 60, ds ? &ds->icon_go_up : nullptr },
{ 88, ds ? &ds->icon_home : nullptr },
};
for (int i = 0; i < 4; i++) {
int bx = nav_btns[i].x;
int by = 4;
c.fill_rect(bx, by, 24, 24, btn_bg);
if (nav_btns[i].icon) {
int ix = bx + (24 - nav_btns[i].icon->width) / 2;
int iy = by + (24 - nav_btns[i].icon->height) / 2;
c.icon(ix, iy, *nav_btns[i].icon);
}
}
// View toggle button
{
int bx = 120, by = 4;
c.fill_rect(bx, by, 24, 24, btn_bg);
if (fm->grid_view) {
for (int r = 0; r < 2; r++)
for (int cc = 0; cc < 2; cc++)
c.fill_rect(bx + 5 + cc * 8, by + 5 + r * 8, 6, 6, colors::TEXT_COLOR);
} else {
for (int r = 0; r < 3; r++)
c.fill_rect(bx + 5, by + 5 + r * 5, 14, 2, colors::TEXT_COLOR);
}
}
// Separator line between nav group and action group
c.vline(152, 6, 20, colors::BORDER);
// Action buttons: Copy, Cut, Paste, Rename, New Folder, Delete
bool has_sel = fm->selected >= 0 && fm->selected < fm->entry_count
&& !fm->at_drives_root && !fm->at_apps_view
&& fm->entry_types[fm->selected] != 3;
bool has_clip = fm->clipboard_has_data && !fm->at_drives_root && !fm->at_apps_view;
Color dim_bg = Color::from_rgb(0xF0, 0xF0, 0xF0);
// Icon toolbar buttons: Copy, Cut, Paste, Rename, New Folder, Delete
struct ActionBtn { int x; SvgIcon* icon; bool enabled; };
ActionBtn action_btns[] = {
{ 160, ds ? &ds->icon_copy : nullptr, has_sel },
{ 188, ds ? &ds->icon_cut : nullptr, has_sel },
{ 216, ds ? &ds->icon_paste : nullptr, has_clip },
{ 244, ds ? &ds->icon_rename : nullptr, has_sel },
{ 272, ds ? &ds->icon_folder_new : nullptr, !fm->at_drives_root && !fm->at_apps_view },
{ 300, ds ? &ds->icon_delete : nullptr, has_sel },
};
for (int i = 0; i < 6; i++) {
int bx = action_btns[i].x;
int by = 4;
c.fill_rect(bx, by, 24, 24, action_btns[i].enabled ? btn_bg : dim_bg);
if (action_btns[i].icon && action_btns[i].icon->pixels) {
int ix = bx + (24 - action_btns[i].icon->width) / 2;
int iy = by + (24 - action_btns[i].icon->height) / 2;
c.icon(ix, iy, *action_btns[i].icon);
}
}
// Toolbar separator
c.hline(0, FM_TOOLBAR_H - 1, c.w, colors::BORDER);
// ---- Path bar ----
int pathbar_y = FM_TOOLBAR_H;
if (fm->pathbar_editing) {
// Editable text input
c.fill_rect(0, pathbar_y, c.w, FM_PATHBAR_H, colors::WHITE);
c.rect(0, pathbar_y, c.w, FM_PATHBAR_H, colors::ACCENT);
int fh = system_font_height();
int ty = pathbar_y + (FM_PATHBAR_H - fh) / 2;
c.text(8, ty, fm->pathbar_buf, colors::TEXT_COLOR);
// Cursor
char prefix[256];
int cpos = fm->pathbar_cursor;
if (cpos > 255) cpos = 255;
for (int k = 0; k < cpos; k++) prefix[k] = fm->pathbar_buf[k];
prefix[cpos] = '\0';
int cx = 8 + text_width(prefix);
c.vline(cx, ty, fh, colors::ACCENT);
} else {
c.fill_rect(0, pathbar_y, c.w, FM_PATHBAR_H, Color::from_rgb(0xF0, 0xF0, 0xF0));
const char* pathbar_text = fm->current_path;
if (fm->at_drives_root) pathbar_text = "Computer";
else if (fm->at_apps_view) pathbar_text = "Apps";
c.text(8, pathbar_y + 4, pathbar_text, colors::TEXT_COLOR);
}
// Path bar separator
c.hline(0, pathbar_y + FM_PATHBAR_H - 1, c.w, colors::BORDER);
}
void filemanager_on_draw(Window* win, Framebuffer& fb) {
FileManagerState* fm = (FileManagerState*)win->app_data;
if (!fm) return;
Canvas c(win);
c.fill(colors::WINDOW_BG);
Color toolbar_color = Color::from_rgb(0xF5, 0xF5, 0xF5);
Color btn_bg = Color::from_rgb(0xE8, 0xE8, 0xE8);
Color dim = Color::from_rgb(0x88, 0x88, 0x88);
DesktopState* ds = fm->desktop;
// Draw header (toolbar + path bar)
filemanager_draw_header(c, fm, toolbar_color, btn_bg);
if (fm->grid_view) {
// ---- Grid View ----
int list_y = FM_TOOLBAR_H + FM_PATHBAR_H;
int list_h = c.h - list_y;
int cols = (c.w - FM_SCROLLBAR_W) / FM_GRID_CELL_W;
if (cols < 1) cols = 1;
int rows = (fm->entry_count + cols - 1) / cols;
int content_h = rows * FM_GRID_CELL_H;
// Update scrollbar
fm->scrollbar.bounds = {c.w - FM_SCROLLBAR_W, list_y, FM_SCROLLBAR_W, list_h};
fm->scrollbar.content_height = content_h;
fm->scrollbar.view_height = list_h;
for (int i = 0; i < fm->entry_count; i++) {
int col = i % cols;
int row = i / cols;
int cell_x = col * FM_GRID_CELL_W;
int cell_y = list_y + row * FM_GRID_CELL_H - fm->scrollbar.scroll_offset;
// Skip if entirely off-screen
if (cell_y + FM_GRID_CELL_H <= list_y || cell_y >= c.h) continue;
// Selection highlight
if (i == fm->selected) {
int sy = gui_max(cell_y, list_y);
int sh = gui_min(cell_y + FM_GRID_CELL_H, c.h) - sy;
int sw = gui_min(FM_GRID_CELL_W, c.w - FM_SCROLLBAR_W - cell_x);
if (sh > 0 && sw > 0)
c.fill_rect(cell_x, sy, sw, sh, colors::MENU_HOVER);
}
// Large icon centered horizontally
int icon_x = cell_x + (FM_GRID_CELL_W - FM_GRID_ICON) / 2;
int icon_y = cell_y + FM_GRID_PAD;
// Check for special folder icon (type 7 in Computer, or type 1 dir with matching name)
int sfi = -1;
if (fm->entry_types[i] == 7) sfi = fm->drive_indices[i];
else if (ds && fm->entry_types[i] == 1) sfi = special_folder_index(fm->entry_names[i]);
if (sfi >= 0 && ds && ds->icon_special_folder_lg[sfi].pixels) {
c.icon(icon_x, icon_y, ds->icon_special_folder_lg[sfi]);
} else if (fm->entry_types[i] == 6 && i < fm->app_icon_count && fm->app_icons_lg[i].pixels) {
c.icon(icon_x, icon_y, fm->app_icons_lg[i]);
} else if (ds && fm->entry_types[i] == 5 && ds->icon_apps_lg.pixels) {
c.icon(icon_x, icon_y, ds->icon_apps_lg);
} else if (ds && fm->entry_types[i] == 4 && ds->icon_home_folder_lg.pixels) {
c.icon(icon_x, icon_y, ds->icon_home_folder_lg);
} else if (ds && fm->entry_types[i] == 3 && ds->icon_drive_lg.pixels) {
c.icon(icon_x, icon_y, ds->icon_drive_lg);
} else if (ds && fm->entry_types[i] == 1 && ds->icon_folder_lg.pixels) {
c.icon(icon_x, icon_y, ds->icon_folder_lg);
} else if (ds && fm->entry_types[i] == 2 && ds->icon_exec_lg.pixels) {
c.icon(icon_x, icon_y, ds->icon_exec_lg);
} else if (ds && ds->icon_file_lg.pixels) {
c.icon(icon_x, icon_y, ds->icon_file_lg);
} else {
Color icon_c = fm->is_dir[i]
? Color::from_rgb(0xFF, 0xBD, 0x2E)
: Color::from_rgb(0x90, 0x90, 0x90);
int iy_clip = gui_max(icon_y, list_y);
int ih_clip = FM_GRID_ICON - (iy_clip - icon_y);
if (ih_clip > 0)
c.fill_rect(icon_x, iy_clip, FM_GRID_ICON, ih_clip, icon_c);
}
// Filename centered below icon, truncated if needed
// If renaming this entry, draw the rename textbox instead
if (fm->rename_active && fm->rename_idx == i) {
int ty = icon_y + FM_GRID_ICON + 2;
int tw = FM_GRID_CELL_W - 4;
if (ty >= list_y && ty + system_font_height() + 4 <= c.h) {
c.fill_rect(cell_x + 2, ty - 1, tw, system_font_height() + 2, colors::WHITE);
c.rect(cell_x + 2, ty - 1, tw, system_font_height() + 2, colors::ACCENT);
// Draw rename text (truncated to cell width)
char display[16];
int rlen = fm->rename_len;
if (rlen > 10) rlen = 10;
for (int k = 0; k < rlen; k++) display[k] = fm->rename_buf[k];
display[rlen] = '\0';
c.text(cell_x + 4, ty, display, colors::TEXT_COLOR);
// Cursor
char prefix[64];
int cpos = fm->rename_cursor < rlen ? fm->rename_cursor : rlen;
for (int k = 0; k < cpos; k++) prefix[k] = fm->rename_buf[k];
prefix[cpos] = '\0';
int cx = cell_x + 4 + text_width(prefix);
c.vline(cx, ty, system_font_height(), colors::ACCENT);
}
} else {
char label[16];
int nlen = montauk::slen(fm->entry_names[i]);
if (nlen > 9) {
for (int k = 0; k < 9; k++) label[k] = fm->entry_names[i][k];
label[9] = '.';
label[10] = '.';
label[11] = '\0';
} else {
montauk::strncpy(label, fm->entry_names[i], 15);
}
int tw = text_width(label);
int tx = cell_x + (FM_GRID_CELL_W - tw) / 2;
if (tx < cell_x) tx = cell_x;
int ty = icon_y + FM_GRID_ICON + 2;
if (ty >= list_y && ty + system_font_height() <= c.h)
c.text(tx, ty, label, colors::TEXT_COLOR);
}
}
} else {
// ---- List View ----
// ---- Column headers ----
int header_y = FM_TOOLBAR_H + FM_PATHBAR_H;
c.fill_rect(0, header_y, c.w, FM_HEADER_H, Color::from_rgb(0xF8, 0xF8, 0xF8));
int name_col_x = 8;
int size_col_x = c.w - FM_SCROLLBAR_W - 120;
int type_col_x = c.w - FM_SCROLLBAR_W - 60;
c.text(name_col_x, header_y + 2, "Name", dim);
if (size_col_x > 100)
c.text(size_col_x, header_y + 2, "Size", dim);
if (type_col_x > 160)
c.text(type_col_x, header_y + 2, "Type", dim);
// Header separator
c.hline(0, header_y + FM_HEADER_H - 1, c.w, colors::BORDER);
// Column separator lines
if (size_col_x > 100) {
c.vline(size_col_x - 4, header_y, c.h - header_y, colors::BORDER);
}
// ---- File entries ----
int list_y = header_y + FM_HEADER_H;
int list_h = c.h - list_y;
int visible_items = list_h / FM_ITEM_H;
int content_h = fm->entry_count * FM_ITEM_H;
// Update scrollbar
fm->scrollbar.bounds = {c.w - FM_SCROLLBAR_W, list_y, FM_SCROLLBAR_W, list_h};
fm->scrollbar.content_height = content_h;
fm->scrollbar.view_height = list_h;
int scroll_items = fm->scrollbar.scroll_offset / FM_ITEM_H;
for (int i = scroll_items; i < fm->entry_count && (i - scroll_items) < visible_items + 1; i++) {
int iy = list_y + (i - scroll_items) * FM_ITEM_H - (fm->scrollbar.scroll_offset % FM_ITEM_H);
if (iy + FM_ITEM_H <= list_y || iy >= c.h) continue;
// Highlight selected
if (i == fm->selected) {
int sy = gui_max(iy, list_y);
int sh = gui_min(iy + FM_ITEM_H, c.h) - sy;
if (sh > 0)
c.fill_rect(0, sy, c.w - FM_SCROLLBAR_W, sh, colors::MENU_HOVER);
}
// Icon (skip if it would bleed above the list area)
int ico_x = 8;
int ico_y = iy + (FM_ITEM_H - 16) / 2;
// Check for special folder icon
int sfi_sm = -1;
if (fm->entry_types[i] == 7) sfi_sm = fm->drive_indices[i];
else if (ds && fm->entry_types[i] == 1) sfi_sm = special_folder_index(fm->entry_names[i]);
if (ico_y < list_y) { /* clipped by header repaint */ }
else if (sfi_sm >= 0 && ds && ds->icon_special_folder[sfi_sm].pixels) {
c.icon(ico_x, ico_y, ds->icon_special_folder[sfi_sm]);
} else if (fm->entry_types[i] == 6 && i < fm->app_icon_count && fm->app_icons_sm[i].pixels) {
c.icon(ico_x, ico_y, fm->app_icons_sm[i]);
} else if (ds && fm->entry_types[i] == 5 && ds->icon_apps.pixels) {
c.icon(ico_x, ico_y, ds->icon_apps);
} else if (ds && fm->entry_types[i] == 4 && ds->icon_home_folder.pixels) {
c.icon(ico_x, ico_y, ds->icon_home_folder);
} else if (ds && fm->entry_types[i] == 3 && ds->icon_drive.pixels) {
c.icon(ico_x, ico_y, ds->icon_drive);
} else if (ds && fm->entry_types[i] == 1 && ds->icon_folder.pixels) {
c.icon(ico_x, ico_y, ds->icon_folder);
} else if (ds && fm->entry_types[i] == 2 && ds->icon_exec.pixels) {
c.icon(ico_x, ico_y, ds->icon_exec);
} else if (ds && ds->icon_file.pixels) {
c.icon(ico_x, ico_y, ds->icon_file);
} else {
Color icon_c = fm->is_dir[i]
? Color::from_rgb(0xFF, 0xBD, 0x2E)
: Color::from_rgb(0x90, 0x90, 0x90);
int iy_clip = gui_max(ico_y, list_y);
int ih_clip = 16 - (iy_clip - ico_y);
if (ih_clip > 0)
c.fill_rect(ico_x, iy_clip, 16, ih_clip, icon_c);
}
// Name (or rename textbox)
int tx = 30;
int fm_sfh = system_font_height();
int ty = iy + (FM_ITEM_H - fm_sfh) / 2;
if (fm->rename_active && fm->rename_idx == i && ty >= list_y && ty + fm_sfh <= c.h) {
// Rename textbox inline
int rw = (size_col_x > 100 ? size_col_x - 8 : c.w - FM_SCROLLBAR_W - 8) - tx;
c.fill_rect(tx - 2, ty - 1, rw, fm_sfh + 2, colors::WHITE);
c.rect(tx - 2, ty - 1, rw, fm_sfh + 2, colors::ACCENT);
c.text(tx, ty, fm->rename_buf, colors::TEXT_COLOR);
// Cursor
char prefix[64];
int cpos = fm->rename_cursor;
if (cpos > 63) cpos = 63;
for (int k = 0; k < cpos; k++) prefix[k] = fm->rename_buf[k];
prefix[cpos] = '\0';
int cx = tx + text_width(prefix);
c.vline(cx, ty, fm_sfh, colors::ACCENT);
} else {
if (ty >= list_y && ty + fm_sfh <= c.h)
c.text(tx, ty, fm->entry_names[i], colors::TEXT_COLOR);
}
// Size
if (size_col_x > 100 && !fm->is_dir[i] && ty >= list_y && ty + fm_sfh <= c.h) {
char size_str[16];
format_size(size_str, fm->entry_sizes[i]);
c.text(size_col_x, ty, size_str, dim);
}
// Type
if (type_col_x > 160 && ty >= list_y && ty + fm_sfh <= c.h) {
const char* type_str = "File";
if (fm->entry_types[i] == 6) type_str = "App";
else if (fm->entry_types[i] == 5) type_str = "Apps";
else if (fm->entry_types[i] == 4) type_str = "Home";
else if (fm->entry_types[i] == 3) type_str = "Drive";
else if (fm->entry_types[i] == 1) type_str = "Dir";
else if (fm->entry_types[i] == 2) type_str = "Exec";
c.text(type_col_x, ty, type_str, dim);
}
}
}
// ---- Scrollbar ----
if (fm->scrollbar.content_height > fm->scrollbar.view_height) {
Color sb_fg_color = (fm->scrollbar.hovered || fm->scrollbar.dragging)
? fm->scrollbar.hover_fg : fm->scrollbar.fg;
int sbx = fm->scrollbar.bounds.x;
int sby = fm->scrollbar.bounds.y;
int sbw = fm->scrollbar.bounds.w;
int sbh = fm->scrollbar.bounds.h;
c.fill_rect(sbx, sby, sbw, sbh, colors::SCROLLBAR_BG);
int th = fm->scrollbar.thumb_height();
int tty = fm->scrollbar.thumb_y();
c.fill_rect(sbx + 1, tty, sbw - 2, th, sb_fg_color);
}
// Repaint header on top of content to clip scrolled icon overflow
if (fm->scrollbar.scroll_offset > 0)
filemanager_draw_header(c, fm, toolbar_color, btn_bg);
// ---- Context menu overlay ----
if (fm->ctx_open && fm->ctx_item_count > 0) {
int cmx = fm->ctx_x;
int cmy = fm->ctx_y;
int cmh = fm->ctx_item_count * CTX_ITEM_H + 8;
// Clamp to window bounds
if (cmx + CTX_MENU_W > c.w) cmx = c.w - CTX_MENU_W;
if (cmy + cmh > c.h) cmy = c.h - cmh;
if (cmx < 0) cmx = 0;
if (cmy < 0) cmy = 0;
// Shadow
c.fill_rect(cmx + 2, cmy + 2, CTX_MENU_W, cmh, Color::from_rgb(0x80, 0x80, 0x80));
// Background
c.fill_rounded_rect(cmx, cmy, CTX_MENU_W, cmh, 4, colors::WHITE);
c.rect(cmx, cmy, CTX_MENU_W, cmh, colors::BORDER);
for (int i = 0; i < fm->ctx_item_count; i++) {
int iy = cmy + 4 + i * CTX_ITEM_H;
// Hover highlight
if (i == fm->ctx_hover)
c.fill_rect(cmx + 2, iy, CTX_MENU_W - 4, CTX_ITEM_H, colors::MENU_HOVER);
int fh = system_font_height();
int ty = iy + (CTX_ITEM_H - fh) / 2;
c.text(cmx + 12, ty, ctx_label(fm->ctx_items[i]), colors::TEXT_COLOR);
}
}
}
} // namespace filemanager
@@ -0,0 +1,50 @@
/*
* about.cpp
* About tab for the embedded desktop settings app
* Copyright (c) 2026 Daniel Hammer
*/
#include "settings_internal.hpp"
namespace settings_app {
void settings_draw_about(Canvas& c, SettingsState* st) {
st->uptime_ms = montauk::get_milliseconds();
Color dim = Color::from_rgb(0x88, 0x88, 0x88);
int x = 16;
int y = 20;
char line[128];
int sfh = system_font_height();
int line_h = sfh + 6;
// OS name in 2x size
c.text_2x(x, y, st->sys_info.osName, st->desktop->settings.accent_color);
int large_h = (fonts::system_font && fonts::system_font->valid)
? fonts::system_font->get_line_height(fonts::LARGE_SIZE) : (FONT_HEIGHT * 2);
y += large_h + 8;
snprintf(line, sizeof(line), "Version %s", st->sys_info.osVersion);
c.text(x, y, line, colors::TEXT_COLOR);
y += line_h + 8;
c.hline(x, y, c.w - 2 * x, colors::BORDER);
y += 12;
snprintf(line, sizeof(line), "API version: %d", (int)st->sys_info.apiVersion);
c.kv_line(x, &y, line, colors::TEXT_COLOR, line_h);
int up_sec = (int)(st->uptime_ms / 1000);
int up_min = up_sec / 60;
int up_hr = up_min / 60;
snprintf(line, sizeof(line), "Uptime: %d:%02d:%02d", up_hr, up_min % 60, up_sec % 60);
c.kv_line(x, &y, line, colors::TEXT_COLOR, line_h);
y += 8;
c.hline(x, y, c.w - 2 * x, colors::BORDER);
y += 12;
c.text(x, y, "Copyright (c) 2026 Daniel Hammer", dim);
}
} // namespace settings_app
+128
View File
@@ -0,0 +1,128 @@
/*
* app.cpp
* Settings launcher and desktop integration entry points
* Copyright (c) 2026 Daniel Hammer
*/
#include "settings_internal.hpp"
namespace settings_app {
static void settings_on_draw(Window* win, Framebuffer& fb) {
SettingsState* st = (SettingsState*)win->app_data;
if (!st) return;
Canvas c(win);
c.fill(colors::WINDOW_BG);
Color accent = st->desktop->settings.accent_color;
int sfh = system_font_height();
// Draw tab bar background
c.fill_rect(0, 0, c.w, TAB_BAR_H, Color::from_rgb(0xF5, 0xF5, 0xF5));
c.hline(0, TAB_BAR_H - 1, c.w, colors::BORDER);
// Draw tabs
int tab_w = c.w / TAB_COUNT;
for (int i = 0; i < TAB_COUNT; i++) {
int tx = i * tab_w;
bool active = (i == st->active_tab);
if (active) {
c.fill_rect(tx, 0, tab_w, TAB_BAR_H, colors::WINDOW_BG);
// Active tab underline
c.fill_rect(tx + 4, TAB_BAR_H - 3, tab_w - 8, 3, accent);
}
int tw = text_width(tab_labels[i]);
Color tc = active ? accent : Color::from_rgb(0x66, 0x66, 0x66);
c.text(tx + (tab_w - tw) / 2, (TAB_BAR_H - sfh) / 2, tab_labels[i], tc);
}
// Draw tab content below the tab bar
Canvas content(win->content + TAB_BAR_H * win->content_w,
win->content_w, win->content_h - TAB_BAR_H);
switch (st->active_tab) {
case 0: settings_draw_appearance(content, st); break;
case 1: settings_draw_display(content, st); break;
case 2: settings_draw_users(content, st); break;
case 3: settings_draw_about(content, st); break;
}
}
static void settings_on_mouse(Window* win, MouseEvent& ev) {
SettingsState* st = (SettingsState*)win->app_data;
if (!st || !ev.left_pressed()) return;
Rect cr = win->content_rect();
int mx = ev.x - cr.x;
int my = ev.y - cr.y;
// Tab bar click
if (my >= 0 && my < TAB_BAR_H) {
int tab_w = win->content_w / TAB_COUNT;
int tab = mx / tab_w;
if (tab >= 0 && tab < TAB_COUNT) {
st->active_tab = tab;
}
return;
}
// Content area (offset by TAB_BAR_H)
int cy = my - TAB_BAR_H;
switch (st->active_tab) {
case 0:
settings_handle_appearance_click(win, st, mx, cy);
break;
case 1:
settings_handle_display_click(st, mx, cy);
break;
case 2:
settings_handle_users_click(win, st, mx, cy);
break;
default:
break;
}
}
static void settings_on_key(Window* win, const Montauk::KeyEvent& key) {
// Settings main window no longer needs keyboard handling.
(void)win;
(void)key;
}
static void settings_on_close(Window* win) {
if (win->app_data) {
montauk::mfree(win->app_data);
win->app_data = nullptr;
}
}
} // namespace settings_app
void open_settings(DesktopState* ds) {
int idx = desktop_create_window(ds, "Settings", 200, 100, 480, 420);
if (idx < 0) return;
Window* win = &ds->windows[idx];
settings_app::SettingsState* st =
(settings_app::SettingsState*)montauk::malloc(sizeof(settings_app::SettingsState));
montauk::memset(st, 0, sizeof(settings_app::SettingsState));
st->desktop = ds;
st->active_tab = 0;
montauk::get_info(&st->sys_info);
st->uptime_ms = montauk::get_milliseconds();
st->wp_scanned = false;
st->wp_files.count = 0;
st->selected_user = -1;
st->users_loaded = false;
st->status_msg[0] = '\0';
win->app_data = st;
win->on_draw = settings_app::settings_on_draw;
win->on_mouse = settings_app::settings_on_mouse;
win->on_key = settings_app::settings_on_key;
win->on_close = settings_app::settings_on_close;
}
@@ -0,0 +1,234 @@
/*
* appearance.cpp
* Appearance tab for the embedded desktop settings app
* Copyright (c) 2026 Daniel Hammer
*/
#include "settings_internal.hpp"
namespace settings_app {
void settings_draw_appearance(Canvas& c, SettingsState* st) {
DesktopSettings& s = st->desktop->settings;
Color accent = s.accent_color;
Color dim = Color::from_rgb(0x88, 0x88, 0x88);
int x = 16;
int y = 12;
int sfh = system_font_height();
int line_h = sfh + 10;
// Section: Background
c.text(x, y, "Background", colors::TEXT_COLOR);
y += line_h;
// Radio buttons: Gradient / Solid Color / Image
bool mode_grad = s.bg_gradient && !s.bg_image;
bool mode_solid = !s.bg_gradient && !s.bg_image;
bool mode_image = s.bg_image;
draw_radio(c, x, y, mode_grad, accent);
c.text(x + 20, y + 2, "Gradient", colors::TEXT_COLOR);
draw_radio(c, x + 120, y, mode_solid, accent);
c.text(x + 140, y + 2, "Solid", colors::TEXT_COLOR);
draw_radio(c, x + 220, y, mode_image, accent);
c.text(x + 240, y + 2, "Image", colors::TEXT_COLOR);
y += line_h + 4;
if (mode_image) {
// Scan for images lazily
if (!st->wp_scanned) {
wallpaper_scan_home(st->desktop->home_dir, &st->wp_files);
st->wp_scanned = true;
}
c.text(x, y, "Wallpapers", dim);
y += sfh + 6;
if (st->wp_files.count == 0) {
c.text(x + 8, y, "No .jpg files found", dim);
y += WP_ITEM_H;
} else {
for (int i = 0; i < st->wp_files.count && i < 8; i++) {
// Build full path for comparison
char fullpath[256];
montauk::strcpy(fullpath, st->desktop->home_dir);
str_append(fullpath, "/", 256);
str_append(fullpath, st->wp_files.names[i], 256);
bool selected = s.bg_image &&
montauk::streq(s.bg_image_path, fullpath);
if (selected) {
c.fill_rounded_rect(x, y, c.w - 2 * x, WP_ITEM_H, 3, accent);
}
// Truncate long filenames
char label[40];
int nlen = montauk::slen(st->wp_files.names[i]);
if (nlen > 35) {
montauk::strncpy(label, st->wp_files.names[i], 32);
label[32] = '.';
label[33] = '.';
label[34] = '.';
label[35] = '\0';
} else {
montauk::strncpy(label, st->wp_files.names[i], 39);
}
Color tc = selected ? colors::WHITE : colors::TEXT_COLOR;
c.text(x + 8, y + (WP_ITEM_H - sfh) / 2, label, tc);
y += WP_ITEM_H;
}
}
y += 6;
} else if (mode_grad) {
// Top color swatches
c.text(x, y + 4, "Top", dim);
draw_swatch_row(c, x + 70, y, bg_palette, s.bg_grad_top, accent);
y += SWATCH_SIZE + 14;
// Bottom color swatches
c.text(x, y + 4, "Bottom", dim);
draw_swatch_row(c, x + 70, y, bg_palette, s.bg_grad_bottom, accent);
y += SWATCH_SIZE + 14;
} else {
// Solid color swatches
c.text(x, y + 4, "Color", dim);
draw_swatch_row(c, x + 70, y, bg_palette, s.bg_solid, accent);
y += SWATCH_SIZE + 14;
}
// Separator
c.hline(x, y, c.w - 2 * x, colors::BORDER);
y += 12;
// Panel color
c.text(x, y + 4, "Panel Color", colors::TEXT_COLOR);
draw_swatch_row(c, x + 110, y, panel_palette, s.panel_color, accent);
y += SWATCH_SIZE + 14;
// Separator
c.hline(x, y, c.w - 2 * x, colors::BORDER);
y += 12;
// Accent color
c.text(x, y + 4, "Accent Color", colors::TEXT_COLOR);
draw_swatch_row(c, x + 110, y, accent_palette, s.accent_color, accent);
}
bool settings_handle_appearance_click(Window* win, SettingsState* st, int mx, int cy) {
DesktopSettings& s = st->desktop->settings;
int x = 16;
int sfh = system_font_height();
int line_h = sfh + 10;
int y = 12;
bool mode_grad = s.bg_gradient && !s.bg_image;
bool mode_image = s.bg_image;
// "Background" label
y += line_h;
// Radio: Gradient
if (mx >= x && mx < x + 100 && cy >= y && cy < y + 16) {
s.bg_gradient = true;
s.bg_image = false;
settings_persist(st);
return true;
}
// Radio: Solid
if (mx >= x + 120 && mx < x + 210 && cy >= y && cy < y + 16) {
s.bg_gradient = false;
s.bg_image = false;
settings_persist(st);
return true;
}
// Radio: Image
if (mx >= x + 220 && mx < x + 320 && cy >= y && cy < y + 16) {
s.bg_image = true;
s.bg_gradient = false;
if (!st->wp_scanned) {
wallpaper_scan_home(st->desktop->home_dir, &st->wp_files);
st->wp_scanned = true;
}
settings_persist(st);
return true;
}
y += line_h + 4;
int idx;
if (mode_image) {
// Wallpaper file list
y += sfh + 6;
// File list clicks
for (int i = 0; i < st->wp_files.count && i < 8; i++) {
if (cy >= y && cy < y + WP_ITEM_H &&
mx >= x && mx < win->content_w - x) {
// Build full path and load wallpaper
char fullpath[256];
montauk::strcpy(fullpath, st->desktop->home_dir);
str_append(fullpath, "/", 256);
str_append(fullpath, st->wp_files.names[i], 256);
wallpaper_load(&s, fullpath,
st->desktop->screen_w, st->desktop->screen_h);
settings_persist(st);
return true;
}
y += WP_ITEM_H;
}
if (st->wp_files.count == 0) y += WP_ITEM_H;
y += 6;
} else if (mode_grad) {
// Top swatches
if (swatch_hit(mx, cy, x + 70, y, &idx)) {
s.bg_grad_top = bg_palette[idx];
settings_persist(st);
return true;
}
y += SWATCH_SIZE + 14;
// Bottom swatches
if (swatch_hit(mx, cy, x + 70, y, &idx)) {
s.bg_grad_bottom = bg_palette[idx];
settings_persist(st);
return true;
}
y += SWATCH_SIZE + 14;
} else {
// Solid color swatches
if (swatch_hit(mx, cy, x + 70, y, &idx)) {
s.bg_solid = bg_palette[idx];
settings_persist(st);
return true;
}
y += SWATCH_SIZE + 14;
}
// Separator
y += 12;
// Panel color swatches
if (swatch_hit(mx, cy, x + 110, y, &idx)) {
s.panel_color = panel_palette[idx];
settings_persist(st);
return true;
}
y += SWATCH_SIZE + 14;
// Separator
y += 12;
// Accent color swatches
if (swatch_hit(mx, cy, x + 110, y, &idx)) {
s.accent_color = accent_palette[idx];
settings_persist(st);
return true;
}
return false;
}
} // namespace settings_app
@@ -0,0 +1,519 @@
/*
* dialogs.cpp
* User-management dialogs for the embedded desktop settings app
* Copyright (c) 2026 Daniel Hammer
*/
#include "settings_internal.hpp"
namespace settings_app {
static void dialog_append(char* buf, int* len, int max, char ch) {
if (*len < max - 1) { buf[*len] = ch; (*len)++; buf[*len] = '\0'; }
}
static void dialog_backspace(char* buf, int* len) {
if (*len > 0) { (*len)--; buf[*len] = '\0'; }
}
static void dialog_close_self(DesktopState* ds, void* app_data) {
for (int i = 0; i < ds->window_count; i++) {
if (ds->windows[i].app_data == app_data) {
desktop_close_window(ds, i);
return;
}
}
}
static void draw_input_field(Canvas& c, int x, int y, int w, int h,
const char* label, const char* value,
bool focused, bool masked, Color accent) {
int sfh = system_font_height();
Color dim = Color::from_rgb(0x88, 0x88, 0x88);
c.text(x, y, label, dim);
y += sfh + 2;
Color border = focused ? accent : colors::BORDER;
c.fill_rounded_rect(x, y, w, h, 3, Color::from_rgb(0xF5, 0xF5, 0xF5));
c.rect(x, y, w, h, border);
if (masked) {
int vlen = montauk::slen(value);
char dots[65];
for (int i = 0; i < vlen && i < 64; i++) dots[i] = '*';
dots[vlen < 64 ? vlen : 64] = '\0';
c.text(x + 8, y + (h - sfh) / 2, dots, colors::TEXT_COLOR);
} else {
c.text(x + 8, y + (h - sfh) / 2, value, colors::TEXT_COLOR);
}
if (focused) {
int tw = masked ? text_width("*") * montauk::slen(value) : text_width(value);
c.fill_rect(x + 8 + tw, y + 6, 2, h - 12, accent);
}
}
// ============================================================================
// Add User Dialog
// ============================================================================
struct AddUserDialogState {
DesktopState* ds;
SettingsState* parent;
Color accent;
int field; // 0=username, 1=display_name, 2=password
char username[32]; int username_len;
char display[64]; int display_len;
char password[64]; int password_len;
bool role_admin;
char error[64];
};
static void adduser_on_draw(Window* win, Framebuffer& fb) {
AddUserDialogState* st = (AddUserDialogState*)win->app_data;
if (!st) return;
Canvas c(win);
c.fill(colors::WINDOW_BG);
Color accent = st->accent;
int sfh = system_font_height();
int pad = 16;
int fw = c.w - 2 * pad;
int y = pad;
// Fields
draw_input_field(c, pad, y, fw, USER_FIELD_H, "Username", st->username,
st->field == 0, false, accent);
y += sfh + 2 + USER_FIELD_H + 10;
draw_input_field(c, pad, y, fw, USER_FIELD_H, "Display Name", st->display,
st->field == 1, false, accent);
y += sfh + 2 + USER_FIELD_H + 10;
draw_input_field(c, pad, y, fw, USER_FIELD_H, "Password", st->password,
st->field == 2, true, accent);
y += sfh + 2 + USER_FIELD_H + 12;
// Role toggle
Color dim = Color::from_rgb(0x88, 0x88, 0x88);
c.text(pad, y + 4, "Role:", dim);
int rbx = pad + 60;
draw_toggle_btn(c, rbx, y, 60, USER_BTN_H, "User", !st->role_admin, accent);
draw_toggle_btn(c, rbx + 68, y, 60, USER_BTN_H, "Admin", st->role_admin, accent);
y += USER_BTN_H + 14;
// Error message
if (st->error[0]) {
c.text(pad, y, st->error, Color::from_rgb(0xD0, 0x3E, 0x3E));
y += sfh + 6;
}
// Buttons at bottom
int btn_y = c.h - USER_BTN_H - pad;
c.fill_rounded_rect(pad, btn_y, 80, USER_BTN_H, 4, accent);
int tw = text_width("Create");
c.text(pad + (80 - tw) / 2, btn_y + (USER_BTN_H - sfh) / 2, "Create", colors::WHITE);
c.fill_rounded_rect(pad + 88, btn_y, 80, USER_BTN_H, 4, colors::WINDOW_BG);
c.rect(pad + 88, btn_y, 80, USER_BTN_H, colors::BORDER);
tw = text_width("Cancel");
c.text(pad + 88 + (80 - tw) / 2, btn_y + (USER_BTN_H - sfh) / 2, "Cancel", colors::TEXT_COLOR);
}
static void adduser_submit(AddUserDialogState* st) {
if (st->username_len == 0) {
montauk::strcpy(st->error, "Username is required");
return;
}
if (st->password_len == 0) {
montauk::strcpy(st->error, "Password is required");
return;
}
const char* dname = st->display_len > 0 ? st->display : st->username;
const char* role = st->role_admin ? "admin" : "user";
if (montauk::user::create_user(st->username, dname, st->password, role)) {
st->parent->users_loaded = false;
settings_set_status(st->parent, "User created");
dialog_close_self(st->ds, st);
} else {
montauk::strcpy(st->error, "Failed (username taken?)");
}
}
static void adduser_on_mouse(Window* win, MouseEvent& ev) {
AddUserDialogState* st = (AddUserDialogState*)win->app_data;
if (!st || !ev.left_pressed()) return;
Rect cr = win->content_rect();
int mx = ev.x - cr.x;
int my = ev.y - cr.y;
int pad = 16;
int sfh = system_font_height();
int y = pad;
// Username field hit
y += sfh + 2;
if (my >= y && my < y + USER_FIELD_H) { st->field = 0; return; }
y += USER_FIELD_H + 10;
// Display field hit
y += sfh + 2;
if (my >= y && my < y + USER_FIELD_H) { st->field = 1; return; }
y += USER_FIELD_H + 10;
// Password field hit
y += sfh + 2;
if (my >= y && my < y + USER_FIELD_H) { st->field = 2; return; }
y += USER_FIELD_H + 12;
// Role toggle
int rbx = pad + 60;
if (mx >= rbx && mx < rbx + 60 && my >= y && my < y + USER_BTN_H) {
st->role_admin = false; return;
}
if (mx >= rbx + 68 && mx < rbx + 128 && my >= y && my < y + USER_BTN_H) {
st->role_admin = true; return;
}
// Bottom buttons
int btn_y = win->content_h - USER_BTN_H - pad;
if (my >= btn_y && my < btn_y + USER_BTN_H) {
if (mx >= pad && mx < pad + 80) { adduser_submit(st); return; }
if (mx >= pad + 88 && mx < pad + 168) { dialog_close_self(st->ds, st); return; }
}
}
static void adduser_on_key(Window* win, const Montauk::KeyEvent& key) {
AddUserDialogState* st = (AddUserDialogState*)win->app_data;
if (!st || !key.pressed) return;
if (key.ascii == '\n' || key.ascii == '\r' || key.scancode == 0x1C) {
adduser_submit(st); return;
}
if (key.scancode == 0x01) { dialog_close_self(st->ds, st); return; }
if (key.scancode == 0x0F) { st->field = (st->field + 1) % 3; return; }
if (key.ascii == '\b' || key.scancode == 0x0E) {
switch (st->field) {
case 0: dialog_backspace(st->username, &st->username_len); break;
case 1: dialog_backspace(st->display, &st->display_len); break;
case 2: dialog_backspace(st->password, &st->password_len); break;
}
return;
}
if (key.ascii >= 0x20 && key.ascii < 0x7F) {
switch (st->field) {
case 0: dialog_append(st->username, &st->username_len, 31, key.ascii); break;
case 1: dialog_append(st->display, &st->display_len, 63, key.ascii); break;
case 2: dialog_append(st->password, &st->password_len, 63, key.ascii); break;
}
}
}
static void adduser_on_close(Window* win) {
if (win->app_data) { montauk::mfree(win->app_data); win->app_data = nullptr; }
}
void open_add_user_dialog(SettingsState* parent) {
DesktopState* ds = parent->desktop;
int w = 340, h = 340;
int wx = (ds->screen_w - w) / 2;
int wy = (ds->screen_h - h) / 2;
int idx = desktop_create_window(ds, "Add User", wx, wy, w, h);
if (idx < 0) return;
Window* win = &ds->windows[idx];
AddUserDialogState* st = (AddUserDialogState*)montauk::malloc(sizeof(AddUserDialogState));
montauk::memset(st, 0, sizeof(AddUserDialogState));
st->ds = ds;
st->parent = parent;
st->accent = ds->settings.accent_color;
win->app_data = st;
win->on_draw = adduser_on_draw;
win->on_mouse = adduser_on_mouse;
win->on_key = adduser_on_key;
win->on_close = adduser_on_close;
}
// ============================================================================
// Change Password Dialog
// ============================================================================
struct ChPwdDialogState {
DesktopState* ds;
SettingsState* parent;
Color accent;
char username[32];
char display_name[64];
int field; // 0=new, 1=confirm
char new_pwd[64]; int new_len;
char confirm[64]; int confirm_len;
char error[64];
};
static void chpwd_on_draw(Window* win, Framebuffer& fb) {
ChPwdDialogState* st = (ChPwdDialogState*)win->app_data;
if (!st) return;
Canvas c(win);
c.fill(colors::WINDOW_BG);
Color accent = st->accent;
int sfh = system_font_height();
int pad = 16;
int fw = c.w - 2 * pad;
int y = pad;
// Title
char title[80];
snprintf(title, sizeof(title), "Change password for %s", st->display_name);
c.text(pad, y, title, colors::TEXT_COLOR);
y += sfh + 12;
draw_input_field(c, pad, y, fw, USER_FIELD_H, "New Password", st->new_pwd,
st->field == 0, true, accent);
y += sfh + 2 + USER_FIELD_H + 10;
draw_input_field(c, pad, y, fw, USER_FIELD_H, "Confirm Password", st->confirm,
st->field == 1, true, accent);
y += sfh + 2 + USER_FIELD_H + 12;
// Error
if (st->error[0]) {
c.text(pad, y, st->error, Color::from_rgb(0xD0, 0x3E, 0x3E));
}
// Buttons at bottom
int btn_y = c.h - USER_BTN_H - pad;
c.fill_rounded_rect(pad, btn_y, 80, USER_BTN_H, 4, accent);
int tw = text_width("Save");
c.text(pad + (80 - tw) / 2, btn_y + (USER_BTN_H - sfh) / 2, "Save", colors::WHITE);
c.fill_rounded_rect(pad + 88, btn_y, 80, USER_BTN_H, 4, colors::WINDOW_BG);
c.rect(pad + 88, btn_y, 80, USER_BTN_H, colors::BORDER);
tw = text_width("Cancel");
c.text(pad + 88 + (80 - tw) / 2, btn_y + (USER_BTN_H - sfh) / 2, "Cancel", colors::TEXT_COLOR);
}
static void chpwd_submit(ChPwdDialogState* st) {
if (st->new_len == 0) {
montauk::strcpy(st->error, "Password cannot be empty");
return;
}
if (!montauk::streq(st->new_pwd, st->confirm)) {
montauk::strcpy(st->error, "Passwords don't match");
return;
}
montauk::user::change_password(st->username, st->new_pwd);
settings_set_status(st->parent, "Password changed");
dialog_close_self(st->ds, st);
}
static void chpwd_on_mouse(Window* win, MouseEvent& ev) {
ChPwdDialogState* st = (ChPwdDialogState*)win->app_data;
if (!st || !ev.left_pressed()) return;
Rect cr = win->content_rect();
int mx = ev.x - cr.x;
int my = ev.y - cr.y;
int pad = 16;
int sfh = system_font_height();
int y = pad + sfh + 12;
// New password field
y += sfh + 2;
if (my >= y && my < y + USER_FIELD_H) { st->field = 0; return; }
y += USER_FIELD_H + 10;
// Confirm field
y += sfh + 2;
if (my >= y && my < y + USER_FIELD_H) { st->field = 1; return; }
// Bottom buttons
int btn_y = win->content_h - USER_BTN_H - pad;
if (my >= btn_y && my < btn_y + USER_BTN_H) {
if (mx >= pad && mx < pad + 80) { chpwd_submit(st); return; }
if (mx >= pad + 88 && mx < pad + 168) { dialog_close_self(st->ds, st); return; }
}
}
static void chpwd_on_key(Window* win, const Montauk::KeyEvent& key) {
ChPwdDialogState* st = (ChPwdDialogState*)win->app_data;
if (!st || !key.pressed) return;
if (key.ascii == '\n' || key.ascii == '\r' || key.scancode == 0x1C) {
chpwd_submit(st); return;
}
if (key.scancode == 0x01) { dialog_close_self(st->ds, st); return; }
if (key.scancode == 0x0F) { st->field = (st->field + 1) % 2; return; }
if (key.ascii == '\b' || key.scancode == 0x0E) {
if (st->field == 0) dialog_backspace(st->new_pwd, &st->new_len);
else dialog_backspace(st->confirm, &st->confirm_len);
return;
}
if (key.ascii >= 0x20 && key.ascii < 0x7F) {
if (st->field == 0) dialog_append(st->new_pwd, &st->new_len, 63, key.ascii);
else dialog_append(st->confirm, &st->confirm_len, 63, key.ascii);
}
}
static void chpwd_on_close(Window* win) {
if (win->app_data) { montauk::mfree(win->app_data); win->app_data = nullptr; }
}
void open_change_pwd_dialog(SettingsState* parent) {
if (parent->selected_user < 0) return;
DesktopState* ds = parent->desktop;
int w = 340, h = 260;
int wx = (ds->screen_w - w) / 2;
int wy = (ds->screen_h - h) / 2;
int idx = desktop_create_window(ds, "Change Password", wx, wy, w, h);
if (idx < 0) return;
Window* win = &ds->windows[idx];
ChPwdDialogState* st = (ChPwdDialogState*)montauk::malloc(sizeof(ChPwdDialogState));
montauk::memset(st, 0, sizeof(ChPwdDialogState));
st->ds = ds;
st->parent = parent;
st->accent = ds->settings.accent_color;
montauk::strncpy(st->username, parent->users[parent->selected_user].username, 31);
montauk::strncpy(st->display_name, parent->users[parent->selected_user].display_name, 63);
win->app_data = st;
win->on_draw = chpwd_on_draw;
win->on_mouse = chpwd_on_mouse;
win->on_key = chpwd_on_key;
win->on_close = chpwd_on_close;
}
// ============================================================================
// Delete User Dialog
// ============================================================================
struct DeleteUserDialogState {
DesktopState* ds;
SettingsState* parent;
char username[32];
bool hover_delete, hover_cancel;
};
static void deluser_on_draw(Window* win, Framebuffer& fb) {
DeleteUserDialogState* st = (DeleteUserDialogState*)win->app_data;
if (!st) return;
Canvas c(win);
c.fill(colors::WINDOW_BG);
int sfh = system_font_height();
char msg[96];
snprintf(msg, sizeof(msg), "Delete user \"%s\"?", st->username);
int tw = text_width(msg);
c.text((c.w - tw) / 2, 24, msg, colors::TEXT_COLOR);
const char* warn = "This action cannot be undone.";
tw = text_width(warn);
c.text((c.w - tw) / 2, 24 + sfh + 8, warn, Color::from_rgb(0x88, 0x88, 0x88));
// Buttons
int btn_w = 100, btn_h = 32;
int btn_y = c.h - btn_h - 20;
int gap = 20;
int total = btn_w * 2 + gap;
int bx = (c.w - total) / 2;
Color del_bg = st->hover_delete
? Color::from_rgb(0xDD, 0x44, 0x44)
: Color::from_rgb(0xD0, 0x3E, 0x3E);
c.button(bx, btn_y, btn_w, btn_h, "Delete", del_bg, colors::WHITE, 4);
Color cancel_bg = st->hover_cancel
? Color::from_rgb(0x99, 0x99, 0x99)
: Color::from_rgb(0x88, 0x88, 0x88);
c.button(bx + btn_w + gap, btn_y, btn_w, btn_h, "Cancel", cancel_bg, colors::WHITE, 4);
}
static void deluser_on_mouse(Window* win, MouseEvent& ev) {
DeleteUserDialogState* st = (DeleteUserDialogState*)win->app_data;
if (!st) return;
Rect cr = win->content_rect();
int lx = ev.x - cr.x;
int ly = ev.y - cr.y;
int btn_w = 100, btn_h = 32;
int btn_y = win->content_h - btn_h - 20;
int gap = 20;
int total = btn_w * 2 + gap;
int bx = (win->content_w - total) / 2;
Rect db = {bx, btn_y, btn_w, btn_h};
Rect cb = {bx + btn_w + gap, btn_y, btn_w, btn_h};
st->hover_delete = db.contains(lx, ly);
st->hover_cancel = cb.contains(lx, ly);
if (ev.left_pressed()) {
if (st->hover_delete) {
montauk::user::delete_user(st->username);
st->parent->users_loaded = false;
st->parent->selected_user = -1;
settings_set_status(st->parent, "User deleted");
dialog_close_self(st->ds, st);
return;
}
if (st->hover_cancel) {
dialog_close_self(st->ds, st);
return;
}
}
}
static void deluser_on_key(Window* win, const Montauk::KeyEvent& key) {
DeleteUserDialogState* st = (DeleteUserDialogState*)win->app_data;
if (!st || !key.pressed) return;
if (key.ascii == '\n' || key.ascii == '\r') {
montauk::user::delete_user(st->username);
st->parent->users_loaded = false;
st->parent->selected_user = -1;
settings_set_status(st->parent, "User deleted");
dialog_close_self(st->ds, st);
}
if (key.scancode == 0x01) {
dialog_close_self(st->ds, st);
}
}
static void deluser_on_close(Window* win) {
if (win->app_data) { montauk::mfree(win->app_data); win->app_data = nullptr; }
}
void open_delete_user_dialog(SettingsState* parent) {
if (parent->selected_user < 0) return;
DesktopState* ds = parent->desktop;
// Don't allow deleting yourself
if (montauk::streq(parent->users[parent->selected_user].username, ds->current_user)) {
settings_set_status(parent, "Cannot delete current user");
return;
}
int w = 300, h = 150;
int wx = (ds->screen_w - w) / 2;
int wy = (ds->screen_h - h) / 2;
int idx = desktop_create_window(ds, "Delete User", wx, wy, w, h);
if (idx < 0) return;
Window* win = &ds->windows[idx];
DeleteUserDialogState* st = (DeleteUserDialogState*)montauk::malloc(sizeof(DeleteUserDialogState));
montauk::memset(st, 0, sizeof(DeleteUserDialogState));
st->ds = ds;
st->parent = parent;
montauk::strncpy(st->username, parent->users[parent->selected_user].username, 31);
win->app_data = st;
win->on_draw = deluser_on_draw;
win->on_mouse = deluser_on_mouse;
win->on_key = deluser_on_key;
win->on_close = deluser_on_close;
}
} // namespace settings_app
@@ -0,0 +1,174 @@
/*
* display.cpp
* Display tab for the embedded desktop settings app
* Copyright (c) 2026 Daniel Hammer
*/
#include "settings_internal.hpp"
namespace settings_app {
void apply_ui_scale(int scale) {
switch (scale) {
case 0: fonts::UI_SIZE=14; fonts::TITLE_SIZE=14; fonts::TERM_SIZE=14; fonts::LARGE_SIZE=22; break;
case 2: fonts::UI_SIZE=22; fonts::TITLE_SIZE=22; fonts::TERM_SIZE=22; fonts::LARGE_SIZE=34; break;
default: fonts::UI_SIZE=18; fonts::TITLE_SIZE=18; fonts::TERM_SIZE=18; fonts::LARGE_SIZE=28; break;
}
}
void settings_draw_display(Canvas& c, SettingsState* st) {
DesktopSettings& s = st->desktop->settings;
Color accent = s.accent_color;
int x = 16;
int y = 20;
int btn_w = 60;
int btn_h = 28;
// Window Shadows
c.text(x, y + 6, "Window Shadows", colors::TEXT_COLOR);
int bx = x + 180;
draw_toggle_btn(c, bx, y, btn_w, btn_h, "On", s.show_shadows, accent);
draw_toggle_btn(c, bx + btn_w + 8, y, btn_w, btn_h, "Off", !s.show_shadows, accent);
y += btn_h + 20;
// Separator
c.hline(x, y, c.w - 2 * x, colors::BORDER);
y += 16;
// Clock Format
c.text(x, y + 6, "Clock Format", colors::TEXT_COLOR);
bx = x + 180;
draw_toggle_btn(c, bx, y, btn_w, btn_h, "24h", s.clock_24h, accent);
draw_toggle_btn(c, bx + btn_w + 8, y, btn_w, btn_h, "12h", !s.clock_24h, accent);
y += btn_h + 20;
// Separator
c.hline(x, y, c.w - 2 * x, colors::BORDER);
y += 16;
// UI Scale
c.text(x, y + 6, "UI Scale", colors::TEXT_COLOR);
bx = x + 180;
int sbw = 68;
draw_toggle_btn(c, bx, y, sbw, btn_h, "Small", s.ui_scale == 0, accent);
draw_toggle_btn(c, bx + sbw + 8, y, sbw, btn_h, "Default", s.ui_scale == 1, accent);
draw_toggle_btn(c, bx + (sbw + 8) * 2, y, sbw, btn_h, "Large", s.ui_scale == 2, accent);
y += btn_h + 20;
// Separator
c.hline(x, y, c.w - 2 * x, colors::BORDER);
y += 16;
// UTC Offset
c.text(x, y + 6, "UTC Offset", colors::TEXT_COLOR);
bx = x + 180;
// [-] button
draw_toggle_btn(c, bx, y, 36, btn_h, "-", false, accent);
// Offset label
int off = s.tz_offset_minutes;
int offH = off / 60;
int offM = off % 60;
if (offM < 0) offM = -offM;
char tz_label[16];
if (offM)
snprintf(tz_label, sizeof(tz_label), "UTC%s%d:%02d", offH >= 0 ? "+" : "", offH, offM);
else
snprintf(tz_label, sizeof(tz_label), "UTC%s%d", offH >= 0 ? "+" : "", offH);
int tw = text_width(tz_label);
int label_w = 90;
c.text(bx + 36 + (label_w - tw) / 2, y + 6, tz_label, colors::TEXT_COLOR);
// [+] button
draw_toggle_btn(c, bx + 36 + label_w, y, 36, btn_h, "+", false, accent);
}
bool settings_handle_display_click(SettingsState* st, int mx, int cy) {
DesktopSettings& s = st->desktop->settings;
int x = 16;
int y = 20;
int btn_w = 60;
int btn_h = 28;
int bx = x + 180;
// Window Shadows: On
if (mx >= bx && mx < bx + btn_w && cy >= y && cy < y + btn_h) {
s.show_shadows = true;
settings_persist(st);
return true;
}
// Window Shadows: Off
if (mx >= bx + btn_w + 8 && mx < bx + btn_w * 2 + 8 && cy >= y && cy < y + btn_h) {
s.show_shadows = false;
settings_persist(st);
return true;
}
y += btn_h + 20 + 16;
// Clock: 24h
if (mx >= bx && mx < bx + btn_w && cy >= y && cy < y + btn_h) {
s.clock_24h = true;
settings_persist(st);
return true;
}
// Clock: 12h
if (mx >= bx + btn_w + 8 && mx < bx + btn_w * 2 + 8 && cy >= y && cy < y + btn_h) {
s.clock_24h = false;
settings_persist(st);
return true;
}
y += btn_h + 20 + 16;
// UI Scale buttons
int sbw = 68;
// Small
if (mx >= bx && mx < bx + sbw && cy >= y && cy < y + btn_h) {
s.ui_scale = 0;
apply_ui_scale(0);
montauk::win_setscale(0);
settings_persist(st);
return true;
}
// Default
if (mx >= bx + sbw + 8 && mx < bx + sbw * 2 + 8 && cy >= y && cy < y + btn_h) {
s.ui_scale = 1;
apply_ui_scale(1);
montauk::win_setscale(1);
settings_persist(st);
return true;
}
// Large
if (mx >= bx + (sbw + 8) * 2 && mx < bx + sbw * 3 + 16 && cy >= y && cy < y + btn_h) {
s.ui_scale = 2;
apply_ui_scale(2);
montauk::win_setscale(2);
settings_persist(st);
return true;
}
y += btn_h + 20 + 16;
// UTC Offset: [-] button
if (mx >= bx && mx < bx + 36 && cy >= y && cy < y + btn_h) {
if (s.tz_offset_minutes > -720) {
s.tz_offset_minutes -= 60;
montauk::settz(s.tz_offset_minutes);
settings_persist_tz(st);
}
return true;
}
// UTC Offset: [+] button
int label_w = 90;
if (mx >= bx + 36 + label_w && mx < bx + 36 + label_w + 36 && cy >= y && cy < y + btn_h) {
if (s.tz_offset_minutes < 840) {
s.tz_offset_minutes += 60;
montauk::settz(s.tz_offset_minutes);
settings_persist_tz(st);
}
return true;
}
return false;
}
} // namespace settings_app
@@ -0,0 +1,80 @@
/*
* helpers.cpp
* Shared helpers for the embedded desktop settings app
* Copyright (c) 2026 Daniel Hammer
*/
#include "settings_internal.hpp"
namespace settings_app {
bool color_eq(Color a, Color b) {
return a.r == b.r && a.g == b.g && a.b == b.b;
}
bool settings_status_visible(SettingsState* st) {
return st->status_msg[0] &&
(montauk::get_milliseconds() - st->status_time < 4000);
}
void settings_set_status(SettingsState* st, const char* msg) {
montauk::strncpy(st->status_msg, msg ? msg : "", (int)sizeof(st->status_msg));
st->status_time = montauk::get_milliseconds();
}
int find_swatch(const Color* palette, Color current) {
for (int i = 0; i < SWATCH_COUNT; i++) {
if (color_eq(palette[i], current)) return i;
}
return -1;
}
void draw_swatch_row(Canvas& c, int x, int y, const Color* palette, Color selected, Color accent) {
int sel = find_swatch(palette, selected);
for (int i = 0; i < SWATCH_COUNT; i++) {
int sx = x + i * (SWATCH_SIZE + SWATCH_GAP);
// Draw selection border
if (i == sel) {
c.fill_rounded_rect(sx - 2, y - 2, SWATCH_SIZE + 4, SWATCH_SIZE + 4, 4, accent);
}
c.fill_rounded_rect(sx, y, SWATCH_SIZE, SWATCH_SIZE, 3, palette[i]);
// Thin border for light colors
c.rect(sx, y, SWATCH_SIZE, SWATCH_SIZE, Color::from_rgb(0xCC, 0xCC, 0xCC));
}
}
void draw_radio(Canvas& c, int x, int y, bool selected, Color accent) {
int r = 7;
// Outer circle (simple square approximation with rounded rect)
c.fill_rounded_rect(x, y, r * 2, r * 2, r, Color::from_rgb(0xCC, 0xCC, 0xCC));
c.fill_rounded_rect(x + 1, y + 1, r * 2 - 2, r * 2 - 2, r - 1, colors::WHITE);
if (selected) {
c.fill_rounded_rect(x + 4, y + 4, r * 2 - 8, r * 2 - 8, r - 4, accent);
}
}
void draw_toggle_btn(Canvas& c, int x, int y, int bw, int bh,
const char* label, bool active, Color accent) {
Color bg = active ? accent : colors::WINDOW_BG;
Color fg = active ? colors::WHITE : colors::TEXT_COLOR;
c.fill_rounded_rect(x, y, bw, bh, 4, bg);
if (!active) {
c.rect(x, y, bw, bh, colors::BORDER);
}
int tw = text_width(label);
int fh = system_font_height();
c.text(x + (bw - tw) / 2, y + (bh - fh) / 2, label, fg);
}
bool swatch_hit(int mx, int my, int row_x, int row_y, int* out_idx) {
for (int i = 0; i < SWATCH_COUNT; i++) {
int sx = row_x + i * (SWATCH_SIZE + SWATCH_GAP);
if (mx >= sx && mx < sx + SWATCH_SIZE && my >= row_y && my < row_y + SWATCH_SIZE) {
*out_idx = i;
return true;
}
}
return false;
}
} // namespace settings_app
@@ -0,0 +1,56 @@
/*
* persistence.cpp
* Settings persistence helpers for the embedded desktop settings app
* Copyright (c) 2026 Daniel Hammer
*/
#include "settings_internal.hpp"
namespace settings_app {
static int64_t color_to_int(Color c) {
return ((int64_t)c.r << 16) | ((int64_t)c.g << 8) | c.b;
}
void settings_persist(SettingsState* st) {
DesktopSettings& s = st->desktop->settings;
const char* user = st->desktop->current_user;
montauk::toml::Doc doc;
doc.init();
// Background mode
const char* mode = s.bg_image ? "image" : (s.bg_gradient ? "gradient" : "solid");
montauk::config::set_string(&doc, "background.mode", mode);
// Wallpaper path
if (s.bg_image && s.bg_image_path[0])
montauk::config::set_string(&doc, "wallpaper.path", s.bg_image_path);
// Background colors
montauk::config::set_int(&doc, "background.solid_color", color_to_int(s.bg_solid));
montauk::config::set_int(&doc, "background.grad_top", color_to_int(s.bg_grad_top));
montauk::config::set_int(&doc, "background.grad_bottom", color_to_int(s.bg_grad_bottom));
// Appearance
montauk::config::set_int(&doc, "appearance.panel_color", color_to_int(s.panel_color));
montauk::config::set_int(&doc, "appearance.accent_color", color_to_int(s.accent_color));
// Display
montauk::config::set_int(&doc, "display.ui_scale", s.ui_scale);
montauk::config::set_bool(&doc, "display.clock_24h", s.clock_24h);
montauk::config::set_bool(&doc, "display.show_shadows", s.show_shadows);
montauk::config::save_user(user, "desktop", &doc);
doc.destroy();
}
void settings_persist_tz(SettingsState* st) {
montauk::toml::Doc doc;
doc.init();
montauk::config::set_int(&doc, "timezone.offset_minutes", st->desktop->settings.tz_offset_minutes);
montauk::config::save("timezone", &doc);
doc.destroy();
}
} // namespace settings_app
@@ -0,0 +1,117 @@
/*
* settings_internal.hpp
* Shared declarations for the embedded desktop settings app
* Copyright (c) 2026 Daniel Hammer
*/
#pragma once
#include "../apps_common.hpp"
#include "../../wallpaper.hpp"
#include <montauk/config.h>
#include <montauk/user.h>
namespace settings_app {
struct SettingsState {
DesktopState* desktop;
int active_tab; // 0=Appearance, 1=Display, 2=Users, 3=About
Montauk::SysInfo sys_info;
uint64_t uptime_ms;
WallpaperFileList wp_files;
bool wp_scanned;
// Users tab
montauk::user::UserInfo users[16];
int user_count;
int selected_user; // index into users[], or -1
bool users_loaded;
char status_msg[128];
uint64_t status_time;
};
inline constexpr int SWATCH_COUNT = 8;
inline constexpr int SWATCH_SIZE = 24;
inline constexpr int SWATCH_GAP = 6;
// Background colors (light tones)
inline const Color bg_palette[SWATCH_COUNT] = {
Color::from_rgb(0xD0, 0xD8, 0xE8), // light blue-gray (default)
Color::from_rgb(0xE8, 0xDD, 0xCB), // warm beige
Color::from_rgb(0xC8, 0xE6, 0xD0), // mint green
Color::from_rgb(0xD8, 0xD0, 0xE8), // lavender
Color::from_rgb(0xB8, 0xBE, 0xC8), // slate
Color::from_rgb(0xF0, 0xF0, 0xF0), // white
Color::from_rgb(0xE8, 0xD0, 0xD8), // soft pink
Color::from_rgb(0xE8, 0xE0, 0xC8), // light gold
};
// Panel colors (dark tones)
inline const Color panel_palette[SWATCH_COUNT] = {
Color::from_rgb(0x2B, 0x3E, 0x50), // dark blue-gray (default)
Color::from_rgb(0x2D, 0x2D, 0x2D), // dark charcoal
Color::from_rgb(0x1B, 0x2A, 0x4A), // navy
Color::from_rgb(0x1A, 0x3A, 0x3A), // dark teal
Color::from_rgb(0x1A, 0x3A, 0x1A), // dark green
Color::from_rgb(0x30, 0x20, 0x40), // dark purple
Color::from_rgb(0x40, 0x1A, 0x1A), // dark red
Color::from_rgb(0x10, 0x10, 0x10), // black
};
// Accent colors
inline const Color accent_palette[SWATCH_COUNT] = {
Color::from_rgb(0x36, 0x7B, 0xF0), // blue (default)
Color::from_rgb(0x00, 0x9B, 0x9B), // teal
Color::from_rgb(0x2E, 0x9E, 0x3E), // green
Color::from_rgb(0xE0, 0x8A, 0x20), // orange
Color::from_rgb(0xD0, 0x3E, 0x3E), // red
Color::from_rgb(0x7B, 0x3E, 0xB8), // purple
Color::from_rgb(0xD0, 0x5C, 0x9E), // pink
Color::from_rgb(0x44, 0x44, 0xCC), // indigo
};
inline constexpr int TAB_BAR_H = 36;
inline constexpr int TAB_COUNT = 4;
inline constexpr const char* tab_labels[TAB_COUNT] = { "Appearance", "Display", "Users", "About" };
inline constexpr int WP_ITEM_H = 24;
inline constexpr int USER_BTN_H = 30;
inline constexpr int USER_BTN_W = 100;
inline constexpr int USER_FIELD_H = 32;
bool color_eq(Color a, Color b);
bool settings_status_visible(SettingsState* st);
void settings_set_status(SettingsState* st, const char* msg);
int find_swatch(const Color* palette, Color current);
void draw_swatch_row(Canvas& c, int x, int y, const Color* palette, Color selected, Color accent);
void draw_radio(Canvas& c, int x, int y, bool selected, Color accent);
void draw_toggle_btn(Canvas& c, int x, int y, int bw, int bh,
const char* label, bool active, Color accent);
bool swatch_hit(int mx, int my, int row_x, int row_y, int* out_idx);
void apply_ui_scale(int scale);
void settings_draw_appearance(Canvas& c, SettingsState* st);
bool settings_handle_appearance_click(Window* win, SettingsState* st, int mx, int cy);
void settings_draw_display(Canvas& c, SettingsState* st);
bool settings_handle_display_click(SettingsState* st, int mx, int cy);
void settings_draw_users(Canvas& c, SettingsState* st);
bool settings_handle_users_click(Window* win, SettingsState* st, int mx, int cy);
void settings_draw_about(Canvas& c, SettingsState* st);
void settings_persist(SettingsState* st);
void settings_persist_tz(SettingsState* st);
void users_reload(SettingsState* st);
int user_row_height();
void open_add_user_dialog(SettingsState* parent);
void open_change_pwd_dialog(SettingsState* parent);
void open_delete_user_dialog(SettingsState* parent);
} // namespace settings_app
@@ -0,0 +1,182 @@
/*
* users.cpp
* Users tab for the embedded desktop settings app
* Copyright (c) 2026 Daniel Hammer
*/
#include "settings_internal.hpp"
namespace settings_app {
void users_reload(SettingsState* st) {
st->user_count = montauk::user::load_users(st->users, 16);
st->users_loaded = true;
}
int user_row_height() {
return system_font_height() * 2 + 12; // Two lines of text + padding
}
void settings_draw_users(Canvas& c, SettingsState* st) {
if (!st->users_loaded) users_reload(st);
Color accent = st->desktop->settings.accent_color;
Color dim = Color::from_rgb(0x88, 0x88, 0x88);
Color card_bg = Color::from_rgb(0xF8, 0xF8, 0xF8);
int x = 16;
int y = 20;
int sfh = system_font_height();
int content_w = c.w - 2 * x;
int row_h = user_row_height();
if (!st->desktop->is_admin) {
c.text(x, y, "Admin access required to manage users.", dim);
return;
}
// User list
for (int i = 0; i < st->user_count; i++) {
bool sel = (i == st->selected_user);
bool is_current = montauk::streq(st->users[i].username, st->desktop->current_user);
if (sel) {
c.fill_rounded_rect(x, y, content_w, row_h, 4, accent);
} else if (i % 2 == 0) {
c.fill_rounded_rect(x, y, content_w, row_h, 4, card_bg);
}
Color tc = sel ? colors::WHITE : colors::TEXT_COLOR;
Color sc = sel ? Color::from_rgb(0xDD, 0xDD, 0xFF) : dim;
// Display name (primary line)
int text_y = y + 4;
c.text(x + 12, text_y, st->users[i].display_name, tc);
// Username (secondary line)
char sub[48];
if (is_current) {
snprintf(sub, sizeof(sub), "@%s (you)", st->users[i].username);
} else {
snprintf(sub, sizeof(sub), "@%s", st->users[i].username);
}
c.text(x + 12, text_y + sfh + 2, sub, sc);
// Role badge
const char* role = st->users[i].role;
int rw = text_width(role) + 12;
int badge_x = c.w - x - rw - 8;
int badge_y = y + (row_h - sfh - 4) / 2;
if (sel) {
c.fill_rounded_rect(badge_x, badge_y, rw, sfh + 4, (sfh + 4) / 2,
Color::from_rgb(0xFF, 0xFF, 0xFF));
c.text(badge_x + 6, badge_y + 2, role, accent);
} else {
bool is_admin_role = montauk::streq(role, "admin");
Color badge_bg = is_admin_role
? Color::from_rgb(0xE8, 0xD8, 0xF0)
: Color::from_rgb(0xE0, 0xE8, 0xF0);
Color badge_fg = is_admin_role
? Color::from_rgb(0x7B, 0x3E, 0xB8)
: Color::from_rgb(0x36, 0x7B, 0xF0);
c.fill_rounded_rect(badge_x, badge_y, rw, sfh + 4, (sfh + 4) / 2, badge_bg);
c.text(badge_x + 6, badge_y + 2, role, badge_fg);
}
y += row_h + 4;
}
// Separator
y += 8;
c.hline(x, y, content_w, colors::BORDER);
y += 16;
// Status message
if (settings_status_visible(st)) {
c.text(x, y, st->status_msg, accent);
y += sfh + 8;
}
// Action buttons
int btn_x = x;
// Add User
c.fill_rounded_rect(btn_x, y, 90, USER_BTN_H, 4, accent);
{
int tw = text_width("Add User");
c.text(btn_x + (90 - tw) / 2, y + (USER_BTN_H - sfh) / 2, "Add User", colors::WHITE);
}
btn_x += 98;
// Change Password (disabled when no selection)
{
bool enabled = st->selected_user >= 0;
Color bg = enabled ? accent : Color::from_rgb(0xCC, 0xCC, 0xCC);
c.fill_rounded_rect(btn_x, y, USER_BTN_W, USER_BTN_H, 4, bg);
int tw = text_width("Change Pwd");
c.text(btn_x + (USER_BTN_W - tw) / 2, y + (USER_BTN_H - sfh) / 2, "Change Pwd", colors::WHITE);
}
btn_x += USER_BTN_W + 8;
// Delete (disabled when no selection)
{
bool enabled = st->selected_user >= 0;
Color del_bg = enabled ? Color::from_rgb(0xD0, 0x3E, 0x3E) : Color::from_rgb(0xCC, 0xCC, 0xCC);
c.fill_rounded_rect(btn_x, y, 70, USER_BTN_H, 4, del_bg);
int tw = text_width("Delete");
c.text(btn_x + (70 - tw) / 2, y + (USER_BTN_H - sfh) / 2, "Delete", colors::WHITE);
}
}
bool settings_handle_users_click(Window* win, SettingsState* st, int mx, int cy) {
if (!st->desktop->is_admin) return false;
int x = 16;
int sfh = system_font_height();
int y = 20;
int content_w = win->content_w - 2 * x;
int row_h = user_row_height();
// User list rows
for (int i = 0; i < st->user_count; i++) {
if (cy >= y && cy < y + row_h && mx >= x && mx < x + content_w) {
st->selected_user = (st->selected_user == i) ? -1 : i;
return true;
}
y += row_h + 4;
}
// Separator + gap
y += 8 + 1 + 16;
// Status message (if shown, takes space)
if (settings_status_visible(st)) {
y += sfh + 8;
}
// Action buttons
int btn_x = x;
// Add User
if (mx >= btn_x && mx < btn_x + 90 && cy >= y && cy < y + USER_BTN_H) {
open_add_user_dialog(st);
return true;
}
btn_x += 98;
// Change Password
if (mx >= btn_x && mx < btn_x + USER_BTN_W && cy >= y && cy < y + USER_BTN_H) {
if (st->selected_user >= 0) open_change_pwd_dialog(st);
return true;
}
btn_x += USER_BTN_W + 8;
// Delete
if (mx >= btn_x && mx < btn_x + 70 && cy >= y && cy < y + USER_BTN_H) {
if (st->selected_user >= 0) open_delete_user_dialog(st);
return true;
}
return false;
}
} // namespace settings_app