feat: add settings panel to Terminal
This commit is contained in:
@@ -0,0 +1,526 @@
|
||||
/*
|
||||
* settings.cpp
|
||||
* MontaukOS Terminal - settings panel
|
||||
*
|
||||
* Copyright (c) 2026 Daniel Hammer
|
||||
*/
|
||||
|
||||
#include "settings.hpp"
|
||||
|
||||
#include <montauk/config.h>
|
||||
#include <montauk/heap.h>
|
||||
#include <montauk/string.h>
|
||||
#include <montauk/syscall.h>
|
||||
#include <gui/gui.hpp>
|
||||
#include <gui/canvas.hpp>
|
||||
#include <gui/mtk.hpp>
|
||||
#include <gui/standalone.hpp>
|
||||
#include <gui/truetype.hpp>
|
||||
|
||||
extern "C" {
|
||||
#include <stdio.h>
|
||||
}
|
||||
|
||||
using namespace gui;
|
||||
|
||||
namespace termset {
|
||||
|
||||
// ==== Themes ====
|
||||
|
||||
struct ThemeEntry {
|
||||
const char* id; // stable key written to the config file
|
||||
const char* name; // shown in the panel
|
||||
TermPalette palette;
|
||||
};
|
||||
|
||||
// Palette order is the ANSI one: black, red, green, yellow, blue, magenta,
|
||||
// cyan, white, then the eight bright variants.
|
||||
static const ThemeEntry kThemes[] = {
|
||||
{
|
||||
"montauk-dark", "Montauk Dark",
|
||||
TERM_PALETTE_DEFAULT
|
||||
},
|
||||
{
|
||||
"solarized-dark", "Solarized Dark",
|
||||
{
|
||||
Color::from_hex(0x002B36), Color::from_hex(0x839496),
|
||||
Color::from_hex(0x93A1A1),
|
||||
{
|
||||
Color::from_hex(0x073642), Color::from_hex(0xDC322F),
|
||||
Color::from_hex(0x859900), Color::from_hex(0xB58900),
|
||||
Color::from_hex(0x268BD2), Color::from_hex(0xD33682),
|
||||
Color::from_hex(0x2AA198), Color::from_hex(0xEEE8D5),
|
||||
Color::from_hex(0x586E75), Color::from_hex(0xCB4B16),
|
||||
Color::from_hex(0x93A1A1), Color::from_hex(0x657B83),
|
||||
Color::from_hex(0x839496), Color::from_hex(0x6C71C4),
|
||||
Color::from_hex(0x93A1A1), Color::from_hex(0xFDF6E3),
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"solarized-light", "Solarized Light",
|
||||
{
|
||||
Color::from_hex(0xFDF6E3), Color::from_hex(0x657B83),
|
||||
Color::from_hex(0x586E75),
|
||||
{
|
||||
Color::from_hex(0x073642), Color::from_hex(0xDC322F),
|
||||
Color::from_hex(0x859900), Color::from_hex(0xB58900),
|
||||
Color::from_hex(0x268BD2), Color::from_hex(0xD33682),
|
||||
Color::from_hex(0x2AA198), Color::from_hex(0xEEE8D5),
|
||||
Color::from_hex(0x586E75), Color::from_hex(0xCB4B16),
|
||||
Color::from_hex(0x93A1A1), Color::from_hex(0x657B83),
|
||||
Color::from_hex(0x839496), Color::from_hex(0x6C71C4),
|
||||
Color::from_hex(0x93A1A1), Color::from_hex(0xFDF6E3),
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"gruvbox-dark", "Gruvbox Dark",
|
||||
{
|
||||
Color::from_hex(0x282828), Color::from_hex(0xEBDBB2),
|
||||
Color::from_hex(0xEBDBB2),
|
||||
{
|
||||
Color::from_hex(0x282828), Color::from_hex(0xCC241D),
|
||||
Color::from_hex(0x98971A), Color::from_hex(0xD79921),
|
||||
Color::from_hex(0x458588), Color::from_hex(0xB16286),
|
||||
Color::from_hex(0x689D6A), Color::from_hex(0xA89984),
|
||||
Color::from_hex(0x928374), Color::from_hex(0xFB4934),
|
||||
Color::from_hex(0xB8BB26), Color::from_hex(0xFABD2F),
|
||||
Color::from_hex(0x83A598), Color::from_hex(0xD3869B),
|
||||
Color::from_hex(0x8EC07C), Color::from_hex(0xEBDBB2),
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"nord", "Nord",
|
||||
{
|
||||
Color::from_hex(0x2E3440), Color::from_hex(0xD8DEE9),
|
||||
Color::from_hex(0xD8DEE9),
|
||||
{
|
||||
Color::from_hex(0x3B4252), Color::from_hex(0xBF616A),
|
||||
Color::from_hex(0xA3BE8C), Color::from_hex(0xEBCB8B),
|
||||
Color::from_hex(0x81A1C1), Color::from_hex(0xB48EAD),
|
||||
Color::from_hex(0x88C0D0), Color::from_hex(0xE5E9F0),
|
||||
Color::from_hex(0x4C566A), Color::from_hex(0xD08770),
|
||||
Color::from_hex(0xB9D4A0), Color::from_hex(0xF0D399),
|
||||
Color::from_hex(0x8FA8CE), Color::from_hex(0xC3A0BB),
|
||||
Color::from_hex(0x8FBCBB), Color::from_hex(0xECEFF4),
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"dracula", "Dracula",
|
||||
{
|
||||
Color::from_hex(0x282A36), Color::from_hex(0xF8F8F2),
|
||||
Color::from_hex(0xF8F8F2),
|
||||
{
|
||||
Color::from_hex(0x21222C), Color::from_hex(0xFF5555),
|
||||
Color::from_hex(0x50FA7B), Color::from_hex(0xF1FA8C),
|
||||
Color::from_hex(0xBD93F9), Color::from_hex(0xFF79C6),
|
||||
Color::from_hex(0x8BE9FD), Color::from_hex(0xF8F8F2),
|
||||
Color::from_hex(0x6272A4), Color::from_hex(0xFF6E6E),
|
||||
Color::from_hex(0x69FF94), Color::from_hex(0xFFFFA5),
|
||||
Color::from_hex(0xD6ACFF), Color::from_hex(0xFF92DF),
|
||||
Color::from_hex(0xA4FFFF), Color::from_hex(0xFFFFFF),
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"paper-light", "Paper Light",
|
||||
{
|
||||
Color::from_hex(0xFFFFFF), Color::from_hex(0x33333A),
|
||||
Color::from_hex(0x33333A),
|
||||
{
|
||||
Color::from_hex(0x2E3436), Color::from_hex(0xC01C28),
|
||||
Color::from_hex(0x26A269), Color::from_hex(0xA2734C),
|
||||
Color::from_hex(0x12488B), Color::from_hex(0xA347BA),
|
||||
Color::from_hex(0x2AA1B3), Color::from_hex(0x8B8E8F),
|
||||
Color::from_hex(0x5E5C64), Color::from_hex(0xF66151),
|
||||
Color::from_hex(0x33D17A), Color::from_hex(0xE9AD0C),
|
||||
Color::from_hex(0x2A7BDE), Color::from_hex(0xC061CB),
|
||||
Color::from_hex(0x33C7DE), Color::from_hex(0x3D3846),
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
static constexpr int kThemeCount = (int)(sizeof(kThemes) / sizeof(kThemes[0]));
|
||||
|
||||
static constexpr int FONT_SIZE_MIN = 8;
|
||||
static constexpr int FONT_SIZE_DEFAULT = 18;
|
||||
static constexpr int FONT_SIZE_MAX = 64;
|
||||
static constexpr int FONT_SIZE_STEP = 2;
|
||||
|
||||
// ==== Panel state ====
|
||||
|
||||
static constexpr int PANEL_W = 380;
|
||||
static constexpr int PANEL_H = 416;
|
||||
|
||||
static constexpr int PAD = 16;
|
||||
static constexpr int CARD_PAD = 4;
|
||||
static constexpr int ROW_H = 34;
|
||||
static constexpr int ROW_GAP = 2;
|
||||
static constexpr int STEP_BTN_W = 32;
|
||||
static constexpr int SWATCH = 12;
|
||||
static constexpr int SWATCH_GAP = 4;
|
||||
static constexpr int SWATCH_N = 6;
|
||||
|
||||
struct Panel {
|
||||
int win_id;
|
||||
uint32_t* pixels;
|
||||
int width;
|
||||
int height;
|
||||
bool open;
|
||||
int mouse_x;
|
||||
int mouse_y;
|
||||
int theme_index;
|
||||
Callbacks cb;
|
||||
};
|
||||
|
||||
static Panel g_panel = {-1, nullptr, PANEL_W, PANEL_H, false, -1, -1, 0, {nullptr, nullptr}};
|
||||
|
||||
// ==== Preferences ====
|
||||
|
||||
static void current_user(char* out, int cap) {
|
||||
if (montauk::getuser(out, cap) <= 0 || !out[0])
|
||||
montauk::strcpy(out, "default");
|
||||
}
|
||||
|
||||
static int theme_index_by_id(const char* id) {
|
||||
for (int i = 0; i < kThemeCount; i++) {
|
||||
if (montauk::streq(kThemes[i].id, id)) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
static int clamp_font_size(int size) {
|
||||
if (size < FONT_SIZE_MIN) return FONT_SIZE_MIN;
|
||||
if (size > FONT_SIZE_MAX) return FONT_SIZE_MAX;
|
||||
return size;
|
||||
}
|
||||
|
||||
static void save_prefs() {
|
||||
char user[64];
|
||||
current_user(user, sizeof(user));
|
||||
|
||||
montauk::toml::Doc doc = montauk::config::load_user(user, "terminal");
|
||||
montauk::config::set_string(&doc, "appearance.theme", kThemes[g_panel.theme_index].id);
|
||||
montauk::config::set_int(&doc, "appearance.font_size", fonts::TERM_SIZE);
|
||||
montauk::config::save_user(user, "terminal", &doc);
|
||||
doc.destroy();
|
||||
}
|
||||
|
||||
void load_prefs() {
|
||||
char user[64];
|
||||
current_user(user, sizeof(user));
|
||||
|
||||
montauk::toml::Doc doc = montauk::config::load_user(user, "terminal");
|
||||
int idx = theme_index_by_id(doc.get_string("appearance.theme", kThemes[0].id));
|
||||
int size = (int)doc.get_int("appearance.font_size", fonts::TERM_SIZE);
|
||||
doc.destroy();
|
||||
|
||||
if (idx < 0) idx = 0;
|
||||
g_panel.theme_index = idx;
|
||||
g_term_palette = kThemes[idx].palette;
|
||||
fonts::TERM_SIZE = clamp_font_size(size);
|
||||
}
|
||||
|
||||
// ==== Applying changes ====
|
||||
|
||||
static void select_theme(int idx) {
|
||||
if (idx < 0 || idx >= kThemeCount || idx == g_panel.theme_index) return;
|
||||
|
||||
TermPalette from = g_term_palette;
|
||||
g_panel.theme_index = idx;
|
||||
g_term_palette = kThemes[idx].palette;
|
||||
if (g_panel.cb.theme_changed)
|
||||
g_panel.cb.theme_changed(from, g_term_palette);
|
||||
save_prefs();
|
||||
}
|
||||
|
||||
static void step_font_size(int delta) {
|
||||
int size = clamp_font_size(fonts::TERM_SIZE + delta);
|
||||
if (size == fonts::TERM_SIZE) return;
|
||||
|
||||
fonts::TERM_SIZE = size;
|
||||
if (g_panel.cb.font_changed)
|
||||
g_panel.cb.font_changed();
|
||||
save_prefs();
|
||||
render();
|
||||
}
|
||||
|
||||
void zoom_font(int direction) {
|
||||
step_font_size(direction * FONT_SIZE_STEP);
|
||||
}
|
||||
|
||||
// ==== Layout ====
|
||||
//
|
||||
// Sections are a muted label over a bordered card, matching the rest of the
|
||||
// system settings apps. The window titlebar already names the panel, so there
|
||||
// is deliberately no heading inside it.
|
||||
|
||||
struct Layout {
|
||||
Rect theme_card;
|
||||
Rect theme_rows[kThemeCount];
|
||||
Rect font_minus;
|
||||
Rect font_plus;
|
||||
Rect font_value;
|
||||
Rect reset_btn;
|
||||
Rect close_btn;
|
||||
int theme_label_y;
|
||||
int font_label_y;
|
||||
};
|
||||
|
||||
static Layout compute_layout() {
|
||||
Layout lo = {};
|
||||
int fh = system_font_height();
|
||||
int content_w = g_panel.width - PAD * 2;
|
||||
int y = PAD;
|
||||
|
||||
lo.theme_label_y = y;
|
||||
y += fh + 6;
|
||||
|
||||
lo.theme_card = {PAD, y, content_w,
|
||||
kThemeCount * ROW_H + (kThemeCount - 1) * ROW_GAP + CARD_PAD * 2};
|
||||
int row_y = y + CARD_PAD;
|
||||
for (int i = 0; i < kThemeCount; i++) {
|
||||
lo.theme_rows[i] = {lo.theme_card.x + CARD_PAD, row_y,
|
||||
content_w - CARD_PAD * 2, ROW_H};
|
||||
row_y += ROW_H + ROW_GAP;
|
||||
}
|
||||
y += lo.theme_card.h + 20;
|
||||
|
||||
// Font size is a single setting, so it reads as a plain row -- label left,
|
||||
// stepper right-aligned to the same content edge as the card above --
|
||||
// rather than a one-row card.
|
||||
int ctrl_h = 28;
|
||||
lo.font_label_y = y + (ctrl_h - fh) / 2;
|
||||
lo.font_plus = {PAD + content_w - STEP_BTN_W, y, STEP_BTN_W, ctrl_h};
|
||||
lo.font_value = {lo.font_plus.x - 6 - 56, y, 56, ctrl_h};
|
||||
lo.font_minus = {lo.font_value.x - 6 - STEP_BTN_W, y, STEP_BTN_W, ctrl_h};
|
||||
|
||||
int btn_h = 30;
|
||||
int btn_y = g_panel.height - PAD - btn_h;
|
||||
lo.close_btn = {g_panel.width - PAD - 90, btn_y, 90, btn_h};
|
||||
lo.reset_btn = {PAD, btn_y, 140, btn_h};
|
||||
return lo;
|
||||
}
|
||||
|
||||
// ==== Rendering ====
|
||||
|
||||
// Background, four representative ANSI colors and the foreground, drawn as a
|
||||
// mini strip so each theme is identifiable without applying it. Every swatch
|
||||
// gets a hairline border -- without it a white background swatch vanishes into
|
||||
// the card on the light themes.
|
||||
static void draw_theme_preview(Canvas& c, const TermPalette& p, int x, int y,
|
||||
const mtk::Theme& theme) {
|
||||
Color strip[SWATCH_N] = {
|
||||
p.bg, p.ansi[1], p.ansi[2], p.ansi[4], p.ansi[5], p.fg
|
||||
};
|
||||
for (int i = 0; i < SWATCH_N; i++) {
|
||||
int sx = x + i * (SWATCH + SWATCH_GAP);
|
||||
mtk::draw_rounded_frame(c, {sx, y, SWATCH, SWATCH}, 3, strip[i],
|
||||
mtk::mix(theme.border, strip[i], 80));
|
||||
}
|
||||
}
|
||||
|
||||
void render() {
|
||||
if (!g_panel.open || g_panel.win_id < 0 || !g_panel.pixels) return;
|
||||
|
||||
Canvas c(g_panel.pixels, g_panel.width, g_panel.height);
|
||||
mtk::Theme theme = mtk::make_theme();
|
||||
c.fill(theme.window_bg);
|
||||
|
||||
Layout lo = compute_layout();
|
||||
int fh = system_font_height();
|
||||
|
||||
c.text(PAD, lo.theme_label_y, "Theme", theme.text_subtle);
|
||||
mtk::draw_rounded_frame(c, lo.theme_card, 6, theme.surface_alt, theme.border);
|
||||
|
||||
int preview_w = SWATCH_N * (SWATCH + SWATCH_GAP) - SWATCH_GAP;
|
||||
for (int i = 0; i < kThemeCount; i++) {
|
||||
const Rect& row = lo.theme_rows[i];
|
||||
bool selected = (i == g_panel.theme_index);
|
||||
bool hovered = row.contains(g_panel.mouse_x, g_panel.mouse_y);
|
||||
|
||||
if (selected)
|
||||
mtk::draw_list_row(c, row, true, false, theme);
|
||||
else if (hovered)
|
||||
c.fill_rounded_rect(row.x, row.y, row.w, row.h, theme.radius_md,
|
||||
theme.surface_hover);
|
||||
|
||||
c.text(row.x + 10, row.y + (row.h - fh) / 2, kThemes[i].name,
|
||||
selected ? theme.text_inverse : theme.text);
|
||||
draw_theme_preview(c, kThemes[i].palette,
|
||||
row.x + row.w - 10 - preview_w,
|
||||
row.y + (row.h - SWATCH) / 2, theme);
|
||||
}
|
||||
|
||||
c.text(PAD, lo.font_label_y, "Font size", theme.text);
|
||||
|
||||
char value[16];
|
||||
snprintf(value, sizeof(value), "%d px", fonts::TERM_SIZE);
|
||||
int vw = text_width(value);
|
||||
c.text(lo.font_value.x + (lo.font_value.w - vw) / 2,
|
||||
lo.font_value.y + (lo.font_value.h - fh) / 2, value, theme.text);
|
||||
|
||||
bool can_shrink = fonts::TERM_SIZE > FONT_SIZE_MIN;
|
||||
bool can_grow = fonts::TERM_SIZE < FONT_SIZE_MAX;
|
||||
mtk::draw_button(c, lo.font_minus, "-", mtk::BUTTON_SECONDARY,
|
||||
mtk::widget_state(false,
|
||||
can_shrink && lo.font_minus.contains(
|
||||
g_panel.mouse_x, g_panel.mouse_y),
|
||||
can_shrink),
|
||||
theme);
|
||||
mtk::draw_button(c, lo.font_plus, "+", mtk::BUTTON_SECONDARY,
|
||||
mtk::widget_state(false,
|
||||
can_grow && lo.font_plus.contains(
|
||||
g_panel.mouse_x, g_panel.mouse_y),
|
||||
can_grow),
|
||||
theme);
|
||||
|
||||
mtk::draw_button(c, lo.reset_btn, "Reset defaults", mtk::BUTTON_SECONDARY,
|
||||
mtk::widget_state(false,
|
||||
lo.reset_btn.contains(g_panel.mouse_x,
|
||||
g_panel.mouse_y)),
|
||||
theme);
|
||||
mtk::draw_button(c, lo.close_btn, "Close", mtk::BUTTON_PRIMARY,
|
||||
mtk::widget_state(false,
|
||||
lo.close_btn.contains(g_panel.mouse_x,
|
||||
g_panel.mouse_y)),
|
||||
theme);
|
||||
|
||||
montauk::win_present(g_panel.win_id);
|
||||
}
|
||||
|
||||
// ==== Input ====
|
||||
|
||||
static void reset_defaults() {
|
||||
select_theme(0);
|
||||
int target = FONT_SIZE_DEFAULT;
|
||||
if (target != fonts::TERM_SIZE)
|
||||
step_font_size(target - fonts::TERM_SIZE);
|
||||
}
|
||||
|
||||
static void handle_mouse(const montauk::abi::WinEvent& ev) {
|
||||
g_panel.mouse_x = ev.mouse.x;
|
||||
g_panel.mouse_y = ev.mouse.y;
|
||||
|
||||
bool pressed = (ev.mouse.buttons & 1) && !(ev.mouse.prev_buttons & 1);
|
||||
if (!pressed) return;
|
||||
|
||||
Layout lo = compute_layout();
|
||||
|
||||
for (int i = 0; i < kThemeCount; i++) {
|
||||
if (lo.theme_rows[i].contains(g_panel.mouse_x, g_panel.mouse_y)) {
|
||||
select_theme(i);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (lo.font_minus.contains(g_panel.mouse_x, g_panel.mouse_y)) {
|
||||
step_font_size(-FONT_SIZE_STEP);
|
||||
} else if (lo.font_plus.contains(g_panel.mouse_x, g_panel.mouse_y)) {
|
||||
step_font_size(FONT_SIZE_STEP);
|
||||
} else if (lo.reset_btn.contains(g_panel.mouse_x, g_panel.mouse_y)) {
|
||||
reset_defaults();
|
||||
} else if (lo.close_btn.contains(g_panel.mouse_x, g_panel.mouse_y)) {
|
||||
close();
|
||||
}
|
||||
}
|
||||
|
||||
static void handle_key(const montauk::abi::KeyEvent& key) {
|
||||
if (!key.pressed) return;
|
||||
|
||||
if (key.scancode == 0x01) { // Escape
|
||||
close();
|
||||
return;
|
||||
}
|
||||
if (key.ascii == '+' || key.ascii == '=') {
|
||||
step_font_size(FONT_SIZE_STEP);
|
||||
} else if (key.ascii == '-') {
|
||||
step_font_size(-FONT_SIZE_STEP);
|
||||
} else if (key.scancode == 0x48) { // Up
|
||||
select_theme(g_panel.theme_index - 1);
|
||||
} else if (key.scancode == 0x50) { // Down
|
||||
select_theme(g_panel.theme_index + 1);
|
||||
}
|
||||
}
|
||||
|
||||
// ==== Lifecycle ====
|
||||
|
||||
void init(const Callbacks& cb) {
|
||||
g_panel.cb = cb;
|
||||
}
|
||||
|
||||
bool is_open() {
|
||||
return g_panel.open;
|
||||
}
|
||||
|
||||
void open() {
|
||||
if (g_panel.open) {
|
||||
render();
|
||||
return;
|
||||
}
|
||||
|
||||
montauk::abi::WinCreateResult wres;
|
||||
if (montauk::win_create("Terminal Settings", PANEL_W, PANEL_H, &wres) < 0
|
||||
|| wres.id < 0)
|
||||
return;
|
||||
|
||||
g_panel.win_id = wres.id;
|
||||
g_panel.pixels = (uint32_t*)(uintptr_t)wres.pixelVa;
|
||||
g_panel.width = PANEL_W;
|
||||
g_panel.height = PANEL_H;
|
||||
g_panel.mouse_x = -1;
|
||||
g_panel.mouse_y = -1;
|
||||
g_panel.open = true;
|
||||
render();
|
||||
}
|
||||
|
||||
void close() {
|
||||
if (!g_panel.open) return;
|
||||
if (g_panel.win_id >= 0)
|
||||
montauk::win_destroy(g_panel.win_id);
|
||||
g_panel.win_id = -1;
|
||||
g_panel.pixels = nullptr;
|
||||
g_panel.open = false;
|
||||
}
|
||||
|
||||
void poll() {
|
||||
if (!g_panel.open || g_panel.win_id < 0) return;
|
||||
|
||||
bool redraw = false;
|
||||
montauk::abi::WinEvent ev;
|
||||
int r;
|
||||
while ((r = montauk::win_poll(g_panel.win_id, &ev)) > 0) {
|
||||
if (ev.type == 3) {
|
||||
close();
|
||||
return;
|
||||
}
|
||||
|
||||
if (ev.type == 0) {
|
||||
handle_key(ev.key);
|
||||
} else if (ev.type == 1) {
|
||||
handle_mouse(ev);
|
||||
} else if (ev.type == 2) {
|
||||
g_panel.width = ev.resize.w;
|
||||
g_panel.height = ev.resize.h;
|
||||
g_panel.pixels = (uint32_t*)(uintptr_t)montauk::win_resize(
|
||||
g_panel.win_id, ev.resize.w, ev.resize.h);
|
||||
}
|
||||
redraw = true;
|
||||
|
||||
if (!g_panel.open) return;
|
||||
}
|
||||
|
||||
if (r < 0) {
|
||||
close();
|
||||
return;
|
||||
}
|
||||
|
||||
if (redraw)
|
||||
render();
|
||||
}
|
||||
|
||||
} // namespace termset
|
||||
Reference in New Issue
Block a user