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
+4 -9
View File
@@ -68,9 +68,9 @@ LDFLAGS := \
# ---- C++ source files ---- # ---- C++ source files ----
CORE_SRCS := main.cpp window.cpp panel.cpp compose.cpp input.cpp dialogs.cpp launcher.cpp font_data.cpp stb_truetype_impl.cpp CORE_SRCS := main.cpp window.cpp panel.cpp compose.cpp input.cpp dialogs.cpp launcher.cpp font_data.cpp stb_truetype_impl.cpp
APP_SRCS := $(wildcard apps/*.cpp) APP_SRCS := $(sort $(shell find apps -name '*.cpp' -print))
SRCS := $(CORE_SRCS) $(APP_SRCS) SRCS := $(CORE_SRCS) $(APP_SRCS)
OBJS := $(addprefix $(OBJDIR)/,$(notdir $(SRCS:.cpp=.o))) OBJS := $(addprefix $(OBJDIR)/,$(SRCS:.cpp=.o))
# ---- Target ---- # ---- Target ----
@@ -88,14 +88,9 @@ $(TARGET): $(OBJS) $(LIBS) $(LINK_LD) Makefile
mkdir -p $(BINDIR)/os mkdir -p $(BINDIR)/os
$(CXX) $(CXXFLAGS) $(LDFLAGS) $(OBJS) $(LIBS) -o $@ $(CXX) $(CXXFLAGS) $(LDFLAGS) $(OBJS) $(LIBS) -o $@
# Core sources # Sources
$(OBJDIR)/%.o: %.cpp Makefile $(OBJDIR)/%.o: %.cpp Makefile
mkdir -p $(OBJDIR) mkdir -p $(dir $@)
$(CXX) $(CXXFLAGS) -c $< -o $@
# App sources
$(OBJDIR)/%.o: apps/%.cpp Makefile
mkdir -p $(OBJDIR)
$(CXX) $(CXXFLAGS) -c $< -o $@ $(CXX) $(CXXFLAGS) -c $< -o $@
-include $(OBJS:.o=.d) -include $(OBJS:.o=.d)
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