/* * main.cpp * MontaukOS Settings: every settings page in one window, filed by * category and searchable by name, description and keywords. Pages are * catalog items (gui/app_catalog.hpp); opening one asks the desktop to * launch it. * Copyright (c) 2026 Daniel Hammer */ #include #include #include #include #include #include #include #include #include #include extern "C" { #include } using namespace gui; static constexpr int WIN_W = 860; static constexpr int WIN_H = 580; static constexpr int SIDEBAR_W = 232; static constexpr int SEARCH_H = 32; static constexpr int SEARCH_ICON_X = 9; // magnifier inset static constexpr int SEARCH_TEXT_X = 31; // text inset, after the magnifier static constexpr int SEARCH_RADIUS = 4; // matches the sidebar rows static constexpr int CLEAR_SIZE = 16; static constexpr int CAT_TOP = 62; static constexpr int CAT_ROW_H = 34; static constexpr int PAD = 24; static constexpr int HEADER_H = 86; static constexpr int GROUP_H = 36; static constexpr int TILE_H = 68; static constexpr int TILE_GAP = 10; static constexpr int TILE_MIN_W = 280; static constexpr int TILE_ICON = 32; static constexpr int TITLE_SIZE = 24; static constexpr int SCROLL_STEP = 48; static constexpr int MAX_ENTRIES = 128; static constexpr int MAX_CATEGORIES = 16; static constexpr int MAX_PLACED = MAX_ENTRIES + MAX_CATEGORIES; static constexpr int CAT_ALL = -1; static constexpr int CAT_NONE = -2; static constexpr Color SIDEBAR_BG = Color::from_rgb(0xF6, 0xF6, 0xF6); static constexpr Color SIDEBAR_HOVER = Color::from_rgb(0xEA, 0xEA, 0xEA); static constexpr Color SYMBOLIC = Color::from_rgb(0x5C, 0x61, 0x6C); static constexpr Color SEARCH_BG = Color::from_rgb(0xE6, 0xE6, 0xE6); static constexpr Color SEARCH_HOVER = Color::from_rgb(0xDF, 0xDF, 0xDF); static constexpr Color CLEAR_BG = Color::from_rgb(0xA8, 0xA8, 0xA8); static constexpr Color CLEAR_HOVER = Color::from_rgb(0x88, 0x88, 0x88); // One settings page. struct Entry { int item; // index into g_ds->desktop_items int category; // index into g_categories SvgIcon icon; // TILE_ICON px }; struct Category { char name[24]; SvgIcon icon; // 16 px symbolic int count; }; // A laid-out group heading or tile. Rects are in content coordinates: x is // window x, y is relative to the top of the scrolled pane (which starts with // the title header, so tiles begin at HEADER_H). enum PlacedKind : uint8_t { PLACED_HEADING, PLACED_TILE }; struct Placed { PlacedKind kind; int ref; // category index (heading) or entry index (tile) Rect r; }; static WsWindow g_win; static DesktopState* g_ds = nullptr; static Color g_accent = colors::ACCENT; static Entry g_entries[MAX_ENTRIES]; static int g_entry_count = 0; static Category g_categories[MAX_CATEGORIES]; static int g_category_count = 0; static SvgIcon g_icon_all; static SvgIcon g_icon_search; static SvgIcon g_icon_clear; // View static int g_category = CAT_ALL; static char g_query[64] = {}; static mtk::TextInputState g_search = {}; // Keyboard focus: the search field, the tiles (arrows move between them), or // neither. Typing with neither focuses the search field. static bool g_search_focus = false; static bool g_grid_focus = false; static int g_scroll = 0; static int g_selected = -1; // index into g_placed (a tile), keyboard focus static int g_hover_tile = -1; // index into g_placed static int g_hover_cat = CAT_NONE; static int g_mouse_x = -1; static int g_mouse_y = -1; static Placed g_placed[MAX_PLACED]; static int g_placed_count = 0; static int g_content_h = 0; static int g_result_count = 0; static mtk::Theme app_theme() { return mtk::make_theme(g_accent); } // ==== Text helpers ==== static char fold(char c) { return (c >= 'A' && c <= 'Z') ? c + 32 : c; } // Case-insensitive (ASCII) search for needle[0..nlen) in haystack. static int find_folded(const char* haystack, const char* needle, int nlen) { if (nlen <= 0) return 0; for (int i = 0; haystack[i]; i++) { int j = 0; while (j < nlen && haystack[i + j] && fold(haystack[i + j]) == fold(needle[j])) j++; if (j == nlen) return i; } return -1; } static bool is_word_start(const char* s, int i) { if (i == 0) return true; char p = s[i - 1]; return p == ' ' || p == '-' || p == '&' || p == '/' || p == ','; } // src cut to fit max_w pixels, with a trailing "..." when cut. static void fit_text(const char* src, char* out, int out_len, int max_w, TrueTypeFont* font = nullptr, int size = 0) { auto width = [&](const char* s) { return font ? gui::text_width(font, s, size) : text_width(s); }; montauk::strncpy(out, src ? src : "", out_len); if (out_len <= 4 || width(out) <= max_w) return; int ellipsis_w = width("..."); int len = montauk::slen(out); while (len > 0) { out[len] = '\0'; if (width(out) + ellipsis_w <= max_w) break; len = montauk::utf8::prev_boundary(out, len); } if (len > out_len - 4) len = montauk::utf8::floor_boundary(out, len, out_len - 4); if (len < 0) len = 0; montauk::strcpy(out + len, "..."); } static void draw_bold(Canvas& c, int x, int y, const char* s, Color color, int size) { TrueTypeFont* font = fonts::system_bold ? fonts::system_bold : fonts::system_font; gui::draw_text(c, font, x, y, s, color, size); } // ==== Catalog ==== static const DesktopItem& entry_item(int e) { return g_ds->desktop_items[g_entries[e].item]; } static const char* category_icon_path(const char* name) { struct { const char* name; const char* icon; } map[] = { { "Personalization", "org.gnome.Settings-appearance-symbolic.svg" }, { "Network", "org.gnome.Settings-network-symbolic.svg" }, { "Devices", "computer-symbolic.svg" }, { "Time & Language", "preferences-system-time-symbolic.svg" }, { "Accounts", "system-users-symbolic.svg" }, { "Privacy & Security", "security-high-symbolic.svg" }, { "System", "preferences-system-symbolic.svg" }, }; for (auto& m : map) if (montauk::streq(m.name, name)) return m.icon; return "applications-system-symbolic.svg"; } static SvgIcon load_symbolic(const char* file) { char path[128]; snprintf(path, sizeof(path), "0:/os/icons/%s", file); return svg_load(path, 16, 16, SYMBOLIC); } static int find_category(const char* name) { for (int i = 0; i < g_category_count; i++) if (app_catalog_compare_ci(g_categories[i].name, name) == 0) return i; return -1; } static int add_category(const char* name) { if (g_category_count >= MAX_CATEGORIES) return -1; Category& cat = g_categories[g_category_count]; montauk::strncpy(cat.name, name, sizeof(cat.name)); cat.count = 0; cat.icon = {}; return g_category_count++; } static void load_identity() { char user[64] = {}; if (montauk::getuser(user, sizeof(user)) <= 0 || !user[0]) montauk::strcpy(user, "default"); montauk::strncpy(g_ds->current_user, user, sizeof(g_ds->current_user)); // UI affordance only (admin_only pages); the kernel enforces what each // page may actually do, and the desktop refuses to launch them anyway. montauk::user::UserInfo users[16]; int count = montauk::user::load_users(users, 16); for (int i = 0; i < count; i++) { if (montauk::streq(users[i].username, user)) { g_ds->is_admin = montauk::streq(users[i].role, "admin"); break; } } } static void load_catalog() { app_catalog_add_builtin_pages(g_ds, 0); app_catalog_add_manifest_apps(g_ds); // The fixed categories first, in their display order; any others a // manifest names follow, by name. for (const char* name : kSettingsCategories) add_category(name); int fixed = g_category_count; for (int i = 0; i < g_ds->desktop_item_count && g_entry_count < MAX_ENTRIES; i++) { DesktopItem& item = g_ds->desktop_items[i]; // The catalog loads small icons for every app; Settings draws its own. if (item.icon.pixels) svg_free(item.icon); if (!app_catalog_in_settings(item)) continue; if (item.admin_only && !g_ds->is_admin) continue; const char* cat_name = item.settings_category[0] ? item.settings_category : kSettingsDefaultCategory; int cat = find_category(cat_name); if (cat < 0) cat = add_category(cat_name); if (cat < 0) cat = find_category(kSettingsDefaultCategory); Entry& e = g_entries[g_entry_count++]; e.item = i; e.category = cat; e.icon = item.icon_path[0] ? svg_load(item.icon_path, TILE_ICON, TILE_ICON, colors::ICON_COLOR) : SvgIcon{}; g_categories[cat].count++; } // Sort the extra categories by name, keeping each entry's index right. for (int i = fixed + 1; i < g_category_count; i++) { for (int j = i; j > fixed && app_catalog_compare_ci(g_categories[j].name, g_categories[j - 1].name) < 0; j--) { Category tmp = g_categories[j]; g_categories[j] = g_categories[j - 1]; g_categories[j - 1] = tmp; for (int e = 0; e < g_entry_count; e++) { if (g_entries[e].category == j) g_entries[e].category = j - 1; else if (g_entries[e].category == j - 1) g_entries[e].category = j; } } } // Entries in display order: by category, then by name. for (int i = 1; i < g_entry_count; i++) { Entry tmp = g_entries[i]; int j = i - 1; while (j >= 0) { const Entry& prev = g_entries[j]; int by_cat = prev.category - tmp.category; int by_name = app_catalog_compare_ci(g_ds->desktop_items[prev.item].name, g_ds->desktop_items[tmp.item].name); if (by_cat < 0 || (by_cat == 0 && by_name <= 0)) break; g_entries[j + 1] = g_entries[j]; j--; } g_entries[j + 1] = tmp; } for (int i = 0; i < g_category_count; i++) { if (g_categories[i].count > 0) g_categories[i].icon = load_symbolic(category_icon_path(g_categories[i].name)); } g_icon_all = load_symbolic("view-app-grid-symbolic.svg"); g_icon_search = load_symbolic("system-search-symbolic.svg"); g_icon_clear = svg_load("0:/os/icons/window-close-symbolic.svg", 14, 14, colors::WHITE); } // ==== Search ==== // A query of only spaces is no search. static bool searching() { for (const char* p = g_query; *p; p++) if (*p != ' ') return true; return false; } // How well entry e matches the query, lower is better, or -1 for no match. // Every space-separated term must appear in the name, category, description // or keywords; names that start with (or have a word starting with) the // query rank first. static int match_score(int e) { const DesktopItem& item = entry_item(e); const char* cat = g_categories[g_entries[e].category].name; const char* p = g_query; int first_len = -1; while (*p) { while (*p == ' ') p++; const char* term = p; while (*p && *p != ' ') p++; int len = (int)(p - term); if (len == 0) continue; if (first_len < 0) first_len = len; if (find_folded(item.name, term, len) < 0 && find_folded(cat, term, len) < 0 && find_folded(item.description, term, len) < 0 && find_folded(item.keywords, term, len) < 0) return -1; } if (first_len < 0) return -1; const char* first = g_query; while (*first == ' ') first++; int at = find_folded(item.name, first, first_len); if (at == 0) return 0; for (int i = 0; at >= 0 && item.name[i]; i++) { if (is_word_start(item.name, i) && find_folded(item.name + i, first, first_len) == 0) return 1; } if (at > 0) return 2; return 3; } // ==== Layout ==== // The scrolled pane right of the sidebar, less the side padding. static Rect body_rect() { return {SIDEBAR_W + PAD, 0, g_win.width - SIDEBAR_W - PAD * 2, g_win.height}; } static Rect search_rect() { return {12, 14, SIDEBAR_W - 24, SEARCH_H}; } // The query's area, as mtk text input expects it (text starts at x + 8). static Rect search_text_rect() { Rect sr = search_rect(); int x = sr.x + SEARCH_TEXT_X - 8; int right = sr.x + sr.w - 8 - CLEAR_SIZE - 4; return {x, sr.y, right - x, sr.h}; } static Rect clear_button_rect() { Rect sr = search_rect(); return {sr.x + sr.w - 8 - CLEAR_SIZE, sr.y + (sr.h - CLEAR_SIZE) / 2, CLEAR_SIZE, CLEAR_SIZE}; } // Sidebar rows: "All Settings", then each category that has pages. static int sidebar_row_count() { int n = 1; for (int i = 0; i < g_category_count; i++) if (g_categories[i].count > 0) n++; return n; } static int sidebar_row_category(int row) { if (row == 0) return CAT_ALL; int seen = 0; for (int i = 0; i < g_category_count; i++) { if (g_categories[i].count == 0) continue; if (++seen == row) return i; } return CAT_NONE; } static Rect sidebar_row_rect(int row) { return {6, CAT_TOP + row * CAT_ROW_H, SIDEBAR_W - 13, CAT_ROW_H - 2}; } static int sidebar_category_at(int x, int y) { for (int row = 0; row < sidebar_row_count(); row++) if (sidebar_row_rect(row).contains(x, y)) return sidebar_row_category(row); return CAT_NONE; } static void relayout() { // Which entries show, in order. int order[MAX_ENTRIES]; int count = 0; if (searching()) { int scores[MAX_ENTRIES]; for (int e = 0; e < g_entry_count; e++) { int s = match_score(e); if (s < 0) continue; // Insertion by score; entries are already in category/name order. int j = count++; while (j > 0 && scores[j - 1] > s) { order[j] = order[j - 1]; scores[j] = scores[j - 1]; j--; } order[j] = e; scores[j] = s; } } else { for (int e = 0; e < g_entry_count; e++) if (g_category == CAT_ALL || g_entries[e].category == g_category) order[count++] = e; } g_result_count = count; Rect body = body_rect(); int cols = (body.w + TILE_GAP) / (TILE_MIN_W + TILE_GAP); if (cols < 1) cols = 1; int tile_w = (body.w - (cols - 1) * TILE_GAP) / cols; bool grouped = !searching() && g_category == CAT_ALL; g_placed_count = 0; int y = HEADER_H, col = 0, prev_cat = -1; for (int i = 0; i < count && g_placed_count < MAX_PLACED; i++) { int e = order[i]; if (grouped && g_entries[e].category != prev_cat) { if (col != 0) { y += TILE_H + TILE_GAP; col = 0; } prev_cat = g_entries[e].category; g_placed[g_placed_count++] = {PLACED_HEADING, prev_cat, {body.x, y, body.w, GROUP_H}}; y += GROUP_H; if (g_placed_count >= MAX_PLACED) break; } Rect r = {body.x + col * (tile_w + TILE_GAP), y, tile_w, TILE_H}; g_placed[g_placed_count++] = {PLACED_TILE, e, r}; if (++col == cols) { col = 0; y += TILE_H + TILE_GAP; } } if (col != 0) y += TILE_H + TILE_GAP; g_content_h = y + PAD; int max_scroll = g_content_h - body.h; if (max_scroll < 0) max_scroll = 0; if (g_scroll > max_scroll) g_scroll = max_scroll; if (g_scroll < 0) g_scroll = 0; if (g_selected >= g_placed_count || (g_selected >= 0 && g_placed[g_selected].kind != PLACED_TILE)) g_selected = -1; if (g_hover_tile >= g_placed_count) g_hover_tile = -1; } static Rect placed_on_screen(int i) { Rect r = g_placed[i].r; r.y -= g_scroll; return r; } static int tile_at(int x, int y) { for (int i = 0; i < g_placed_count; i++) if (g_placed[i].kind == PLACED_TILE && placed_on_screen(i).contains(x, y)) return i; return -1; } static int first_tile() { for (int i = 0; i < g_placed_count; i++) if (g_placed[i].kind == PLACED_TILE) return i; return -1; } static void scroll_into_view(int i) { if (i < 0) return; Rect body = body_rect(); const Rect& r = g_placed[i].r; // A group's first tile brings its heading along, and the first group // the title header. int top = r.y; if (i > 0 && g_placed[i - 1].kind == PLACED_HEADING) top = g_placed[i - 1].r.y; if (i == first_tile()) top = 0; if (top < g_scroll) g_scroll = top; else if (r.y + r.h + TILE_GAP > g_scroll + body.h) g_scroll = r.y + r.h + TILE_GAP - body.h; relayout(); } // ==== Actions ==== static void open_entry(int e) { if (e < 0 || e >= g_entry_count) return; gui::win_request_launch_item(g_win.id, entry_item(e).id); } static void select_category(int cat) { g_category = cat; g_query[0] = '\0'; mtk::text_input_reset(g_search); g_scroll = 0; g_selected = -1; relayout(); } static void query_changed() { g_scroll = 0; relayout(); // Enter opens the best match. g_selected = searching() ? first_tile() : -1; } // Next tile in direction (dx, dy) from the selected one, by screen position. static int neighbour_tile(int dx, int dy) { if (g_selected < 0) return first_tile(); const Rect& cur = g_placed[g_selected].r; int best = -1; int best_primary = 0, best_secondary = 0; for (int i = 0; i < g_placed_count; i++) { if (i == g_selected || g_placed[i].kind != PLACED_TILE) continue; const Rect& r = g_placed[i].r; int primary, secondary; if (dy != 0) { primary = dy > 0 ? r.y - cur.y : cur.y - r.y; secondary = r.x > cur.x ? r.x - cur.x : cur.x - r.x; } else { if (r.y != cur.y) continue; primary = dx > 0 ? r.x - cur.x : cur.x - r.x; secondary = 0; } if (primary <= 0) continue; if (best < 0 || primary < best_primary || (primary == best_primary && secondary < best_secondary)) { best = i; best_primary = primary; best_secondary = secondary; } } return best >= 0 ? best : g_selected; } // ==== Rendering ==== // A filled field with a leading magnifier that only shows the accent ring and // caret while focused, and a clear button once there is a query. static void draw_search(Canvas& c, const mtk::Theme& theme) { Rect sr = search_rect(); int fh = system_font_height(); if (g_search_focus) { mtk::fill_round_rect_aa(c, sr, SEARCH_RADIUS, g_accent); mtk::fill_round_rect_aa(c, {sr.x + 1, sr.y + 1, sr.w - 2, sr.h - 2}, SEARCH_RADIUS - 1, colors::WHITE); } else { mtk::fill_round_rect_aa(c, sr, SEARCH_RADIUS, sr.contains(g_mouse_x, g_mouse_y) ? SEARCH_HOVER : SEARCH_BG); } if (g_icon_search.pixels) c.icon(sr.x + SEARCH_ICON_X, sr.y + (sr.h - g_icon_search.height) / 2, g_icon_search); Rect tr = search_text_rect(); int tx = tr.x + 8; int ty = sr.y + (sr.h - fh) / 2; int len = mtk::text_input_len(g_query); if (g_search_focus && mtk::text_input_has_selection(g_search, g_query, sizeof(g_query))) { int sx = tx + mtk::text_input_prefix_width( g_query, mtk::text_input_selection_start(g_search, len), false); int ex = tx + mtk::text_input_prefix_width( g_query, mtk::text_input_selection_end(g_search, len), false); if (ex > tr.x + tr.w) ex = tr.x + tr.w; if (ex > sx) c.fill_rect(sx, sr.y + 6, ex - sx, sr.h - 12, theme.text_selection); } if (g_query[0]) c.text(tx, ty, g_query, theme.text); else c.text(tx, ty, "Search settings", theme.text_muted); if (g_search_focus) { int cx = tx + mtk::text_input_prefix_width( g_query, mtk::text_input_clamp_cursor(g_search.cursor, len), false); c.fill_rect(cx - 1, sr.y + 8, 2, sr.h - 16, g_accent); } if (g_query[0]) { Rect cb = clear_button_rect(); mtk::fill_round_rect_aa(c, cb, CLEAR_SIZE / 2, cb.contains(g_mouse_x, g_mouse_y) ? CLEAR_HOVER : CLEAR_BG); if (g_icon_clear.pixels) c.icon(cb.x + (cb.w - g_icon_clear.width) / 2, cb.y + (cb.h - g_icon_clear.height) / 2, g_icon_clear); } } static void draw_sidebar(Canvas& c, const mtk::Theme& theme) { c.fill_rect(0, 0, SIDEBAR_W, g_win.height, SIDEBAR_BG); c.vline(SIDEBAR_W - 1, 0, g_win.height, theme.border); draw_search(c, theme); int fh = system_font_height(); int rows = sidebar_row_count(); for (int row = 0; row < rows; row++) { int cat = sidebar_row_category(row); Rect r = sidebar_row_rect(row); bool current = !searching() && cat == g_category; if (current) c.fill_rounded_rect(r.x, r.y, r.w, r.h, 4, mtk::accent_hover_tint(g_accent)); else if (cat == g_hover_cat) c.fill_rounded_rect(r.x, r.y, r.w, r.h, 4, SIDEBAR_HOVER); const SvgIcon& icon = cat == CAT_ALL ? g_icon_all : g_categories[cat].icon; if (icon.pixels) c.icon(r.x + 10, r.y + (r.h - icon.height) / 2, icon); const char* label = cat == CAT_ALL ? "All Settings" : g_categories[cat].name; int count = cat == CAT_ALL ? g_entry_count : g_categories[cat].count; char count_text[8]; snprintf(count_text, sizeof(count_text), "%d", count); int count_w = text_width(count_text); char fit[48]; fit_text(label, fit, sizeof(fit), r.w - 34 - count_w - 16); int ty = r.y + (r.h - fh) / 2; c.text(r.x + 34, ty, fit, theme.text); c.text(r.x + r.w - 10 - count_w, ty, count_text, theme.text_muted); } } static void draw_tile(Canvas& c, int i, const mtk::Theme& theme) { Rect r = placed_on_screen(i); if (r.y + r.h <= 0 || r.y >= g_win.height) return; int e = g_placed[i].ref; const DesktopItem& item = entry_item(e); bool hovered = i == g_hover_tile; bool selected = i == g_selected && (g_grid_focus || searching()); mtk::draw_rounded_frame(c, r, theme.radius_md + 2, hovered ? theme.surface_hover : theme.surface, selected ? theme.accent : theme.border); if (selected) mtk::draw_rounded_frame(c, {r.x + 1, r.y + 1, r.w - 2, r.h - 2}, theme.radius_md + 1, hovered ? theme.surface_hover : theme.surface, theme.accent); const SvgIcon& icon = g_entries[e].icon; if (icon.pixels) c.icon(r.x + 16, r.y + (r.h - icon.height) / 2, icon); int fh = system_font_height(); int tx = r.x + 16 + TILE_ICON + 14; int text_w = r.w - (tx - r.x) - 14; int name_y = r.y + (r.h - fh * 2 - 2) / 2; // Name, then the description (led by the category when the results mix // categories). char name[48]; fit_text(item.name, name, sizeof(name), text_w, fonts::system_bold ? fonts::system_bold : fonts::system_font, fonts::UI_SIZE); draw_bold(c, tx, name_y, name, theme.text, fonts::UI_SIZE); char line[128]; if (searching()) snprintf(line, sizeof(line), "%s%s%s", g_categories[g_entries[e].category].name, item.description[0] ? " - " : "", item.description); else montauk::strncpy(line, item.description[0] ? item.description : "Open settings", sizeof(line)); char desc[128]; fit_text(line, desc, sizeof(desc), text_w); c.text(tx, name_y + fh + 2, desc, theme.text_subtle); } static void draw_body(Canvas& c, const mtk::Theme& theme) { int fh = system_font_height(); for (int i = 0; i < g_placed_count; i++) { if (g_placed[i].kind == PLACED_TILE) { draw_tile(c, i, theme); continue; } Rect r = placed_on_screen(i); if (r.y + r.h <= 0 || r.y >= g_win.height) continue; const Category& cat = g_categories[g_placed[i].ref]; int ty = r.y + r.h - fh - 10; if (cat.icon.pixels) c.icon(r.x, ty + (fh - cat.icon.height) / 2, cat.icon); c.text(r.x + 24, ty, cat.name, theme.text_subtle); } if (g_result_count == 0) { Rect body = body_rect(); const char* msg = searching() ? "Try another word, such as \"display\" or \"wifi\"." : "Nothing to show here."; c.text(body.x + (body.w - text_width(msg)) / 2, HEADER_H + 48 - g_scroll, msg, theme.text_muted); } Rect body = body_rect(); if (mtk::scrollbar_needed(g_content_h, body.h)) { Rect view = {SIDEBAR_W, 0, g_win.width - SIDEBAR_W, body.h}; Rect track = mtk::scrollbar_track_rect(view); Rect thumb = mtk::scrollbar_thumb_rect(track, g_content_h, body.h, g_scroll); mtk::draw_scrollbar(c, track, thumb, false, false, theme); } } static void draw_header(Canvas& c, const mtk::Theme& theme) { // Part of the scrolled pane: it scrolls away with the tiles. int x0 = SIDEBAR_W; int y0 = 20 - g_scroll; if (y0 + HEADER_H <= 0) return; char title[96]; char subtitle[128]; if (searching()) { montauk::strcpy(title, "Search Results"); if (g_result_count == 0) snprintf(subtitle, sizeof(subtitle), "No settings match \"%s\"", g_query); else snprintf(subtitle, sizeof(subtitle), "%d %s for \"%s\" - press Enter to open the first", g_result_count, g_result_count == 1 ? "result" : "results", g_query); } else if (g_category == CAT_ALL) { montauk::strcpy(title, "All Settings"); int cats = 0; for (int i = 0; i < g_category_count; i++) if (g_categories[i].count > 0) cats++; snprintf(subtitle, sizeof(subtitle), "%d settings in %d categories", g_entry_count, cats); } else { montauk::strncpy(title, g_categories[g_category].name, sizeof(title)); int n = g_categories[g_category].count; snprintf(subtitle, sizeof(subtitle), "%d %s", n, n == 1 ? "setting" : "settings"); } int tx = x0 + PAD; int max_w = g_win.width - tx - PAD; char fit[128]; TrueTypeFont* bold = fonts::system_bold ? fonts::system_bold : fonts::system_font; fit_text(title, fit, sizeof(fit), max_w, bold, TITLE_SIZE); draw_bold(c, tx, y0, fit, theme.text, TITLE_SIZE); fit_text(subtitle, fit, sizeof(fit), max_w); c.text(tx, y0 + gui::text_height(bold, TITLE_SIZE) + 6, fit, theme.text_subtle); } static void render() { mtk::StandaloneHost host(&g_win); Canvas c = host.canvas(); mtk::Theme theme = app_theme(); c.fill(theme.window_bg); draw_header(c, theme); draw_body(c, theme); draw_sidebar(c, theme); if (g_search.context.open) mtk::draw_text_input_context_menu( c, g_search, theme, mtk::text_input_has_selection(g_search, g_query, sizeof(g_query))); host.present(); } // ==== Input ==== // The query stops growing once it fills the field (the field does not scroll). static bool query_fits() { return text_width(g_query) <= search_text_rect().w - 8 - 2; } static void focus_search(bool select_all) { g_search_focus = true; g_grid_focus = false; if (select_all) { g_search.selection_anchor = 0; g_search.cursor = mtk::text_input_len(g_query); } } static void clear_query() { g_query[0] = '\0'; mtk::text_input_reset(g_search); query_changed(); } // True if the key changed the query. static bool edit_query(const montauk::abi::KeyEvent& key) { char before[sizeof(g_query)]; montauk::memcpy(before, g_query, sizeof(g_query)); mtk::TextInputState saved = g_search; int r = mtk::text_input_key(g_search, g_query, sizeof(g_query), key); if (!(r & mtk::TEXT_INPUT_CHANGED)) return false; if (!query_fits()) { montauk::memcpy(g_query, before, sizeof(g_query)); g_search = saved; return false; } query_changed(); return true; } static void handle_key(const montauk::abi::KeyEvent& key) { if (!key.pressed) return; uint8_t nav = montauk::nav_scancode(key); if (key.scancode == 0x01) { // Escape if (g_search.context.open) mtk::context_menu_close(g_search.context); else if (g_grid_focus) focus_search(false); else if (g_query[0]) clear_query(); else if (g_search_focus) g_search_focus = false; else g_win.closed = true; return; } if (key.scancode == 0x1C) { // Enter if (g_selected >= 0) open_entry(g_placed[g_selected].ref); return; } if (key.ctrl && (key.ascii == 'f' || key.ascii == 'F')) { focus_search(true); return; } if (key.scancode == 0x0F) { // Tab if (g_grid_focus) { focus_search(false); return; } g_grid_focus = true; g_search_focus = false; if (g_selected < 0) g_selected = first_tile(); scroll_into_view(g_selected); return; } if (g_grid_focus) { int next = -1; if (nav == 0x48) next = neighbour_tile(0, -1); else if (nav == 0x50) next = neighbour_tile(0, 1); else if (nav == 0x4B) next = neighbour_tile(-1, 0); else if (nav == 0x4D) next = neighbour_tile(1, 0); if (next >= 0 || nav == 0x48 || nav == 0x50 || nav == 0x4B || nav == 0x4D) { // Up from the top row returns to the search field. if (nav == 0x48 && next == g_selected) focus_search(false); else g_selected = next; scroll_into_view(g_selected); return; } // Anything else types into the search field. g_grid_focus = false; } else if (nav == 0x50) { // Down: into the results g_grid_focus = true; g_search_focus = false; if (g_selected < 0) g_selected = first_tile(); scroll_into_view(g_selected); return; } // Typing anywhere searches. if (edit_query(key)) g_search_focus = true; } static void handle_mouse(const montauk::abi::WinEvent& ev) { g_mouse_x = ev.mouse.x; g_mouse_y = ev.mouse.y; uint8_t buttons = ev.mouse.buttons, prev = ev.mouse.prev_buttons; bool pressed = (buttons & 1) && !(prev & 1); // The search field (and its Cut/Copy/Paste menu) sees the event first. Rect sr = search_rect(); Rect tr = search_text_rect(); bool in_search = sr.contains(g_mouse_x, g_mouse_y); bool in_text = tr.contains(g_mouse_x, g_mouse_y); if (pressed && g_query[0] && !g_search.context.open && clear_button_rect().contains(g_mouse_x, g_mouse_y)) { clear_query(); focus_search(false); return; } if (g_search.context.open || g_search.dragging || in_text) { char before[sizeof(g_query)]; montauk::memcpy(before, g_query, sizeof(g_query)); int r = mtk::text_input_handle_mouse(g_search, tr, g_query, sizeof(g_query), g_mouse_x, g_mouse_y, buttons, prev, g_win.width, g_win.height, g_search_focus); if ((r & mtk::TEXT_INPUT_CONSUMED) && in_text) focus_search(false); if (r & mtk::TEXT_INPUT_CHANGED) { if (!query_fits()) { montauk::memcpy(g_query, before, sizeof(g_query)); mtk::text_input_reset(g_search, mtk::text_input_len(g_query)); } query_changed(); } if (r != mtk::TEXT_INPUT_NONE) return; } // The magnifier and padding focus the field too, caret at the end. if (pressed && in_search) { focus_search(false); mtk::text_input_reset(g_search, mtk::text_input_len(g_query)); return; } if (ev.mouse.scroll != 0 && g_mouse_x >= SIDEBAR_W) { g_scroll -= ev.mouse.scroll * SCROLL_STEP; relayout(); } g_hover_tile = tile_at(g_mouse_x, g_mouse_y); g_hover_cat = g_mouse_x < SIDEBAR_W ? sidebar_category_at(g_mouse_x, g_mouse_y) : CAT_NONE; if (!pressed) return; // A click anywhere else takes focus off the field. g_search_focus = false; if (g_hover_cat != CAT_NONE) { select_category(g_hover_cat); g_grid_focus = false; } else if (g_hover_tile >= 0) { g_selected = g_hover_tile; open_entry(g_placed[g_hover_tile].ref); } } extern "C" void _start() { if (!fonts::init()) montauk::exit(1); g_accent = mtk::load_system_accent(); g_ds = (DesktopState*)montauk::malloc(sizeof(DesktopState)); if (!g_ds) montauk::exit(1); montauk::memset(g_ds, 0, sizeof(DesktopState)); load_identity(); load_catalog(); if (!g_win.create("Settings", WIN_W, WIN_H)) montauk::exit(1); relayout(); render(); while (g_win.id >= 0 && !g_win.closed) { montauk::abi::WinEvent ev; int result = g_win.poll(&ev); if (result < 0) break; if (result == 0) { montauk::sleep_ms(16); continue; } if (ev.type == montauk::abi::WIN_EVENT_CLOSE) break; if (ev.type == montauk::abi::WIN_EVENT_MOUSE) handle_mouse(ev); else if (ev.type == montauk::abi::WIN_EVENT_KEY) handle_key(ev.key); else if (ev.type == montauk::abi::WIN_EVENT_RESIZE) relayout(); render(); } g_win.destroy(); montauk::exit(0); }