diff --git a/kernel/src/Api/BuildNo.hpp b/kernel/src/Api/BuildNo.hpp index 34ee96d..3ca90c7 100644 --- a/kernel/src/Api/BuildNo.hpp +++ b/kernel/src/Api/BuildNo.hpp @@ -12,4 +12,4 @@ #pragma once -#define MONTAUK_BUILD_NUMBER 4 +#define MONTAUK_BUILD_NUMBER 5 diff --git a/programs/include/gui/desktop.hpp b/programs/include/gui/desktop.hpp index 8f76ee5..59c7168 100644 --- a/programs/include/gui/desktop.hpp +++ b/programs/include/gui/desktop.hpp @@ -46,6 +46,9 @@ struct DesktopItem { bool menu_visible; bool launch_with_home; bool admin_only; + // File extensions the app opens (manifest files.types): lowercase, no dot, + // space-separated with a leading and trailing space, e.g. " jpg jpeg ". + char file_types[160]; }; enum LauncherItemKind : uint8_t { @@ -121,6 +124,10 @@ struct DesktopState { int launcher_scroll; bool super_left_down; bool super_right_down; + // Live modifier state, tracked from every key event (modifier presses and + // releases are delivered too) so mouse handlers can see Ctrl/Shift. + bool kbd_shift_down; + bool kbd_ctrl_down; SvgIcon icon_terminal; SvgIcon icon_filemanager; diff --git a/programs/src/desktop/apps/filemanager/actions.cpp b/programs/src/desktop/apps/filemanager/actions.cpp index 0b91715..29b0d6a 100644 --- a/programs/src/desktop/apps/filemanager/actions.cpp +++ b/programs/src/desktop/apps/filemanager/actions.cpp @@ -49,6 +49,9 @@ void filemanager_do_paste(FileManagerState* fm) { void filemanager_start_rename(FileManagerState* fm) { if (fm->selected < 0 || fm->selected >= fm->entry_count) return; + // Rename is single-target; with several entries selected, `selected` is + // only the focus and renaming it would be a surprise. + if (fm->multi_count > 1) return; if (filemanager_is_computer_view(fm) || filemanager_is_virtual_view(fm) || fm->entry_types[fm->selected] == FM_ENTRY_DRIVE) return; @@ -228,10 +231,14 @@ void filemanager_open_ctx_menu(FileManagerState* fm, int local_x, int local_y, i fm->ctx_items[fm->ctx_item_count++] = CTX_OPEN; fm->ctx_items[fm->ctx_item_count++] = CTX_PROPERTIES; } else { + bool several = fm->multi_count > 1; fm->ctx_items[fm->ctx_item_count++] = CTX_OPEN; + if (!several && !fm->is_dir[target_idx]) + fm->ctx_items[fm->ctx_item_count++] = CTX_OPEN_WITH; 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; + if (!several) + fm->ctx_items[fm->ctx_item_count++] = CTX_RENAME; fm->ctx_items[fm->ctx_item_count++] = CTX_DELETE; fm->ctx_items[fm->ctx_item_count++] = CTX_PROPERTIES; } @@ -247,6 +254,13 @@ void filemanager_open_ctx_menu(FileManagerState* fm, int local_x, int local_y, i if (fm->clipboard_count > 0) fm->ctx_items[fm->ctx_item_count++] = CTX_PASTE; fm->ctx_items[fm->ctx_item_count++] = CTX_NEW_FOLDER; + if (fm->entry_count > 0) + fm->ctx_items[fm->ctx_item_count++] = CTX_SELECT_ALL; + fm->ctx_items[fm->ctx_item_count++] = CTX_SORT_NAME; + fm->ctx_items[fm->ctx_item_count++] = CTX_SORT_SIZE; + fm->ctx_items[fm->ctx_item_count++] = CTX_SORT_TYPE; + fm->ctx_items[fm->ctx_item_count++] = CTX_SORT_MODIFIED; + fm->ctx_items[fm->ctx_item_count++] = CTX_REFRESH; } } } diff --git a/programs/src/desktop/apps/filemanager/app.cpp b/programs/src/desktop/apps/filemanager/app.cpp index 6ffab89..11f630c 100644 --- a/programs/src/desktop/apps/filemanager/app.cpp +++ b/programs/src/desktop/apps/filemanager/app.cpp @@ -37,6 +37,7 @@ static void open_filemanager_internal(DesktopState* ds, const char* initial_path FileManagerState* fm = (FileManagerState*)montauk::malloc(sizeof(FileManagerState)); montauk::memset(fm, 0, sizeof(FileManagerState)); fm->selected = -1; + fm->sel_anchor = -1; fm->last_click_item = -1; fm->history_pos = -1; fm->history_count = 0; diff --git a/programs/src/desktop/apps/filemanager/events.cpp b/programs/src/desktop/apps/filemanager/events.cpp index f5f31c7..36339a4 100644 --- a/programs/src/desktop/apps/filemanager/events.cpp +++ b/programs/src/desktop/apps/filemanager/events.cpp @@ -8,6 +8,51 @@ namespace filemanager { +static void filemanager_show_selection_properties(FileManagerState* fm, int target) { + if (fm->multi_count > 1 && fm->entry_count > 0) { + int* sel = (int*)montauk::malloc((uint64_t)fm->entry_count * sizeof(int)); + if (sel) { + int n = filemanager_collect_selection(fm, sel, fm->entry_count); + if (n > 0) filemanager_show_multi_properties(fm, sel, n); + montauk::mfree(sel); + } + } else { + filemanager_show_properties(fm, target); + } +} + +static bool filemanager_is_listing(const FileManagerState* fm) { + return !filemanager_is_computer_view(fm) && !filemanager_is_virtual_view(fm); +} + +// Left click on an entry. Ctrl toggles it, Shift selects the range from the +// anchor (Ctrl+Shift adds that range); a plain click selects it alone, and a +// second plain click within 400 ms opens it. +static void filemanager_click_entry(FileManagerState* fm, int idx) { + DesktopState* ds = fm->desktop; + bool ctrl = ds && ds->kbd_ctrl_down; + bool shift = ds && ds->kbd_shift_down; + + if (ctrl || shift) { + if (shift) filemanager_select_range(fm, idx, ctrl); + else filemanager_toggle_selection(fm, idx); + fm->last_click_item = -1; + fm->last_click_time = 0; + return; + } + + uint64_t now = montauk::get_milliseconds(); + if (fm->last_click_item == idx && (now - fm->last_click_time) < 400) { + filemanager_open_entry(fm, idx); + fm->last_click_item = -1; + fm->last_click_time = 0; + } else { + filemanager_select_single(fm, idx); + fm->last_click_item = idx; + fm->last_click_time = now; + } +} + void filemanager_on_mouse(Window* win, MouseEvent& ev) { FileManagerState* fm = (FileManagerState*)win->app_data; if (!fm) return; @@ -26,13 +71,14 @@ void filemanager_on_mouse(Window* win, MouseEvent& ev) { // ---- Context menu interaction ---- if (fm->ctx_open) { mtk::ContextMenuState state = filemanager_context_state(fm); - mtk::ContextMenuItem items[8]; - filemanager_build_context_items(fm, items, 8); + mtk::ContextMenuItem items[FM_CTX_MAX_ITEMS]; + filemanager_build_context_items(fm, items, FM_CTX_MAX_ITEMS); int action = mtk::context_menu_handle_mouse(state, items, fm->ctx_item_count, local_x, local_y, ev.buttons, ev.prev_buttons, - cw, win->content_h); + cw, win->content_h, + FM_CTX_MENU_W); filemanager_store_context_state(fm, state); if (action >= 0) { @@ -50,20 +96,14 @@ void filemanager_on_mouse(Window* win, MouseEvent& ev) { 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; - case CTX_PROPERTIES: { - if (fm->multi_count > 1 && fm->entry_count > 0) { - int* sel = (int*)montauk::malloc((uint64_t)fm->entry_count * sizeof(int)); - if (sel) { - int n = filemanager_collect_selection(fm, sel, fm->entry_count); - if (n > 0) filemanager_show_multi_properties(fm, sel, n); - montauk::mfree(sel); - } - } else { - filemanager_show_properties(fm, target); - } - break; - } + case CTX_PROPERTIES: filemanager_show_selection_properties(fm, target); break; case CTX_REFRESH: filemanager_refresh(fm); break; + case CTX_OPEN_WITH: filemanager_show_open_with(fm, target); break; + case CTX_SELECT_ALL: filemanager_select_all(fm); break; + case CTX_SORT_NAME: filemanager_set_sort(fm, FM_SORT_NAME); break; + case CTX_SORT_SIZE: filemanager_set_sort(fm, FM_SORT_SIZE); break; + case CTX_SORT_TYPE: filemanager_set_sort(fm, FM_SORT_TYPE); break; + case CTX_SORT_MODIFIED: filemanager_set_sort(fm, FM_SORT_MODIFIED); break; } return; } @@ -123,23 +163,10 @@ void filemanager_on_mouse(Window* win, MouseEvent& ev) { int clicked_idx = filemanager_grid_index_at(fm, cols, local_x, rel_y); if (clicked_idx >= 0 && clicked_idx < fm->entry_count) { - uint64_t now = montauk::get_milliseconds(); - filemanager_clear_multi_selection(fm); - - 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; - } + filemanager_click_entry(fm, clicked_idx); } else { // Background click in grid area: start marquee drag - fm->selected = -1; - filemanager_clear_multi_selection(fm); + filemanager_select_single(fm, -1); fm->marquee_active = true; fm->marquee_start_x = local_x; fm->marquee_start_y = rel_y; @@ -149,30 +176,30 @@ void filemanager_on_mouse(Window* win, MouseEvent& ev) { } } else { // List view clicks - int list_y = FM_TOOLBAR_H + FM_PATHBAR_H + FM_HEADER_H; + int header_y = FM_TOOLBAR_H + FM_PATHBAR_H; + int list_y = header_y + FM_HEADER_H; + if (local_y >= header_y && local_y < list_y && filemanager_is_listing(fm)) { + // Column header click: sort by that column (again = reverse). + FileManagerListColumns cols = filemanager_list_columns(cw); + if (cols.type_x >= 0 && local_x >= cols.type_x) + filemanager_set_sort(fm, FM_SORT_TYPE); + else if (cols.size_x >= 0 && local_x >= cols.size_x) + filemanager_set_sort(fm, FM_SORT_SIZE); + else if (cols.modified_x >= 0 && local_x >= cols.modified_x - 4) + filemanager_set_sort(fm, FM_SORT_MODIFIED); + else + filemanager_set_sort(fm, FM_SORT_NAME); + return; + } 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(); - filemanager_clear_multi_selection(fm); - - // 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; - } + filemanager_click_entry(fm, clicked_idx); } else { // Background click in list area: start marquee drag - fm->selected = -1; - filemanager_clear_multi_selection(fm); + filemanager_select_single(fm, -1); fm->marquee_active = true; fm->marquee_start_x = local_x; fm->marquee_start_y = rel_y; @@ -271,13 +298,10 @@ void filemanager_on_mouse(Window* win, MouseEvent& ev) { target_idx = idx; // Preserve multi-selection if right-clicking inside it, // otherwise replace it with a single selection. - if (!(fm->multi_count > 0 && fm->multi_selected[idx])) { - filemanager_clear_multi_selection(fm); - fm->selected = idx; - } + if (!filemanager_is_entry_selected(fm, idx)) + filemanager_select_single(fm, idx); } else { - filemanager_clear_multi_selection(fm); - fm->selected = -1; + filemanager_select_single(fm, -1); } } } else { @@ -289,13 +313,10 @@ void filemanager_on_mouse(Window* win, MouseEvent& ev) { target_idx = idx; // Preserve multi-selection if right-clicking inside it, // otherwise replace it with a single selection. - if (!(fm->multi_count > 0 && fm->multi_selected[idx])) { - filemanager_clear_multi_selection(fm); - fm->selected = idx; - } + if (!filemanager_is_entry_selected(fm, idx)) + filemanager_select_single(fm, idx); } else { - filemanager_clear_multi_selection(fm); - fm->selected = -1; + filemanager_select_single(fm, -1); } } } @@ -319,27 +340,103 @@ void filemanager_on_mouse(Window* win, MouseEvent& ev) { } } -// Keep the selected row inside the visible list area so arrow-key navigation +static int filemanager_grid_cols(Window* win) { + int cols = (win->content_rect().w - FM_SCROLLBAR_W) / FM_GRID_CELL_W; + return cols < 1 ? 1 : cols; +} + +// Keep the focused entry inside the visible area so keyboard navigation // scrolls the view along with the selection. static void filemanager_scroll_to_selected(Window* win, FileManagerState* fm) { - if (fm->grid_view) return; if (fm->selected < 0 || fm->selected >= fm->entry_count) return; - int list_y = FM_TOOLBAR_H + FM_PATHBAR_H + FM_HEADER_H; + int list_y = fm->grid_view ? FM_TOOLBAR_H + FM_PATHBAR_H + : FM_TOOLBAR_H + FM_PATHBAR_H + FM_HEADER_H; int list_h = win->content_h - list_y; if (list_h <= 0) return; - int top = fm->selected * FM_ITEM_H; + int top, item_h, content_h; + if (fm->grid_view) { + int cols = filemanager_grid_cols(win); + int row = fm->selected / cols; + top = filemanager_grid_row_y(fm, row, cols); + item_h = filemanager_grid_row_height(fm, row, cols); + content_h = filemanager_grid_content_height(fm, cols); + } else { + top = fm->selected * FM_ITEM_H; + item_h = FM_ITEM_H; + content_h = fm->entry_count * FM_ITEM_H; + } + int off = fm->scrollbar.scroll_offset; if (top < off) off = top; - else if (top + FM_ITEM_H > off + list_h) off = top + FM_ITEM_H - list_h; + else if (top + item_h > off + list_h) off = top + item_h - list_h; - int max_scroll = fm->entry_count * FM_ITEM_H - list_h; + int max_scroll = content_h - list_h; if (off > max_scroll) off = max_scroll; if (off < 0) off = 0; fm->scrollbar.scroll_offset = off; } +// Move keyboard focus to target (clamped). With extend (Shift held) the +// selection grows from the anchor instead of moving with the focus. +static void filemanager_move_focus(Window* win, FileManagerState* fm, int target, bool extend) { + if (fm->entry_count <= 0) return; + if (target < 0) target = 0; + if (target >= fm->entry_count) target = fm->entry_count - 1; + if (extend) filemanager_select_range(fm, target, false); + else filemanager_select_single(fm, target); + filemanager_scroll_to_selected(win, fm); +} + +static bool filemanager_prefix_match_ci(const char* name, const char* prefix, int len) { + for (int i = 0; i < len; i++) { + char a = name[i]; + char b = prefix[i]; + if (!a) return false; + if (a >= 'A' && a <= 'Z') a += 32; + if (b >= 'A' && b <= 'Z') b += 32; + if (a != b) return false; + } + return true; +} + +// Type-to-jump. Keys typed within a second of each other build up a prefix; +// repeating a single character instead cycles through the names starting +// with it. +static void filemanager_typeahead(Window* win, FileManagerState* fm, + const montauk::abi::KeyEvent& key) { + char ch[4]; + int n = montauk::utf8::key_utf8(key, ch); + if (n <= 0 || fm->entry_count <= 0) return; + + uint64_t now = montauk::get_milliseconds(); + if (now - fm->typeahead_time > 1000) fm->typeahead_len = 0; + fm->typeahead_time = now; + if (fm->typeahead_len == 0 && ch[0] == ' ') return; + if (fm->typeahead_len + n >= (int)sizeof(fm->typeahead)) return; + montauk::memcpy(fm->typeahead + fm->typeahead_len, ch, n); + fm->typeahead_len += n; + fm->typeahead[fm->typeahead_len] = '\0'; + + bool cycle = fm->typeahead_len % n == 0; + for (int off = n; cycle && off < fm->typeahead_len; off += n) { + for (int k = 0; k < n; k++) { + if (fm->typeahead[off + k] != ch[k]) { cycle = false; break; } + } + } + int prefix_len = cycle ? n : fm->typeahead_len; + + int start = fm->selected < 0 ? 0 : fm->selected + (cycle ? 1 : 0); + for (int k = 0; k < fm->entry_count; k++) { + int i = (start + k) % fm->entry_count; + if (filemanager_prefix_match_ci(fm->entry_names[i], fm->typeahead, prefix_len)) { + filemanager_move_focus(win, fm, i, false); + return; + } + } +} + void filemanager_on_key(Window* win, const montauk::abi::KeyEvent& key) { FileManagerState* fm = (FileManagerState*)win->app_data; if (!fm || !key.pressed) return; @@ -411,61 +508,71 @@ void filemanager_on_key(Window* win, const montauk::abi::KeyEvent& key) { } // ---- Keyboard shortcuts ---- - if (key.ctrl) { - if (key.ascii == 'c' || key.ascii == 'C') { - filemanager_do_copy(fm); + if (key.ctrl && !key.alt) { + char ch = key.ascii; + if (ch >= 'A' && ch <= 'Z') ch += 32; + switch (ch) { + case 'c': filemanager_do_copy(fm); return; + case 'x': filemanager_do_cut(fm); return; + case 'v': filemanager_do_paste(fm); return; + case 'a': + if (filemanager_is_listing(fm)) filemanager_select_all(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); + case 'l': filemanager_start_pathbar(fm); return; + case 'n': + if (key.shift) filemanager_new_folder(fm); return; + default: break; } } - // F2 = rename + // F2 = rename, F5 = refresh if (key.scancode == 0x3C) { filemanager_start_rename(fm); return; } + if (key.scancode == 0x3F) { + filemanager_refresh(fm); + return; + } + + uint8_t nav = montauk::nav_scancode(key); + int cur = fm->selected; + bool have_focus = cur >= 0 && cur < fm->entry_count; + int cols = fm->grid_view ? filemanager_grid_cols(win) : 1; if (key.ascii == '\b' || key.scancode == 0x0E) { filemanager_go_up(fm); - } else if (montauk::nav_scancode(key) == 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--; - } - filemanager_scroll_to_selected(win, fm); - } else if (montauk::nav_scancode(key) == 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++; - } - filemanager_scroll_to_selected(win, fm); - } else if (montauk::nav_scancode(key) == 0x4B && !key.alt && fm->grid_view) { + } else if (key.scancode == 0x01) { + // Escape: drop the selection + filemanager_select_single(fm, -1); + } else if (nav == 0x48 && !key.alt) { + // Up arrow (one row up in the grid) + int target = !have_focus ? 0 : (cur >= cols ? cur - cols : cur); + filemanager_move_focus(win, fm, target, key.shift); + } else if (nav == 0x50 && !key.alt) { + // Down arrow (one row down in the grid) + int target = !have_focus ? 0 : (cur + cols < fm->entry_count ? cur + cols : cur); + filemanager_move_focus(win, fm, target, key.shift); + } else if (nav == 0x4B && !key.alt && fm->grid_view) { // Left arrow (grid view only) - if (fm->selected > 0) fm->selected--; - } else if (montauk::nav_scancode(key) == 0x4D && !key.alt && fm->grid_view) { + filemanager_move_focus(win, fm, have_focus ? cur - 1 : 0, key.shift); + } else if (nav == 0x4D && !key.alt && fm->grid_view) { // Right arrow (grid view only) - if (fm->selected < fm->entry_count - 1) fm->selected++; + filemanager_move_focus(win, fm, have_focus ? cur + 1 : 0, key.shift); + } else if (nav == 0x47) { + // Home + filemanager_move_focus(win, fm, 0, key.shift); + } else if (nav == 0x4F) { + // End + filemanager_move_focus(win, fm, fm->entry_count - 1, key.shift); + } else if ((key.ascii == '\n' || key.ascii == '\r') && key.alt) { + // Alt+Enter: properties + if (have_focus) filemanager_show_selection_properties(fm, cur); } 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 (montauk::nav_scancode(key) == 0x53) { + if (have_focus && filemanager_is_entry_selected(fm, cur)) + filemanager_open_entry(fm, cur); + } else if (nav == 0x53) { // Delete key filemanager_delete_selected(fm); } else if (key.alt && montauk::nav_scancode(key) == 0x4B) { @@ -474,6 +581,8 @@ void filemanager_on_key(Window* win, const montauk::abi::KeyEvent& key) { } else if (key.alt && montauk::nav_scancode(key) == 0x4D) { // Alt+Right: go forward filemanager_go_forward(fm); + } else if (!key.ctrl && !key.alt) { + filemanager_typeahead(win, fm, key); } } diff --git a/programs/src/desktop/apps/filemanager/filemanager_internal.hpp b/programs/src/desktop/apps/filemanager/filemanager_internal.hpp index 848f6df..01d03d1 100644 --- a/programs/src/desktop/apps/filemanager/filemanager_internal.hpp +++ b/programs/src/desktop/apps/filemanager/filemanager_internal.hpp @@ -48,12 +48,21 @@ struct FileManagerVirtualEntry { // misbehaving driver and bounds memory. Far above any realistic directory. inline constexpr int FM_ENTRY_HARD_CAP = 8192; +inline constexpr int FM_CTX_MAX_ITEMS = 10; + // Single-file deletes at or below this size are freed inline; larger files go // to the background worker + progress dialog. Freeing a big file rewrites the // filesystem allocation bitmaps and can take seconds on real disks, which would // otherwise stall the desktop (Files runs inside desktop.elf). inline constexpr uint64_t FILEMANAGER_DELETE_INLINE_MAX_BYTES = 4ull * 1024 * 1024; +enum FileManagerSortKey : uint8_t { + FM_SORT_NAME = 0, + FM_SORT_SIZE, + FM_SORT_TYPE, + FM_SORT_MODIFIED, +}; + struct FileManagerState { char current_path[256]; FileManagerLocation history[16]; @@ -67,7 +76,10 @@ struct FileManagerState { int64_t* entry_mtimes; // last-modified time, UTC unix seconds (0 = unknown) int entry_count; int entry_cap; // allocated capacity of the per-entry arrays + // Focused entry. With no multi-selection it is also the selection; while + // multi_count > 0 only multi_selected marks what is selected. int selected; + int sel_anchor; // fixed end of a Shift range selection (-1 = none) int scroll_offset; bool* is_dir; int last_click_item; @@ -84,7 +96,7 @@ struct FileManagerState { int clipboard_count; bool clipboard_is_cut; - // Multi-selection (marquee selection), sized with the entry arrays. + // Multi-selection (marquee, Ctrl/Shift-click), sized with the entry arrays. bool* multi_selected; int multi_count; @@ -99,7 +111,7 @@ struct FileManagerState { bool ctx_open; int ctx_x, ctx_y; int ctx_target_idx; // -1 = background - int ctx_items[8]; + int ctx_items[FM_CTX_MAX_ITEMS]; int ctx_item_count; int ctx_hover; @@ -121,11 +133,22 @@ struct FileManagerState { FileManagerVirtualEntry* virtual_entries; int virtual_entry_count; + // Sort order of real directory listings (folders always come first). + FileManagerSortKey sort_key; + bool sort_desc; + + // Type-to-jump: characters typed in quick succession select the first + // entry whose name starts with them. + char typeahead[32]; + int typeahead_len; + uint64_t typeahead_time; + // Background file operation (copy/move/delete) + its progress dialog. void* active_fileop; // FileOpJob*, non-null while an operation runs // File Manager-owned dialogs void* delete_confirm_dialog; + void* open_with_dialog; }; inline constexpr int FM_TOOLBAR_H = 32; @@ -138,6 +161,37 @@ inline constexpr int FM_GRID_CELL_H = 80; inline constexpr int FM_GRID_CELL_H_WRAPPED = 92; inline constexpr int FM_GRID_ICON = 48; inline constexpr int FM_GRID_PAD = 4; +inline constexpr int FM_CTX_MENU_W = 184; + +// List view column layout. Columns are dropped right to left as the window +// narrows (Modified first, then Type, then Size) so the name keeps room. +struct FileManagerListColumns { + int name_x; + int name_right; // right edge available to the name text + int modified_x; // -1 = hidden + int size_x; // -1 = hidden + int type_x; // -1 = hidden +}; + +inline FileManagerListColumns filemanager_list_columns(int content_w) { + static constexpr int MIN_NAME_W = 160; + static constexpr int TYPE_W = 96; + static constexpr int SIZE_W = 76; + static constexpr int MODIFIED_W = 132; + + FileManagerListColumns cols = {8, 0, -1, -1, -1}; + int right = content_w - FM_SCROLLBAR_W; + if (right - SIZE_W >= MIN_NAME_W) { right -= SIZE_W; cols.size_x = right; } + if (right - TYPE_W >= MIN_NAME_W) { + // Type sits right of Size; shift Size left to make room. + cols.type_x = content_w - FM_SCROLLBAR_W - TYPE_W; + if (cols.size_x >= 0) cols.size_x -= TYPE_W; + right -= TYPE_W; + } + if (right - MODIFIED_W >= MIN_NAME_W) { right -= MODIFIED_W; cols.modified_x = right; } + cols.name_right = right - 8; + return cols; +} // Special user folders: name, icon filename, index into DesktopState arrays inline constexpr int SF_COUNT = DesktopState::SPECIAL_FOLDER_COUNT; @@ -160,6 +214,12 @@ inline constexpr int CTX_DELETE = 5; inline constexpr int CTX_NEW_FOLDER = 6; inline constexpr int CTX_PROPERTIES = 7; inline constexpr int CTX_REFRESH = 8; +inline constexpr int CTX_OPEN_WITH = 9; +inline constexpr int CTX_SELECT_ALL = 10; +inline constexpr int CTX_SORT_NAME = 11; +inline constexpr int CTX_SORT_SIZE = 12; +inline constexpr int CTX_SORT_TYPE = 13; +inline constexpr int CTX_SORT_MODIFIED = 14; inline bool filemanager_is_virtual_view(const FileManagerState* fm) { return fm && fm->virtual_view != FM_VIRTUAL_VIEW_NONE; @@ -179,6 +239,9 @@ inline const char* filemanager_virtual_view_label(FileManagerVirtualViewKind vie int special_folder_index(const char* name); const char* ctx_label(int action); +const char* ctx_shortcut(const FileManagerState* fm, int action); +// Menu items of different groups are split by a separator line. +int ctx_group(int action); inline mtk::ContextMenuState filemanager_context_state(const FileManagerState* fm) { if (!fm) return {}; @@ -199,19 +262,20 @@ inline void filemanager_build_context_items(const FileManagerState* fm, int max_items) { if (!fm || !items || max_items <= 0) return; int count = fm->ctx_item_count < max_items ? fm->ctx_item_count : max_items; - for (int i = 0; i < count; i++) - items[i] = {ctx_label(fm->ctx_items[i]), fm->ctx_items[i], true}; + for (int i = 0; i < count; i++) { + int action = fm->ctx_items[i]; + items[i] = {ctx_label(action), action, true, ctx_shortcut(fm, action), + i + 1 < count && ctx_group(action) != ctx_group(fm->ctx_items[i + 1])}; + } } 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); +// Lowercased extension after the last '.', without the dot ("" if none). +// Leading-dot names (".profile") count as having no extension. +void file_extension(const char* name, char* out, int out_max); +// Human-readable type for the list view's Type column ("PDF file", "Folder"). +void filemanager_type_label(const FileManagerState* fm, int idx, char* out, int out_max); void filemanager_build_fullpath(char* out, int out_max, const char* dir, const char* name); const char* path_basename(const char* path); @@ -226,6 +290,11 @@ void filemanager_free_arrays(FileManagerState* fm); void filemanager_read_drives(FileManagerState* fm); void filemanager_read_dir(FileManagerState* fm); +// Re-sort a real directory listing by sort_key/sort_desc, keeping the +// selection on the same entries. No-op for Computer and virtual views. +void filemanager_sort_entries(FileManagerState* fm); +// Pick a sort column: a new key sorts ascending, the current key flips order. +void filemanager_set_sort(FileManagerState* fm, FileManagerSortKey key); void filemanager_free_app_icons(FileManagerState* fm); void filemanager_read_virtual_view(FileManagerState* fm, FileManagerVirtualViewKind view); void filemanager_read_apps(FileManagerState* fm); @@ -293,6 +362,26 @@ void filemanager_commit_pathbar(FileManagerState* fm); void filemanager_open_entry(FileManagerState* fm, int idx); +// ---- File associations (associations.cpp, openwith.cpp) ---- +// Default app for a file: the user's "Always use" choice from +// /config/associations.toml, else the first app (by name) whose manifest +// lists the extension in files.types. Returns a desktop_items index or -1. +int filemanager_assoc_default(DesktopState* ds, const char* ext); +// Apps whose manifest claims ext come first ("recommended"); every other +// launchable app follows. Returns the total count, *recommended gets how many +// of those lead the list. Indices refer to ds->desktop_items. +int filemanager_assoc_candidates(DesktopState* ds, const char* ext, + int* out, int max, int* recommended); +// Remember item as the user's default app for ext. Returns false if ext cannot +// be stored as a config key or the save failed. +bool filemanager_assoc_set_default(DesktopState* ds, const char* ext, int item_index); +// True if the file looks like text (no NULs / stray control bytes in its +// first KiB). Empty files count as text. +bool filemanager_file_looks_textual(const char* path); +// Launch a desktop item with a file path argument. +bool filemanager_launch_with(DesktopState* ds, int item_index, const char* path); +void filemanager_show_open_with(FileManagerState* fm, int target_idx); + // ---- Async file operations (copy/move/delete on a worker thread) ---- enum FileOpKind : int { FILE_OP_COPY = 0, @@ -313,6 +402,15 @@ int filemanager_grid_index_at(FileManagerState* fm, int cols, int x, int rel_y); // Selection helpers void filemanager_clear_multi_selection(FileManagerState* fm); +// Replace the selection with a single entry (or none for idx < 0). +void filemanager_select_single(FileManagerState* fm, int idx); +// Ctrl-click: add idx to or remove it from the selection. +void filemanager_toggle_selection(FileManagerState* fm, int idx); +// Shift-click / Shift+arrow: select the range from sel_anchor to idx. With +// additive set (Ctrl+Shift) the range is added to the existing selection. +void filemanager_select_range(FileManagerState* fm, int idx, bool additive); +void filemanager_select_all(FileManagerState* fm); +bool filemanager_is_entry_selected(const FileManagerState* fm, int idx); // Fill out with selected entry indices. Returns count. Uses multi_selected if // non-empty, otherwise falls back to fm->selected (0 or 1 results). int filemanager_collect_selection(const FileManagerState* fm, int* out, int max); diff --git a/programs/src/desktop/apps/filemanager/filesystem.cpp b/programs/src/desktop/apps/filemanager/filesystem.cpp index 853fda6..a2a61aa 100644 --- a/programs/src/desktop/apps/filemanager/filesystem.cpp +++ b/programs/src/desktop/apps/filemanager/filesystem.cpp @@ -100,6 +100,8 @@ void filemanager_free_arrays(FileManagerState* fm) { static void filemanager_reset_list_state(FileManagerState* fm) { fm->selected = -1; + fm->sel_anchor = -1; + fm->typeahead_len = 0; fm->scroll_offset = 0; fm->scrollbar.scroll_offset = 0; fm->last_click_item = -1; @@ -344,42 +346,113 @@ void filemanager_read_dir(FileManagerState* fm) { if (fm->entry_count >= FM_ENTRY_HARD_CAP) break; } - // Sort: directories first, then alphabetical (case-insensitive) - for (int i = 1; i < fm->entry_count; i++) { - char tmp_name[128]; - int tmp_type = fm->entry_types[i]; - uint64_t tmp_size = fm->entry_sizes[i]; - int64_t tmp_mtime = fm->entry_mtimes[i]; - bool tmp_isdir = fm->is_dir[i]; - montauk::strcpy(tmp_name, fm->entry_names[i]); + filemanager_reset_list_state(fm); + filemanager_sort_entries(fm); +} - 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; +// Folders always lead; within each group the active column decides, with the +// name as tie-breaker so equal sizes/types/times still list predictably. +static int filemanager_compare_entries(const FileManagerState* fm, int a, int b) { + if (fm->is_dir[a] != fm->is_dir[b]) return fm->is_dir[a] ? -1 : 1; - 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->entry_mtimes[j + 1] = fm->entry_mtimes[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->entry_mtimes[j + 1] = tmp_mtime; - fm->is_dir[j + 1] = tmp_isdir; + int c = 0; + switch (fm->sort_key) { + case FM_SORT_SIZE: + if (fm->entry_sizes[a] != fm->entry_sizes[b]) + c = fm->entry_sizes[a] < fm->entry_sizes[b] ? -1 : 1; + break; + case FM_SORT_MODIFIED: + if (fm->entry_mtimes[a] != fm->entry_mtimes[b]) + c = fm->entry_mtimes[a] < fm->entry_mtimes[b] ? -1 : 1; + break; + case FM_SORT_TYPE: { + char ea[16], eb[16]; + file_extension(fm->entry_names[a], ea, sizeof(ea)); + file_extension(fm->entry_names[b], eb, sizeof(eb)); + c = str_compare_ci(ea, eb); + break; + } + default: + break; + } + if (c == 0) c = str_compare_ci(fm->entry_names[a], fm->entry_names[b]); + return fm->sort_desc ? -c : c; +} + +template +static void filemanager_permute(T* arr, const int* perm, int n, void* scratch) { + T* tmp = (T*)scratch; + for (int j = 0; j < n; j++) tmp[j] = arr[perm[j]]; + montauk::memcpy(arr, tmp, (uint64_t)n * sizeof(T)); +} + +static int filemanager_remap_index(int idx, const int* inv, int n) { + return (idx >= 0 && idx < n) ? inv[idx] : idx; +} + +void filemanager_sort_entries(FileManagerState* fm) { + if (!fm || filemanager_is_computer_view(fm) || filemanager_is_virtual_view(fm)) return; + int n = fm->entry_count; + if (n < 2) return; + + // Sort an index permutation (stable bottom-up merge sort), then apply it + // to every parallel array once instead of swapping 128-byte names around. + int* perm = (int*)montauk::malloc((uint64_t)n * sizeof(int)); + int* tmp = (int*)montauk::malloc((uint64_t)n * sizeof(int)); + void* scratch = montauk::malloc((uint64_t)n * 128); + if (!perm || !tmp || !scratch) { + montauk::mfree(perm); + montauk::mfree(tmp); + montauk::mfree(scratch); + return; } - filemanager_reset_list_state(fm); + for (int i = 0; i < n; i++) perm[i] = i; + for (int width = 1; width < n; width *= 2) { + for (int lo = 0; lo < n; lo += 2 * width) { + int mid = lo + width < n ? lo + width : n; + int hi = lo + 2 * width < n ? lo + 2 * width : n; + int i = lo, j = mid, k = lo; + while (i < mid && j < hi) + tmp[k++] = filemanager_compare_entries(fm, perm[j], perm[i]) < 0 ? perm[j++] : perm[i++]; + while (i < mid) tmp[k++] = perm[i++]; + while (j < hi) tmp[k++] = perm[j++]; + } + int* swap = perm; perm = tmp; tmp = swap; + } + + auto* names = (char(*)[128])scratch; + for (int j = 0; j < n; j++) montauk::memcpy(names[j], fm->entry_names[perm[j]], 128); + montauk::memcpy(fm->entry_names, names, (uint64_t)n * 128); + filemanager_permute(fm->entry_types, perm, n, scratch); + filemanager_permute(fm->entry_sizes, perm, n, scratch); + filemanager_permute(fm->entry_mtimes, perm, n, scratch); + filemanager_permute(fm->is_dir, perm, n, scratch); + filemanager_permute(fm->multi_selected, perm, n, scratch); + + // Keep focus, anchor and click tracking on the same entries. + int* inv = tmp; + for (int j = 0; j < n; j++) inv[perm[j]] = j; + fm->selected = filemanager_remap_index(fm->selected, inv, n); + fm->sel_anchor = filemanager_remap_index(fm->sel_anchor, inv, n); + fm->last_click_item = filemanager_remap_index(fm->last_click_item, inv, n); + fm->rename_idx = filemanager_remap_index(fm->rename_idx, inv, n); + + montauk::mfree(perm); + montauk::mfree(tmp); + montauk::mfree(scratch); +} + +void filemanager_set_sort(FileManagerState* fm, FileManagerSortKey key) { + if (!fm) return; + if (fm->sort_key == key) { + fm->sort_desc = !fm->sort_desc; + } else { + fm->sort_key = key; + // Largest and newest first is what one usually wants from these. + fm->sort_desc = key == FM_SORT_SIZE || key == FM_SORT_MODIFIED; + } + filemanager_sort_entries(fm); } void filemanager_free_app_icons(FileManagerState* fm) { diff --git a/programs/src/desktop/apps/filemanager/helpers.cpp b/programs/src/desktop/apps/filemanager/helpers.cpp index 4bf51f7..c93efbd 100644 --- a/programs/src/desktop/apps/filemanager/helpers.cpp +++ b/programs/src/desktop/apps/filemanager/helpers.cpp @@ -15,6 +15,91 @@ void filemanager_clear_multi_selection(FileManagerState* fm) { fm->multi_count = 0; } +bool filemanager_is_entry_selected(const FileManagerState* fm, int idx) { + if (!fm || idx < 0 || idx >= fm->entry_count) return false; + if (fm->multi_count > 0) return fm->multi_selected[idx]; + return idx == fm->selected; +} + +void filemanager_select_single(FileManagerState* fm, int idx) { + if (!fm) return; + filemanager_clear_multi_selection(fm); + fm->selected = (idx >= 0 && idx < fm->entry_count) ? idx : -1; + fm->sel_anchor = fm->selected; +} + +// A multi-selection of one entry collapses back to the plain single-selection +// form, which the rename/toolbar logic treats as "one item selected". +static void filemanager_normalize_selection(FileManagerState* fm) { + if (fm->multi_count != 1) return; + for (int i = 0; i < fm->entry_count; i++) { + if (fm->multi_selected[i]) { + filemanager_clear_multi_selection(fm); + fm->selected = i; + return; + } + } +} + +// Turn a plain single selection into the multi form before extending it. +static void filemanager_seed_multi_selection(FileManagerState* fm) { + if (fm->multi_count > 0) return; + if (fm->selected >= 0 && fm->selected < fm->entry_count) { + fm->multi_selected[fm->selected] = true; + fm->multi_count = 1; + } +} + +void filemanager_toggle_selection(FileManagerState* fm, int idx) { + if (!fm || idx < 0 || idx >= fm->entry_count) return; + filemanager_seed_multi_selection(fm); + + if (fm->multi_selected[idx]) { + fm->multi_selected[idx] = false; + fm->multi_count--; + } else { + fm->multi_selected[idx] = true; + fm->multi_count++; + } + fm->selected = fm->multi_count > 0 ? idx : -1; + fm->sel_anchor = idx; + filemanager_normalize_selection(fm); +} + +void filemanager_select_range(FileManagerState* fm, int idx, bool additive) { + if (!fm || idx < 0 || idx >= fm->entry_count) return; + + int anchor = fm->sel_anchor; + if (anchor < 0 || anchor >= fm->entry_count) + anchor = (fm->selected >= 0 && fm->selected < fm->entry_count) ? fm->selected : idx; + + if (additive) filemanager_seed_multi_selection(fm); + else filemanager_clear_multi_selection(fm); + + int lo = anchor < idx ? anchor : idx; + int hi = anchor < idx ? idx : anchor; + for (int i = lo; i <= hi; i++) { + if (!fm->multi_selected[i]) { + fm->multi_selected[i] = true; + fm->multi_count++; + } + } + fm->selected = idx; + fm->sel_anchor = anchor; + filemanager_normalize_selection(fm); +} + +void filemanager_select_all(FileManagerState* fm) { + if (!fm || fm->entry_count <= 0) return; + if (fm->entry_count == 1) { + filemanager_select_single(fm, 0); + return; + } + for (int i = 0; i < fm->entry_count; i++) fm->multi_selected[i] = true; + fm->multi_count = fm->entry_count; + if (fm->selected < 0 || fm->selected >= fm->entry_count) fm->selected = 0; +} + int filemanager_collect_selection(const FileManagerState* fm, int* out, int max) { if (!fm || !out || max <= 0) return 0; int n = 0; @@ -46,10 +131,64 @@ const char* ctx_label(int action) { case CTX_NEW_FOLDER: return "New Folder"; case CTX_PROPERTIES: return "Properties"; case CTX_REFRESH: return "Refresh"; + case CTX_OPEN_WITH: return "Open With..."; + case CTX_SELECT_ALL: return "Select All"; + case CTX_SORT_NAME: return "Sort by Name"; + case CTX_SORT_SIZE: return "Sort by Size"; + case CTX_SORT_TYPE: return "Sort by Type"; + case CTX_SORT_MODIFIED: return "Sort by Modified"; default: return "?"; } } +const char* ctx_shortcut(const FileManagerState* fm, int action) { + // The active sort column shows its direction in the shortcut slot. + int sort_action = -1; + if (fm) { + switch (fm->sort_key) { + case FM_SORT_NAME: sort_action = CTX_SORT_NAME; break; + case FM_SORT_SIZE: sort_action = CTX_SORT_SIZE; break; + case FM_SORT_TYPE: sort_action = CTX_SORT_TYPE; break; + case FM_SORT_MODIFIED: sort_action = CTX_SORT_MODIFIED; break; + } + } + if (action == sort_action) return fm->sort_desc ? "Desc" : "Asc"; + + switch (action) { + case CTX_OPEN: return "Enter"; + case CTX_COPY: return "Ctrl+C"; + case CTX_CUT: return "Ctrl+X"; + case CTX_PASTE: return "Ctrl+V"; + case CTX_RENAME: return "F2"; + case CTX_DELETE: return "Del"; + case CTX_NEW_FOLDER: return "Ctrl+Shift+N"; + case CTX_PROPERTIES: return "Alt+Enter"; + case CTX_REFRESH: return "F5"; + case CTX_SELECT_ALL: return "Ctrl+A"; + default: return nullptr; + } +} + +int ctx_group(int action) { + switch (action) { + case CTX_OPEN: + case CTX_OPEN_WITH: return 0; + case CTX_COPY: + case CTX_CUT: + case CTX_PASTE: return 1; + case CTX_RENAME: + case CTX_DELETE: + case CTX_NEW_FOLDER: return 2; + case CTX_SELECT_ALL: return 3; + case CTX_SORT_NAME: + case CTX_SORT_SIZE: + case CTX_SORT_TYPE: + case CTX_SORT_MODIFIED: return 4; + case CTX_REFRESH: return 5; + default: return 6; // Properties + } +} + bool str_ends_with(const char* s, const char* suffix) { int slen = montauk::slen(s); int suflen = montauk::slen(suffix); @@ -64,42 +203,64 @@ bool str_ends_with(const char* s, const char* suffix) { return true; } -bool is_image_file(const char* name) { - return str_ends_with(name, ".jpg") - || str_ends_with(name, ".jpeg") - || str_ends_with(name, ".svg"); -} - -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 file_extension(const char* name, char* out, int out_max) { + if (!out || out_max <= 0) return; + out[0] = '\0'; + if (!name) return; + + const char* dot = nullptr; + for (const char* p = name; *p; p++) { + if (*p == '.') dot = p; + } + if (!dot || dot == name || !dot[1]) return; + + int n = 0; + for (const char* p = dot + 1; *p && n < out_max - 1; p++) { + char ch = *p; + out[n++] = (ch >= 'A' && ch <= 'Z') ? ch + 32 : ch; + } + out[n] = '\0'; +} + +void filemanager_type_label(const FileManagerState* fm, int idx, char* out, int out_max) { + if (!out || out_max <= 0) return; + const char* fixed = nullptr; + switch (fm->entry_types[idx]) { + case FM_ENTRY_VIRTUAL_ITEM: + fixed = fm->virtual_view == FM_VIRTUAL_VIEW_SYSTEM_CONFIGURATION ? "Setting" : "App"; + break; + case FM_ENTRY_APPS_ROOT: fixed = "Apps"; break; + case FM_ENTRY_SYSTEM_CONFIGURATION_ROOT: fixed = "Settings"; break; + case FM_ENTRY_HOME: fixed = "Home"; break; + case FM_ENTRY_DRIVE: fixed = "Drive"; break; + case FM_ENTRY_SPECIAL_DIR: + case FM_ENTRY_DIR: fixed = "Folder"; break; + case FM_ENTRY_EXEC: fixed = "Program"; break; + default: break; + } + if (fixed) { + montauk::strncpy(out, fixed, out_max); + return; + } + + char ext[16]; + file_extension(fm->entry_names[idx], ext, 9); + if (!ext[0]) { + montauk::strncpy(out, "File", out_max); + return; + } + for (int k = 0; ext[k]; k++) { + if (ext[k] >= 'a' && ext[k] <= 'z') ext[k] -= 32; + } + snprintf(out, out_max, "%s file", ext); +} + void filemanager_build_fullpath(char* out, int out_max, const char* dir, const char* name) { montauk::strcpy(out, dir); int plen = montauk::slen(out); diff --git a/programs/src/desktop/apps/filemanager/navigation.cpp b/programs/src/desktop/apps/filemanager/navigation.cpp index 05b5ac3..f8a24ea 100644 --- a/programs/src/desktop/apps/filemanager/navigation.cpp +++ b/programs/src/desktop/apps/filemanager/navigation.cpp @@ -281,28 +281,28 @@ void filemanager_open_entry(FileManagerState* fm, int idx) { return; } - // Open file with appropriate app + // Open the file with its default app (see associations.cpp). + DesktopState* ds = fm->desktop; + const char* name = fm->entry_names[idx]; 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); + filemanager_build_fullpath(fullpath, 512, fm->current_path, name); + if (str_ends_with(name, ".elf")) { + desktop_spawn_app(ds, fullpath); + return; } + + char ext[16]; + file_extension(name, ext, sizeof(ext)); + int app = filemanager_assoc_default(ds, ext); + if (app >= 0 && filemanager_launch_with(ds, app, fullpath)) return; + + // Nothing claims this type: text still goes straight to the Text Editor + // (READMEs, Makefiles, odd extensions); anything else asks. + if (filemanager_file_looks_textual(fullpath)) { + desktop_spawn_app(ds, "0:/apps/texteditor/texteditor.elf", fullpath); + return; + } + filemanager_show_open_with(fm, idx); } } // namespace filemanager diff --git a/programs/src/desktop/apps/filemanager/render.cpp b/programs/src/desktop/apps/filemanager/render.cpp index 7922bab..256c860 100644 --- a/programs/src/desktop/apps/filemanager/render.cpp +++ b/programs/src/desktop/apps/filemanager/render.cpp @@ -135,6 +135,21 @@ static void draw_marquee(Canvas& c, FileManagerState* fm, int list_y) { } } +// Column title, plus a small arrow when it is the active sort column. +static void draw_column_header(Canvas& c, int x, int header_y, const char* label, + bool active, bool desc, Color color) { + c.text(x, header_y + 2, label, active ? colors::TEXT_COLOR : color); + if (!active) return; + + int ax = x + text_width(label) + 6; + int ay = header_y + FM_HEADER_H / 2 - 2; + for (int r = 0; r < 4; r++) { + // Ascending points up, descending points down. + int row = desc ? 3 - r : r; + c.hline(ax + 3 - r, ay + row, r * 2 + 1, colors::TEXT_COLOR); + } +} + int filemanager_grid_row_height(FileManagerState* fm, int row, int cols) { if (!fm || cols <= 0 || row < 0) return FM_GRID_CELL_H; @@ -342,8 +357,7 @@ void filemanager_on_draw(Window* win, Framebuffer& fb) { if (cell_y + row_h <= list_y || cell_y >= c.h) continue; // Selection highlight (single or multi) - bool cell_selected = (i == fm->selected) || - (fm->multi_selected && fm->multi_selected[i]); + bool cell_selected = filemanager_is_entry_selected(fm, i); if (cell_selected) { int sy = gui_max(cell_y, list_y); int sh = gui_min(cell_y + row_h, c.h) - sy; @@ -446,23 +460,32 @@ void filemanager_on_draw(Window* win, Framebuffer& fb) { 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; + FileManagerListColumns cols = filemanager_list_columns(c.w); + // Sorting only applies to real directories; Computer and the virtual + // views keep their curated order and show no sort arrow. + bool sortable = !filemanager_is_computer_view(fm) && !filemanager_is_virtual_view(fm); - 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); + draw_column_header(c, cols.name_x, header_y, "Name", + sortable && fm->sort_key == FM_SORT_NAME, fm->sort_desc, dim); + if (cols.modified_x >= 0) + draw_column_header(c, cols.modified_x, header_y, "Modified", + sortable && fm->sort_key == FM_SORT_MODIFIED, fm->sort_desc, dim); + if (cols.size_x >= 0) + draw_column_header(c, cols.size_x, header_y, "Size", + sortable && fm->sort_key == FM_SORT_SIZE, fm->sort_desc, dim); + if (cols.type_x >= 0) + draw_column_header(c, cols.type_x, header_y, "Type", + sortable && fm->sort_key == FM_SORT_TYPE, fm->sort_desc, 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); - } + int first_col_x = cols.modified_x >= 0 ? cols.modified_x + : cols.size_x >= 0 ? cols.size_x : cols.type_x; + if (first_col_x >= 0) + c.vline(first_col_x - 4, header_y, c.h - header_y, colors::BORDER); + int tz = montauk::gettz(); // ---- File entries ---- int list_y = header_y + FM_HEADER_H; @@ -482,8 +505,7 @@ void filemanager_on_draw(Window* win, Framebuffer& fb) { if (iy + FM_ITEM_H <= list_y || iy >= c.h) continue; // Highlight selected (single or marquee multi-selection) - bool row_selected = (i == fm->selected) || - (fm->multi_selected && fm->multi_selected[i]); + bool row_selected = filemanager_is_entry_selected(fm, i); if (row_selected) { int sy = gui_max(iy, list_y); int sh = gui_min(iy + FM_ITEM_H, c.h) - sy; @@ -540,7 +562,7 @@ void filemanager_on_draw(Window* win, Framebuffer& fb) { 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; + int rw = cols.name_right - 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); @@ -556,8 +578,7 @@ void filemanager_on_draw(Window* win, Framebuffer& fb) { if (ty >= list_y && ty + fm_sfh <= c.h) { // Truncate name to fit the name column width const char* name = fm->entry_names[i]; - int name_max_w = size_col_x - tx - 8; - if (name_max_w < 20) name_max_w = c.w - FM_SCROLLBAR_W - tx - 8; + int name_max_w = cols.name_right - tx; char label[64]; montauk::utf8::copy(label, 64, name); @@ -581,28 +602,30 @@ void filemanager_on_draw(Window* win, Framebuffer& fb) { } } + bool row_text_visible = ty >= list_y && ty + fm_sfh <= c.h; + + // Modified + if (cols.modified_x >= 0 && row_text_visible && fm->entry_mtimes[i] > 0) { + char date_str[24]; + format_datetime(date_str, sizeof(date_str), fm->entry_mtimes[i], tz); + c.text(cols.modified_x, ty, date_str, dim); + } + // Size - if (size_col_x > 100 && !fm->is_dir[i] && ty >= list_y && ty + fm_sfh <= c.h) { + if (cols.size_x >= 0 && !fm->is_dir[i] && row_text_visible) { char size_str[16]; format_size(size_str, fm->entry_sizes[i]); - c.text(size_col_x, ty, size_str, dim); + c.text(cols.size_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] == FM_ENTRY_VIRTUAL_ITEM) { - type_str = fm->virtual_view == FM_VIRTUAL_VIEW_SYSTEM_CONFIGURATION - ? "Setting" - : "App"; - } - else if (fm->entry_types[i] == FM_ENTRY_APPS_ROOT) type_str = "Apps"; - else if (fm->entry_types[i] == FM_ENTRY_SYSTEM_CONFIGURATION_ROOT) type_str = "Settings"; - else if (fm->entry_types[i] == FM_ENTRY_HOME) type_str = "Home"; - else if (fm->entry_types[i] == FM_ENTRY_DRIVE) type_str = "Drive"; - else if (fm->entry_types[i] == FM_ENTRY_DIR) type_str = "Dir"; - else if (fm->entry_types[i] == FM_ENTRY_EXEC) type_str = "Exec"; - c.text(type_col_x, ty, type_str, dim); + if (cols.type_x >= 0 && row_text_visible) { + char type_str[24]; + filemanager_type_label(fm, i, type_str, sizeof(type_str)); + char type_fit[24]; + fit_label_with_ellipsis(type_fit, sizeof(type_fit), type_str, + c.w - FM_SCROLLBAR_W - cols.type_x - 4); + c.text(cols.type_x, ty, type_fit, dim); } } @@ -637,10 +660,10 @@ void filemanager_on_draw(Window* win, Framebuffer& fb) { theme.border = colors::BORDER; theme.text = colors::TEXT_COLOR; - mtk::ContextMenuItem items[8]; - filemanager_build_context_items(fm, items, 8); + mtk::ContextMenuItem items[FM_CTX_MAX_ITEMS]; + filemanager_build_context_items(fm, items, FM_CTX_MAX_ITEMS); mtk::draw_context_menu(c, filemanager_context_state(fm), items, - fm->ctx_item_count, theme); + fm->ctx_item_count, theme, FM_CTX_MENU_W); } } diff --git a/programs/src/desktop/input.cpp b/programs/src/desktop/input.cpp index c1e8f1c..bece980 100644 --- a/programs/src/desktop/input.cpp +++ b/programs/src/desktop/input.cpp @@ -887,6 +887,8 @@ void gui::desktop_handle_keyboard(DesktopState* ds, const montauk::abi::KeyEvent montauk::abi::KeyEvent translated_key = key; montauk::keyboard::translate(ds->keyboard, &translated_key); const montauk::abi::KeyEvent& routed_key = translated_key; + ds->kbd_shift_down = routed_key.shift; + ds->kbd_ctrl_down = routed_key.ctrl; if (ds->screen_locked) { handle_lock_keyboard(ds, routed_key); diff --git a/programs/src/desktop/main.cpp b/programs/src/desktop/main.cpp index f057cd0..58ff58a 100644 --- a/programs/src/desktop/main.cpp +++ b/programs/src/desktop/main.cpp @@ -181,6 +181,27 @@ void desktop_scan_apps(DesktopState* ds) { snprintf(item->binary_path, sizeof(item->binary_path), "0:/apps/%s/%s", dirname, binary); + // File types the app opens, used by Files for default apps and + // "Open With". Stored lowercased as " ext1 ext2 " for whole-word + // matching; entries that would overflow the buffer are dropped. + if (auto* types = doc.get_array("files.types")) { + int len = 0; + item->file_types[len++] = ' '; + for (int t = 0; t < types->array.count; t++) { + auto* v = types->array.items[t]; + if (v->type != montauk::toml::Type::String || !v->str[0]) continue; + const char* ext = v->str[0] == '.' ? v->str + 1 : v->str; + int elen = montauk::slen(ext); + if (elen == 0 || len + elen + 2 > (int)sizeof(item->file_types)) continue; + for (int k = 0; k < elen; k++) { + char ch = ext[k]; + item->file_types[len++] = (ch >= 'A' && ch <= 'Z') ? ch + 32 : ch; + } + item->file_types[len++] = ' '; + } + item->file_types[len < 2 ? 0 : len] = '\0'; + } + // Load icon from app directory item->icon = {}; item->icon_path[0] = '\0'; diff --git a/programs/src/fontpreview/manifest.toml b/programs/src/fontpreview/manifest.toml index 8c54550..1314727 100644 --- a/programs/src/fontpreview/manifest.toml +++ b/programs/src/fontpreview/manifest.toml @@ -12,3 +12,7 @@ visible = false # starting directory. Don't hand it home_dir when launched from the Apps # folder (it would try to parse the directory path as a font). pass_home_dir = false + +[files] +# Extensions Files opens with this app (lowercase, no dot) +types = ["ttf"] diff --git a/programs/src/imageviewer/manifest.toml b/programs/src/imageviewer/manifest.toml index c23747a..640e445 100644 --- a/programs/src/imageviewer/manifest.toml +++ b/programs/src/imageviewer/manifest.toml @@ -6,3 +6,7 @@ icon = "image-viewer.svg" [menu] category = "Media" visible = false + +[files] +# Extensions Files opens with this app (lowercase, no dot) +types = ["jpg", "jpeg", "png", "bmp", "gif", "tga", "svg"] diff --git a/programs/src/music/manifest.toml b/programs/src/music/manifest.toml index ec3e6a3..dc37971 100644 --- a/programs/src/music/manifest.toml +++ b/programs/src/music/manifest.toml @@ -9,3 +9,7 @@ visible = true [launch] pass_home_dir = false + +[files] +# Extensions Files opens with this app (lowercase, no dot) +types = ["mp3", "wav"] diff --git a/programs/src/pdfviewer/manifest.toml b/programs/src/pdfviewer/manifest.toml index 236cc01..87888dd 100644 --- a/programs/src/pdfviewer/manifest.toml +++ b/programs/src/pdfviewer/manifest.toml @@ -6,3 +6,7 @@ icon = "pdf-viewer.svg" [menu] category = "Productivity" visible = false + +[files] +# Extensions Files opens with this app (lowercase, no dot) +types = ["pdf"] diff --git a/programs/src/spreadsheet/manifest.toml b/programs/src/spreadsheet/manifest.toml index 1690066..17c6e1a 100644 --- a/programs/src/spreadsheet/manifest.toml +++ b/programs/src/spreadsheet/manifest.toml @@ -6,3 +6,7 @@ icon = "spreadsheet.svg" [menu] category = "Productivity" visible = true + +[files] +# Extensions Files opens with this app (lowercase, no dot) +types = ["mss"] diff --git a/programs/src/texteditor/manifest.toml b/programs/src/texteditor/manifest.toml index a918c91..fdf51ca 100644 --- a/programs/src/texteditor/manifest.toml +++ b/programs/src/texteditor/manifest.toml @@ -9,3 +9,7 @@ visible = true [launch] pass_home_dir = false + +[files] +# Extensions Files opens with this app (lowercase, no dot) +types = ["txt", "md", "log", "toml", "ini", "cfg", "conf", "json", "xml", "csv", "c", "h", "cc", "cpp", "hpp", "rs", "s", "asm", "ld", "mk", "sh", "py", "js", "css", "html"] diff --git a/programs/src/video/manifest.toml b/programs/src/video/manifest.toml index 2859ca0..c932c95 100644 --- a/programs/src/video/manifest.toml +++ b/programs/src/video/manifest.toml @@ -6,3 +6,7 @@ icon = "video.svg" [menu] category = "Media" visible = true + +[files] +# Extensions Files opens with this app (lowercase, no dot) +types = ["avi"] diff --git a/programs/src/wordprocessor/manifest.toml b/programs/src/wordprocessor/manifest.toml index 6507d52..cb95137 100644 --- a/programs/src/wordprocessor/manifest.toml +++ b/programs/src/wordprocessor/manifest.toml @@ -6,3 +6,7 @@ icon = "wordprocessor.svg" [menu] category = "Productivity" visible = true + +[files] +# Extensions Files opens with this app (lowercase, no dot) +types = ["mwp"]