924 lines
33 KiB
C++
924 lines
33 KiB
C++
/*
|
|
* 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);
|
|
}
|