diff --git a/kernel/src/Api/BuildNo.hpp b/kernel/src/Api/BuildNo.hpp index 9a9ec1c..dec456a 100644 --- a/kernel/src/Api/BuildNo.hpp +++ b/kernel/src/Api/BuildNo.hpp @@ -12,4 +12,4 @@ #pragma once -#define MONTAUK_BUILD_NUMBER 168 +#define MONTAUK_BUILD_NUMBER 175 diff --git a/programs/include/gui/terminal.hpp b/programs/include/gui/terminal.hpp index ac8a4b4..01b0a3b 100644 --- a/programs/include/gui/terminal.hpp +++ b/programs/include/gui/terminal.hpp @@ -60,32 +60,47 @@ struct TerminalState { int csi_current_param; }; +// ==== Palette ==== +// +// The palette is a mutable global rather than a set of constants because the +// terminal lets the user switch themes at runtime. Cells store resolved colors, +// so a theme switch also has to rewrite the cells that were painted with the +// outgoing palette -- see terminal_remap_palette below. + +struct TermPalette { + Color bg; + Color fg; + Color cursor; + Color ansi[16]; +}; + +inline constexpr TermPalette TERM_PALETTE_DEFAULT = { + colors::TERM_BG, + colors::TERM_FG, + colors::TERM_FG, + { + Color::from_hex(0x000000), Color::from_hex(0xCC0000), + Color::from_hex(0x4E9A06), Color::from_hex(0xC4A000), + Color::from_hex(0x3465A4), Color::from_hex(0x75507B), + Color::from_hex(0x06989A), Color::from_hex(0xD3D7CF), + Color::from_hex(0x555753), Color::from_hex(0xEF2929), + Color::from_hex(0x8AE234), Color::from_hex(0xFCE94F), + Color::from_hex(0x729FCF), Color::from_hex(0xAD7FA8), + Color::from_hex(0x34E2E2), Color::from_hex(0xEEEEEC), + } +}; + +inline TermPalette g_term_palette = TERM_PALETTE_DEFAULT; + // Standard ANSI color palette as ARGB pixels static inline Color term_ansi_color(int idx) { - switch (idx) { - case 0: return Color::from_hex(0x000000); - case 1: return Color::from_hex(0xCC0000); - case 2: return Color::from_hex(0x4E9A06); - case 3: return Color::from_hex(0xC4A000); - case 4: return Color::from_hex(0x3465A4); - case 5: return Color::from_hex(0x75507B); - case 6: return Color::from_hex(0x06989A); - case 7: return Color::from_hex(0xD3D7CF); - case 8: return Color::from_hex(0x555753); - case 9: return Color::from_hex(0xEF2929); - case 10: return Color::from_hex(0x8AE234); - case 11: return Color::from_hex(0xFCE94F); - case 12: return Color::from_hex(0x729FCF); - case 13: return Color::from_hex(0xAD7FA8); - case 14: return Color::from_hex(0x34E2E2); - case 15: return Color::from_hex(0xEEEEEC); - default: return colors::TERM_FG; - } + if (idx < 0 || idx > 15) return g_term_palette.fg; + return g_term_palette.ansi[idx]; } // ANSI 256-color palette (0-15 = standard, 16-231 = RGB cube, 232-255 = grayscale) static inline Color term_ansi_256_color(int idx) { - if (idx < 0) return colors::TERM_FG; + if (idx < 0) return g_term_palette.fg; if (idx <= 15) return term_ansi_color(idx); if (idx <= 231) { // 6x6x6 RGB cube: idx 16 = (0,0,0), idx 231 = (5,5,5) @@ -119,6 +134,47 @@ static inline void terminal_invalidate_render_cache(TerminalState* t) { t->render_cursor_visible = false; } +// Translate one already-resolved cell color from an outgoing palette to the +// incoming one. Colors that are not palette entries -- 256-color cube and +// grayscale ramp values -- are left exactly as the program asked for them. +static inline Color term_remap_color(Color c, const TermPalette& from, + const TermPalette& to) { + if (term_color_equal(c, from.bg)) return to.bg; + if (term_color_equal(c, from.fg)) return to.fg; + for (int i = 0; i < 16; i++) { + if (term_color_equal(c, from.ansi[i])) return to.ansi[i]; + } + return c; +} + +// Repaint an existing terminal in a new palette. Cells carry resolved colors, +// so switching themes without this leaves all scrollback -- and the shell's +// colored prompt -- in the old theme's colors. +static inline void terminal_remap_palette(TerminalState* t, + const TermPalette& from, + const TermPalette& to) { + if (!t || !t->cells) return; + + int total = (t->rows + t->max_scrollback) * t->cols; + for (int i = 0; i < total; i++) { + t->cells[i].fg = term_remap_color(t->cells[i].fg, from, to); + t->cells[i].bg = term_remap_color(t->cells[i].bg, from, to); + } + + if (t->alt_cells) { + int screen = t->rows * t->cols; + for (int i = 0; i < screen; i++) { + t->alt_cells[i].fg = term_remap_color(t->alt_cells[i].fg, from, to); + t->alt_cells[i].bg = term_remap_color(t->alt_cells[i].bg, from, to); + } + } + + t->current_fg = term_remap_color(t->current_fg, from, to); + t->current_bg = term_remap_color(t->current_bg, from, to); + terminal_invalidate_render_cache(t); + t->dirty = true; +} + static inline Color terminal_erase_bg(TerminalState* t) { return t->current_bg; } @@ -163,8 +219,8 @@ static inline void terminal_init_cells(TerminalState* t, int cols, int rows, int t->scrollback_lines = 0; t->max_scrollback = max_sb; t->view_offset = 0; - t->current_fg = colors::TERM_FG; - t->current_bg = colors::TERM_BG; + t->current_fg = g_term_palette.fg; + t->current_bg = g_term_palette.bg; t->cursor_visible = false; t->alt_screen_active = false; t->reverse_video = false; @@ -202,10 +258,10 @@ static inline void terminal_init_cells(TerminalState* t, int cols, int rows, int return; } for (int i = 0; i < total_cells; i++) { - t->cells[i] = {' ', colors::TERM_FG, colors::TERM_BG}; + t->cells[i] = {' ', g_term_palette.fg, g_term_palette.bg}; } for (int i = 0; i < screen_cells; i++) { - t->alt_cells[i] = {' ', colors::TERM_FG, colors::TERM_BG}; + t->alt_cells[i] = {' ', g_term_palette.fg, g_term_palette.bg}; } } @@ -258,7 +314,7 @@ static inline void terminal_enter_alt_screen(TerminalState* t) { TermCell* screen = term_screen_row(t, 0); for (int i = 0; i < total; i++) { t->alt_cells[i] = screen[i]; - screen[i] = {' ', colors::TERM_FG, colors::TERM_BG}; + screen[i] = {' ', g_term_palette.fg, g_term_palette.bg}; } t->view_offset = 0; t->cursor_x = 0; @@ -437,8 +493,8 @@ static inline void terminal_process_csi(TerminalState* t, char cmd) { } if (code == 0) { - t->current_fg = colors::TERM_FG; - t->current_bg = colors::TERM_BG; + t->current_fg = g_term_palette.fg; + t->current_bg = g_term_palette.bg; t->reverse_video = false; } else if (code == 1) { // Bold: map to bright version of current color @@ -486,15 +542,15 @@ static inline void terminal_process_csi(TerminalState* t, char cmd) { } else if (code >= 100 && code <= 107) { t->current_bg = term_ansi_color(code - 100 + 8); } else if (code == 39) { - t->current_fg = colors::TERM_FG; + t->current_fg = g_term_palette.fg; } else if (code == 49) { - t->current_bg = colors::TERM_BG; + t->current_bg = g_term_palette.bg; } } if (t->csi_param_count == 0) { // ESC[m with no params = reset - t->current_fg = colors::TERM_FG; - t->current_bg = colors::TERM_BG; + t->current_fg = g_term_palette.fg; + t->current_bg = g_term_palette.bg; t->reverse_video = false; } break; @@ -545,8 +601,8 @@ static inline void terminal_feed(TerminalState* t, const char* data, int len) { for (int j = 0; j < 8; j++) t->csi_params[j] = 0; } else if (ch == 'c') { // Reset terminal - t->current_fg = colors::TERM_FG; - t->current_bg = colors::TERM_BG; + t->current_fg = g_term_palette.fg; + t->current_bg = g_term_palette.bg; t->cursor_x = 0; t->cursor_y = 0; t->parse_state = TerminalState::STATE_NORMAL; @@ -640,18 +696,21 @@ static inline void terminal_draw_cursor_pixels(const TermCell& cell, uint32_t* pixels, int pw, int ph, int px, int py, int cell_w, int cell_h, bool use_ttf, GlyphCache* gc) { + // The block cursor takes its color from the palette and punches the glyph + // out in the background color, so it stays legible on light themes too -- + // a hardcoded white block disappears on a white background. terminal_fill_pixel_rect(pixels, pw, ph, px, py, cell_w, cell_h, - colors::WHITE.to_pixel()); + g_term_palette.cursor.to_pixel()); if (cell.ch <= 32 && cell.ch >= 0) return; if (use_ttf) { int baseline = py + gc->ascent; fonts::mono->draw_char_to_buffer(pixels, pw, ph, - px, baseline, (unsigned char)cell.ch, colors::BLACK, gc); + px, baseline, (unsigned char)cell.ch, g_term_palette.bg, gc); } else { terminal_draw_bitmap_char(pixels, pw, ph, px, py, (unsigned char)cell.ch, - colors::BLACK.to_pixel()); + g_term_palette.bg.to_pixel()); } } @@ -713,7 +772,7 @@ static inline void terminal_render(TerminalState* t, uint32_t* pixels, int pw, i t->render_base_row != base_row || t->render_term_cols != t->cols || t->render_term_rows != t->rows; - uint32_t bg_px = colors::TERM_BG.to_pixel(); + uint32_t bg_px = g_term_palette.bg.to_pixel(); if (full_redraw) { int row_bytes = pw * sizeof(uint32_t); for (int i = 0; i < pw; i++) pixels[i] = bg_px; @@ -792,9 +851,9 @@ static inline void terminal_resize(TerminalState* t, int new_cols, int new_rows) // Clear new buffers for (int i = 0; i < new_total; i++) - new_cells[i] = {' ', colors::TERM_FG, colors::TERM_BG}; + new_cells[i] = {' ', g_term_palette.fg, g_term_palette.bg}; for (int i = 0; i < new_rows * new_cols; i++) - new_alt[i] = {' ', colors::TERM_FG, colors::TERM_BG}; + new_alt[i] = {' ', g_term_palette.fg, g_term_palette.bg}; // Copy content: scrollback + visible screen int old_content = t->scrollback_lines + t->rows; diff --git a/programs/src/terminal/Makefile b/programs/src/terminal/Makefile index 703cb13..cedf13c 100644 --- a/programs/src/terminal/Makefile +++ b/programs/src/terminal/Makefile @@ -41,7 +41,7 @@ LDFLAGS := \ -Wl,--gc-sections \ -T $(LINK_LD) -SRCS := main.cpp stb_truetype_impl.cpp font_data.cpp +SRCS := main.cpp settings.cpp stb_truetype_impl.cpp font_data.cpp OBJS := $(addprefix $(OBJDIR)/,$(SRCS:.cpp=.o)) DEPS := $(OBJS:.o=.d) diff --git a/programs/src/terminal/main.cpp b/programs/src/terminal/main.cpp index 70db6cc..0249413 100644 --- a/programs/src/terminal/main.cpp +++ b/programs/src/terminal/main.cpp @@ -1,7 +1,6 @@ /* * main.cpp * MontaukOS Terminal - standalone Window Server app - * Preserves the old desktop-integrated terminal layout and tab behavior. * * Two run modes: * - Windowed (default): runs as a Window Server client inside the desktop. @@ -21,8 +20,11 @@ #include #include #include +#include #include +#include "settings.hpp" + extern "C" { #include } @@ -39,6 +41,8 @@ static constexpr int TERM_TAB_GAP = 4; static constexpr int TERM_PLUS_W = 28; static constexpr int TERM_PLUS_PAD = 8; static constexpr int TERM_TAB_PAD = 8; +static constexpr int TERM_COG_W = 28; +static constexpr int TERM_COG_GAP = 6; struct TermTabs { TerminalState* tabs[TERM_MAX_TABS]; @@ -55,6 +59,9 @@ static bool g_force_redraw = true; static int g_last_win_w = 0; static int g_last_win_h = 0; static bool g_show_clock = false; // console (full-screen) mode draws a live clock +// The settings panel is a Window Server window, so it only exists in windowed +// mode; the console session has no window server to open it in. +static bool g_show_cog = false; static char g_clock_text[16] = {}; static montauk::abi::ProcInfo g_kill_procs[256]; static int g_kill_pids[256]; @@ -180,13 +187,59 @@ static bool term_poll_tabs() { return changed || g_force_redraw; } +// ==== Tab bar colors ==== +// +// The tab bar is derived from the active terminal palette rather than +// hardcoded, so a light theme gets a light chrome with dark labels instead of +// the dark-theme strip the terminal originally shipped. + +struct TabBarColors { + Color bar_bg; + Color tab_active_bg; + Color tab_inactive_bg; + Color label_active; + Color label_inactive; + Color close_active; + Color close_inactive; + Color furniture; + Color clock; +}; + +// Chrome shades are cut from the terminal background. A dark background can +// absorb a heavy darkening (the amounts below reproduce the original #1C1C1C +// bar and #262626 tabs on the default palette); a light one only tolerates a +// gentle one before the chrome reads as a different, darker window. +static Color term_shade(Color bg, uint8_t dark_amount, uint8_t light_amount) { + int lum = (bg.r * 30 + bg.g * 59 + bg.b * 11) / 100; + return mtk::darken(bg, lum > 140 ? light_amount : dark_amount); +} + +static TabBarColors term_bar_colors() { + Color bg = g_term_palette.bg; + Color fg = g_term_palette.fg; + + TabBarColors t; + t.bar_bg = term_shade(bg, 96, 30); + t.tab_active_bg = bg; + t.tab_inactive_bg = term_shade(bg, 40, 12); + t.label_active = fg; + t.label_inactive = mtk::mix(bg, fg, 130); + t.close_active = mtk::mix(bg, fg, 110); + t.close_inactive = mtk::mix(bg, fg, 50); + t.furniture = t.label_inactive; + t.clock = mtk::mix(bg, fg, 175); + return t; +} + // Right-hand tab-bar furniture. In console mode a live clock sits at the far // right and the new-tab (+) button shifts left to make room; in windowed mode // there is no clock and the + keeps its original far-right position. struct TabBarRight { int clock_x; int plus_x; + int cog_x; bool has_clock; + bool has_cog; }; static TabBarRight term_tabbar_right(int width) { @@ -200,9 +253,65 @@ static TabBarRight term_tabbar_right(int width) { right_limit = r.clock_x - 12; } r.plus_x = right_limit - TERM_PLUS_W; + // The cog keeps its slot even when the tab limit hides the + button, so it + // does not jump under the pointer as tabs are opened and closed. + r.has_cog = g_show_cog; + r.cog_x = r.plus_x - TERM_COG_GAP - TERM_COG_W; return r; } +static Rect term_cog_rect(int width) { + TabBarRight right = term_tabbar_right(width); + return {right.cog_x, 7, TERM_COG_W, 22}; +} + +// Flat cog glyph, drawn rather than loaded -- the icon set ships no gear. +// +// The shape is analytic: a disc with the hub bored out, plus eight teeth formed +// by intersecting an outer ring with four symmetric bands (two axis-aligned, +// two diagonal). Coverage is sampled 3x3 per pixel and blended against the +// button fill, so the curves stay smooth at this size without an alpha buffer. +// Units are sixths of a pixel, which keeps pixel centers and subsample offsets +// exact -- this app does no floating point. +static void term_draw_cog(Canvas& c, const Rect& box, Color fg, Color bg) { + static constexpr int R_HUB = 15; // 2.5 px + static constexpr int R_BODY = 33; // 5.5 px + static constexpr int R_OUT = 48; // 8.0 px + static constexpr int TOOTH = 10; // 1.7 px half-width + static constexpr int TOOTH_DIAG = 14; // TOOTH * sqrt(2) + + auto inked = [](int dx, int dy) -> bool { + int d2 = dx * dx + dy * dy; + if (d2 <= R_HUB * R_HUB) return false; + if (d2 <= R_BODY * R_BODY) return true; + if (d2 > R_OUT * R_OUT) return false; + + int ax = dx < 0 ? -dx : dx; + int ay = dy < 0 ? -dy : dy; + int au = (dx + dy) < 0 ? -(dx + dy) : (dx + dy); + int av = (dx - dy) < 0 ? -(dx - dy) : (dx - dy); + return ax <= TOOTH || ay <= TOOTH || + au <= TOOTH_DIAG || av <= TOOTH_DIAG; + }; + + for (int y = 0; y < box.h; y++) { + for (int x = 0; x < box.w; x++) { + int cov = 0; + for (int sy = -2; sy <= 2; sy += 2) { + for (int sx = -2; sx <= 2; sx += 2) { + if (inked(6 * x + 3 + sx - 3 * box.w, + 6 * y + 3 + sy - 3 * box.h)) + cov++; + } + } + if (cov == 0) continue; + Color px = (cov == 9) ? fg + : mtk::mix(bg, fg, (uint8_t)((cov * 255) / 9)); + c.put_pixel(box.x + x, box.y + y, px); + } + } +} + // Refresh the cached clock string from the wall clock; returns true when the // displayed text changed (so the caller can request a repaint). static bool term_refresh_clock() { @@ -263,8 +372,8 @@ static bool term_render_into(uint32_t* pixels, int width, int height) { if (!g_force_redraw && !ts->dirty) return false; Canvas c(pixels, width, height); - Color bar_bg = Color::from_hex(0x1C1C1C); - c.fill_rect(0, 0, width, TERM_TAB_BAR_H, bar_bg); + TabBarColors bar = term_bar_colors(); + c.fill_rect(0, 0, width, TERM_TAB_BAR_H, bar.bar_bg); int fh = system_font_height(); int tab_x = TERM_TAB_PAD; @@ -277,20 +386,20 @@ static bool term_render_into(uint32_t* pixels, int width, int height) { if (active) { int ty = 5; int th = TERM_TAB_BAR_H - ty; - c.fill_rounded_rect(tab_x, ty, TERM_TAB_W, th, 6, colors::TERM_BG); - c.fill_rect(tab_x, TERM_TAB_BAR_H - 6, TERM_TAB_W, 6, colors::TERM_BG); + c.fill_rounded_rect(tab_x, ty, TERM_TAB_W, th, 6, bar.tab_active_bg); + c.fill_rect(tab_x, TERM_TAB_BAR_H - 6, TERM_TAB_W, 6, bar.tab_active_bg); int text_y = ty + (th - fh) / 2; - c.text(tab_x + 12, text_y, label, Color::from_hex(0xE0E0E0)); - c.text(tab_x + TERM_TAB_W - 20, text_y, "x", Color::from_hex(0x707070)); + c.text(tab_x + 12, text_y, label, bar.label_active); + c.text(tab_x + TERM_TAB_W - 20, text_y, "x", bar.close_active); } else { int ty = 7; int th = 22; - c.fill_rounded_rect(tab_x, ty, TERM_TAB_W, th, 5, Color::from_hex(0x262626)); + c.fill_rounded_rect(tab_x, ty, TERM_TAB_W, th, 5, bar.tab_inactive_bg); int text_y = ty + (th - fh) / 2; - c.text(tab_x + 12, text_y, label, Color::from_hex(0x6E6E6E)); - c.text(tab_x + TERM_TAB_W - 20, text_y, "x", Color::from_hex(0x444444)); + c.text(tab_x + 12, text_y, label, bar.label_inactive); + c.text(tab_x + TERM_TAB_W - 20, text_y, "x", bar.close_inactive); } tab_x += TERM_TAB_W + TERM_TAB_GAP; @@ -300,17 +409,23 @@ static bool term_render_into(uint32_t* pixels, int width, int height) { if (right.has_clock) { int text_y = (TERM_TAB_BAR_H - fh) / 2; - c.text(right.clock_x, text_y, g_clock_text, Color::from_hex(0x9A9A9A)); + c.text(right.clock_x, text_y, g_clock_text, bar.clock); } if (g_tabs.tab_count < TERM_MAX_TABS) { int plus_h = 22; int py = 7; int px = right.plus_x; - c.fill_rounded_rect(px, py, TERM_PLUS_W, plus_h, 5, Color::from_hex(0x262626)); + c.fill_rounded_rect(px, py, TERM_PLUS_W, plus_h, 5, bar.tab_inactive_bg); int pw_text = text_width(fonts::system_font, "+", fonts::UI_SIZE); c.text(px + (TERM_PLUS_W - pw_text) / 2, py + (plus_h - fh) / 2, "+", - Color::from_hex(0x6E6E6E)); + bar.furniture); + } + + if (right.has_cog) { + Rect cog = term_cog_rect(width); + c.fill_rounded_rect(cog.x, cog.y, cog.w, cog.h, 5, bar.tab_inactive_bg); + term_draw_cog(c, cog, bar.furniture, bar.tab_inactive_bg); } uint32_t* term_pixels = pixels + TERM_TAB_BAR_H * width; @@ -376,6 +491,11 @@ static void term_handle_mouse_core(int mx, int my, int scroll, bool left_click, tab_x += TERM_TAB_W + TERM_TAB_GAP; } + if (g_show_cog && term_cog_rect(width).contains(mx, my)) { + termset::open(); + return; + } + int px = term_tabbar_right(width).plus_x; if (g_tabs.tab_count < TERM_MAX_TABS && mx >= px && mx < px + TERM_PLUS_W) { @@ -421,25 +541,15 @@ static void term_handle_key_core(const montauk::abi::KeyEvent& key, return; } - // Ctrl+Plus/Equal: zoom in + // Ctrl+Plus/Equal and Ctrl+Minus zoom. Both go through the settings panel so + // the keyboard shortcut and the panel agree on bounds and both persist. if (key.ctrl && key.pressed && (key.ascii == '+' || key.ascii == '=')) { - if (fonts::TERM_SIZE < 64) { - fonts::TERM_SIZE += 2; - for (int i = 0; i < g_tabs.tab_count; i++) - g_tabs.tabs[i]->dirty = true; - term_request_redraw(); - } + termset::zoom_font(1); return; } - // Ctrl+Minus: zoom out if (key.ctrl && key.pressed && key.ascii == '-') { - if (fonts::TERM_SIZE > 8) { - fonts::TERM_SIZE -= 2; - for (int i = 0; i < g_tabs.tab_count; i++) - g_tabs.tabs[i]->dirty = true; - term_request_redraw(); - } + termset::zoom_font(-1); return; } @@ -450,6 +560,26 @@ static void term_handle_key(const montauk::abi::KeyEvent& key) { term_handle_key_core(key, g_win.width, g_win.height); } +// ==== Settings panel plumbing ==== + +// Cells carry resolved colors, so a theme switch has to rewrite every tab's +// grid -- including scrollback -- from the outgoing palette to the new one. +static void term_on_theme_changed(const TermPalette& from, const TermPalette& to) { + for (int i = 0; i < g_tabs.tab_count; i++) + terminal_remap_palette(g_tabs.tabs[i], from, to); + term_request_redraw(); +} + +// A font size change alters the cell metrics; term_render_into reflows the +// grid to the new cell count on the next frame. +static void term_on_font_changed() { + for (int i = 0; i < g_tabs.tab_count; i++) { + terminal_invalidate_render_cache(g_tabs.tabs[i]); + g_tabs.tabs[i]->dirty = true; + } + term_request_redraw(); +} + static void term_cleanup() { for (int i = 0; i < g_tabs.tab_count; i++) term_free_tab(g_tabs.tabs[i]); @@ -459,6 +589,8 @@ static void term_cleanup() { // ==== Windowed mode (Window Server client) ==== static void run_windowed() { + g_show_cog = true; + if (!g_win.create("Terminal", INIT_W, INIT_H)) montauk::exit(1); @@ -468,8 +600,8 @@ static void run_windowed() { // and the first TrueType render. Paint the dark background and present // immediately so the compositor sees terminal colors from frame one. { - uint32_t bar_px = Color::from_hex(0x1C1C1C).to_pixel(); - uint32_t bg_px = colors::TERM_BG.to_pixel(); + uint32_t bar_px = term_bar_colors().bar_bg.to_pixel(); + uint32_t bg_px = g_term_palette.bg.to_pixel(); int bar_pixels = TERM_TAB_BAR_H * g_win.width; if (bar_pixels > g_win.width * g_win.height) bar_pixels = g_win.width * g_win.height; @@ -508,6 +640,8 @@ static void run_windowed() { bool quit = false; int r = 0; + termset::poll(); + while ((r = g_win.poll(&ev)) > 0) { redraw = true; @@ -544,6 +678,7 @@ static void run_windowed() { montauk::sleep_ms(16); } + termset::close(); term_cleanup(); g_win.destroy(); } @@ -577,8 +712,8 @@ static void run_console() { // Paint the dark background immediately so the first frame is not a flash // of uninitialized memory while the shell spawns and the first render runs. { - uint32_t bar_px = Color::from_hex(0x1C1C1C).to_pixel(); - uint32_t bg_px = colors::TERM_BG.to_pixel(); + uint32_t bar_px = term_bar_colors().bar_bg.to_pixel(); + uint32_t bg_px = g_term_palette.bg.to_pixel(); int bar_pixels = TERM_TAB_BAR_H * sw; int total = sw * sh; if (bar_pixels > total) bar_pixels = total; @@ -685,6 +820,13 @@ extern "C" void _start() { if (!fonts::init()) montauk::exit(1); + // Must run before the first cell grid is sized: the saved font size decides + // the mono cell metrics, and so how many columns and rows fit. Console mode + // has no settings window, but it shares the saved appearance and the zoom + // shortcut, so it needs the callbacks too. + termset::init({term_on_theme_changed, term_on_font_changed}); + termset::load_prefs(); + char args[256] = {}; int arglen = montauk::getargs(args, sizeof(args)); diff --git a/programs/src/terminal/settings.cpp b/programs/src/terminal/settings.cpp new file mode 100644 index 0000000..effc53e --- /dev/null +++ b/programs/src/terminal/settings.cpp @@ -0,0 +1,526 @@ +/* + * settings.cpp + * MontaukOS Terminal - settings panel + * + * Copyright (c) 2026 Daniel Hammer + */ + +#include "settings.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +extern "C" { +#include +} + +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 diff --git a/programs/src/terminal/settings.hpp b/programs/src/terminal/settings.hpp new file mode 100644 index 0000000..ee05876 --- /dev/null +++ b/programs/src/terminal/settings.hpp @@ -0,0 +1,46 @@ +/* + * settings.hpp + * MontaukOS Terminal - settings panel + * Copyright (c) 2026 Daniel Hammer + */ + +#pragma once + +#include + +namespace termset { + +// The panel owns the appearance preferences; the terminal owns the tabs. These +// callbacks let the panel hand a change back so every tab can be repainted: +// theme_changed also carries the outgoing palette, which the terminal needs to +// translate the colors already baked into its cells. +struct Callbacks { + void (*theme_changed)(const gui::TermPalette& from, const gui::TermPalette& to); + void (*font_changed)(); +}; + +// Load the saved theme and font size and apply them to the globals. Call +// before the first tab is created -- there is nothing to repaint yet, so this +// deliberately does not fire the callbacks. +void load_prefs(); + +void init(const Callbacks& cb); + +bool is_open(); + +// Open (or focus) the settings window. +void open(); + +void close(); + +// Pump the settings window's events. No-op when the panel is closed. +void poll(); + +void render(); + +// Step the terminal font size by `direction` zoom steps (negative shrinks). +// Shared with the terminal's Ctrl+Plus / Ctrl+Minus shortcut so both routes +// obey the same bounds and both persist. +void zoom_font(int direction); + +} // namespace termset