From b140825121fc6bd101bb5b1ee9c1b9cfec03e292 Mon Sep 17 00:00:00 2001 From: Daniel Hammer Date: Wed, 1 Apr 2026 23:08:25 +0200 Subject: [PATCH] refactor: split Files and Settings apps into multiple files --- programs/src/desktop/Makefile | 13 +- programs/src/desktop/apps/app_filemanager.cpp | 1976 ----------------- programs/src/desktop/apps/app_settings.cpp | 1464 ------------ .../src/desktop/apps/filemanager/actions.cpp | 178 ++ programs/src/desktop/apps/filemanager/app.cpp | 83 + .../src/desktop/apps/filemanager/events.cpp | 395 ++++ .../apps/filemanager/filemanager_internal.hpp | 160 ++ .../desktop/apps/filemanager/filesystem.cpp | 469 ++++ .../src/desktop/apps/filemanager/helpers.cpp | 95 + .../desktop/apps/filemanager/navigation.cpp | 233 ++ .../src/desktop/apps/filemanager/render.cpp | 421 ++++ programs/src/desktop/apps/settings/about.cpp | 50 + programs/src/desktop/apps/settings/app.cpp | 128 ++ .../src/desktop/apps/settings/appearance.cpp | 234 ++ .../src/desktop/apps/settings/dialogs.cpp | 519 +++++ .../src/desktop/apps/settings/display.cpp | 174 ++ .../src/desktop/apps/settings/helpers.cpp | 80 + .../src/desktop/apps/settings/persistence.cpp | 56 + .../apps/settings/settings_internal.hpp | 117 + programs/src/desktop/apps/settings/users.cpp | 182 ++ 20 files changed, 3578 insertions(+), 3449 deletions(-) delete mode 100644 programs/src/desktop/apps/app_filemanager.cpp delete mode 100644 programs/src/desktop/apps/app_settings.cpp create mode 100644 programs/src/desktop/apps/filemanager/actions.cpp create mode 100644 programs/src/desktop/apps/filemanager/app.cpp create mode 100644 programs/src/desktop/apps/filemanager/events.cpp create mode 100644 programs/src/desktop/apps/filemanager/filemanager_internal.hpp create mode 100644 programs/src/desktop/apps/filemanager/filesystem.cpp create mode 100644 programs/src/desktop/apps/filemanager/helpers.cpp create mode 100644 programs/src/desktop/apps/filemanager/navigation.cpp create mode 100644 programs/src/desktop/apps/filemanager/render.cpp create mode 100644 programs/src/desktop/apps/settings/about.cpp create mode 100644 programs/src/desktop/apps/settings/app.cpp create mode 100644 programs/src/desktop/apps/settings/appearance.cpp create mode 100644 programs/src/desktop/apps/settings/dialogs.cpp create mode 100644 programs/src/desktop/apps/settings/display.cpp create mode 100644 programs/src/desktop/apps/settings/helpers.cpp create mode 100644 programs/src/desktop/apps/settings/persistence.cpp create mode 100644 programs/src/desktop/apps/settings/settings_internal.hpp create mode 100644 programs/src/desktop/apps/settings/users.cpp diff --git a/programs/src/desktop/Makefile b/programs/src/desktop/Makefile index 671f170..681de9a 100644 --- a/programs/src/desktop/Makefile +++ b/programs/src/desktop/Makefile @@ -68,9 +68,9 @@ LDFLAGS := \ # ---- 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 -APP_SRCS := $(wildcard apps/*.cpp) +APP_SRCS := $(sort $(shell find apps -name '*.cpp' -print)) SRCS := $(CORE_SRCS) $(APP_SRCS) -OBJS := $(addprefix $(OBJDIR)/,$(notdir $(SRCS:.cpp=.o))) +OBJS := $(addprefix $(OBJDIR)/,$(SRCS:.cpp=.o)) # ---- Target ---- @@ -88,14 +88,9 @@ $(TARGET): $(OBJS) $(LIBS) $(LINK_LD) Makefile mkdir -p $(BINDIR)/os $(CXX) $(CXXFLAGS) $(LDFLAGS) $(OBJS) $(LIBS) -o $@ -# Core sources +# Sources $(OBJDIR)/%.o: %.cpp Makefile - mkdir -p $(OBJDIR) - $(CXX) $(CXXFLAGS) -c $< -o $@ - -# App sources -$(OBJDIR)/%.o: apps/%.cpp Makefile - mkdir -p $(OBJDIR) + mkdir -p $(dir $@) $(CXX) $(CXXFLAGS) -c $< -o $@ -include $(OBJS:.o=.d) diff --git a/programs/src/desktop/apps/app_filemanager.cpp b/programs/src/desktop/apps/app_filemanager.cpp deleted file mode 100644 index e78f2f8..0000000 --- a/programs/src/desktop/apps/app_filemanager.cpp +++ /dev/null @@ -1,1976 +0,0 @@ -/* - * app_filemanager.cpp - * MontaukOS Desktop - Enhanced File Manager application - * Copyright (c) 2026 Daniel Hammer -*/ - -#include "apps_common.hpp" -#include - -// ============================================================================ -// File Manager state -// ============================================================================ - -static 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; -}; - -static constexpr int FM_TOOLBAR_H = 32; -static constexpr int FM_PATHBAR_H = 24; -static constexpr int FM_HEADER_H = 20; -static constexpr int FM_ITEM_H = 24; -static constexpr int FM_SCROLLBAR_W = 12; -static constexpr int FM_GRID_CELL_W = 80; -static constexpr int FM_GRID_CELL_H = 80; -static constexpr int FM_GRID_ICON = 48; -static constexpr int FM_GRID_PAD = 4; - -// Special user folders: name, icon filename, index into DesktopState arrays -static constexpr int SF_COUNT = 6; -static const char* sf_names[SF_COUNT] = { - "Documents", "Desktop", "Music", "Videos", "Pictures", "Downloads" -}; -static 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" -}; - -// Return special folder index (0-5) or -1 if not a special folder name -static 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; -} - -// Context menu action codes -static constexpr int CTX_OPEN = 0; -static constexpr int CTX_COPY = 1; -static constexpr int CTX_CUT = 2; -static constexpr int CTX_PASTE = 3; -static constexpr int CTX_RENAME = 4; -static constexpr int CTX_DELETE = 5; -static constexpr int CTX_NEW_FOLDER = 6; - -static constexpr int CTX_MENU_W = 140; -static constexpr int CTX_ITEM_H = 24; - -static 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 "?"; - } -} - -// ============================================================================ -// File type detection -// ============================================================================ - -static 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; -} - -static bool is_image_file(const char* name) { - return str_ends_with(name, ".jpg") || str_ends_with(name, ".jpeg"); -} - -static bool is_font_file(const char* name) { - return str_ends_with(name, ".ttf"); -} - -static bool is_pdf_file(const char* name) { - return str_ends_with(name, ".pdf"); -} - -static bool is_spreadsheet_file(const char* name) { - return str_ends_with(name, ".mss"); -} - -static bool is_wordprocessor_file(const char* name) { - return str_ends_with(name, ".mwp"); -} - -static bool is_audio_file(const char* name) { - return str_ends_with(name, ".mp3") || str_ends_with(name, ".wav"); -} - -static bool is_video_file(const char* name) { - return str_ends_with(name, ".avi"); -} - -static 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; -} - -// Forward declarations -static void filemanager_free_app_icons(FileManagerState* fm); - -// ============================================================================ -// Path helpers -// ============================================================================ - -static 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) -static 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; -} - -// ============================================================================ -// Directory reading with sorting and file sizes -// ============================================================================ - -static 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; -} - -static 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; -} - -// ============================================================================ -// Apps view population -// ============================================================================ - -static 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; -} - -static 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; -} - -// ============================================================================ -// Recursive directory deletion -// ============================================================================ - -static 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; -} - -// ============================================================================ -// File copy helper (single file) -// ============================================================================ - -static 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 -static 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; -} - -// ============================================================================ -// Clipboard operations -// ============================================================================ - -static 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; -} - -static 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; -} - -static 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); -} - -// ============================================================================ -// Rename operations -// ============================================================================ - -static 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; -} - -static 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); -} - -static void filemanager_cancel_rename(FileManagerState* fm) { - fm->rename_active = false; -} - -// ============================================================================ -// New folder -// ============================================================================ - -static 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; - } - } -} - -// ============================================================================ -// Delete selected entry -// ============================================================================ - -static 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); -} - -// ============================================================================ -// Context menu -// ============================================================================ - -static 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; - } -} - -static void filemanager_close_ctx_menu(FileManagerState* fm) { - fm->ctx_open = false; -} - -// ============================================================================ -// History management -// ============================================================================ - -static 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; -} - -static 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); -} - -static 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); -} - -static 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); - } -} - -static void filemanager_go_back(FileManagerState* fm) { - if (fm->history_pos <= 0) return; - fm->history_pos--; - filemanager_navigate_to_history(fm); -} - -static void filemanager_go_forward(FileManagerState* fm) { - if (fm->history_pos >= fm->history_count - 1) return; - fm->history_pos++; - filemanager_navigate_to_history(fm); -} - -static 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); -} - -// ============================================================================ -// Path bar editing -// ============================================================================ - -static 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; -} - -static void filemanager_cancel_pathbar(FileManagerState* fm) { - fm->pathbar_editing = false; -} - -static 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); -} - -// ============================================================================ -// Open file/directory/drive -// ============================================================================ - -static 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); - } -} - -// ============================================================================ -// Drawing -// ============================================================================ - -static 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); -} - -static 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); - } - } -} - -// ============================================================================ -// Mouse handling -// ============================================================================ - -static 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; - } - } -} - -// ============================================================================ -// Keyboard handling -// ============================================================================ - -static 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); - } -} - -static 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; - } -} - -// ============================================================================ -// File Manager launcher -// ============================================================================ - -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 < SF_COUNT; sf++) { - char icon_path[128]; - snprintf(icon_path, 128, "0:/icons/%s", 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); -} - -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; -} - -void open_filemanager(DesktopState* ds) { - open_filemanager_internal(ds, nullptr); -} - -void open_filemanager_path(DesktopState* ds, const char* path) { - open_filemanager_internal(ds, path); -} diff --git a/programs/src/desktop/apps/app_settings.cpp b/programs/src/desktop/apps/app_settings.cpp deleted file mode 100644 index 224ad36..0000000 --- a/programs/src/desktop/apps/app_settings.cpp +++ /dev/null @@ -1,1464 +0,0 @@ -/* - * app_settings.cpp - * MontaukOS Desktop - Settings application - * Copyright (c) 2026 Daniel Hammer -*/ - -#include "apps_common.hpp" -#include "../wallpaper.hpp" -#include -#include - -// ============================================================================ -// Settings state -// ============================================================================ - -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; -}; - -// ============================================================================ -// Color palette presets -// ============================================================================ - -static constexpr int SWATCH_COUNT = 8; -static constexpr int SWATCH_SIZE = 24; -static constexpr int SWATCH_GAP = 6; - -// Background colors (light tones) -static 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) -static 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 -static 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 -}; - -// ============================================================================ -// Layout constants -// ============================================================================ - -static constexpr int TAB_BAR_H = 36; -static constexpr int TAB_COUNT = 4; -static const char* tab_labels[TAB_COUNT] = { "Appearance", "Display", "Users", "About" }; - -// ============================================================================ -// Helper: check if two colors match -// ============================================================================ - -static bool color_eq(Color a, Color b) { - return a.r == b.r && a.g == b.g && a.b == b.b; -} - -static bool settings_status_visible(SettingsState* st) { - return st->status_msg[0] && - (montauk::get_milliseconds() - st->status_time < 4000); -} - -static 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(); -} - -// ============================================================================ -// Helper: find selected swatch index in a palette -// ============================================================================ - -static 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; -} - -// ============================================================================ -// Drawing -// ============================================================================ - -static 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)); - } -} - -static 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); - } -} - -static 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); -} - -static constexpr int WP_ITEM_H = 24; - -static 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); -} - -static 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; - } -} - -static 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); -} - -static 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); - - // snprintf(line, sizeof(line), "Build: %s %s", __DATE__, __TIME__); - // c.text(x, y, line, colors::TEXT_COLOR); - y += 8; - - c.hline(x, y, c.w - 2 * x, colors::BORDER); - y += 12; - - c.text(x, y, "Copyright (c) 2026 Daniel Hammer", dim); -} - -// ============================================================================ -// Config persistence -// ============================================================================ - -static int64_t color_to_int(Color c) { - return ((int64_t)c.r << 16) | ((int64_t)c.g << 8) | c.b; -} - -static Color int_to_color(int64_t v) { - return Color::from_rgb((uint8_t)((v >> 16) & 0xFF), - (uint8_t)((v >> 8) & 0xFF), - (uint8_t)(v & 0xFF)); -} - -static 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(); -} - -static 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(); -} - -// ============================================================================ -// Users tab -// ============================================================================ - -static void users_reload(SettingsState* st) { - st->user_count = montauk::user::load_users(st->users, 16); - st->users_loaded = true; -} - -static constexpr int USER_BTN_H = 30; -static constexpr int USER_BTN_W = 100; -static constexpr int USER_FIELD_H = 32; - -// Forward declarations for dialog openers -static void open_add_user_dialog(SettingsState* st); -static void open_change_pwd_dialog(SettingsState* st); -static void open_delete_user_dialog(SettingsState* st); - -static int user_row_height() { - return system_font_height() * 2 + 12; // Two lines of text + padding -} - -static 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 (st->status_msg[0] && (montauk::get_milliseconds() - st->status_time < 3000)) { - 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); - } -} - -// ============================================================================ -// Dialog helpers -// ============================================================================ - -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; - montauk::strcpy(st->parent->status_msg, "User created"); - st->parent->status_time = montauk::get_milliseconds(); - 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; } -} - -static 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); - montauk::strcpy(st->parent->status_msg, "Password changed"); - st->parent->status_time = montauk::get_milliseconds(); - 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; } -} - -static 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; - montauk::strcpy(st->parent->status_msg, "User deleted"); - st->parent->status_time = montauk::get_milliseconds(); - 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; - montauk::strcpy(st->parent->status_msg, "User deleted"); - st->parent->status_time = montauk::get_milliseconds(); - 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; } -} - -static 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)) { - montauk::strcpy(parent->status_msg, "Cannot delete current user"); - parent->status_time = montauk::get_milliseconds(); - 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; -} - -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 - // Create a sub-canvas offset by TAB_BAR_H - 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; - } -} - -// ============================================================================ -// Mouse interaction -// ============================================================================ - -static 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; -} - -static void settings_on_mouse(Window* win, MouseEvent& ev) { - SettingsState* st = (SettingsState*)win->app_data; - if (!st) return; - - if (!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; - DesktopSettings& s = st->desktop->settings; - - if (st->active_tab == 0) { - // Appearance tab - 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; - } - // 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; - } - // 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; - } - 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; - } - 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; - } - 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; - } - 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; - } - 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; - } - 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; - } - } else if (st->active_tab == 1) { - // Display tab - 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; - } - // 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; - } - 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; - } - // 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; - } - 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; - } - // 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; - } - // 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; - } - 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; - } - // 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; - } - } else if (st->active_tab == 2) { - // Users tab - if (!st->desktop->is_admin) return; - - 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; - } - y += row_h + 4; - } - - // Separator + gap - y += 8 + 1 + 16; - - // Status message (if shown, takes space) - if (st->status_msg[0] && (montauk::get_milliseconds() - st->status_time < 3000)) { - 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; - } - 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; - } - 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; - } - } -} - -// ============================================================================ -// Keyboard -// ============================================================================ - -static void settings_on_key(Window* win, const Montauk::KeyEvent& key) { - // Settings main window no longer needs keyboard handling — - // text input is handled by dialog windows. - (void)win; (void)key; -} - -// ============================================================================ -// Cleanup -// ============================================================================ - -static void settings_on_close(Window* win) { - if (win->app_data) { - montauk::mfree(win->app_data); - win->app_data = nullptr; - } -} - -// ============================================================================ -// Settings launcher -// ============================================================================ - -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]; - SettingsState* st = (SettingsState*)montauk::malloc(sizeof(SettingsState)); - montauk::memset(st, 0, sizeof(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_on_draw; - win->on_mouse = settings_on_mouse; - win->on_key = settings_on_key; - win->on_close = settings_on_close; -} diff --git a/programs/src/desktop/apps/filemanager/actions.cpp b/programs/src/desktop/apps/filemanager/actions.cpp new file mode 100644 index 0000000..8dfa2f0 --- /dev/null +++ b/programs/src/desktop/apps/filemanager/actions.cpp @@ -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 diff --git a/programs/src/desktop/apps/filemanager/app.cpp b/programs/src/desktop/apps/filemanager/app.cpp new file mode 100644 index 0000000..24caf03 --- /dev/null +++ b/programs/src/desktop/apps/filemanager/app.cpp @@ -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); +} diff --git a/programs/src/desktop/apps/filemanager/events.cpp b/programs/src/desktop/apps/filemanager/events.cpp new file mode 100644 index 0000000..5675519 --- /dev/null +++ b/programs/src/desktop/apps/filemanager/events.cpp @@ -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 diff --git a/programs/src/desktop/apps/filemanager/filemanager_internal.hpp b/programs/src/desktop/apps/filemanager/filemanager_internal.hpp new file mode 100644 index 0000000..0f0b5c5 --- /dev/null +++ b/programs/src/desktop/apps/filemanager/filemanager_internal.hpp @@ -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 diff --git a/programs/src/desktop/apps/filemanager/filesystem.cpp b/programs/src/desktop/apps/filemanager/filesystem.cpp new file mode 100644 index 0000000..5fd674a --- /dev/null +++ b/programs/src/desktop/apps/filemanager/filesystem.cpp @@ -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 + +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 diff --git a/programs/src/desktop/apps/filemanager/helpers.cpp b/programs/src/desktop/apps/filemanager/helpers.cpp new file mode 100644 index 0000000..b28992a --- /dev/null +++ b/programs/src/desktop/apps/filemanager/helpers.cpp @@ -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 diff --git a/programs/src/desktop/apps/filemanager/navigation.cpp b/programs/src/desktop/apps/filemanager/navigation.cpp new file mode 100644 index 0000000..fb9e0b6 --- /dev/null +++ b/programs/src/desktop/apps/filemanager/navigation.cpp @@ -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 diff --git a/programs/src/desktop/apps/filemanager/render.cpp b/programs/src/desktop/apps/filemanager/render.cpp new file mode 100644 index 0000000..7c31fde --- /dev/null +++ b/programs/src/desktop/apps/filemanager/render.cpp @@ -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 diff --git a/programs/src/desktop/apps/settings/about.cpp b/programs/src/desktop/apps/settings/about.cpp new file mode 100644 index 0000000..e24e121 --- /dev/null +++ b/programs/src/desktop/apps/settings/about.cpp @@ -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 diff --git a/programs/src/desktop/apps/settings/app.cpp b/programs/src/desktop/apps/settings/app.cpp new file mode 100644 index 0000000..93fa659 --- /dev/null +++ b/programs/src/desktop/apps/settings/app.cpp @@ -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; +} diff --git a/programs/src/desktop/apps/settings/appearance.cpp b/programs/src/desktop/apps/settings/appearance.cpp new file mode 100644 index 0000000..def5972 --- /dev/null +++ b/programs/src/desktop/apps/settings/appearance.cpp @@ -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 diff --git a/programs/src/desktop/apps/settings/dialogs.cpp b/programs/src/desktop/apps/settings/dialogs.cpp new file mode 100644 index 0000000..6a5e53a --- /dev/null +++ b/programs/src/desktop/apps/settings/dialogs.cpp @@ -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 diff --git a/programs/src/desktop/apps/settings/display.cpp b/programs/src/desktop/apps/settings/display.cpp new file mode 100644 index 0000000..759b9d0 --- /dev/null +++ b/programs/src/desktop/apps/settings/display.cpp @@ -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 diff --git a/programs/src/desktop/apps/settings/helpers.cpp b/programs/src/desktop/apps/settings/helpers.cpp new file mode 100644 index 0000000..46b5614 --- /dev/null +++ b/programs/src/desktop/apps/settings/helpers.cpp @@ -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 diff --git a/programs/src/desktop/apps/settings/persistence.cpp b/programs/src/desktop/apps/settings/persistence.cpp new file mode 100644 index 0000000..b0210d6 --- /dev/null +++ b/programs/src/desktop/apps/settings/persistence.cpp @@ -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 diff --git a/programs/src/desktop/apps/settings/settings_internal.hpp b/programs/src/desktop/apps/settings/settings_internal.hpp new file mode 100644 index 0000000..d542c12 --- /dev/null +++ b/programs/src/desktop/apps/settings/settings_internal.hpp @@ -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 +#include + +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 diff --git a/programs/src/desktop/apps/settings/users.cpp b/programs/src/desktop/apps/settings/users.cpp new file mode 100644 index 0000000..fd4ad8a --- /dev/null +++ b/programs/src/desktop/apps/settings/users.cpp @@ -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