Files
MontaukOS/programs/src/terminal/settings.cpp
T

566 lines
19 KiB
C++

/*
* 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
},
{
"pure-black", "Pure Black",
{
Color::from_hex(0x000000), Color::from_hex(0xFFFFFF),
Color::from_hex(0xFFFFFF),
{
Color::from_hex(0x000000), Color::from_hex(0xD00000),
Color::from_hex(0x00C000), Color::from_hex(0xC0C000),
Color::from_hex(0x4060E0), Color::from_hex(0xC000C0),
Color::from_hex(0x00C0C0), Color::from_hex(0xE5E5E5),
Color::from_hex(0x7F7F7F), Color::from_hex(0xFF4040),
Color::from_hex(0x40FF40), Color::from_hex(0xFFFF40),
Color::from_hex(0x6080FF), Color::from_hex(0xFF40FF),
Color::from_hex(0x40FFFF), Color::from_hex(0xFFFFFF),
}
}
},
{
"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 = 452;
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}};
// The console session takes over the whole framebuffer with no desktop around
// it, so it defaults to the plain black palette rather than the windowed
// terminal's lighter grey.
static constexpr const char* kConsoleDefaultTheme = "pure-black";
static bool g_console_session = false;
// ==== 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");
// The console session has no panel, so its theme is a default rather than a
// choice -- writing it back would let a zoom in the console silently retheme
// every windowed terminal. Leaving the key untouched round-trips whatever
// the user actually picked.
if (!g_console_session)
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(bool console_session) {
g_console_session = console_session;
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", ""));
int size = (int)doc.get_int("appearance.font_size", fonts::TERM_SIZE);
doc.destroy();
// A saved theme is an explicit choice and wins in either mode; with none
// saved, the full-screen console starts on Pure Black and the windowed
// terminal on the default palette.
if (idx < 0) {
idx = console_session ? theme_index_by_id(kConsoleDefaultTheme) : 0;
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