From 02a0cf66ad52584df1ad3f0088f7250b595cfd08 Mon Sep 17 00:00:00 2001 From: Daniel Hammer Date: Mon, 6 Jul 2026 18:07:23 +0200 Subject: [PATCH 1/9] feat: add Character Map (charmap) MTK app A standalone Window Server app for browsing curated sets of characters and copying them to the clipboard for pasting into other apps. - Four sets (Latin, Punctuation, Currency, Symbols) shown as a responsive, scrollable tile grid with category tabs, a live preview/detail footer, and a Copy button. - Click a tile (or use arrow keys + Enter/Space) to select and copy; a transient toast confirms the copy. Mouse wheel scrolls; Tab cycles sets. - Styled with the MTK theme (accent, surfaces, rounded tiles) to match the other Montauk apps, modeled on the calculator app. The character set is deliberately the single-byte Windows-1252 range the system font can render (the glyph cache only holds codepoints 0-255). Copying writes the raw byte, so glyphs render identically here and in every other single-byte-text Montauk app (copying UTF-8 would render as mojibake). Registered in programs/GNUmakefile and scripts/install_apps.sh alongside calculator; menu category "Applications", icon accessories-character-map.svg. Co-Authored-By: Claude Opus 4.8 --- programs/GNUmakefile | 13 +- programs/src/charmap/Makefile | 86 +++ programs/src/charmap/font_data.cpp | 1 + programs/src/charmap/main.cpp | 628 +++++++++++++++++++++ programs/src/charmap/manifest.toml | 11 + programs/src/charmap/stb_truetype_impl.cpp | 33 ++ scripts/install_apps.sh | 1 + 7 files changed, 769 insertions(+), 4 deletions(-) create mode 100644 programs/src/charmap/Makefile create mode 100644 programs/src/charmap/font_data.cpp create mode 100644 programs/src/charmap/main.cpp create mode 100644 programs/src/charmap/manifest.toml create mode 100644 programs/src/charmap/stb_truetype_impl.cpp diff --git a/programs/GNUmakefile b/programs/GNUmakefile index e74da1e..5b7d4fa 100644 --- a/programs/GNUmakefile +++ b/programs/GNUmakefile @@ -59,7 +59,7 @@ BINDIR := bin PROGRAMS := $(notdir $(wildcard src/*)) # Programs with custom Makefiles (built separately). -CUSTOM_BUILDS := 2048 doom fetch wiki wikipedia weather imageviewer fontpreview spreadsheet wordprocessor pdfviewer disks devexplorer installer audio music video bluetooth network terminal klog procmgr powermgr calculator desktop login shell rpgdemo paint tcc lua screenshot texteditor mandelbrot printers timezone printd printctl dialogs test_dialogs test_dl libloader libhello crashpad +CUSTOM_BUILDS := 2048 doom fetch wiki wikipedia weather imageviewer fontpreview spreadsheet wordprocessor pdfviewer disks devexplorer installer audio music video bluetooth network terminal klog procmgr powermgr calculator charmap desktop login shell rpgdemo paint tcc lua screenshot texteditor mandelbrot printers timezone printd printctl dialogs test_dialogs test_dl libloader libhello crashpad SYSTEM_PROGRAMS := $(filter-out $(CUSTOM_BUILDS),$(PROGRAMS)) # Build targets: system programs go to bin/os/, apps go to bin/apps//. @@ -89,9 +89,9 @@ CONFIGDIR := data/config CONFIGSRC := $(wildcard $(CONFIGDIR)/*.toml) CONFIGDST := $(patsubst $(CONFIGDIR)/%,$(BINDIR)/config/%,$(CONFIGSRC)) -.PHONY: all clean 2048 doom fetch wiki wikipedia weather imageviewer fontpreview spreadsheet wordprocessor pdfviewer disks devexplorer installer audio music video bluetooth network terminal klog procmgr powermgr calculator login desktop shell rpgdemo paint tcc lua screenshot texteditor mandelbrot printers timezone printd printctl dialogs icons fonts configs bearssl libc tls libjpeg libjpegwrite install-apps libloader libs libhello test_dialogs test_dl crashpad check-syscalls gen-syscalls +.PHONY: all clean 2048 doom fetch wiki wikipedia weather imageviewer fontpreview spreadsheet wordprocessor pdfviewer disks devexplorer installer audio music video bluetooth network terminal klog procmgr powermgr calculator charmap login desktop shell rpgdemo paint tcc lua screenshot texteditor mandelbrot printers timezone printd printctl dialogs icons fonts configs bearssl libc tls libjpeg libjpegwrite install-apps libloader libs libhello test_dialogs test_dl crashpad check-syscalls gen-syscalls -all: bearssl libc libjpeg libjpegwrite tls libloader libs libhello $(TARGETS) fetch wiki wikipedia weather imageviewer fontpreview spreadsheet wordprocessor pdfviewer disks devexplorer installer audio music video bluetooth network terminal klog procmgr powermgr calculator 2048 doom rpgdemo paint tcc lua screenshot texteditor mandelbrot printers timezone printd printctl dialogs login desktop shell icons fonts install-apps test_dialogs test_dl crashpad $(MANDST) $(WWWDST) $(CA_CERTS) $(CONFIGDST) $(FWDST) +all: bearssl libc libjpeg libjpegwrite tls libloader libs libhello $(TARGETS) fetch wiki wikipedia weather imageviewer fontpreview spreadsheet wordprocessor pdfviewer disks devexplorer installer audio music video bluetooth network terminal klog procmgr powermgr calculator charmap 2048 doom rpgdemo paint tcc lua screenshot texteditor mandelbrot printers timezone printd printctl dialogs login desktop shell icons fonts install-apps test_dialogs test_dl crashpad $(MANDST) $(WWWDST) $(CA_CERTS) $(CONFIGDST) $(FWDST) # Build BearSSL static library (cross-compiled for freestanding x86_64). BEARSSL_INCLUDES := -isystem $(shell cd .. && pwd)/kernel/freestnd-c-hdrs/x86_64/include -isystem $(abspath include/libc) @@ -223,6 +223,10 @@ powermgr: libc calculator: libc $(MAKE) -C src/calculator +# Build character map standalone GUI tool (depends on libc). +charmap: libc + $(MAKE) -C src/charmap + # Build printers standalone GUI tool (depends on libc, bearssl, and tls). printers: bearssl libc tls $(MAKE) -C src/printers @@ -331,7 +335,7 @@ test_dialogs: libc libloader dialogs $(MAKE) -C src/test_dialogs # Install app bundles (manifests, icons, data files) into bin/apps//. -install-apps: 2048 doom rpgdemo paint spreadsheet wordprocessor weather wikipedia imageviewer fontpreview pdfviewer disks devexplorer installer audio music video bluetooth network terminal klog procmgr powermgr calculator screenshot texteditor mandelbrot printers timezone crashpad +install-apps: 2048 doom rpgdemo paint spreadsheet wordprocessor weather wikipedia imageviewer fontpreview pdfviewer disks devexplorer installer audio music video bluetooth network terminal klog procmgr powermgr calculator charmap screenshot texteditor mandelbrot printers timezone crashpad ../scripts/install_apps.sh # Copy man pages into bin/man/ so mkramdisk.sh picks them up. @@ -397,6 +401,7 @@ clean: $(MAKE) -C src/procmgr clean $(MAKE) -C src/powermgr clean $(MAKE) -C src/calculator clean + $(MAKE) -C src/charmap clean $(MAKE) -C src/printers clean $(MAKE) -C src/timezone clean $(MAKE) -C src/paint clean diff --git a/programs/src/charmap/Makefile b/programs/src/charmap/Makefile new file mode 100644 index 0000000..f637897 --- /dev/null +++ b/programs/src/charmap/Makefile @@ -0,0 +1,86 @@ +# Makefile for charmap (standalone Character Map) on MontaukOS +# Copyright (c) 2026 Daniel Hammer + +MAKEFLAGS += -rR +.SUFFIXES: + +# ---- Toolchain ---- + +TOOLCHAIN_PREFIX := $(shell cd ../../.. && pwd)/toolchain/local/bin/x86_64-elf- +ifneq ($(wildcard $(TOOLCHAIN_PREFIX)gcc),) + CXX := $(TOOLCHAIN_PREFIX)g++ +else + CXX := g++ +endif + +# ---- Paths ---- + +PROG_INC := ../../include +LINK_LD := ../../link.ld +BINDIR := ../../bin +OBJDIR := obj +LIBDIR := ../../lib + +# ---- Compiler flags ---- + +CXXFLAGS := \ + -std=gnu++20 \ + -g -O2 -pipe \ + -Wall \ + -Wextra \ + -Wno-unused-parameter \ + -Wno-unused-function \ + -nostdinc \ + -ffreestanding \ + -fno-stack-protector \ + -fno-stack-check \ + -fno-PIC \ + -fno-rtti \ + -fno-exceptions \ + -ffunction-sections \ + -fdata-sections \ + -m64 \ + -march=x86-64 \ + -msse \ + -msse2 \ + -mno-red-zone \ + -mcmodel=small \ + -I $(PROG_INC) \ + -isystem $(PROG_INC)/libc \ + -isystem ../../../kernel/freestnd-c-hdrs/x86_64/include \ + -isystem ../../../kernel/freestnd-cxx-hdrs/x86_64/include + +# ---- Linker flags ---- + +LDFLAGS := \ + -nostdlib \ + -static \ + -Wl,--build-id=none \ + -Wl,--gc-sections \ + -Wl,-m,elf_x86_64 \ + -z max-page-size=0x1000 \ + -T $(LINK_LD) + +# ---- Source files ---- + +SRCS := main.cpp stb_truetype_impl.cpp font_data.cpp +OBJS := $(addprefix $(OBJDIR)/,$(SRCS:.cpp=.o)) + +# ---- Target ---- + +TARGET := $(BINDIR)/apps/charmap/charmap.elf + +.PHONY: all clean + +all: $(TARGET) + +$(TARGET): $(OBJS) $(LINK_LD) Makefile + mkdir -p $(BINDIR)/apps/charmap + $(CXX) $(CXXFLAGS) $(LDFLAGS) $(OBJS) $(LIBDIR)/libc/liblibc.a -o $@ + +$(OBJDIR)/%.o: %.cpp Makefile + mkdir -p $(OBJDIR) + $(CXX) $(CXXFLAGS) -c $< -o $@ + +clean: + rm -rf $(OBJDIR) $(TARGET) diff --git a/programs/src/charmap/font_data.cpp b/programs/src/charmap/font_data.cpp new file mode 100644 index 0000000..a591f69 --- /dev/null +++ b/programs/src/charmap/font_data.cpp @@ -0,0 +1 @@ +#include "../desktop/font_data.cpp" diff --git a/programs/src/charmap/main.cpp b/programs/src/charmap/main.cpp new file mode 100644 index 0000000..ae29226 --- /dev/null +++ b/programs/src/charmap/main.cpp @@ -0,0 +1,628 @@ +/* + * main.cpp + * MontaukOS Character Map - standalone Window Server app + * + * Browse curated sets of characters, click (or use the keyboard) to select, + * and copy to the system clipboard for pasting into other apps. + * + * Character model: MontaukOS's GUI text stack is single-byte (Windows-1252, + * with the 0x80-0x9F block mapped to typographic punctuation in + * gui::decode_single_byte_codepoint). The glyph cache only holds codepoints + * 0-255, so that byte range is exactly the set of glyphs the system font can + * render. We therefore present that set and copy the *raw byte* to the + * clipboard: it renders identically here and in every other Montauk app + * (copying multi-byte UTF-8 would render as mojibake in the single-byte stack). + * + * Copyright (c) 2026 Daniel Hammer + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +extern "C" { +#include +} + +using namespace gui; + +// ==== Character data ==== + +struct Glyph { + uint8_t byte; // Windows-1252 byte value (what gets copied / rendered) + const char* name; // human-readable description +}; + +struct CharSet { + const char* label; + const Glyph* glyphs; + int count; +}; + +// Accented Latin letters and ligatures (things you can't easily type). +static const Glyph LATIN[] = { + {0xC0, "Latin Capital Letter A With Grave"}, + {0xC1, "Latin Capital Letter A With Acute"}, + {0xC2, "Latin Capital Letter A With Circumflex"}, + {0xC3, "Latin Capital Letter A With Tilde"}, + {0xC4, "Latin Capital Letter A With Diaeresis"}, + {0xC5, "Latin Capital Letter A With Ring Above"}, + {0xC6, "Latin Capital Letter AE"}, + {0xC7, "Latin Capital Letter C With Cedilla"}, + {0xC8, "Latin Capital Letter E With Grave"}, + {0xC9, "Latin Capital Letter E With Acute"}, + {0xCA, "Latin Capital Letter E With Circumflex"}, + {0xCB, "Latin Capital Letter E With Diaeresis"}, + {0xCC, "Latin Capital Letter I With Grave"}, + {0xCD, "Latin Capital Letter I With Acute"}, + {0xCE, "Latin Capital Letter I With Circumflex"}, + {0xCF, "Latin Capital Letter I With Diaeresis"}, + {0xD0, "Latin Capital Letter Eth"}, + {0xD1, "Latin Capital Letter N With Tilde"}, + {0xD2, "Latin Capital Letter O With Grave"}, + {0xD3, "Latin Capital Letter O With Acute"}, + {0xD4, "Latin Capital Letter O With Circumflex"}, + {0xD5, "Latin Capital Letter O With Tilde"}, + {0xD6, "Latin Capital Letter O With Diaeresis"}, + {0xD8, "Latin Capital Letter O With Stroke"}, + {0xD9, "Latin Capital Letter U With Grave"}, + {0xDA, "Latin Capital Letter U With Acute"}, + {0xDB, "Latin Capital Letter U With Circumflex"}, + {0xDC, "Latin Capital Letter U With Diaeresis"}, + {0xDD, "Latin Capital Letter Y With Acute"}, + {0xDE, "Latin Capital Letter Thorn"}, + {0x8A, "Latin Capital Letter S With Caron"}, + {0x8C, "Latin Capital Ligature OE"}, + {0x8E, "Latin Capital Letter Z With Caron"}, + {0x9F, "Latin Capital Letter Y With Diaeresis"}, + {0xDF, "Latin Small Letter Sharp S"}, + {0xE0, "Latin Small Letter A With Grave"}, + {0xE1, "Latin Small Letter A With Acute"}, + {0xE2, "Latin Small Letter A With Circumflex"}, + {0xE3, "Latin Small Letter A With Tilde"}, + {0xE4, "Latin Small Letter A With Diaeresis"}, + {0xE5, "Latin Small Letter A With Ring Above"}, + {0xE6, "Latin Small Letter AE"}, + {0xE7, "Latin Small Letter C With Cedilla"}, + {0xE8, "Latin Small Letter E With Grave"}, + {0xE9, "Latin Small Letter E With Acute"}, + {0xEA, "Latin Small Letter E With Circumflex"}, + {0xEB, "Latin Small Letter E With Diaeresis"}, + {0xEC, "Latin Small Letter I With Grave"}, + {0xED, "Latin Small Letter I With Acute"}, + {0xEE, "Latin Small Letter I With Circumflex"}, + {0xEF, "Latin Small Letter I With Diaeresis"}, + {0xF0, "Latin Small Letter Eth"}, + {0xF1, "Latin Small Letter N With Tilde"}, + {0xF2, "Latin Small Letter O With Grave"}, + {0xF3, "Latin Small Letter O With Acute"}, + {0xF4, "Latin Small Letter O With Circumflex"}, + {0xF5, "Latin Small Letter O With Tilde"}, + {0xF6, "Latin Small Letter O With Diaeresis"}, + {0xF8, "Latin Small Letter O With Stroke"}, + {0xF9, "Latin Small Letter U With Grave"}, + {0xFA, "Latin Small Letter U With Acute"}, + {0xFB, "Latin Small Letter U With Circumflex"}, + {0xFC, "Latin Small Letter U With Diaeresis"}, + {0xFD, "Latin Small Letter Y With Acute"}, + {0xFE, "Latin Small Letter Thorn"}, + {0xFF, "Latin Small Letter Y With Diaeresis"}, + {0x9A, "Latin Small Letter S With Caron"}, + {0x9C, "Latin Small Ligature OE"}, + {0x9E, "Latin Small Letter Z With Caron"}, + {0x83, "Latin Small Letter F With Hook"}, +}; + +// Typographic punctuation. +static const Glyph PUNCT[] = { + {0xA1, "Inverted Exclamation Mark"}, + {0xBF, "Inverted Question Mark"}, + {0x91, "Left Single Quotation Mark"}, + {0x92, "Right Single Quotation Mark"}, + {0x93, "Left Double Quotation Mark"}, + {0x94, "Right Double Quotation Mark"}, + {0x82, "Single Low-9 Quotation Mark"}, + {0x84, "Double Low-9 Quotation Mark"}, + {0xAB, "Left-Pointing Double Angle Quotation Mark"}, + {0xBB, "Right-Pointing Double Angle Quotation Mark"}, + {0x8B, "Single Left-Pointing Angle Quotation Mark"}, + {0x9B, "Single Right-Pointing Angle Quotation Mark"}, + {0x96, "En Dash"}, + {0x97, "Em Dash"}, + {0x85, "Horizontal Ellipsis"}, + {0x95, "Bullet"}, + {0xB7, "Middle Dot"}, + {0x86, "Dagger"}, + {0x87, "Double Dagger"}, + {0xB6, "Pilcrow Sign"}, + {0xA7, "Section Sign"}, + {0x89, "Per Mille Sign"}, +}; + +// Currency symbols. +static const Glyph CURRENCY[] = { + {0x24, "Dollar Sign"}, + {0xA2, "Cent Sign"}, + {0xA3, "Pound Sign"}, + {0xA5, "Yen Sign"}, + {0x80, "Euro Sign"}, + {0xA4, "Currency Sign"}, + {0x83, "Latin Small Letter F With Hook (Florin)"}, +}; + +// Math, technical, and miscellaneous symbols. +static const Glyph SYMBOLS[] = { + {0xA9, "Copyright Sign"}, + {0xAE, "Registered Sign"}, + {0x99, "Trade Mark Sign"}, + {0xB0, "Degree Sign"}, + {0xB1, "Plus-Minus Sign"}, + {0xD7, "Multiplication Sign"}, + {0xF7, "Division Sign"}, + {0xB5, "Micro Sign"}, + {0xAC, "Not Sign"}, + {0xBC, "Vulgar Fraction One Quarter"}, + {0xBD, "Vulgar Fraction One Half"}, + {0xBE, "Vulgar Fraction Three Quarters"}, + {0xB9, "Superscript One"}, + {0xB2, "Superscript Two"}, + {0xB3, "Superscript Three"}, + {0xAA, "Feminine Ordinal Indicator"}, + {0xBA, "Masculine Ordinal Indicator"}, + {0xA6, "Broken Bar"}, + {0xB4, "Acute Accent"}, + {0xA8, "Diaeresis"}, + {0xAF, "Macron"}, + {0xB8, "Cedilla"}, + {0x88, "Modifier Letter Circumflex Accent"}, + {0x98, "Small Tilde"}, +}; + +#define SET(arr, label) { label, arr, (int)(sizeof(arr) / sizeof(arr[0])) } +static const CharSet SETS[] = { + SET(LATIN, "Latin"), + SET(PUNCT, "Punctuation"), + SET(CURRENCY, "Currency"), + SET(SYMBOLS, "Symbols"), +}; +static constexpr int SET_COUNT = (int)(sizeof(SETS) / sizeof(SETS[0])); + +// ==== Layout constants ==== + +static constexpr int INIT_W = 680; +static constexpr int INIT_H = 500; +static constexpr int HEADER_H = 52; +static constexpr int TAB_H = 40; +static constexpr int FOOTER_H = 84; +static constexpr int GRID_PAD = 12; +static constexpr int CELL_GAP = 6; +static constexpr int CELL_TARGET = 54; // desired tile edge in px +static constexpr int SCROLLBAR_W = 8; + +static constexpr int TITLE_SIZE = 20; +static constexpr int TAB_SIZE = 16; +static constexpr int CELL_SIZE = 26; // glyph font size inside a tile +static constexpr int PREVIEW_SIZE = 40; +static constexpr int NAME_SIZE = 16; +static constexpr int META_SIZE = 14; +static constexpr int TOAST_SIZE = 14; + +static constexpr uint64_t TOAST_MS = 1400; + +// ==== App state ==== + +static int g_set = 0; // active character set index +static int g_sel = 0; // selected glyph index within active set +static int g_hover = -1; // hovered glyph index, or -1 +static int g_tab_hover = -1; // hovered tab index, or -1 +static bool g_copy_hover = false; +static bool g_copy_pressed = false; +static int g_scroll = 0; // vertical scroll offset in px +static uint64_t g_toast_until = 0; +static char g_toast[48] = {}; + +static mtk::Theme g_theme; + +// ==== Derived layout ==== + +struct Layout { + Rect header; + Rect tabs; + Rect grid; // interior area available for the tile grid + Rect footer; + Rect copy_btn; + int cols; + int cell_w; // tile slot width (includes gap handling) + int cell_h; // tile slot height + int content_h; // full scrollable height + int max_scroll; +}; + +static const CharSet& cur_set() { return SETS[g_set]; } + +static Layout compute_layout(int w, int h) { + Layout L {}; + L.header = { 0, 0, w, HEADER_H }; + L.tabs = { 0, HEADER_H, w, TAB_H }; + int grid_top = HEADER_H + TAB_H; + L.footer = { 0, h - FOOTER_H, w, FOOTER_H }; + L.grid = { GRID_PAD, grid_top + GRID_PAD, + w - GRID_PAD * 2, (h - FOOTER_H) - grid_top - GRID_PAD * 2 }; + if (L.grid.w < CELL_TARGET) L.grid.w = CELL_TARGET; + if (L.grid.h < 1) L.grid.h = 1; + + int avail = L.grid.w - SCROLLBAR_W; + if (avail < CELL_TARGET) avail = CELL_TARGET; + L.cols = (avail + CELL_GAP) / (CELL_TARGET + CELL_GAP); + if (L.cols < 1) L.cols = 1; + L.cell_w = (avail - CELL_GAP * (L.cols - 1)) / L.cols; + if (L.cell_w < 8) L.cell_w = 8; + L.cell_h = L.cell_w; + + int rows = (cur_set().count + L.cols - 1) / L.cols; + L.content_h = rows > 0 ? rows * L.cell_h + (rows - 1) * CELL_GAP : 0; + L.max_scroll = L.content_h - L.grid.h; + if (L.max_scroll < 0) L.max_scroll = 0; + + // Copy button in the footer, right-aligned. + int btn_w = 96, btn_h = 34; + L.copy_btn = { w - GRID_PAD - btn_w, + L.footer.y + (FOOTER_H - btn_h) / 2, btn_w, btn_h }; + return L; +} + +// Screen rect of tile `i` (may be off-screen; caller clips). +static Rect tile_rect(const Layout& L, int i) { + int row = i / L.cols; + int col = i % L.cols; + int x = L.grid.x + col * (L.cell_w + CELL_GAP); + int y = L.grid.y + row * (L.cell_h + CELL_GAP) - g_scroll; + return { x, y, L.cell_w, L.cell_h }; +} + +static int hit_tile(const Layout& L, int mx, int my) { + if (my < L.grid.y || my >= L.grid.y + L.grid.h) return -1; + if (mx < L.grid.x || mx >= L.grid.x + L.grid.w - SCROLLBAR_W) return -1; + for (int i = 0; i < cur_set().count; i++) { + Rect r = tile_rect(L, i); + if (r.contains(mx, my)) return i; + } + return -1; +} + +static int hit_tab(const Layout& L, int mx, int my) { + if (my < L.tabs.y || my >= L.tabs.y + L.tabs.h) return -1; + int tab_w = L.tabs.w / SET_COUNT; + if (tab_w <= 0) return -1; + int idx = mx / tab_w; + if (idx < 0 || idx >= SET_COUNT) return -1; + return idx; +} + +static void clamp_scroll(const Layout& L) { + if (g_scroll > L.max_scroll) g_scroll = L.max_scroll; + if (g_scroll < 0) g_scroll = 0; +} + +// Keep the selected tile fully visible (used after keyboard navigation). +static void ensure_visible(const Layout& L) { + int row = g_sel / L.cols; + int top = row * (L.cell_h + CELL_GAP); + int bot = top + L.cell_h; + if (top - g_scroll < 0) g_scroll = top; + else if (bot - g_scroll > L.grid.h) g_scroll = bot - L.grid.h; + clamp_scroll(L); +} + +// ==== Helpers ==== + +static void glyph_str(uint8_t byte, char out[2]) { + out[0] = (char)byte; + out[1] = '\0'; +} + +static int codepoint_of(uint8_t byte) { + return decode_single_byte_codepoint((int)byte); +} + +static void copy_selected() { + const Glyph& g = cur_set().glyphs[g_sel]; + char s[2]; + glyph_str(g.byte, s); + gui::clipboard_set_text(s, 1); + + // Toast: Copied "" + int n = 0; + const char* pfx = "Copied "; + while (pfx[n]) { g_toast[n] = pfx[n]; n++; } + g_toast[n++] = '"'; + g_toast[n++] = (char)g.byte; + g_toast[n++] = '"'; + g_toast[n] = '\0'; + g_toast_until = montauk::get_milliseconds() + TOAST_MS; +} + +// ==== Rendering ==== + +static void draw_centered_glyph(Canvas& c, const Rect& r, uint8_t byte, + Color color, int size) { + char s[2]; + glyph_str(byte, s); + int tw = text_width(fonts::system_font, s, size); + int th = text_height(fonts::system_font, size); + draw_text(c, fonts::system_font, r.x + (r.w - tw) / 2, + r.y + (r.h - th) / 2, s, color, size); +} + +static void render(Canvas& c) { + Layout L = compute_layout(c.w, c.h); + clamp_scroll(L); + + c.fill(g_theme.window_bg); + + // ---- Tile grid (drawn first; header/tabs/footer are painted over any + // spill from partially-scrolled edge rows) ---- + const CharSet& set = cur_set(); + for (int i = 0; i < set.count; i++) { + Rect r = tile_rect(L, i); + if (r.y + r.h <= L.grid.y || r.y >= L.grid.y + L.grid.h) continue; + + bool selected = (i == g_sel); + bool hovered = (i == g_hover); + Color fill = g_theme.surface; + Color glyph = g_theme.text; + Color border = g_theme.border; + if (selected) { + fill = g_theme.accent; + glyph = g_theme.accent_fg; + border = g_theme.accent; + } else if (hovered) { + fill = g_theme.surface_hover; + } + c.fill_rounded_rect(r.x, r.y, r.w, r.h, g_theme.radius_md, border); + c.fill_rounded_rect(r.x + 1, r.y + 1, r.w - 2, r.h - 2, + g_theme.radius_md > 0 ? g_theme.radius_md - 1 : 0, fill); + draw_centered_glyph(c, r, set.glyphs[i].byte, glyph, CELL_SIZE); + } + + // ---- Scrollbar ---- + if (L.max_scroll > 0 && L.content_h > 0) { + int track_x = L.grid.x + L.grid.w - SCROLLBAR_W + 2; + int track_h = L.grid.h; + int thumb_h = track_h * L.grid.h / L.content_h; + if (thumb_h < 24) thumb_h = 24; + int thumb_y = L.grid.y + (track_h - thumb_h) * g_scroll / L.max_scroll; + c.fill_rounded_rect(track_x, thumb_y, SCROLLBAR_W - 4, thumb_h, + (SCROLLBAR_W - 4) / 2, g_theme.border); + } + + // ---- Tab bar (opaque) ---- + c.fill_rect(L.tabs.x, L.tabs.y, L.tabs.w, L.tabs.h, g_theme.window_bg); + int tab_w = L.tabs.w / SET_COUNT; + for (int t = 0; t < SET_COUNT; t++) { + int tx = t * tab_w; + bool active = (t == g_set); + if (t == g_tab_hover && !active) + c.fill_rect(tx, L.tabs.y, tab_w, L.tabs.h, g_theme.surface_hover); + Color tc = active ? g_theme.accent + : (t == g_tab_hover ? g_theme.text : g_theme.text_muted); + int lw = text_width(fonts::system_font, SETS[t].label, TAB_SIZE); + int lh = text_height(fonts::system_font, TAB_SIZE); + draw_text(c, fonts::system_font, tx + (tab_w - lw) / 2, + L.tabs.y + (L.tabs.h - lh) / 2 - 1, SETS[t].label, tc, TAB_SIZE); + if (active) { + int uw = lw + 16; + c.fill_rounded_rect(tx + (tab_w - uw) / 2, L.tabs.y + L.tabs.h - 3, + uw, 3, 1, g_theme.accent); + } + } + c.fill_rect(L.tabs.x, L.tabs.y + L.tabs.h - 1, L.tabs.w, 1, g_theme.border); + + // ---- Header (opaque) ---- + c.fill_rect(L.header.x, L.header.y, L.header.w, L.header.h, g_theme.window_bg); + TrueTypeFont* title_font = + (fonts::system_bold && fonts::system_bold->valid) ? fonts::system_bold + : fonts::system_font; + int title_h = text_height(title_font, TITLE_SIZE); + draw_text(c, title_font, GRID_PAD, (HEADER_H - title_h) / 2, + "Character Map", g_theme.text, TITLE_SIZE); + + // Toast pill (right side of header). + if (g_toast[0] && montauk::get_milliseconds() < g_toast_until) { + int tw = text_width(fonts::system_font, g_toast, TOAST_SIZE); + int pill_w = tw + 24, pill_h = 26; + int px = c.w - GRID_PAD - pill_w; + int py = (HEADER_H - pill_h) / 2; + c.fill_rounded_rect(px, py, pill_w, pill_h, pill_h / 2, g_theme.accent_soft); + draw_text(c, fonts::system_font, px + 12, + py + (pill_h - text_height(fonts::system_font, TOAST_SIZE)) / 2, + g_toast, g_theme.accent_hover, TOAST_SIZE); + } + c.fill_rect(L.header.x, L.header.y + L.header.h - 1, L.header.w, 1, g_theme.border); + + // ---- Footer (opaque) ---- + c.fill_rect(L.footer.x, L.footer.y, L.footer.w, L.footer.h, g_theme.surface_alt); + c.fill_rect(L.footer.x, L.footer.y, L.footer.w, 1, g_theme.border); + + const Glyph& sg = set.glyphs[g_sel]; + + // Preview box. + int pv = FOOTER_H - 20; + Rect preview = { GRID_PAD, L.footer.y + 10, pv, pv }; + c.fill_rounded_rect(preview.x, preview.y, preview.w, preview.h, + g_theme.radius_md, g_theme.border); + c.fill_rounded_rect(preview.x + 1, preview.y + 1, preview.w - 2, preview.h - 2, + g_theme.radius_md - 1, colors::WHITE); + draw_centered_glyph(c, preview, sg.byte, g_theme.text, PREVIEW_SIZE); + + // Name + metadata. + int cp = codepoint_of(sg.byte); + int text_x = preview.x + preview.w + 14; + draw_text(c, title_font, text_x, L.footer.y + 16, sg.name, + g_theme.text, NAME_SIZE); + char meta[64]; + snprintf(meta, sizeof(meta), "U+%04X dec %d Alt 0%d", + cp, cp, (int)sg.byte); + draw_text(c, fonts::system_font, text_x, L.footer.y + 16 + NAME_SIZE + 8, + meta, g_theme.text_subtle, META_SIZE); + + // Copy button. + Color btn_bg = g_theme.accent; + if (g_copy_pressed) btn_bg = g_theme.accent_hover; + else if (g_copy_hover) btn_bg = mtk::darken(g_theme.accent, 16); + draw_button(c, title_font, L.copy_btn.x, L.copy_btn.y, L.copy_btn.w, + L.copy_btn.h, "Copy", btn_bg, g_theme.accent_fg, + g_theme.radius_md, TAB_SIZE); +} + +// ==== Event handling ==== + +static bool handle_mouse(const montauk::abi::WinEvent& ev, int w, int h) { + Layout L = compute_layout(w, h); + clamp_scroll(L); + bool redraw = false; + + int mx = ev.mouse.x, my = ev.mouse.y; + + // Wheel scroll. + if (ev.mouse.scroll != 0) { + g_scroll -= ev.mouse.scroll * (L.cell_h + CELL_GAP) / 2; + clamp_scroll(L); + redraw = true; + } + + int tile = hit_tile(L, mx, my); + if (tile != g_hover) { g_hover = tile; redraw = true; } + + int tab = hit_tab(L, mx, my); + if (tab != g_tab_hover) { g_tab_hover = tab; redraw = true; } + + bool copy_hov = L.copy_btn.contains(mx, my); + if (copy_hov != g_copy_hover) { g_copy_hover = copy_hov; redraw = true; } + + bool pressed = (ev.mouse.buttons & 1) && !(ev.mouse.prev_buttons & 1); + bool released = !(ev.mouse.buttons & 1) && (ev.mouse.prev_buttons & 1); + + if (pressed) { + if (tab >= 0 && tab != g_set) { + g_set = tab; + g_sel = 0; + g_scroll = 0; + redraw = true; + } else if (tile >= 0) { + g_sel = tile; + copy_selected(); // click a tile = select + copy + redraw = true; + } else if (copy_hov) { + g_copy_pressed = true; + redraw = true; + } + } + + if (released && g_copy_pressed) { + g_copy_pressed = false; + if (copy_hov) copy_selected(); + redraw = true; + } + + return redraw; +} + +static bool handle_key(const montauk::abi::KeyEvent& key, int w, int h) { + if (!key.pressed) return false; + Layout L = compute_layout(w, h); + clamp_scroll(L); + const int count = cur_set().count; + bool redraw = false; + + switch (key.scancode) { + case 0x4B: // Left + if (g_sel > 0) { g_sel--; ensure_visible(L); redraw = true; } + break; + case 0x4D: // Right + if (g_sel < count - 1) { g_sel++; ensure_visible(L); redraw = true; } + break; + case 0x48: // Up + if (g_sel - L.cols >= 0) { g_sel -= L.cols; ensure_visible(L); redraw = true; } + break; + case 0x50: // Down + if (g_sel + L.cols < count) { g_sel += L.cols; ensure_visible(L); redraw = true; } + break; + case 0x0F: // Tab -> next character set + g_set = (g_set + (key.shift ? SET_COUNT - 1 : 1)) % SET_COUNT; + g_sel = 0; g_scroll = 0; redraw = true; + break; + case 0x1C: // Enter + case 0x39: // Space + copy_selected(); + redraw = true; + break; + default: + break; + } + return redraw; +} + +extern "C" void _start() { + fonts::init(); + g_theme = mtk::make_theme(); + + WsWindow win; + if (!win.create("Character Map", INIT_W, INIT_H)) + montauk::exit(1); + + { + Canvas canvas = win.canvas(); + render(canvas); + } + win.present(); + + bool toast_shown = true; + while (win.id >= 0 && !win.closed) { + montauk::abi::WinEvent ev; + int r = win.poll(&ev); + + if (r < 0) break; + if (r == 0) { + // Expire the toast even when idle so the pill disappears. + if (toast_shown && g_toast[0] && + montauk::get_milliseconds() >= g_toast_until) { + Canvas canvas = win.canvas(); + render(canvas); + win.present(); + toast_shown = false; + } + montauk::sleep_ms(16); + continue; + } + + bool redraw = false; + if (ev.type == 3) break; // close + if (ev.type == 2 || ev.type == 4) { // resize / scale + g_hover = -1; + g_tab_hover = -1; + redraw = true; + } else if (ev.type == 1) { + redraw = handle_mouse(ev, win.width, win.height); + } else if (ev.type == 0) { + redraw = handle_key(ev.key, win.width, win.height); + } + + if (redraw) { + Canvas canvas = win.canvas(); + render(canvas); + win.present(); + toast_shown = true; + } + } + + win.destroy(); + montauk::exit(0); +} diff --git a/programs/src/charmap/manifest.toml b/programs/src/charmap/manifest.toml new file mode 100644 index 0000000..3db18fd --- /dev/null +++ b/programs/src/charmap/manifest.toml @@ -0,0 +1,11 @@ +[app] +name = "Character Map" +binary = "charmap.elf" +icon = "accessories-character-map.svg" + +[menu] +category = "Applications" +visible = true + +[launch] +pass_home_dir = false diff --git a/programs/src/charmap/stb_truetype_impl.cpp b/programs/src/charmap/stb_truetype_impl.cpp new file mode 100644 index 0000000..dd91785 --- /dev/null +++ b/programs/src/charmap/stb_truetype_impl.cpp @@ -0,0 +1,33 @@ +/* + * stb_truetype_impl.cpp + * Single compilation unit for stb_truetype in MontaukOS freestanding environment + * Copyright (c) 2026 Daniel Hammer + */ + +#include +#include +#include +#include +#include + +#define STBTT_ifloor(x) ((int) stb_floor(x)) +#define STBTT_iceil(x) ((int) stb_ceil(x)) +#define STBTT_sqrt(x) stb_sqrt(x) +#define STBTT_pow(x,y) stb_pow(x,y) +#define STBTT_fmod(x,y) stb_fmod(x,y) +#define STBTT_cos(x) stb_cos(x) +#define STBTT_acos(x) stb_acos(x) +#define STBTT_fabs(x) stb_fabs(x) + +#define STBTT_malloc(x,u) ((void)(u), montauk::malloc(x)) +#define STBTT_free(x,u) ((void)(u), montauk::mfree(x)) + +#define STBTT_memcpy(d,s,n) montauk::memcpy(d,s,n) +#define STBTT_memset(d,v,n) montauk::memset(d,v,n) + +#define STBTT_strlen(x) montauk::slen(x) + +#define STBTT_assert(x) ((void)(x)) + +#define STB_TRUETYPE_IMPLEMENTATION +#include diff --git a/scripts/install_apps.sh b/scripts/install_apps.sh index a6bf332..4b92761 100755 --- a/scripts/install_apps.sh +++ b/scripts/install_apps.sh @@ -37,6 +37,7 @@ APPS=( "procmgr|apps/scalable/system-monitor.svg" "powermgr|apps/scalable/gnome-power-statistics.svg" "calculator|apps/scalable/accessories-calculator.svg" + "charmap|apps/scalable/accessories-character-map.svg" "printers|devices/scalable/preferences-devices-printer.svg" "timezone|categories/scalable/preferences-system-time.svg" "rpgdemo|apps/scalable/utilities-terminal.svg" From 95beb8ae1ae055e25db132a553e0d6b883db81cd Mon Sep 17 00:00:00 2001 From: Daniel Hammer Date: Mon, 6 Jul 2026 20:54:37 +0200 Subject: [PATCH 2/9] feat: remove charmap in-window header, float the copy toast The top header bar duplicated the window-manager title bar. Drop it: tabs now sit at the top of the content area (window shrinks by the header's height). The "Copied" confirmation moves from the header to a floating accent toast anchored to the lower-right of the grid, drawn last. Co-Authored-By: Claude Opus 4.8 --- programs/src/charmap/main.cpp | 41 ++++++++++++++--------------------- 1 file changed, 16 insertions(+), 25 deletions(-) diff --git a/programs/src/charmap/main.cpp b/programs/src/charmap/main.cpp index ae29226..3b2505e 100644 --- a/programs/src/charmap/main.cpp +++ b/programs/src/charmap/main.cpp @@ -197,8 +197,7 @@ static constexpr int SET_COUNT = (int)(sizeof(SETS) / sizeof(SETS[0])); // ==== Layout constants ==== static constexpr int INIT_W = 680; -static constexpr int INIT_H = 500; -static constexpr int HEADER_H = 52; +static constexpr int INIT_H = 448; static constexpr int TAB_H = 40; static constexpr int FOOTER_H = 84; static constexpr int GRID_PAD = 12; @@ -206,7 +205,6 @@ static constexpr int CELL_GAP = 6; static constexpr int CELL_TARGET = 54; // desired tile edge in px static constexpr int SCROLLBAR_W = 8; -static constexpr int TITLE_SIZE = 20; static constexpr int TAB_SIZE = 16; static constexpr int CELL_SIZE = 26; // glyph font size inside a tile static constexpr int PREVIEW_SIZE = 40; @@ -233,7 +231,6 @@ static mtk::Theme g_theme; // ==== Derived layout ==== struct Layout { - Rect header; Rect tabs; Rect grid; // interior area available for the tile grid Rect footer; @@ -249,9 +246,8 @@ static const CharSet& cur_set() { return SETS[g_set]; } static Layout compute_layout(int w, int h) { Layout L {}; - L.header = { 0, 0, w, HEADER_H }; - L.tabs = { 0, HEADER_H, w, TAB_H }; - int grid_top = HEADER_H + TAB_H; + L.tabs = { 0, 0, w, TAB_H }; + int grid_top = TAB_H; L.footer = { 0, h - FOOTER_H, w, FOOTER_H }; L.grid = { GRID_PAD, grid_top + GRID_PAD, w - GRID_PAD * 2, (h - FOOTER_H) - grid_top - GRID_PAD * 2 }; @@ -425,27 +421,10 @@ static void render(Canvas& c) { } c.fill_rect(L.tabs.x, L.tabs.y + L.tabs.h - 1, L.tabs.w, 1, g_theme.border); - // ---- Header (opaque) ---- - c.fill_rect(L.header.x, L.header.y, L.header.w, L.header.h, g_theme.window_bg); + // Bold font for the footer name and Copy button. TrueTypeFont* title_font = (fonts::system_bold && fonts::system_bold->valid) ? fonts::system_bold : fonts::system_font; - int title_h = text_height(title_font, TITLE_SIZE); - draw_text(c, title_font, GRID_PAD, (HEADER_H - title_h) / 2, - "Character Map", g_theme.text, TITLE_SIZE); - - // Toast pill (right side of header). - if (g_toast[0] && montauk::get_milliseconds() < g_toast_until) { - int tw = text_width(fonts::system_font, g_toast, TOAST_SIZE); - int pill_w = tw + 24, pill_h = 26; - int px = c.w - GRID_PAD - pill_w; - int py = (HEADER_H - pill_h) / 2; - c.fill_rounded_rect(px, py, pill_w, pill_h, pill_h / 2, g_theme.accent_soft); - draw_text(c, fonts::system_font, px + 12, - py + (pill_h - text_height(fonts::system_font, TOAST_SIZE)) / 2, - g_toast, g_theme.accent_hover, TOAST_SIZE); - } - c.fill_rect(L.header.x, L.header.y + L.header.h - 1, L.header.w, 1, g_theme.border); // ---- Footer (opaque) ---- c.fill_rect(L.footer.x, L.footer.y, L.footer.w, L.footer.h, g_theme.surface_alt); @@ -480,6 +459,18 @@ static void render(Canvas& c) { draw_button(c, title_font, L.copy_btn.x, L.copy_btn.y, L.copy_btn.w, L.copy_btn.h, "Copy", btn_bg, g_theme.accent_fg, g_theme.radius_md, TAB_SIZE); + + // ---- Toast (drawn last; floats over the lower-right of the grid) ---- + if (g_toast[0] && montauk::get_milliseconds() < g_toast_until) { + int tw = text_width(fonts::system_font, g_toast, TOAST_SIZE); + int pill_w = tw + 24, pill_h = 28; + int px = c.w - GRID_PAD - pill_w; + int py = L.footer.y - GRID_PAD - pill_h; + c.fill_rounded_rect(px, py, pill_w, pill_h, pill_h / 2, g_theme.accent); + draw_text(c, fonts::system_font, px + 12, + py + (pill_h - text_height(fonts::system_font, TOAST_SIZE)) / 2, + g_toast, g_theme.accent_fg, TOAST_SIZE); + } } // ==== Event handling ==== From fdf586305b611eb06d884268d1dcd834c158ce0f Mon Sep 17 00:00:00 2001 From: Daniel Hammer Date: Mon, 6 Jul 2026 21:01:51 +0200 Subject: [PATCH 3/9] feat: use the shared mtk tab bar for charmap's top tabs Replace charmap's bespoke tab drawing/hit-testing with mtk::draw_tab_bar / mtk::hit_tab_bar so the top tabs match the canonical MontaukOS style used by the Desktop Settings panel: a surface-colored bar with a bottom border, the active tab cut out in window_bg with a 3px accent underline, active label in accent and inactive labels in text_subtle. Tab height is now 36 (theme.tab_h). Drops the custom hover tint (the canonical style has none). Co-Authored-By: Claude Opus 4.8 --- programs/src/charmap/main.cpp | 44 +++++++---------------------------- 1 file changed, 9 insertions(+), 35 deletions(-) diff --git a/programs/src/charmap/main.cpp b/programs/src/charmap/main.cpp index 3b2505e..a8f3e11 100644 --- a/programs/src/charmap/main.cpp +++ b/programs/src/charmap/main.cpp @@ -26,6 +26,7 @@ #include #include #include +#include extern "C" { #include @@ -198,7 +199,7 @@ static constexpr int SET_COUNT = (int)(sizeof(SETS) / sizeof(SETS[0])); static constexpr int INIT_W = 680; static constexpr int INIT_H = 448; -static constexpr int TAB_H = 40; +static constexpr int TAB_H = 36; // matches mtk theme.tab_h / Settings TAB_BAR_H static constexpr int FOOTER_H = 84; static constexpr int GRID_PAD = 12; static constexpr int CELL_GAP = 6; @@ -219,7 +220,6 @@ static constexpr uint64_t TOAST_MS = 1400; static int g_set = 0; // active character set index static int g_sel = 0; // selected glyph index within active set static int g_hover = -1; // hovered glyph index, or -1 -static int g_tab_hover = -1; // hovered tab index, or -1 static bool g_copy_hover = false; static bool g_copy_pressed = false; static int g_scroll = 0; // vertical scroll offset in px @@ -227,6 +227,7 @@ static uint64_t g_toast_until = 0; static char g_toast[48] = {}; static mtk::Theme g_theme; +static const char* g_tab_labels[SET_COUNT] = {}; // populated from SETS in _start // ==== Derived layout ==== @@ -293,15 +294,6 @@ static int hit_tile(const Layout& L, int mx, int my) { return -1; } -static int hit_tab(const Layout& L, int mx, int my) { - if (my < L.tabs.y || my >= L.tabs.y + L.tabs.h) return -1; - int tab_w = L.tabs.w / SET_COUNT; - if (tab_w <= 0) return -1; - int idx = mx / tab_w; - if (idx < 0 || idx >= SET_COUNT) return -1; - return idx; -} - static void clamp_scroll(const Layout& L) { if (g_scroll > L.max_scroll) g_scroll = L.max_scroll; if (g_scroll < 0) g_scroll = 0; @@ -399,27 +391,10 @@ static void render(Canvas& c) { (SCROLLBAR_W - 4) / 2, g_theme.border); } - // ---- Tab bar (opaque) ---- - c.fill_rect(L.tabs.x, L.tabs.y, L.tabs.w, L.tabs.h, g_theme.window_bg); - int tab_w = L.tabs.w / SET_COUNT; - for (int t = 0; t < SET_COUNT; t++) { - int tx = t * tab_w; - bool active = (t == g_set); - if (t == g_tab_hover && !active) - c.fill_rect(tx, L.tabs.y, tab_w, L.tabs.h, g_theme.surface_hover); - Color tc = active ? g_theme.accent - : (t == g_tab_hover ? g_theme.text : g_theme.text_muted); - int lw = text_width(fonts::system_font, SETS[t].label, TAB_SIZE); - int lh = text_height(fonts::system_font, TAB_SIZE); - draw_text(c, fonts::system_font, tx + (tab_w - lw) / 2, - L.tabs.y + (L.tabs.h - lh) / 2 - 1, SETS[t].label, tc, TAB_SIZE); - if (active) { - int uw = lw + 16; - c.fill_rounded_rect(tx + (tab_w - uw) / 2, L.tabs.y + L.tabs.h - 3, - uw, 3, 1, g_theme.accent); - } - } - c.fill_rect(L.tabs.x, L.tabs.y + L.tabs.h - 1, L.tabs.w, 1, g_theme.border); + // ---- Tab bar (opaque) — canonical MontaukOS top-tab style, matching the + // Desktop Settings panel (surface bar, window_bg active tab, accent + // underline). Covers any grid spill above the grid top. ---- + mtk::draw_tab_bar(c, L.tabs, g_tab_labels, SET_COUNT, g_set, g_theme); // Bold font for the footer name and Copy button. TrueTypeFont* title_font = @@ -492,8 +467,7 @@ static bool handle_mouse(const montauk::abi::WinEvent& ev, int w, int h) { int tile = hit_tile(L, mx, my); if (tile != g_hover) { g_hover = tile; redraw = true; } - int tab = hit_tab(L, mx, my); - if (tab != g_tab_hover) { g_tab_hover = tab; redraw = true; } + int tab = mtk::hit_tab_bar(L.tabs, SET_COUNT, mx, my); bool copy_hov = L.copy_btn.contains(mx, my); if (copy_hov != g_copy_hover) { g_copy_hover = copy_hov; redraw = true; } @@ -564,6 +538,7 @@ static bool handle_key(const montauk::abi::KeyEvent& key, int w, int h) { extern "C" void _start() { fonts::init(); g_theme = mtk::make_theme(); + for (int i = 0; i < SET_COUNT; i++) g_tab_labels[i] = SETS[i].label; WsWindow win; if (!win.create("Character Map", INIT_W, INIT_H)) @@ -598,7 +573,6 @@ extern "C" void _start() { if (ev.type == 3) break; // close if (ev.type == 2 || ev.type == 4) { // resize / scale g_hover = -1; - g_tab_hover = -1; redraw = true; } else if (ev.type == 1) { redraw = handle_mouse(ev, win.width, win.height); From 743eca2051086898c18e5a7e35da1d40959a5c08 Mon Sep 17 00:00:00 2001 From: Daniel Hammer Date: Mon, 6 Jul 2026 21:07:19 +0200 Subject: [PATCH 4/9] feat: use the shared mtk scrollbar in charmap Replace charmap's hand-drawn scrollbar with the mtk scrollbar widgets (scrollbar_track_rect / scrollbar_thumb_rect / draw_scrollbar), matching the Devices and Music apps: standard 12px track colored SCROLLBAR_BG/FG with a draggable thumb. Adds thumb drag, track-click-to-jump, and hover highlight (mirroring devexplorer's handling); the tile grid reserves an SB_GUTTER on the right sized from mtk::SCROLLBAR_W. The bar auto-hides when the set fits. Co-Authored-By: Claude Opus 4.8 --- programs/src/charmap/main.cpp | 69 ++++++++++++++++++++++++++--------- 1 file changed, 52 insertions(+), 17 deletions(-) diff --git a/programs/src/charmap/main.cpp b/programs/src/charmap/main.cpp index a8f3e11..9e5a601 100644 --- a/programs/src/charmap/main.cpp +++ b/programs/src/charmap/main.cpp @@ -204,7 +204,9 @@ static constexpr int FOOTER_H = 84; static constexpr int GRID_PAD = 12; static constexpr int CELL_GAP = 6; static constexpr int CELL_TARGET = 54; // desired tile edge in px -static constexpr int SCROLLBAR_W = 8; +// Gutter reserved on the right of the tile area for the shared MTK scrollbar +// (its 12px track plus a little breathing room from the last tile column). +static constexpr int SB_GUTTER = mtk::SCROLLBAR_W + 6; static constexpr int TAB_SIZE = 16; static constexpr int CELL_SIZE = 26; // glyph font size inside a tile @@ -223,6 +225,9 @@ static int g_hover = -1; // hovered glyph index, or -1 static bool g_copy_hover = false; static bool g_copy_pressed = false; static int g_scroll = 0; // vertical scroll offset in px +static bool g_sb_hover = false; // pointer over the scrollbar track +static bool g_sb_dragging = false; // dragging the scrollbar thumb +static int g_sb_drag_offset = 0; // grab point within the thumb static uint64_t g_toast_until = 0; static char g_toast[48] = {}; @@ -255,7 +260,7 @@ static Layout compute_layout(int w, int h) { if (L.grid.w < CELL_TARGET) L.grid.w = CELL_TARGET; if (L.grid.h < 1) L.grid.h = 1; - int avail = L.grid.w - SCROLLBAR_W; + int avail = L.grid.w - SB_GUTTER; if (avail < CELL_TARGET) avail = CELL_TARGET; L.cols = (avail + CELL_GAP) / (CELL_TARGET + CELL_GAP); if (L.cols < 1) L.cols = 1; @@ -286,7 +291,7 @@ static Rect tile_rect(const Layout& L, int i) { static int hit_tile(const Layout& L, int mx, int my) { if (my < L.grid.y || my >= L.grid.y + L.grid.h) return -1; - if (mx < L.grid.x || mx >= L.grid.x + L.grid.w - SCROLLBAR_W) return -1; + if (mx < L.grid.x || mx >= L.grid.x + L.grid.w - SB_GUTTER) return -1; for (int i = 0; i < cur_set().count; i++) { Rect r = tile_rect(L, i); if (r.contains(mx, my)) return i; @@ -380,16 +385,10 @@ static void render(Canvas& c) { draw_centered_glyph(c, r, set.glyphs[i].byte, glyph, CELL_SIZE); } - // ---- Scrollbar ---- - if (L.max_scroll > 0 && L.content_h > 0) { - int track_x = L.grid.x + L.grid.w - SCROLLBAR_W + 2; - int track_h = L.grid.h; - int thumb_h = track_h * L.grid.h / L.content_h; - if (thumb_h < 24) thumb_h = 24; - int thumb_y = L.grid.y + (track_h - thumb_h) * g_scroll / L.max_scroll; - c.fill_rounded_rect(track_x, thumb_y, SCROLLBAR_W - 4, thumb_h, - (SCROLLBAR_W - 4) / 2, g_theme.border); - } + // ---- Scrollbar (shared MTK widget; no-ops when the set fits) ---- + Rect sb_track = mtk::scrollbar_track_rect(L.grid); + Rect sb_thumb = mtk::scrollbar_thumb_rect(sb_track, L.content_h, L.grid.h, g_scroll); + mtk::draw_scrollbar(c, sb_track, sb_thumb, g_sb_hover, g_sb_dragging, g_theme); // ---- Tab bar (opaque) — canonical MontaukOS top-tab style, matching the // Desktop Settings panel (surface bar, window_bg active tab, accent @@ -457,6 +456,15 @@ static bool handle_mouse(const montauk::abi::WinEvent& ev, int w, int h) { int mx = ev.mouse.x, my = ev.mouse.y; + bool left_down = (ev.mouse.buttons & 1) != 0; + bool pressed = (ev.mouse.buttons & 1) && !(ev.mouse.prev_buttons & 1); + bool released = !(ev.mouse.buttons & 1) && (ev.mouse.prev_buttons & 1); + + // Scrollbar geometry (shared MTK widget). + Rect sb_track = mtk::scrollbar_track_rect(L.grid); + Rect sb_thumb = mtk::scrollbar_thumb_rect(sb_track, L.content_h, L.grid.h, g_scroll); + bool scrollable = !sb_track.empty() && !sb_thumb.empty(); + // Wheel scroll. if (ev.mouse.scroll != 0) { g_scroll -= ev.mouse.scroll * (L.cell_h + CELL_GAP) / 2; @@ -464,6 +472,21 @@ static bool handle_mouse(const montauk::abi::WinEvent& ev, int w, int h) { redraw = true; } + // Release ends any active thumb drag. + if (released && g_sb_dragging) { g_sb_dragging = false; redraw = true; } + + // Active drag follows the pointer. + if (g_sb_dragging && left_down && scrollable) { + g_scroll = mtk::scrollbar_offset_from_thumb_top( + sb_track, L.content_h, L.grid.h, sb_thumb.h, my - g_sb_drag_offset); + clamp_scroll(L); + redraw = true; + } + + // Thumb hover highlight (only meaningful when the set scrolls). + bool sb_hover_now = scrollable && sb_track.contains(mx, my); + if (sb_hover_now != g_sb_hover) { g_sb_hover = sb_hover_now; redraw = true; } + int tile = hit_tile(L, mx, my); if (tile != g_hover) { g_hover = tile; redraw = true; } @@ -472,11 +495,21 @@ static bool handle_mouse(const montauk::abi::WinEvent& ev, int w, int h) { bool copy_hov = L.copy_btn.contains(mx, my); if (copy_hov != g_copy_hover) { g_copy_hover = copy_hov; redraw = true; } - bool pressed = (ev.mouse.buttons & 1) && !(ev.mouse.prev_buttons & 1); - bool released = !(ev.mouse.buttons & 1) && (ev.mouse.prev_buttons & 1); - if (pressed) { - if (tab >= 0 && tab != g_set) { + if (scrollable && sb_thumb.contains(mx, my)) { + // Grab the thumb. + g_sb_dragging = true; + g_sb_drag_offset = my - sb_thumb.y; + redraw = true; + } else if (scrollable && sb_track.contains(mx, my)) { + // Click the track: jump the thumb under the pointer, then drag. + g_sb_dragging = true; + g_sb_drag_offset = sb_thumb.h / 2; + g_scroll = mtk::scrollbar_offset_from_thumb_top( + sb_track, L.content_h, L.grid.h, sb_thumb.h, my - sb_thumb.h / 2); + clamp_scroll(L); + redraw = true; + } else if (tab >= 0 && tab != g_set) { g_set = tab; g_sel = 0; g_scroll = 0; @@ -573,6 +606,8 @@ extern "C" void _start() { if (ev.type == 3) break; // close if (ev.type == 2 || ev.type == 4) { // resize / scale g_hover = -1; + g_sb_hover = false; + g_sb_dragging = false; redraw = true; } else if (ev.type == 1) { redraw = handle_mouse(ev, win.width, win.height); From f8327c6b8c2758675135793acc7fd6ee87c72a45 Mon Sep 17 00:00:00 2001 From: Daniel Hammer Date: Mon, 6 Jul 2026 21:13:30 +0200 Subject: [PATCH 5/9] fix: make charmap scrollbar hug the panel edge The scrollbar was placed inside the padded tile grid, leaving a gap above it (below the tabs) and beside it (before the window edge). Give the scrollbar its own viewport spanning the full panel between the tabs and footer, flush to the right window edge, like the Devices/Music apps. The tile grid keeps its margins and simply reserves the scrollbar width plus a gap on its right. Co-Authored-By: Claude Opus 4.8 --- programs/src/charmap/main.cpp | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/programs/src/charmap/main.cpp b/programs/src/charmap/main.cpp index 9e5a601..93cc3d1 100644 --- a/programs/src/charmap/main.cpp +++ b/programs/src/charmap/main.cpp @@ -204,9 +204,6 @@ static constexpr int FOOTER_H = 84; static constexpr int GRID_PAD = 12; static constexpr int CELL_GAP = 6; static constexpr int CELL_TARGET = 54; // desired tile edge in px -// Gutter reserved on the right of the tile area for the shared MTK scrollbar -// (its 12px track plus a little breathing room from the last tile column). -static constexpr int SB_GUTTER = mtk::SCROLLBAR_W + 6; static constexpr int TAB_SIZE = 16; static constexpr int CELL_SIZE = 26; // glyph font size inside a tile @@ -238,7 +235,8 @@ static const char* g_tab_labels[SET_COUNT] = {}; // populated from SETS in _sta struct Layout { Rect tabs; - Rect grid; // interior area available for the tile grid + Rect grid; // interior area available for the tile grid (with margins) + Rect panel; // full content region between tabs and footer (scrollbar viewport) Rect footer; Rect copy_btn; int cols; @@ -255,13 +253,21 @@ static Layout compute_layout(int w, int h) { L.tabs = { 0, 0, w, TAB_H }; int grid_top = TAB_H; L.footer = { 0, h - FOOTER_H, w, FOOTER_H }; + + // Scrollbar viewport: the whole panel between tabs and footer, so the + // shared MTK scrollbar hugs the window's right edge and spans full height + // (matching Devices/Music) — no inset above or beside it. + L.panel = { 0, grid_top, w, (h - FOOTER_H) - grid_top }; + + // Tile grid keeps left/top/bottom margins; its right edge leaves the + // scrollbar's width plus a GRID_PAD gap. L.grid = { GRID_PAD, grid_top + GRID_PAD, - w - GRID_PAD * 2, (h - FOOTER_H) - grid_top - GRID_PAD * 2 }; + w - mtk::SCROLLBAR_W - GRID_PAD * 2, + (h - FOOTER_H) - grid_top - GRID_PAD * 2 }; if (L.grid.w < CELL_TARGET) L.grid.w = CELL_TARGET; if (L.grid.h < 1) L.grid.h = 1; - int avail = L.grid.w - SB_GUTTER; - if (avail < CELL_TARGET) avail = CELL_TARGET; + int avail = L.grid.w; L.cols = (avail + CELL_GAP) / (CELL_TARGET + CELL_GAP); if (L.cols < 1) L.cols = 1; L.cell_w = (avail - CELL_GAP * (L.cols - 1)) / L.cols; @@ -291,7 +297,7 @@ static Rect tile_rect(const Layout& L, int i) { static int hit_tile(const Layout& L, int mx, int my) { if (my < L.grid.y || my >= L.grid.y + L.grid.h) return -1; - if (mx < L.grid.x || mx >= L.grid.x + L.grid.w - SB_GUTTER) return -1; + if (mx < L.grid.x || mx >= L.grid.x + L.grid.w) return -1; for (int i = 0; i < cur_set().count; i++) { Rect r = tile_rect(L, i); if (r.contains(mx, my)) return i; @@ -386,7 +392,7 @@ static void render(Canvas& c) { } // ---- Scrollbar (shared MTK widget; no-ops when the set fits) ---- - Rect sb_track = mtk::scrollbar_track_rect(L.grid); + Rect sb_track = mtk::scrollbar_track_rect(L.panel); Rect sb_thumb = mtk::scrollbar_thumb_rect(sb_track, L.content_h, L.grid.h, g_scroll); mtk::draw_scrollbar(c, sb_track, sb_thumb, g_sb_hover, g_sb_dragging, g_theme); @@ -461,7 +467,7 @@ static bool handle_mouse(const montauk::abi::WinEvent& ev, int w, int h) { bool released = !(ev.mouse.buttons & 1) && (ev.mouse.prev_buttons & 1); // Scrollbar geometry (shared MTK widget). - Rect sb_track = mtk::scrollbar_track_rect(L.grid); + Rect sb_track = mtk::scrollbar_track_rect(L.panel); Rect sb_thumb = mtk::scrollbar_thumb_rect(sb_track, L.content_h, L.grid.h, g_scroll); bool scrollable = !sb_track.empty() && !sb_thumb.empty(); From 55867c841aa09901ced115f318bc6132ae0aa482 Mon Sep 17 00:00:00 2001 From: Daniel Hammer Date: Mon, 6 Jul 2026 21:18:44 +0200 Subject: [PATCH 6/9] fix: native Copy button and copy confirmation in charmap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hand-rolled Copy button (bold, custom-shaded accent fill) and the floating accent "toast" pill both looked out of place. Replace the button with the canonical mtk::draw_button (BUTTON_PRIMARY), matching the Refresh button in Devices and buttons elsewhere. Replace the floating pill with a plain "Copied to clipboard" status line in the footer, right-aligned beside the Copy button, that fades after ~1.6s — no overlay drawn over the character grid. Co-Authored-By: Claude Opus 4.8 --- programs/src/charmap/main.cpp | 56 +++++++++++++---------------------- 1 file changed, 20 insertions(+), 36 deletions(-) diff --git a/programs/src/charmap/main.cpp b/programs/src/charmap/main.cpp index 93cc3d1..e0dd57c 100644 --- a/programs/src/charmap/main.cpp +++ b/programs/src/charmap/main.cpp @@ -205,14 +205,13 @@ static constexpr int GRID_PAD = 12; static constexpr int CELL_GAP = 6; static constexpr int CELL_TARGET = 54; // desired tile edge in px -static constexpr int TAB_SIZE = 16; static constexpr int CELL_SIZE = 26; // glyph font size inside a tile static constexpr int PREVIEW_SIZE = 40; static constexpr int NAME_SIZE = 16; static constexpr int META_SIZE = 14; -static constexpr int TOAST_SIZE = 14; +static constexpr int STATUS_SIZE = 14; -static constexpr uint64_t TOAST_MS = 1400; +static constexpr uint64_t COPIED_MS = 1600; // ==== App state ==== @@ -225,8 +224,7 @@ static int g_scroll = 0; // vertical scroll offset in px static bool g_sb_hover = false; // pointer over the scrollbar track static bool g_sb_dragging = false; // dragging the scrollbar thumb static int g_sb_drag_offset = 0; // grab point within the thumb -static uint64_t g_toast_until = 0; -static char g_toast[48] = {}; +static uint64_t g_copied_until = 0; // show the "copied" status until this time static mtk::Theme g_theme; static const char* g_tab_labels[SET_COUNT] = {}; // populated from SETS in _start @@ -336,16 +334,7 @@ static void copy_selected() { char s[2]; glyph_str(g.byte, s); gui::clipboard_set_text(s, 1); - - // Toast: Copied "" - int n = 0; - const char* pfx = "Copied "; - while (pfx[n]) { g_toast[n] = pfx[n]; n++; } - g_toast[n++] = '"'; - g_toast[n++] = (char)g.byte; - g_toast[n++] = '"'; - g_toast[n] = '\0'; - g_toast_until = montauk::get_milliseconds() + TOAST_MS; + g_copied_until = montauk::get_milliseconds() + COPIED_MS; } // ==== Rendering ==== @@ -432,25 +421,20 @@ static void render(Canvas& c) { draw_text(c, fonts::system_font, text_x, L.footer.y + 16 + NAME_SIZE + 8, meta, g_theme.text_subtle, META_SIZE); - // Copy button. - Color btn_bg = g_theme.accent; - if (g_copy_pressed) btn_bg = g_theme.accent_hover; - else if (g_copy_hover) btn_bg = mtk::darken(g_theme.accent, 16); - draw_button(c, title_font, L.copy_btn.x, L.copy_btn.y, L.copy_btn.w, - L.copy_btn.h, "Copy", btn_bg, g_theme.accent_fg, - g_theme.radius_md, TAB_SIZE); - - // ---- Toast (drawn last; floats over the lower-right of the grid) ---- - if (g_toast[0] && montauk::get_milliseconds() < g_toast_until) { - int tw = text_width(fonts::system_font, g_toast, TOAST_SIZE); - int pill_w = tw + 24, pill_h = 28; - int px = c.w - GRID_PAD - pill_w; - int py = L.footer.y - GRID_PAD - pill_h; - c.fill_rounded_rect(px, py, pill_w, pill_h, pill_h / 2, g_theme.accent); - draw_text(c, fonts::system_font, px + 12, - py + (pill_h - text_height(fonts::system_font, TOAST_SIZE)) / 2, - g_toast, g_theme.accent_fg, TOAST_SIZE); + // Transient "copied" confirmation: a plain status line just left of the + // Copy button (no floating overlay over the grid). + if (g_copied_until && montauk::get_milliseconds() < g_copied_until) { + const char* msg = "Copied to clipboard"; + int sw = text_width(fonts::system_font, msg, STATUS_SIZE); + int sh = text_height(fonts::system_font, STATUS_SIZE); + draw_text(c, fonts::system_font, L.copy_btn.x - 14 - sw, + L.copy_btn.y + (L.copy_btn.h - sh) / 2, + msg, g_theme.accent, STATUS_SIZE); } + + // Copy button — canonical MTK primary button, like other Montauk apps. + mtk::WidgetState copy_state = mtk::widget_state(false, g_copy_hover, true); + mtk::draw_button(c, L.copy_btn, "Copy", mtk::BUTTON_PRIMARY, copy_state, g_theme); } // ==== Event handling ==== @@ -596,9 +580,9 @@ extern "C" void _start() { if (r < 0) break; if (r == 0) { - // Expire the toast even when idle so the pill disappears. - if (toast_shown && g_toast[0] && - montauk::get_milliseconds() >= g_toast_until) { + // Clear the "copied" status once it expires, even when idle. + if (toast_shown && g_copied_until && + montauk::get_milliseconds() >= g_copied_until) { Canvas canvas = win.canvas(); render(canvas); win.present(); From 8eadf19e97bc18754338fd1a52bc25fc6fe7666f Mon Sep 17 00:00:00 2001 From: Daniel Hammer Date: Mon, 6 Jul 2026 21:21:51 +0200 Subject: [PATCH 7/9] fix: confirm copy via the button label instead of status text Instead of a separate "Copied to clipboard" line beside the Copy button, flip the button's own label to "Copied" for ~1.6s after a copy, then back to "Copy". Simpler and keeps the footer uncluttered. Co-Authored-By: Claude Opus 4.8 --- programs/src/charmap/main.cpp | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) diff --git a/programs/src/charmap/main.cpp b/programs/src/charmap/main.cpp index e0dd57c..9973a04 100644 --- a/programs/src/charmap/main.cpp +++ b/programs/src/charmap/main.cpp @@ -209,7 +209,6 @@ static constexpr int CELL_SIZE = 26; // glyph font size inside a tile static constexpr int PREVIEW_SIZE = 40; static constexpr int NAME_SIZE = 16; static constexpr int META_SIZE = 14; -static constexpr int STATUS_SIZE = 14; static constexpr uint64_t COPIED_MS = 1600; @@ -421,20 +420,12 @@ static void render(Canvas& c) { draw_text(c, fonts::system_font, text_x, L.footer.y + 16 + NAME_SIZE + 8, meta, g_theme.text_subtle, META_SIZE); - // Transient "copied" confirmation: a plain status line just left of the - // Copy button (no floating overlay over the grid). - if (g_copied_until && montauk::get_milliseconds() < g_copied_until) { - const char* msg = "Copied to clipboard"; - int sw = text_width(fonts::system_font, msg, STATUS_SIZE); - int sh = text_height(fonts::system_font, STATUS_SIZE); - draw_text(c, fonts::system_font, L.copy_btn.x - 14 - sw, - L.copy_btn.y + (L.copy_btn.h - sh) / 2, - msg, g_theme.accent, STATUS_SIZE); - } - // Copy button — canonical MTK primary button, like other Montauk apps. + // Its label flips to "Copied" for a moment after a copy as confirmation. + bool just_copied = g_copied_until && montauk::get_milliseconds() < g_copied_until; mtk::WidgetState copy_state = mtk::widget_state(false, g_copy_hover, true); - mtk::draw_button(c, L.copy_btn, "Copy", mtk::BUTTON_PRIMARY, copy_state, g_theme); + mtk::draw_button(c, L.copy_btn, just_copied ? "Copied" : "Copy", + mtk::BUTTON_PRIMARY, copy_state, g_theme); } // ==== Event handling ==== From 4301f39a9eb51f2fd694df76f5a5627bf723c82e Mon Sep 17 00:00:00 2001 From: Daniel Hammer Date: Mon, 6 Jul 2026 21:24:27 +0200 Subject: [PATCH 8/9] fix: grey out the charmap Copy button while showing "Copied" Render the button in its disabled state during the post-copy confirmation window, matching the Bluetooth app's greyed-out "Scanning..." button. Co-Authored-By: Claude Opus 4.8 --- programs/src/charmap/main.cpp | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/programs/src/charmap/main.cpp b/programs/src/charmap/main.cpp index 9973a04..53ca77e 100644 --- a/programs/src/charmap/main.cpp +++ b/programs/src/charmap/main.cpp @@ -421,9 +421,10 @@ static void render(Canvas& c) { meta, g_theme.text_subtle, META_SIZE); // Copy button — canonical MTK primary button, like other Montauk apps. - // Its label flips to "Copied" for a moment after a copy as confirmation. + // After a copy it flips to a greyed-out "Copied" for a moment (the disabled + // state, matching the Bluetooth app's "Scanning..." button) as confirmation. bool just_copied = g_copied_until && montauk::get_milliseconds() < g_copied_until; - mtk::WidgetState copy_state = mtk::widget_state(false, g_copy_hover, true); + mtk::WidgetState copy_state = mtk::widget_state(false, g_copy_hover, !just_copied); mtk::draw_button(c, L.copy_btn, just_copied ? "Copied" : "Copy", mtk::BUTTON_PRIMARY, copy_state, g_theme); } From f7ec6c69d35959c23cec063929e3c7d3ee200fa8 Mon Sep 17 00:00:00 2001 From: Daniel Hammer Date: Mon, 6 Jul 2026 21:26:29 +0200 Subject: [PATCH 9/9] feat: enlarge and vertically center charmap footer text Bump the selected character's name (16->19) and metadata (14->15) sizes, and lay them out as a two-line block vertically centered on the preview glyph box instead of top-aligned. Co-Authored-By: Claude Opus 4.8 --- programs/src/charmap/main.cpp | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/programs/src/charmap/main.cpp b/programs/src/charmap/main.cpp index 53ca77e..4ba870d 100644 --- a/programs/src/charmap/main.cpp +++ b/programs/src/charmap/main.cpp @@ -207,8 +207,8 @@ static constexpr int CELL_TARGET = 54; // desired tile edge in px static constexpr int CELL_SIZE = 26; // glyph font size inside a tile static constexpr int PREVIEW_SIZE = 40; -static constexpr int NAME_SIZE = 16; -static constexpr int META_SIZE = 14; +static constexpr int NAME_SIZE = 19; +static constexpr int META_SIZE = 15; static constexpr uint64_t COPIED_MS = 1600; @@ -409,15 +409,20 @@ static void render(Canvas& c) { g_theme.radius_md - 1, colors::WHITE); draw_centered_glyph(c, preview, sg.byte, g_theme.text, PREVIEW_SIZE); - // Name + metadata. + // Name + metadata, as a two-line block vertically centered on the preview. int cp = codepoint_of(sg.byte); int text_x = preview.x + preview.w + 14; - draw_text(c, title_font, text_x, L.footer.y + 16, sg.name, - g_theme.text, NAME_SIZE); char meta[64]; snprintf(meta, sizeof(meta), "U+%04X dec %d Alt 0%d", cp, cp, (int)sg.byte); - draw_text(c, fonts::system_font, text_x, L.footer.y + 16 + NAME_SIZE + 8, + + int name_h = text_height(title_font, NAME_SIZE); + int meta_h = text_height(fonts::system_font, META_SIZE); + int line_gap = 6; + int block_y = preview.y + (preview.h - (name_h + line_gap + meta_h)) / 2; + + draw_text(c, title_font, text_x, block_y, sg.name, g_theme.text, NAME_SIZE); + draw_text(c, fonts::system_font, text_x, block_y + name_h + line_gap, meta, g_theme.text_subtle, META_SIZE); // Copy button — canonical MTK primary button, like other Montauk apps.