refactor: split Files and Settings apps into multiple files

This commit is contained in:
2026-04-01 23:08:25 +02:00
parent 045f3ee0a5
commit b140825121
20 changed files with 3578 additions and 3449 deletions
@@ -0,0 +1,178 @@
/*
* actions.cpp
* Clipboard, rename, delete, and context-menu actions for the embedded desktop file manager
* Copyright (c) 2026 Daniel Hammer
*/
#include "filemanager_internal.hpp"
namespace filemanager {
void filemanager_do_copy(FileManagerState* fm) {
if (fm->selected < 0 || fm->selected >= fm->entry_count) return;
if (fm->at_drives_root || fm->entry_types[fm->selected] == 3) return;
filemanager_build_fullpath(fm->clipboard_path, 256,
fm->current_path, fm->entry_names[fm->selected]);
fm->clipboard_has_data = true;
fm->clipboard_is_cut = false;
}
void filemanager_do_cut(FileManagerState* fm) {
if (fm->selected < 0 || fm->selected >= fm->entry_count) return;
if (fm->at_drives_root || fm->entry_types[fm->selected] == 3) return;
filemanager_build_fullpath(fm->clipboard_path, 256,
fm->current_path, fm->entry_names[fm->selected]);
fm->clipboard_has_data = true;
fm->clipboard_is_cut = true;
}
void filemanager_do_paste(FileManagerState* fm) {
if (!fm->clipboard_has_data || fm->at_drives_root) return;
const char* basename = path_basename(fm->clipboard_path);
char dst[512];
filemanager_build_fullpath(dst, 512, fm->current_path, basename);
// Check if source is a directory by trying to readdir it
const char* probe[1];
int probe_count = montauk::readdir(fm->clipboard_path, probe, 1);
bool src_is_dir = (probe_count >= 0);
bool ok;
if (src_is_dir)
ok = filemanager_copy_dir_recursive(fm->clipboard_path, dst);
else
ok = filemanager_copy_file(fm->clipboard_path, dst);
if (ok && fm->clipboard_is_cut) {
if (src_is_dir)
filemanager_delete_recursive(fm->clipboard_path);
else
montauk::fdelete(fm->clipboard_path);
fm->clipboard_has_data = false;
}
filemanager_read_dir(fm);
}
void filemanager_start_rename(FileManagerState* fm) {
if (fm->selected < 0 || fm->selected >= fm->entry_count) return;
if (fm->at_drives_root || fm->entry_types[fm->selected] == 3) return;
fm->rename_active = true;
fm->rename_idx = fm->selected;
montauk::strcpy(fm->rename_buf, fm->entry_names[fm->selected]);
fm->rename_len = montauk::slen(fm->rename_buf);
fm->rename_cursor = fm->rename_len;
}
void filemanager_finish_rename(FileManagerState* fm) {
if (!fm->rename_active) return;
fm->rename_active = false;
// If name unchanged, skip
if (montauk::streq(fm->rename_buf, fm->entry_names[fm->rename_idx])) return;
if (fm->rename_len == 0) return;
char old_path[512], new_path[512];
filemanager_build_fullpath(old_path, 512,
fm->current_path, fm->entry_names[fm->rename_idx]);
filemanager_build_fullpath(new_path, 512,
fm->current_path, fm->rename_buf);
if (fm->is_dir[fm->rename_idx]) {
// Directory rename: copy recursively then delete old
if (filemanager_copy_dir_recursive(old_path, new_path))
filemanager_delete_recursive(old_path);
} else {
// File rename: copy then delete old
if (filemanager_copy_file(old_path, new_path))
montauk::fdelete(old_path);
}
filemanager_read_dir(fm);
}
void filemanager_cancel_rename(FileManagerState* fm) {
fm->rename_active = false;
}
void filemanager_new_folder(FileManagerState* fm) {
if (fm->at_drives_root) return;
// Find a unique name
char name[64] = "New Folder";
char path[512];
filemanager_build_fullpath(path, 512, fm->current_path, name);
int attempt = 1;
// Try creating; if it fails, append a number
if (montauk::fmkdir(path) != 0) {
for (attempt = 2; attempt <= 99; attempt++) {
snprintf(name, 64, "New Folder %d", attempt);
filemanager_build_fullpath(path, 512, fm->current_path, name);
if (montauk::fmkdir(path) == 0) break;
}
}
filemanager_read_dir(fm);
// Select the new folder and start rename
for (int i = 0; i < fm->entry_count; i++) {
if (montauk::streq(fm->entry_names[i], name)) {
fm->selected = i;
filemanager_start_rename(fm);
break;
}
}
}
void filemanager_delete_selected(FileManagerState* fm) {
if (fm->selected < 0 || fm->selected >= fm->entry_count) return;
if (fm->at_drives_root || fm->entry_types[fm->selected] == 3) return;
char fullpath[512];
filemanager_build_fullpath(fullpath, 512,
fm->current_path, fm->entry_names[fm->selected]);
if (fm->is_dir[fm->selected])
filemanager_delete_recursive(fullpath);
else
montauk::fdelete(fullpath);
filemanager_read_dir(fm);
}
void filemanager_open_ctx_menu(FileManagerState* fm, int local_x, int local_y, int target_idx) {
fm->ctx_open = true;
fm->ctx_x = local_x;
fm->ctx_y = local_y;
fm->ctx_target_idx = target_idx;
fm->ctx_hover = -1;
fm->ctx_item_count = 0;
if (target_idx >= 0 && target_idx < fm->entry_count) {
int tt = fm->entry_types[target_idx];
if ((fm->at_drives_root && (tt == 3 || tt == 4 || tt == 5 || tt == 7)) ||
(fm->at_apps_view && tt == 6)) {
// Drive, Home, Apps shortcut, or app entry: only Open
fm->ctx_items[fm->ctx_item_count++] = CTX_OPEN;
} else {
fm->ctx_items[fm->ctx_item_count++] = CTX_OPEN;
fm->ctx_items[fm->ctx_item_count++] = CTX_COPY;
fm->ctx_items[fm->ctx_item_count++] = CTX_CUT;
fm->ctx_items[fm->ctx_item_count++] = CTX_RENAME;
fm->ctx_items[fm->ctx_item_count++] = CTX_DELETE;
}
} else {
// Background click
if (fm->clipboard_has_data)
fm->ctx_items[fm->ctx_item_count++] = CTX_PASTE;
fm->ctx_items[fm->ctx_item_count++] = CTX_NEW_FOLDER;
}
}
void filemanager_close_ctx_menu(FileManagerState* fm) {
fm->ctx_open = false;
}
} // namespace filemanager
@@ -0,0 +1,83 @@
/*
* app.cpp
* File manager launcher and desktop integration entry points
* Copyright (c) 2026 Daniel Hammer
*/
#include "filemanager_internal.hpp"
namespace filemanager {
static void open_filemanager_internal(DesktopState* ds, const char* initial_path) {
int idx = desktop_create_window(ds, "Files", 150, 120, 560, 420);
if (idx < 0) return;
Window* win = &ds->windows[idx];
FileManagerState* fm = (FileManagerState*)montauk::malloc(sizeof(FileManagerState));
montauk::memset(fm, 0, sizeof(FileManagerState));
fm->selected = -1;
fm->last_click_item = -1;
fm->history_pos = -1;
fm->history_count = 0;
fm->desktop = ds;
fm->grid_view = true;
fm->scrollbar.init(0, 0, FM_SCROLLBAR_W, 100);
ensure_filemanager_icons_loaded(ds);
if (initial_path && initial_path[0] != '\0') {
int probe_fd = montauk::open(initial_path);
if (probe_fd >= 0) {
montauk::close(probe_fd);
montauk::strncpy(fm->current_path, initial_path, sizeof(fm->current_path));
filemanager_read_dir(fm);
} else {
filemanager_read_drives(fm);
}
} else {
filemanager_read_drives(fm);
}
filemanager_push_history(fm);
win->app_data = fm;
win->on_draw = filemanager_on_draw;
win->on_mouse = filemanager_on_mouse;
win->on_key = filemanager_on_key;
win->on_close = filemanager_on_close;
}
} // namespace filemanager
void ensure_filemanager_icons_loaded(DesktopState* ds) {
if (!ds || ds->icon_drive.pixels) return;
Color defColor = colors::ICON_COLOR;
ds->icon_drive = svg_load("0:/icons/drive-harddisk.svg", 16, 16, defColor);
ds->icon_drive_lg = svg_load("0:/icons/drive-harddisk.svg", 48, 48, defColor);
ds->icon_home_folder = svg_load("0:/icons/folder-blue-home.svg", 16, 16, defColor);
ds->icon_home_folder_lg = svg_load("0:/icons/folder-blue-home.svg", 48, 48, defColor);
ds->icon_apps = svg_load("0:/icons/folder-blue-development.svg", 16, 16, defColor);
ds->icon_apps_lg = svg_load("0:/icons/folder-blue-development.svg", 48, 48, defColor);
for (int sf = 0; sf < filemanager::SF_COUNT; sf++) {
char icon_path[128];
snprintf(icon_path, 128, "0:/icons/%s", filemanager::sf_icons[sf]);
ds->icon_special_folder[sf] = svg_load(icon_path, 16, 16, defColor);
ds->icon_special_folder_lg[sf] = svg_load(icon_path, 48, 48, defColor);
}
ds->icon_delete = svg_load("0:/icons/trash-empty.svg", 16, 16, defColor);
ds->icon_copy = svg_load("0:/icons/edit-copy.svg", 16, 16, defColor);
ds->icon_cut = svg_load("0:/icons/edit-cut.svg", 16, 16, defColor);
ds->icon_paste = svg_load("0:/icons/edit-paste.svg", 16, 16, defColor);
ds->icon_rename = svg_load("0:/icons/edit-rename.svg", 16, 16, defColor);
ds->icon_folder_new = svg_load("0:/icons/folder-new.svg", 16, 16, defColor);
}
void open_filemanager(DesktopState* ds) {
filemanager::open_filemanager_internal(ds, nullptr);
}
void open_filemanager_path(DesktopState* ds, const char* path) {
filemanager::open_filemanager_internal(ds, path);
}
@@ -0,0 +1,395 @@
/*
* events.cpp
* Mouse and keyboard handling for the embedded desktop file manager
* Copyright (c) 2026 Daniel Hammer
*/
#include "filemanager_internal.hpp"
namespace filemanager {
void filemanager_on_mouse(Window* win, MouseEvent& ev) {
FileManagerState* fm = (FileManagerState*)win->app_data;
if (!fm) return;
Rect cr = win->content_rect();
int local_x = ev.x - cr.x;
int local_y = ev.y - cr.y;
int cw = cr.w;
// Scrollbar interaction
MouseEvent local_ev = ev;
local_ev.x = local_x;
local_ev.y = local_y;
fm->scrollbar.handle_mouse(local_ev);
// ---- Context menu interaction ----
if (fm->ctx_open) {
int cmx = fm->ctx_x;
int cmy = fm->ctx_y;
int cmh = fm->ctx_item_count * CTX_ITEM_H + 8;
if (cmx + CTX_MENU_W > cw) cmx = cw - CTX_MENU_W;
if (cmy + cmh > win->content_h) cmy = win->content_h - cmh;
if (cmx < 0) cmx = 0;
if (cmy < 0) cmy = 0;
// Update hover
if (local_x >= cmx && local_x < cmx + CTX_MENU_W &&
local_y >= cmy + 4 && local_y < cmy + 4 + fm->ctx_item_count * CTX_ITEM_H) {
fm->ctx_hover = (local_y - cmy - 4) / CTX_ITEM_H;
} else {
fm->ctx_hover = -1;
}
if (ev.left_pressed()) {
if (fm->ctx_hover >= 0 && fm->ctx_hover < fm->ctx_item_count) {
int action = fm->ctx_items[fm->ctx_hover];
int target = fm->ctx_target_idx;
filemanager_close_ctx_menu(fm);
// Select target if needed
if (target >= 0 && target < fm->entry_count)
fm->selected = target;
switch (action) {
case CTX_OPEN: filemanager_open_entry(fm, target); break;
case CTX_COPY: filemanager_do_copy(fm); break;
case CTX_CUT: filemanager_do_cut(fm); break;
case CTX_PASTE: filemanager_do_paste(fm); break;
case CTX_RENAME: filemanager_start_rename(fm); break;
case CTX_DELETE: filemanager_delete_selected(fm); break;
case CTX_NEW_FOLDER: filemanager_new_folder(fm); break;
}
} else {
filemanager_close_ctx_menu(fm);
}
return;
}
if (ev.right_pressed()) {
filemanager_close_ctx_menu(fm);
return;
}
return;
}
// ---- Cancel rename on click outside rename area ----
if (fm->rename_active && ev.left_pressed()) {
filemanager_finish_rename(fm);
}
if (ev.left_pressed()) {
// Click on path bar area
if (local_y >= FM_TOOLBAR_H && local_y < FM_TOOLBAR_H + FM_PATHBAR_H) {
if (!fm->pathbar_editing)
filemanager_start_pathbar(fm);
return;
}
// Clicking anywhere else while pathbar is editing commits the path
if (fm->pathbar_editing) {
filemanager_commit_pathbar(fm);
}
// Toolbar button clicks
if (local_y < FM_TOOLBAR_H) {
// Navigation buttons
if (local_x >= 4 && local_x < 28) filemanager_go_back(fm);
else if (local_x >= 32 && local_x < 56) filemanager_go_forward(fm);
else if (local_x >= 60 && local_x < 84) filemanager_go_up(fm);
else if (local_x >= 88 && local_x < 112) filemanager_go_home(fm);
else if (local_x >= 120 && local_x < 144) {
fm->grid_view = !fm->grid_view;
fm->scrollbar.scroll_offset = 0;
}
// Action buttons
else if (local_x >= 160 && local_x < 184) filemanager_do_copy(fm);
else if (local_x >= 188 && local_x < 212) filemanager_do_cut(fm);
else if (local_x >= 216 && local_x < 240) filemanager_do_paste(fm);
else if (local_x >= 244 && local_x < 268) filemanager_start_rename(fm);
else if (local_x >= 272 && local_x < 296) filemanager_new_folder(fm);
else if (local_x >= 300 && local_x < 324) filemanager_delete_selected(fm);
return;
}
// File clicks (grid vs list)
if (fm->grid_view) {
int list_y = FM_TOOLBAR_H + FM_PATHBAR_H;
if (local_y >= list_y && local_x < cw - FM_SCROLLBAR_W) {
int cols = (cw - FM_SCROLLBAR_W) / FM_GRID_CELL_W;
if (cols < 1) cols = 1;
int col = local_x / FM_GRID_CELL_W;
int row = (local_y - list_y + fm->scrollbar.scroll_offset) / FM_GRID_CELL_H;
int clicked_idx = row * cols + col;
if (clicked_idx >= 0 && clicked_idx < fm->entry_count && col < cols) {
uint64_t now = montauk::get_milliseconds();
if (fm->last_click_item == clicked_idx &&
(now - fm->last_click_time) < 400) {
filemanager_open_entry(fm, clicked_idx);
fm->last_click_item = -1;
fm->last_click_time = 0;
} else {
fm->selected = clicked_idx;
fm->last_click_item = clicked_idx;
fm->last_click_time = now;
}
} else {
fm->selected = -1;
}
}
} else {
// List view clicks
int list_y = FM_TOOLBAR_H + FM_PATHBAR_H + FM_HEADER_H;
if (local_y >= list_y && local_x < cw - FM_SCROLLBAR_W) {
int rel_y = local_y - list_y + fm->scrollbar.scroll_offset;
int clicked_idx = rel_y / FM_ITEM_H;
if (clicked_idx >= 0 && clicked_idx < fm->entry_count) {
uint64_t now = montauk::get_milliseconds();
// Double-click detection
if (fm->last_click_item == clicked_idx &&
(now - fm->last_click_time) < 400) {
filemanager_open_entry(fm, clicked_idx);
fm->last_click_item = -1;
fm->last_click_time = 0;
} else {
fm->selected = clicked_idx;
fm->last_click_item = clicked_idx;
fm->last_click_time = now;
}
} else {
fm->selected = -1;
}
}
}
}
// ---- Right-click: open context menu ----
if (ev.right_pressed()) {
filemanager_cancel_rename(fm);
// Determine what was right-clicked
int target_idx = -1;
if (local_y >= FM_TOOLBAR_H + FM_PATHBAR_H) {
if (fm->grid_view) {
int list_y = FM_TOOLBAR_H + FM_PATHBAR_H;
if (local_x < cw - FM_SCROLLBAR_W) {
int cols = (cw - FM_SCROLLBAR_W) / FM_GRID_CELL_W;
if (cols < 1) cols = 1;
int col = local_x / FM_GRID_CELL_W;
int row = (local_y - list_y + fm->scrollbar.scroll_offset) / FM_GRID_CELL_H;
int idx = row * cols + col;
if (idx >= 0 && idx < fm->entry_count && col < cols) {
target_idx = idx;
fm->selected = idx;
}
}
} else {
int list_y = FM_TOOLBAR_H + FM_PATHBAR_H + FM_HEADER_H;
if (local_y >= list_y && local_x < cw - FM_SCROLLBAR_W) {
int rel_y = local_y - list_y + fm->scrollbar.scroll_offset;
int idx = rel_y / FM_ITEM_H;
if (idx >= 0 && idx < fm->entry_count) {
target_idx = idx;
fm->selected = idx;
}
}
}
}
filemanager_open_ctx_menu(fm, local_x, local_y, target_idx);
}
// Scroll handling
if (ev.scroll != 0) {
int list_y_start = fm->grid_view
? FM_TOOLBAR_H + FM_PATHBAR_H
: FM_TOOLBAR_H + FM_PATHBAR_H + FM_HEADER_H;
int scroll_step = fm->grid_view ? FM_GRID_CELL_H : FM_ITEM_H;
if (local_y >= list_y_start) {
fm->scrollbar.scroll_offset -= ev.scroll * scroll_step;
int ms = fm->scrollbar.max_scroll();
if (fm->scrollbar.scroll_offset < 0) fm->scrollbar.scroll_offset = 0;
if (fm->scrollbar.scroll_offset > ms) fm->scrollbar.scroll_offset = ms;
}
}
}
void filemanager_on_key(Window* win, const Montauk::KeyEvent& key) {
FileManagerState* fm = (FileManagerState*)win->app_data;
if (!fm || !key.pressed) return;
// ---- Path bar editing key handling ----
if (fm->pathbar_editing) {
if (key.ascii == '\n' || key.ascii == '\r') {
filemanager_commit_pathbar(fm);
} else if (key.scancode == 0x01) {
filemanager_cancel_pathbar(fm);
} else if (key.ascii == '\b' || key.scancode == 0x0E) {
if (fm->pathbar_cursor > 0) {
for (int i = fm->pathbar_cursor - 1; i < fm->pathbar_len - 1; i++)
fm->pathbar_buf[i] = fm->pathbar_buf[i + 1];
fm->pathbar_len--;
fm->pathbar_cursor--;
fm->pathbar_buf[fm->pathbar_len] = '\0';
}
} else if (key.scancode == 0x53) {
if (fm->pathbar_cursor < fm->pathbar_len) {
for (int i = fm->pathbar_cursor; i < fm->pathbar_len - 1; i++)
fm->pathbar_buf[i] = fm->pathbar_buf[i + 1];
fm->pathbar_len--;
fm->pathbar_buf[fm->pathbar_len] = '\0';
}
} else if (key.scancode == 0x4B) {
if (fm->pathbar_cursor > 0) fm->pathbar_cursor--;
} else if (key.scancode == 0x4D) {
if (fm->pathbar_cursor < fm->pathbar_len) fm->pathbar_cursor++;
} else if (key.scancode == 0x47) {
fm->pathbar_cursor = 0;
} else if (key.scancode == 0x4F) {
fm->pathbar_cursor = fm->pathbar_len;
} else if (key.ascii >= 32 && key.ascii < 127 && fm->pathbar_len < 254) {
for (int i = fm->pathbar_len; i > fm->pathbar_cursor; i--)
fm->pathbar_buf[i] = fm->pathbar_buf[i - 1];
fm->pathbar_buf[fm->pathbar_cursor] = key.ascii;
fm->pathbar_cursor++;
fm->pathbar_len++;
fm->pathbar_buf[fm->pathbar_len] = '\0';
}
return;
}
// ---- Rename mode key handling ----
if (fm->rename_active) {
if (key.ascii == '\n' || key.ascii == '\r') {
filemanager_finish_rename(fm);
} else if (key.scancode == 0x01) {
// Escape
filemanager_cancel_rename(fm);
} else if (key.ascii == '\b' || key.scancode == 0x0E) {
// Backspace
if (fm->rename_cursor > 0) {
for (int i = fm->rename_cursor - 1; i < fm->rename_len - 1; i++)
fm->rename_buf[i] = fm->rename_buf[i + 1];
fm->rename_len--;
fm->rename_cursor--;
fm->rename_buf[fm->rename_len] = '\0';
}
} else if (key.scancode == 0x53) {
// Delete key
if (fm->rename_cursor < fm->rename_len) {
for (int i = fm->rename_cursor; i < fm->rename_len - 1; i++)
fm->rename_buf[i] = fm->rename_buf[i + 1];
fm->rename_len--;
fm->rename_buf[fm->rename_len] = '\0';
}
} else if (key.scancode == 0x4B) {
// Left arrow
if (fm->rename_cursor > 0) fm->rename_cursor--;
} else if (key.scancode == 0x4D) {
// Right arrow
if (fm->rename_cursor < fm->rename_len) fm->rename_cursor++;
} else if (key.scancode == 0x47) {
// Home
fm->rename_cursor = 0;
} else if (key.scancode == 0x4F) {
// End
fm->rename_cursor = fm->rename_len;
} else if (key.ascii >= 32 && key.ascii < 127) {
// Printable character (reject / and other FS-unsafe chars)
if (key.ascii != '/' && key.ascii != '\\' && fm->rename_len < 62) {
for (int i = fm->rename_len; i > fm->rename_cursor; i--)
fm->rename_buf[i] = fm->rename_buf[i - 1];
fm->rename_buf[fm->rename_cursor] = key.ascii;
fm->rename_cursor++;
fm->rename_len++;
fm->rename_buf[fm->rename_len] = '\0';
}
}
return;
}
// ---- Context menu: Escape to close ----
if (fm->ctx_open && key.scancode == 0x01) {
filemanager_close_ctx_menu(fm);
return;
}
// ---- Keyboard shortcuts ----
if (key.ctrl) {
if (key.ascii == 'c' || key.ascii == 'C') {
filemanager_do_copy(fm);
return;
}
if (key.ascii == 'x' || key.ascii == 'X') {
filemanager_do_cut(fm);
return;
}
if (key.ascii == 'v' || key.ascii == 'V') {
filemanager_do_paste(fm);
return;
}
}
// F2 = rename
if (key.scancode == 0x3C) {
filemanager_start_rename(fm);
return;
}
if (key.ascii == '\b' || key.scancode == 0x0E) {
filemanager_go_up(fm);
} else if (key.scancode == 0x48) {
// Up arrow
if (fm->grid_view) {
Rect cr_r = win->content_rect();
int cols = (cr_r.w - FM_SCROLLBAR_W) / FM_GRID_CELL_W;
if (cols < 1) cols = 1;
if (fm->selected >= cols) fm->selected -= cols;
} else {
if (fm->selected > 0) fm->selected--;
}
} else if (key.scancode == 0x50) {
// Down arrow
if (fm->grid_view) {
Rect cr_r = win->content_rect();
int cols = (cr_r.w - FM_SCROLLBAR_W) / FM_GRID_CELL_W;
if (cols < 1) cols = 1;
if (fm->selected + cols < fm->entry_count) fm->selected += cols;
} else {
if (fm->selected < fm->entry_count - 1) fm->selected++;
}
} else if (key.scancode == 0x4B && !key.alt && fm->grid_view) {
// Left arrow (grid view only)
if (fm->selected > 0) fm->selected--;
} else if (key.scancode == 0x4D && !key.alt && fm->grid_view) {
// Right arrow (grid view only)
if (fm->selected < fm->entry_count - 1) fm->selected++;
} else if (key.ascii == '\n' || key.ascii == '\r') {
if (fm->selected >= 0 && fm->selected < fm->entry_count)
filemanager_open_entry(fm, fm->selected);
} else if (key.scancode == 0x53) {
// Delete key
filemanager_delete_selected(fm);
} else if (key.alt && key.scancode == 0x4B) {
// Alt+Left: go back
filemanager_go_back(fm);
} else if (key.alt && key.scancode == 0x4D) {
// Alt+Right: go forward
filemanager_go_forward(fm);
}
}
void filemanager_on_close(Window* win) {
if (win->app_data) {
FileManagerState* fm = (FileManagerState*)win->app_data;
filemanager_free_app_icons(fm);
montauk::mfree(win->app_data);
win->app_data = nullptr;
}
}
} // namespace filemanager
@@ -0,0 +1,160 @@
/*
* filemanager_internal.hpp
* Shared declarations for the embedded desktop file manager
* Copyright (c) 2026 Daniel Hammer
*/
#pragma once
#include "../apps_common.hpp"
namespace filemanager {
inline constexpr int FM_MAX_DRIVES = 16;
struct FileManagerState {
char current_path[256];
char history[16][256];
int history_pos;
int history_count;
char entry_names[64][64];
int entry_types[64]; // 0=file, 1=dir, 2=exec, 3=drive, 4=home, 5=apps, 6=app, 7=special_dir
int entry_sizes[64];
int entry_count;
int selected;
int scroll_offset;
bool is_dir[64];
int last_click_item;
uint64_t last_click_time;
Scrollbar scrollbar;
DesktopState* desktop;
bool grid_view;
bool at_drives_root;
int drive_indices[FM_MAX_DRIVES]; // which drive number each entry maps to
// Clipboard
char clipboard_path[256];
bool clipboard_has_data;
bool clipboard_is_cut;
// Context menu
bool ctx_open;
int ctx_x, ctx_y;
int ctx_target_idx; // -1 = background
int ctx_items[8];
int ctx_item_count;
int ctx_hover;
// Rename
bool rename_active;
int rename_idx;
char rename_buf[64];
int rename_cursor;
int rename_len;
// Path bar editing
bool pathbar_editing;
char pathbar_buf[256];
int pathbar_cursor;
int pathbar_len;
// Apps view
bool at_apps_view;
int app_map[16]; // entry index -> external_apps[] index
SvgIcon app_icons_lg[16]; // 48x48 icons for grid view
SvgIcon app_icons_sm[16]; // 16x16 icons for list view
int app_icon_count;
};
inline constexpr int FM_TOOLBAR_H = 32;
inline constexpr int FM_PATHBAR_H = 24;
inline constexpr int FM_HEADER_H = 20;
inline constexpr int FM_ITEM_H = 24;
inline constexpr int FM_SCROLLBAR_W = 12;
inline constexpr int FM_GRID_CELL_W = 80;
inline constexpr int FM_GRID_CELL_H = 80;
inline constexpr int FM_GRID_ICON = 48;
inline constexpr int FM_GRID_PAD = 4;
// Special user folders: name, icon filename, index into DesktopState arrays
inline constexpr int SF_COUNT = DesktopState::SPECIAL_FOLDER_COUNT;
inline constexpr const char* sf_names[SF_COUNT] = {
"Documents", "Desktop", "Music", "Videos", "Pictures", "Downloads"
};
inline constexpr const char* sf_icons[SF_COUNT] = {
"folder-blue-documents.svg", "folder-blue-desktop.svg",
"folder-blue-music.svg", "folder-blue-videos.svg",
"folder-blue-pictures.svg", "folder-blue-downloads.svg"
};
// Context menu action codes
inline constexpr int CTX_OPEN = 0;
inline constexpr int CTX_COPY = 1;
inline constexpr int CTX_CUT = 2;
inline constexpr int CTX_PASTE = 3;
inline constexpr int CTX_RENAME = 4;
inline constexpr int CTX_DELETE = 5;
inline constexpr int CTX_NEW_FOLDER = 6;
inline constexpr int CTX_MENU_W = 140;
inline constexpr int CTX_ITEM_H = 24;
int special_folder_index(const char* name);
const char* ctx_label(int action);
bool str_ends_with(const char* s, const char* suffix);
bool is_image_file(const char* name);
bool is_font_file(const char* name);
bool is_pdf_file(const char* name);
bool is_spreadsheet_file(const char* name);
bool is_wordprocessor_file(const char* name);
bool is_audio_file(const char* name);
bool is_video_file(const char* name);
int detect_file_type(const char* name, bool is_dir);
void filemanager_build_fullpath(char* out, int out_max, const char* dir, const char* name);
const char* path_basename(const char* path);
void filemanager_read_drives(FileManagerState* fm);
void filemanager_read_dir(FileManagerState* fm);
void filemanager_free_app_icons(FileManagerState* fm);
void filemanager_read_apps(FileManagerState* fm);
bool filemanager_delete_recursive(const char* path);
bool filemanager_copy_file(const char* src, const char* dst);
bool filemanager_copy_dir_recursive(const char* src, const char* dst);
void filemanager_do_copy(FileManagerState* fm);
void filemanager_do_cut(FileManagerState* fm);
void filemanager_do_paste(FileManagerState* fm);
void filemanager_start_rename(FileManagerState* fm);
void filemanager_finish_rename(FileManagerState* fm);
void filemanager_cancel_rename(FileManagerState* fm);
void filemanager_new_folder(FileManagerState* fm);
void filemanager_delete_selected(FileManagerState* fm);
void filemanager_open_ctx_menu(FileManagerState* fm, int local_x, int local_y, int target_idx);
void filemanager_close_ctx_menu(FileManagerState* fm);
void filemanager_push_history(FileManagerState* fm);
void filemanager_navigate(FileManagerState* fm, const char* name);
void filemanager_go_up(FileManagerState* fm);
void filemanager_navigate_to_history(FileManagerState* fm);
void filemanager_go_back(FileManagerState* fm);
void filemanager_go_forward(FileManagerState* fm);
void filemanager_go_home(FileManagerState* fm);
void filemanager_start_pathbar(FileManagerState* fm);
void filemanager_cancel_pathbar(FileManagerState* fm);
void filemanager_commit_pathbar(FileManagerState* fm);
void filemanager_open_entry(FileManagerState* fm, int idx);
void filemanager_draw_header(Canvas& c, FileManagerState* fm, Color toolbar_color, Color btn_bg);
void filemanager_on_draw(Window* win, Framebuffer& fb);
void filemanager_on_mouse(Window* win, MouseEvent& ev);
void filemanager_on_key(Window* win, const Montauk::KeyEvent& key);
void filemanager_on_close(Window* win);
} // namespace filemanager
@@ -0,0 +1,469 @@
/*
* filesystem.cpp
* Filesystem enumeration and copy/delete helpers for the embedded desktop file manager
* Copyright (c) 2026 Daniel Hammer
*/
#include "filemanager_internal.hpp"
#include <montauk/toml.h>
namespace filemanager {
void filemanager_read_drives(FileManagerState* fm) {
fm->entry_count = 0;
fm->at_drives_root = true;
fm->current_path[0] = '\0';
// Home folder entry (if we have a valid home directory)
DesktopState* ds = fm->desktop;
if (ds && ds->home_dir[0] != '\0') {
int i = fm->entry_count;
montauk::strncpy(fm->entry_names[i], "Home", 63);
fm->entry_types[i] = 4; // home
fm->entry_sizes[i] = 0;
fm->is_dir[i] = true;
fm->drive_indices[i] = -1;
fm->entry_count++;
}
// Apps entry
{
int i = fm->entry_count;
montauk::strncpy(fm->entry_names[i], "Apps", 63);
fm->entry_types[i] = 5; // apps
fm->entry_sizes[i] = 0;
fm->is_dir[i] = true;
fm->drive_indices[i] = -1;
fm->entry_count++;
}
// Special user folders (Documents, Desktop, Music, etc.) - only if they exist
if (ds && ds->home_dir[0] != '\0') {
for (int sf = 0; sf < SF_COUNT && fm->entry_count < 64; sf++) {
char probe_path[256];
montauk::strcpy(probe_path, ds->home_dir);
int plen = montauk::slen(probe_path);
if (plen > 0 && probe_path[plen - 1] != '/')
str_append(probe_path, "/", 256);
str_append(probe_path, sf_names[sf], 256);
// Probe: try open to check if the directory exists
int probe_fd = montauk::open(probe_path);
if (probe_fd >= 0) {
montauk::close(probe_fd);
int i = fm->entry_count;
montauk::strncpy(fm->entry_names[i], sf_names[sf], 63);
fm->entry_types[i] = 7; // special_dir
fm->entry_sizes[i] = 0;
fm->is_dir[i] = true;
fm->drive_indices[i] = sf; // special folder index
fm->entry_count++;
}
}
}
// Drive entries
int drives[FM_MAX_DRIVES];
int driveCount = montauk::drivelist(drives, FM_MAX_DRIVES);
for (int di = 0; di < driveCount; di++) {
int d = drives[di];
int i = fm->entry_count;
char probe[8];
if (d < 10) {
probe[0] = '0' + d;
probe[1] = ':';
probe[2] = '/';
probe[3] = '\0';
} else {
probe[0] = '1';
probe[1] = '0' + (d - 10);
probe[2] = ':';
probe[3] = '/';
probe[4] = '\0';
}
char label[64];
montauk::strcpy(label, "Drive ");
str_append(label, probe, 64);
montauk::strncpy(fm->entry_names[i], label, 63);
fm->entry_types[i] = 3; // drive
fm->entry_sizes[i] = 0;
fm->is_dir[i] = true;
fm->drive_indices[i] = d;
fm->entry_count++;
if (fm->entry_count >= 64) break;
}
fm->selected = -1;
fm->scroll_offset = 0;
fm->scrollbar.scroll_offset = 0;
fm->last_click_item = -1;
fm->last_click_time = 0;
}
void filemanager_read_dir(FileManagerState* fm) {
fm->at_drives_root = false;
if (fm->at_apps_view) {
fm->at_apps_view = false;
filemanager_free_app_icons(fm);
}
const char* names[64];
fm->entry_count = montauk::readdir(fm->current_path, names, 64);
if (fm->entry_count < 0) fm->entry_count = 0;
// readdir returns full paths from the VFS (e.g. "man/fetch.1" instead
// of just "fetch.1"). Compute the prefix to strip so we get basenames.
const char* after_drive = fm->current_path;
for (int k = 0; after_drive[k]; k++) {
if (after_drive[k] == ':' && after_drive[k + 1] == '/') {
after_drive += k + 2;
break;
}
}
char prefix[256] = {0};
int prefix_len = 0;
if (after_drive[0] != '\0') {
montauk::strcpy(prefix, after_drive);
prefix_len = montauk::slen(prefix);
if (prefix_len > 0 && prefix[prefix_len - 1] != '/') {
prefix[prefix_len++] = '/';
prefix[prefix_len] = '\0';
}
}
for (int i = 0; i < fm->entry_count; i++) {
const char* raw = names[i];
// Strip directory prefix if it matches
if (prefix_len > 0) {
bool match = true;
for (int k = 0; k < prefix_len; k++) {
if (raw[k] != prefix[k]) { match = false; break; }
}
if (match) raw += prefix_len;
}
montauk::strncpy(fm->entry_names[i], raw, 63);
int len = montauk::slen(fm->entry_names[i]);
// Detect directory
if (len > 0 && fm->entry_names[i][len - 1] == '/') {
fm->is_dir[i] = true;
fm->entry_names[i][len - 1] = '\0';
} else {
fm->is_dir[i] = false;
}
fm->entry_types[i] = detect_file_type(fm->entry_names[i], fm->is_dir[i]);
// Get file size
fm->entry_sizes[i] = 0;
if (!fm->is_dir[i]) {
char fullpath[512];
montauk::strcpy(fullpath, fm->current_path);
int plen = montauk::slen(fullpath);
if (plen > 0 && fullpath[plen - 1] != '/') {
str_append(fullpath, "/", 512);
}
str_append(fullpath, fm->entry_names[i], 512);
int fd = montauk::open(fullpath);
if (fd >= 0) {
fm->entry_sizes[i] = (int)montauk::getsize(fd);
montauk::close(fd);
}
}
}
// Sort: directories first, then alphabetical (case-insensitive)
for (int i = 1; i < fm->entry_count; i++) {
char tmp_name[64];
int tmp_type = fm->entry_types[i];
int tmp_size = fm->entry_sizes[i];
bool tmp_isdir = fm->is_dir[i];
montauk::strcpy(tmp_name, fm->entry_names[i]);
int j = i - 1;
while (j >= 0) {
bool swap = false;
if (tmp_isdir && !fm->is_dir[j]) {
swap = true;
} else if (tmp_isdir == fm->is_dir[j]) {
if (str_compare_ci(tmp_name, fm->entry_names[j]) < 0) {
swap = true;
}
}
if (!swap) break;
montauk::strcpy(fm->entry_names[j + 1], fm->entry_names[j]);
fm->entry_types[j + 1] = fm->entry_types[j];
fm->entry_sizes[j + 1] = fm->entry_sizes[j];
fm->is_dir[j + 1] = fm->is_dir[j];
j--;
}
montauk::strcpy(fm->entry_names[j + 1], tmp_name);
fm->entry_types[j + 1] = tmp_type;
fm->entry_sizes[j + 1] = tmp_size;
fm->is_dir[j + 1] = tmp_isdir;
}
fm->selected = -1;
fm->scroll_offset = 0;
fm->scrollbar.scroll_offset = 0;
fm->last_click_item = -1;
fm->last_click_time = 0;
}
void filemanager_free_app_icons(FileManagerState* fm) {
for (int i = 0; i < fm->app_icon_count; i++) {
if (fm->app_icons_lg[i].pixels) {
montauk::mfree(fm->app_icons_lg[i].pixels);
fm->app_icons_lg[i].pixels = nullptr;
}
if (fm->app_icons_sm[i].pixels) {
montauk::mfree(fm->app_icons_sm[i].pixels);
fm->app_icons_sm[i].pixels = nullptr;
}
}
fm->app_icon_count = 0;
}
void filemanager_read_apps(FileManagerState* fm) {
filemanager_free_app_icons(fm);
fm->at_drives_root = false;
fm->at_apps_view = true;
fm->entry_count = 0;
DesktopState* ds = fm->desktop;
if (!ds) return;
// Scan manifests directly (like desktop_scan_apps but load icons at FM sizes)
montauk::fmkdir("0:/apps");
const char* entries[32];
int count = montauk::readdir("0:/apps", entries, 32);
// Extract basenames from readdir results (strip "apps/" prefix)
for (int i = 0; i < count && fm->entry_count < 16; i++) {
const char* raw = entries[i];
// Strip "apps/" prefix
if (raw[0] == 'a' && raw[1] == 'p' && raw[2] == 'p' && raw[3] == 's' && raw[4] == '/')
raw += 5;
char dirname[64];
montauk::strncpy(dirname, raw, 63);
int dlen = montauk::slen(dirname);
if (dlen > 0 && dirname[dlen - 1] == '/') dirname[dlen - 1] = '\0';
if (dirname[0] == '\0') continue;
// Open manifest
char manifest_path[128];
snprintf(manifest_path, 128, "0:/apps/%s/manifest.toml", dirname);
int fh = montauk::open(manifest_path);
if (fh < 0) continue;
uint64_t sz = montauk::getsize(fh);
if (sz == 0 || sz > 4096) { montauk::close(fh); continue; }
char* text = (char*)montauk::malloc(sz + 1);
montauk::read(fh, (uint8_t*)text, 0, sz);
montauk::close(fh);
text[sz] = '\0';
auto doc = montauk::toml::parse(text);
montauk::mfree(text);
const char* name = doc.get_string("app.name", "Unknown");
const char* binary = doc.get_string("app.binary", "");
const char* icon_file = doc.get_string("app.icon", "");
int idx = fm->entry_count;
montauk::strncpy(fm->entry_names[idx], name, 63);
fm->entry_types[idx] = 6; // app entry
fm->entry_sizes[idx] = 0;
fm->is_dir[idx] = false;
// Store binary path in drive_indices as an app_map index
fm->app_map[idx] = -1;
// Find matching external_app by binary path
char bin_path[128];
snprintf(bin_path, 128, "0:/apps/%s/%s", dirname, binary);
for (int x = 0; x < ds->external_app_count; x++) {
if (montauk::streq(ds->external_apps[x].binary_path, bin_path)) {
fm->app_map[idx] = x;
break;
}
}
// Load icons at file manager sizes
fm->app_icons_lg[idx] = {};
fm->app_icons_sm[idx] = {};
if (icon_file[0]) {
char icon_path[128];
snprintf(icon_path, 128, "0:/apps/%s/%s", dirname, icon_file);
Color defColor = colors::ICON_COLOR;
fm->app_icons_lg[idx] = svg_load(icon_path, 48, 48, defColor);
fm->app_icons_sm[idx] = svg_load(icon_path, 16, 16, defColor);
}
doc.destroy();
fm->entry_count++;
fm->app_icon_count = fm->entry_count;
}
fm->selected = -1;
fm->scroll_offset = 0;
fm->scrollbar.scroll_offset = 0;
fm->last_click_item = -1;
fm->last_click_time = 0;
}
bool filemanager_delete_recursive(const char* path) {
// Try deleting as a file (or empty directory) first
if (montauk::fdelete(path) == 0) return true;
// If that failed, it may be a non-empty directory -- enumerate and delete children
const char* names[64];
int count = montauk::readdir(path, names, 64);
if (count < 0) return false;
// Compute the prefix to strip (readdir returns paths relative to drive root)
const char* after_drive = path;
for (int k = 0; after_drive[k]; k++) {
if (after_drive[k] == ':' && after_drive[k + 1] == '/') {
after_drive += k + 2;
break;
}
}
char prefix[256] = {0};
int prefix_len = 0;
if (after_drive[0] != '\0') {
montauk::strcpy(prefix, after_drive);
prefix_len = montauk::slen(prefix);
if (prefix_len > 0 && prefix[prefix_len - 1] != '/') {
prefix[prefix_len++] = '/';
prefix[prefix_len] = '\0';
}
}
for (int i = 0; i < count; i++) {
const char* raw = names[i];
if (prefix_len > 0) {
bool match = true;
for (int k = 0; k < prefix_len; k++) {
if (raw[k] != prefix[k]) { match = false; break; }
}
if (match) raw += prefix_len;
}
char child[512];
montauk::strcpy(child, path);
int plen = montauk::slen(child);
if (plen > 0 && child[plen - 1] != '/')
str_append(child, "/", 512);
str_append(child, raw, 512);
// Strip trailing slash if present (directory marker)
int clen = montauk::slen(child);
if (clen > 0 && child[clen - 1] == '/') child[clen - 1] = '\0';
filemanager_delete_recursive(child);
}
// Now the directory should be empty -- delete it
return montauk::fdelete(path) == 0;
}
bool filemanager_copy_file(const char* src, const char* dst) {
int sfd = montauk::open(src);
if (sfd < 0) return false;
int size = (int)montauk::getsize(sfd);
// Create destination
int dfd = montauk::fcreate(dst);
if (dfd < 0) {
montauk::close(sfd);
return false;
}
if (size > 0) {
// Cap at 4 MB to avoid exhausting memory
if (size > 4 * 1024 * 1024) {
montauk::close(sfd);
montauk::close(dfd);
montauk::fdelete(dst);
return false;
}
uint8_t* buf = (uint8_t*)montauk::malloc(size);
if (!buf) {
montauk::close(sfd);
montauk::close(dfd);
montauk::fdelete(dst);
return false;
}
montauk::read(sfd, buf, 0, size);
montauk::fwrite(dfd, buf, 0, size);
montauk::mfree(buf);
}
montauk::close(sfd);
montauk::close(dfd);
return true;
}
// Recursive directory copy
bool filemanager_copy_dir_recursive(const char* src, const char* dst) {
montauk::fmkdir(dst);
const char* names[64];
int count = montauk::readdir(src, names, 64);
if (count < 0) return false;
// Compute prefix to strip
const char* after_drive = src;
for (int k = 0; after_drive[k]; k++) {
if (after_drive[k] == ':' && after_drive[k + 1] == '/') {
after_drive += k + 2;
break;
}
}
char prefix[256] = {0};
int prefix_len = 0;
if (after_drive[0] != '\0') {
montauk::strcpy(prefix, after_drive);
prefix_len = montauk::slen(prefix);
if (prefix_len > 0 && prefix[prefix_len - 1] != '/') {
prefix[prefix_len++] = '/';
prefix[prefix_len] = '\0';
}
}
for (int i = 0; i < count; i++) {
const char* raw = names[i];
if (prefix_len > 0) {
bool match = true;
for (int k = 0; k < prefix_len; k++) {
if (raw[k] != prefix[k]) { match = false; break; }
}
if (match) raw += prefix_len;
}
bool child_is_dir = false;
char basename[64];
montauk::strncpy(basename, raw, 63);
int blen = montauk::slen(basename);
if (blen > 0 && basename[blen - 1] == '/') {
child_is_dir = true;
basename[blen - 1] = '\0';
}
char src_child[512], dst_child[512];
filemanager_build_fullpath(src_child, 512, src, basename);
filemanager_build_fullpath(dst_child, 512, dst, basename);
if (child_is_dir)
filemanager_copy_dir_recursive(src_child, dst_child);
else
filemanager_copy_file(src_child, dst_child);
}
return true;
}
} // namespace filemanager
@@ -0,0 +1,95 @@
/*
* helpers.cpp
* Shared helpers for the embedded desktop file manager
* Copyright (c) 2026 Daniel Hammer
*/
#include "filemanager_internal.hpp"
namespace filemanager {
int special_folder_index(const char* name) {
for (int i = 0; i < SF_COUNT; i++) {
if (montauk::streq(name, sf_names[i])) return i;
}
return -1;
}
const char* ctx_label(int action) {
switch (action) {
case CTX_OPEN: return "Open";
case CTX_COPY: return "Copy";
case CTX_CUT: return "Cut";
case CTX_PASTE: return "Paste";
case CTX_RENAME: return "Rename";
case CTX_DELETE: return "Delete";
case CTX_NEW_FOLDER: return "New Folder";
default: return "?";
}
}
bool str_ends_with(const char* s, const char* suffix) {
int slen = montauk::slen(s);
int suflen = montauk::slen(suffix);
if (suflen > slen) return false;
for (int i = 0; i < suflen; i++) {
char sc = s[slen - suflen + i];
char ec = suffix[i];
if (sc >= 'A' && sc <= 'Z') sc += 32;
if (ec >= 'A' && ec <= 'Z') ec += 32;
if (sc != ec) return false;
}
return true;
}
bool is_image_file(const char* name) {
return str_ends_with(name, ".jpg") || str_ends_with(name, ".jpeg");
}
bool is_font_file(const char* name) {
return str_ends_with(name, ".ttf");
}
bool is_pdf_file(const char* name) {
return str_ends_with(name, ".pdf");
}
bool is_spreadsheet_file(const char* name) {
return str_ends_with(name, ".mss");
}
bool is_wordprocessor_file(const char* name) {
return str_ends_with(name, ".mwp");
}
bool is_audio_file(const char* name) {
return str_ends_with(name, ".mp3") || str_ends_with(name, ".wav");
}
bool is_video_file(const char* name) {
return str_ends_with(name, ".avi");
}
int detect_file_type(const char* name, bool is_dir) {
if (is_dir) return 1;
if (str_ends_with(name, ".elf")) return 2;
return 0;
}
void filemanager_build_fullpath(char* out, int out_max, const char* dir, const char* name) {
montauk::strcpy(out, dir);
int plen = montauk::slen(out);
if (plen > 0 && out[plen - 1] != '/') str_append(out, "/", out_max);
str_append(out, name, out_max);
}
// Extract basename from a path (pointer into the path string)
const char* path_basename(const char* path) {
const char* last = path;
for (const char* p = path; *p; p++) {
if (*p == '/' && *(p + 1)) last = p + 1;
}
return last;
}
} // namespace filemanager
@@ -0,0 +1,233 @@
/*
* navigation.cpp
* Navigation and file opening logic for the embedded desktop file manager
* Copyright (c) 2026 Daniel Hammer
*/
#include "filemanager_internal.hpp"
namespace filemanager {
void filemanager_push_history(FileManagerState* fm) {
// Don't push if same as current position
if (fm->history_count > 0 && fm->history_pos >= 0) {
if (montauk::streq(fm->history[fm->history_pos], fm->current_path)) return;
}
fm->history_pos++;
if (fm->history_pos >= 16) fm->history_pos = 15;
montauk::strcpy(fm->history[fm->history_pos], fm->current_path);
fm->history_count = fm->history_pos + 1;
}
void filemanager_navigate(FileManagerState* fm, const char* name) {
int path_len = montauk::slen(fm->current_path);
if (path_len > 0 && fm->current_path[path_len - 1] != '/') {
str_append(fm->current_path, "/", 256);
}
str_append(fm->current_path, name, 256);
filemanager_push_history(fm);
filemanager_read_dir(fm);
}
void filemanager_go_up(FileManagerState* fm) {
if (fm->at_drives_root) return;
// From apps view, go back to Computer
if (fm->at_apps_view) {
fm->at_apps_view = false;
filemanager_free_app_icons(fm);
filemanager_read_drives(fm);
filemanager_push_history(fm);
return;
}
int len = montauk::slen(fm->current_path);
// At drive root (e.g. "0:/" or "10:/") -- go to drives view
bool is_drive_root = false;
if (len >= 3 && fm->current_path[len - 1] == '/') {
// Check if path is just "N:/" or "NN:/"
int colon = -1;
for (int i = 0; i < len; i++) {
if (fm->current_path[i] == ':') { colon = i; break; }
}
if (colon >= 0 && colon + 2 == len) is_drive_root = true;
}
if (is_drive_root) {
filemanager_read_drives(fm);
filemanager_push_history(fm);
return;
}
if (len > 0 && fm->current_path[len - 1] == '/') {
fm->current_path[len - 1] = '\0';
len--;
}
int last_slash = -1;
for (int i = len - 1; i >= 0; i--) {
if (fm->current_path[i] == '/') { last_slash = i; break; }
}
if (last_slash >= 0) {
// Keep the slash only if it's the root slash (right after "N:")
if (last_slash > 0 && fm->current_path[last_slash - 1] == ':') {
fm->current_path[last_slash + 1] = '\0';
} else {
fm->current_path[last_slash] = '\0';
}
}
filemanager_push_history(fm);
filemanager_read_dir(fm);
}
void filemanager_navigate_to_history(FileManagerState* fm) {
montauk::strcpy(fm->current_path, fm->history[fm->history_pos]);
if (fm->current_path[0] == '\0') {
filemanager_read_drives(fm);
} else {
filemanager_read_dir(fm);
}
}
void filemanager_go_back(FileManagerState* fm) {
if (fm->history_pos <= 0) return;
fm->history_pos--;
filemanager_navigate_to_history(fm);
}
void filemanager_go_forward(FileManagerState* fm) {
if (fm->history_pos >= fm->history_count - 1) return;
fm->history_pos++;
filemanager_navigate_to_history(fm);
}
void filemanager_go_home(FileManagerState* fm) {
if (fm->at_apps_view) {
fm->at_apps_view = false;
filemanager_free_app_icons(fm);
}
filemanager_read_drives(fm);
filemanager_push_history(fm);
}
void filemanager_start_pathbar(FileManagerState* fm) {
fm->pathbar_editing = true;
if (fm->at_drives_root) {
fm->pathbar_buf[0] = '\0';
fm->pathbar_len = 0;
} else {
montauk::strcpy(fm->pathbar_buf, fm->current_path);
fm->pathbar_len = montauk::slen(fm->pathbar_buf);
}
fm->pathbar_cursor = fm->pathbar_len;
}
void filemanager_cancel_pathbar(FileManagerState* fm) {
fm->pathbar_editing = false;
}
void filemanager_commit_pathbar(FileManagerState* fm) {
fm->pathbar_editing = false;
if (fm->pathbar_len == 0) {
filemanager_read_drives(fm);
filemanager_push_history(fm);
return;
}
montauk::strcpy(fm->current_path, fm->pathbar_buf);
filemanager_push_history(fm);
filemanager_read_dir(fm);
}
void filemanager_open_entry(FileManagerState* fm, int idx) {
if (idx < 0 || idx >= fm->entry_count) return;
// Special user folder in Computer view
if (fm->at_drives_root && fm->entry_types[idx] == 7) {
DesktopState* ds = fm->desktop;
if (ds && ds->home_dir[0] != '\0') {
montauk::strcpy(fm->current_path, ds->home_dir);
int plen = montauk::slen(fm->current_path);
if (plen > 0 && fm->current_path[plen - 1] != '/')
str_append(fm->current_path, "/", 256);
str_append(fm->current_path, fm->entry_names[idx], 256);
filemanager_push_history(fm);
filemanager_read_dir(fm);
}
return;
}
// Apps shortcut in Computer view
if (fm->at_drives_root && fm->entry_types[idx] == 5) {
filemanager_read_apps(fm);
return;
}
// App entry in apps view - launch it
if (fm->at_apps_view && fm->entry_types[idx] == 6) {
DesktopState* ds = fm->desktop;
if (ds && fm->app_map[idx] >= 0 && fm->app_map[idx] < ds->external_app_count) {
const ExternalApp& app = ds->external_apps[fm->app_map[idx]];
if (app.launch_with_home) {
montauk::spawn(app.binary_path, ds->home_dir);
} else {
montauk::spawn(app.binary_path);
}
}
return;
}
if (fm->at_drives_root && fm->entry_types[idx] == 4) {
// Home folder
DesktopState* ds = fm->desktop;
if (ds && ds->home_dir[0] != '\0') {
montauk::strcpy(fm->current_path, ds->home_dir);
filemanager_push_history(fm);
filemanager_read_dir(fm);
}
return;
}
if (fm->at_drives_root && fm->entry_types[idx] == 3) {
int d = fm->drive_indices[idx];
char dpath[8];
if (d < 10) {
dpath[0] = '0' + d; dpath[1] = ':'; dpath[2] = '/'; dpath[3] = '\0';
} else {
dpath[0] = '1'; dpath[1] = '0' + (d - 10); dpath[2] = ':'; dpath[3] = '/'; dpath[4] = '\0';
}
montauk::strcpy(fm->current_path, dpath);
filemanager_push_history(fm);
filemanager_read_dir(fm);
return;
}
if (fm->is_dir[idx]) {
filemanager_navigate(fm, fm->entry_names[idx]);
return;
}
// Open file with appropriate app
char fullpath[512];
filemanager_build_fullpath(fullpath, 512, fm->current_path, fm->entry_names[idx]);
if (is_image_file(fm->entry_names[idx])) {
montauk::spawn("0:/apps/imageviewer/imageviewer.elf", fullpath);
} else if (is_font_file(fm->entry_names[idx])) {
montauk::spawn("0:/apps/fontpreview/fontpreview.elf", fullpath);
} else if (is_pdf_file(fm->entry_names[idx])) {
montauk::spawn("0:/apps/pdfviewer/pdfviewer.elf", fullpath);
} else if (is_spreadsheet_file(fm->entry_names[idx])) {
montauk::spawn("0:/apps/spreadsheet/spreadsheet.elf", fullpath);
} else if (is_wordprocessor_file(fm->entry_names[idx])) {
montauk::spawn("0:/apps/wordprocessor/wordprocessor.elf", fullpath);
} else if (is_audio_file(fm->entry_names[idx])) {
montauk::spawn("0:/apps/music/music.elf", fullpath);
} else if (is_video_file(fm->entry_names[idx])) {
montauk::spawn("0:/apps/video/video.elf", fullpath);
} else if (str_ends_with(fm->entry_names[idx], ".elf")) {
montauk::spawn(fullpath);
} else {
montauk::spawn("0:/apps/texteditor/texteditor.elf", fullpath);
}
}
} // namespace filemanager
@@ -0,0 +1,421 @@
/*
* render.cpp
* Drawing code for the embedded desktop file manager
* Copyright (c) 2026 Daniel Hammer
*/
#include "filemanager_internal.hpp"
namespace filemanager {
void filemanager_draw_header(Canvas& c, FileManagerState* fm, Color toolbar_color, Color btn_bg) {
DesktopState* ds = fm->desktop;
// ---- Toolbar (32px) ----
c.fill_rect(0, 0, c.w, FM_TOOLBAR_H, toolbar_color);
// Navigation buttons: Back, Forward, Up, Home
struct ToolBtn { int x; SvgIcon* icon; };
ToolBtn nav_btns[4] = {
{ 4, ds ? &ds->icon_go_back : nullptr },
{ 32, ds ? &ds->icon_go_forward : nullptr },
{ 60, ds ? &ds->icon_go_up : nullptr },
{ 88, ds ? &ds->icon_home : nullptr },
};
for (int i = 0; i < 4; i++) {
int bx = nav_btns[i].x;
int by = 4;
c.fill_rect(bx, by, 24, 24, btn_bg);
if (nav_btns[i].icon) {
int ix = bx + (24 - nav_btns[i].icon->width) / 2;
int iy = by + (24 - nav_btns[i].icon->height) / 2;
c.icon(ix, iy, *nav_btns[i].icon);
}
}
// View toggle button
{
int bx = 120, by = 4;
c.fill_rect(bx, by, 24, 24, btn_bg);
if (fm->grid_view) {
for (int r = 0; r < 2; r++)
for (int cc = 0; cc < 2; cc++)
c.fill_rect(bx + 5 + cc * 8, by + 5 + r * 8, 6, 6, colors::TEXT_COLOR);
} else {
for (int r = 0; r < 3; r++)
c.fill_rect(bx + 5, by + 5 + r * 5, 14, 2, colors::TEXT_COLOR);
}
}
// Separator line between nav group and action group
c.vline(152, 6, 20, colors::BORDER);
// Action buttons: Copy, Cut, Paste, Rename, New Folder, Delete
bool has_sel = fm->selected >= 0 && fm->selected < fm->entry_count
&& !fm->at_drives_root && !fm->at_apps_view
&& fm->entry_types[fm->selected] != 3;
bool has_clip = fm->clipboard_has_data && !fm->at_drives_root && !fm->at_apps_view;
Color dim_bg = Color::from_rgb(0xF0, 0xF0, 0xF0);
// Icon toolbar buttons: Copy, Cut, Paste, Rename, New Folder, Delete
struct ActionBtn { int x; SvgIcon* icon; bool enabled; };
ActionBtn action_btns[] = {
{ 160, ds ? &ds->icon_copy : nullptr, has_sel },
{ 188, ds ? &ds->icon_cut : nullptr, has_sel },
{ 216, ds ? &ds->icon_paste : nullptr, has_clip },
{ 244, ds ? &ds->icon_rename : nullptr, has_sel },
{ 272, ds ? &ds->icon_folder_new : nullptr, !fm->at_drives_root && !fm->at_apps_view },
{ 300, ds ? &ds->icon_delete : nullptr, has_sel },
};
for (int i = 0; i < 6; i++) {
int bx = action_btns[i].x;
int by = 4;
c.fill_rect(bx, by, 24, 24, action_btns[i].enabled ? btn_bg : dim_bg);
if (action_btns[i].icon && action_btns[i].icon->pixels) {
int ix = bx + (24 - action_btns[i].icon->width) / 2;
int iy = by + (24 - action_btns[i].icon->height) / 2;
c.icon(ix, iy, *action_btns[i].icon);
}
}
// Toolbar separator
c.hline(0, FM_TOOLBAR_H - 1, c.w, colors::BORDER);
// ---- Path bar ----
int pathbar_y = FM_TOOLBAR_H;
if (fm->pathbar_editing) {
// Editable text input
c.fill_rect(0, pathbar_y, c.w, FM_PATHBAR_H, colors::WHITE);
c.rect(0, pathbar_y, c.w, FM_PATHBAR_H, colors::ACCENT);
int fh = system_font_height();
int ty = pathbar_y + (FM_PATHBAR_H - fh) / 2;
c.text(8, ty, fm->pathbar_buf, colors::TEXT_COLOR);
// Cursor
char prefix[256];
int cpos = fm->pathbar_cursor;
if (cpos > 255) cpos = 255;
for (int k = 0; k < cpos; k++) prefix[k] = fm->pathbar_buf[k];
prefix[cpos] = '\0';
int cx = 8 + text_width(prefix);
c.vline(cx, ty, fh, colors::ACCENT);
} else {
c.fill_rect(0, pathbar_y, c.w, FM_PATHBAR_H, Color::from_rgb(0xF0, 0xF0, 0xF0));
const char* pathbar_text = fm->current_path;
if (fm->at_drives_root) pathbar_text = "Computer";
else if (fm->at_apps_view) pathbar_text = "Apps";
c.text(8, pathbar_y + 4, pathbar_text, colors::TEXT_COLOR);
}
// Path bar separator
c.hline(0, pathbar_y + FM_PATHBAR_H - 1, c.w, colors::BORDER);
}
void filemanager_on_draw(Window* win, Framebuffer& fb) {
FileManagerState* fm = (FileManagerState*)win->app_data;
if (!fm) return;
Canvas c(win);
c.fill(colors::WINDOW_BG);
Color toolbar_color = Color::from_rgb(0xF5, 0xF5, 0xF5);
Color btn_bg = Color::from_rgb(0xE8, 0xE8, 0xE8);
Color dim = Color::from_rgb(0x88, 0x88, 0x88);
DesktopState* ds = fm->desktop;
// Draw header (toolbar + path bar)
filemanager_draw_header(c, fm, toolbar_color, btn_bg);
if (fm->grid_view) {
// ---- Grid View ----
int list_y = FM_TOOLBAR_H + FM_PATHBAR_H;
int list_h = c.h - list_y;
int cols = (c.w - FM_SCROLLBAR_W) / FM_GRID_CELL_W;
if (cols < 1) cols = 1;
int rows = (fm->entry_count + cols - 1) / cols;
int content_h = rows * FM_GRID_CELL_H;
// Update scrollbar
fm->scrollbar.bounds = {c.w - FM_SCROLLBAR_W, list_y, FM_SCROLLBAR_W, list_h};
fm->scrollbar.content_height = content_h;
fm->scrollbar.view_height = list_h;
for (int i = 0; i < fm->entry_count; i++) {
int col = i % cols;
int row = i / cols;
int cell_x = col * FM_GRID_CELL_W;
int cell_y = list_y + row * FM_GRID_CELL_H - fm->scrollbar.scroll_offset;
// Skip if entirely off-screen
if (cell_y + FM_GRID_CELL_H <= list_y || cell_y >= c.h) continue;
// Selection highlight
if (i == fm->selected) {
int sy = gui_max(cell_y, list_y);
int sh = gui_min(cell_y + FM_GRID_CELL_H, c.h) - sy;
int sw = gui_min(FM_GRID_CELL_W, c.w - FM_SCROLLBAR_W - cell_x);
if (sh > 0 && sw > 0)
c.fill_rect(cell_x, sy, sw, sh, colors::MENU_HOVER);
}
// Large icon centered horizontally
int icon_x = cell_x + (FM_GRID_CELL_W - FM_GRID_ICON) / 2;
int icon_y = cell_y + FM_GRID_PAD;
// Check for special folder icon (type 7 in Computer, or type 1 dir with matching name)
int sfi = -1;
if (fm->entry_types[i] == 7) sfi = fm->drive_indices[i];
else if (ds && fm->entry_types[i] == 1) sfi = special_folder_index(fm->entry_names[i]);
if (sfi >= 0 && ds && ds->icon_special_folder_lg[sfi].pixels) {
c.icon(icon_x, icon_y, ds->icon_special_folder_lg[sfi]);
} else if (fm->entry_types[i] == 6 && i < fm->app_icon_count && fm->app_icons_lg[i].pixels) {
c.icon(icon_x, icon_y, fm->app_icons_lg[i]);
} else if (ds && fm->entry_types[i] == 5 && ds->icon_apps_lg.pixels) {
c.icon(icon_x, icon_y, ds->icon_apps_lg);
} else if (ds && fm->entry_types[i] == 4 && ds->icon_home_folder_lg.pixels) {
c.icon(icon_x, icon_y, ds->icon_home_folder_lg);
} else if (ds && fm->entry_types[i] == 3 && ds->icon_drive_lg.pixels) {
c.icon(icon_x, icon_y, ds->icon_drive_lg);
} else if (ds && fm->entry_types[i] == 1 && ds->icon_folder_lg.pixels) {
c.icon(icon_x, icon_y, ds->icon_folder_lg);
} else if (ds && fm->entry_types[i] == 2 && ds->icon_exec_lg.pixels) {
c.icon(icon_x, icon_y, ds->icon_exec_lg);
} else if (ds && ds->icon_file_lg.pixels) {
c.icon(icon_x, icon_y, ds->icon_file_lg);
} else {
Color icon_c = fm->is_dir[i]
? Color::from_rgb(0xFF, 0xBD, 0x2E)
: Color::from_rgb(0x90, 0x90, 0x90);
int iy_clip = gui_max(icon_y, list_y);
int ih_clip = FM_GRID_ICON - (iy_clip - icon_y);
if (ih_clip > 0)
c.fill_rect(icon_x, iy_clip, FM_GRID_ICON, ih_clip, icon_c);
}
// Filename centered below icon, truncated if needed
// If renaming this entry, draw the rename textbox instead
if (fm->rename_active && fm->rename_idx == i) {
int ty = icon_y + FM_GRID_ICON + 2;
int tw = FM_GRID_CELL_W - 4;
if (ty >= list_y && ty + system_font_height() + 4 <= c.h) {
c.fill_rect(cell_x + 2, ty - 1, tw, system_font_height() + 2, colors::WHITE);
c.rect(cell_x + 2, ty - 1, tw, system_font_height() + 2, colors::ACCENT);
// Draw rename text (truncated to cell width)
char display[16];
int rlen = fm->rename_len;
if (rlen > 10) rlen = 10;
for (int k = 0; k < rlen; k++) display[k] = fm->rename_buf[k];
display[rlen] = '\0';
c.text(cell_x + 4, ty, display, colors::TEXT_COLOR);
// Cursor
char prefix[64];
int cpos = fm->rename_cursor < rlen ? fm->rename_cursor : rlen;
for (int k = 0; k < cpos; k++) prefix[k] = fm->rename_buf[k];
prefix[cpos] = '\0';
int cx = cell_x + 4 + text_width(prefix);
c.vline(cx, ty, system_font_height(), colors::ACCENT);
}
} else {
char label[16];
int nlen = montauk::slen(fm->entry_names[i]);
if (nlen > 9) {
for (int k = 0; k < 9; k++) label[k] = fm->entry_names[i][k];
label[9] = '.';
label[10] = '.';
label[11] = '\0';
} else {
montauk::strncpy(label, fm->entry_names[i], 15);
}
int tw = text_width(label);
int tx = cell_x + (FM_GRID_CELL_W - tw) / 2;
if (tx < cell_x) tx = cell_x;
int ty = icon_y + FM_GRID_ICON + 2;
if (ty >= list_y && ty + system_font_height() <= c.h)
c.text(tx, ty, label, colors::TEXT_COLOR);
}
}
} else {
// ---- List View ----
// ---- Column headers ----
int header_y = FM_TOOLBAR_H + FM_PATHBAR_H;
c.fill_rect(0, header_y, c.w, FM_HEADER_H, Color::from_rgb(0xF8, 0xF8, 0xF8));
int name_col_x = 8;
int size_col_x = c.w - FM_SCROLLBAR_W - 120;
int type_col_x = c.w - FM_SCROLLBAR_W - 60;
c.text(name_col_x, header_y + 2, "Name", dim);
if (size_col_x > 100)
c.text(size_col_x, header_y + 2, "Size", dim);
if (type_col_x > 160)
c.text(type_col_x, header_y + 2, "Type", dim);
// Header separator
c.hline(0, header_y + FM_HEADER_H - 1, c.w, colors::BORDER);
// Column separator lines
if (size_col_x > 100) {
c.vline(size_col_x - 4, header_y, c.h - header_y, colors::BORDER);
}
// ---- File entries ----
int list_y = header_y + FM_HEADER_H;
int list_h = c.h - list_y;
int visible_items = list_h / FM_ITEM_H;
int content_h = fm->entry_count * FM_ITEM_H;
// Update scrollbar
fm->scrollbar.bounds = {c.w - FM_SCROLLBAR_W, list_y, FM_SCROLLBAR_W, list_h};
fm->scrollbar.content_height = content_h;
fm->scrollbar.view_height = list_h;
int scroll_items = fm->scrollbar.scroll_offset / FM_ITEM_H;
for (int i = scroll_items; i < fm->entry_count && (i - scroll_items) < visible_items + 1; i++) {
int iy = list_y + (i - scroll_items) * FM_ITEM_H - (fm->scrollbar.scroll_offset % FM_ITEM_H);
if (iy + FM_ITEM_H <= list_y || iy >= c.h) continue;
// Highlight selected
if (i == fm->selected) {
int sy = gui_max(iy, list_y);
int sh = gui_min(iy + FM_ITEM_H, c.h) - sy;
if (sh > 0)
c.fill_rect(0, sy, c.w - FM_SCROLLBAR_W, sh, colors::MENU_HOVER);
}
// Icon (skip if it would bleed above the list area)
int ico_x = 8;
int ico_y = iy + (FM_ITEM_H - 16) / 2;
// Check for special folder icon
int sfi_sm = -1;
if (fm->entry_types[i] == 7) sfi_sm = fm->drive_indices[i];
else if (ds && fm->entry_types[i] == 1) sfi_sm = special_folder_index(fm->entry_names[i]);
if (ico_y < list_y) { /* clipped by header repaint */ }
else if (sfi_sm >= 0 && ds && ds->icon_special_folder[sfi_sm].pixels) {
c.icon(ico_x, ico_y, ds->icon_special_folder[sfi_sm]);
} else if (fm->entry_types[i] == 6 && i < fm->app_icon_count && fm->app_icons_sm[i].pixels) {
c.icon(ico_x, ico_y, fm->app_icons_sm[i]);
} else if (ds && fm->entry_types[i] == 5 && ds->icon_apps.pixels) {
c.icon(ico_x, ico_y, ds->icon_apps);
} else if (ds && fm->entry_types[i] == 4 && ds->icon_home_folder.pixels) {
c.icon(ico_x, ico_y, ds->icon_home_folder);
} else if (ds && fm->entry_types[i] == 3 && ds->icon_drive.pixels) {
c.icon(ico_x, ico_y, ds->icon_drive);
} else if (ds && fm->entry_types[i] == 1 && ds->icon_folder.pixels) {
c.icon(ico_x, ico_y, ds->icon_folder);
} else if (ds && fm->entry_types[i] == 2 && ds->icon_exec.pixels) {
c.icon(ico_x, ico_y, ds->icon_exec);
} else if (ds && ds->icon_file.pixels) {
c.icon(ico_x, ico_y, ds->icon_file);
} else {
Color icon_c = fm->is_dir[i]
? Color::from_rgb(0xFF, 0xBD, 0x2E)
: Color::from_rgb(0x90, 0x90, 0x90);
int iy_clip = gui_max(ico_y, list_y);
int ih_clip = 16 - (iy_clip - ico_y);
if (ih_clip > 0)
c.fill_rect(ico_x, iy_clip, 16, ih_clip, icon_c);
}
// Name (or rename textbox)
int tx = 30;
int fm_sfh = system_font_height();
int ty = iy + (FM_ITEM_H - fm_sfh) / 2;
if (fm->rename_active && fm->rename_idx == i && ty >= list_y && ty + fm_sfh <= c.h) {
// Rename textbox inline
int rw = (size_col_x > 100 ? size_col_x - 8 : c.w - FM_SCROLLBAR_W - 8) - tx;
c.fill_rect(tx - 2, ty - 1, rw, fm_sfh + 2, colors::WHITE);
c.rect(tx - 2, ty - 1, rw, fm_sfh + 2, colors::ACCENT);
c.text(tx, ty, fm->rename_buf, colors::TEXT_COLOR);
// Cursor
char prefix[64];
int cpos = fm->rename_cursor;
if (cpos > 63) cpos = 63;
for (int k = 0; k < cpos; k++) prefix[k] = fm->rename_buf[k];
prefix[cpos] = '\0';
int cx = tx + text_width(prefix);
c.vline(cx, ty, fm_sfh, colors::ACCENT);
} else {
if (ty >= list_y && ty + fm_sfh <= c.h)
c.text(tx, ty, fm->entry_names[i], colors::TEXT_COLOR);
}
// Size
if (size_col_x > 100 && !fm->is_dir[i] && ty >= list_y && ty + fm_sfh <= c.h) {
char size_str[16];
format_size(size_str, fm->entry_sizes[i]);
c.text(size_col_x, ty, size_str, dim);
}
// Type
if (type_col_x > 160 && ty >= list_y && ty + fm_sfh <= c.h) {
const char* type_str = "File";
if (fm->entry_types[i] == 6) type_str = "App";
else if (fm->entry_types[i] == 5) type_str = "Apps";
else if (fm->entry_types[i] == 4) type_str = "Home";
else if (fm->entry_types[i] == 3) type_str = "Drive";
else if (fm->entry_types[i] == 1) type_str = "Dir";
else if (fm->entry_types[i] == 2) type_str = "Exec";
c.text(type_col_x, ty, type_str, dim);
}
}
}
// ---- Scrollbar ----
if (fm->scrollbar.content_height > fm->scrollbar.view_height) {
Color sb_fg_color = (fm->scrollbar.hovered || fm->scrollbar.dragging)
? fm->scrollbar.hover_fg : fm->scrollbar.fg;
int sbx = fm->scrollbar.bounds.x;
int sby = fm->scrollbar.bounds.y;
int sbw = fm->scrollbar.bounds.w;
int sbh = fm->scrollbar.bounds.h;
c.fill_rect(sbx, sby, sbw, sbh, colors::SCROLLBAR_BG);
int th = fm->scrollbar.thumb_height();
int tty = fm->scrollbar.thumb_y();
c.fill_rect(sbx + 1, tty, sbw - 2, th, sb_fg_color);
}
// Repaint header on top of content to clip scrolled icon overflow
if (fm->scrollbar.scroll_offset > 0)
filemanager_draw_header(c, fm, toolbar_color, btn_bg);
// ---- Context menu overlay ----
if (fm->ctx_open && fm->ctx_item_count > 0) {
int cmx = fm->ctx_x;
int cmy = fm->ctx_y;
int cmh = fm->ctx_item_count * CTX_ITEM_H + 8;
// Clamp to window bounds
if (cmx + CTX_MENU_W > c.w) cmx = c.w - CTX_MENU_W;
if (cmy + cmh > c.h) cmy = c.h - cmh;
if (cmx < 0) cmx = 0;
if (cmy < 0) cmy = 0;
// Shadow
c.fill_rect(cmx + 2, cmy + 2, CTX_MENU_W, cmh, Color::from_rgb(0x80, 0x80, 0x80));
// Background
c.fill_rounded_rect(cmx, cmy, CTX_MENU_W, cmh, 4, colors::WHITE);
c.rect(cmx, cmy, CTX_MENU_W, cmh, colors::BORDER);
for (int i = 0; i < fm->ctx_item_count; i++) {
int iy = cmy + 4 + i * CTX_ITEM_H;
// Hover highlight
if (i == fm->ctx_hover)
c.fill_rect(cmx + 2, iy, CTX_MENU_W - 4, CTX_ITEM_H, colors::MENU_HOVER);
int fh = system_font_height();
int ty = iy + (CTX_ITEM_H - fh) / 2;
c.text(cmx + 12, ty, ctx_label(fm->ctx_items[i]), colors::TEXT_COLOR);
}
}
}
} // namespace filemanager