feat: move Settings out of Files into its own searchable app with categories

This commit is contained in:
daniel committed 2026-10-24 13:26:47 +02:00
1 parent d3e49a925d
commit 8de98f3ab3
33 files changed
+1183 -93

No files matched your search

+923
View File
@@ -0,0 +1,923 @@
/*
* 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 <montauk/syscall.h>
#include <montauk/string.h>
#include <montauk/heap.h>
#include <montauk/user.h>
#include <montauk/utf8.h>
#include <gui/mtk.hpp>
#include <gui/mtk/settings.hpp>
#include <gui/standalone.hpp>
#include <gui/app_catalog.hpp>
#include <gui/transfer.hpp>
extern "C" {
#include <stdio.h>
}
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);
}