From cffc763719154ff3b536ccd73141306b80c26bcd Mon Sep 17 00:00:00 2001 From: Yuto Nishida Date: Mon, 10 Aug 2026 00:04:55 -0700 Subject: [PATCH] [papyrus] Add a per-(emulator, font) terminal font size setting Terminal panes rendered at a hardcoded 12px with no way to change it. Add a "Font size for selected font" control to the Settings panel, keyed independently per (emulator, font) pair -- each emulator+font combination remembers its own size, mirroring how the terminal font itself is already stored per-emulator. Sizes persist as one JSON map under papyrus:fontSizes, keyed by "backend:font", defaulting to 12 and clamped to 6-40px (settings.ts). The zustand store holds the map; SettingsPanel derives the active pair value and renders a stepper with an editable number that clamps on blur/Enter and re-syncs the field. Terminal.tsx resolves the active pair size and applies it live through a new backend.setFontSize: xterm mutates term.options.fontSize and refits (mirroring setFont/setLineHeight), while ghostty cannot update live so it returns false and the size is folded into the pane remount token. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01GBWyw3DyRvvNmgJpFZBsC5 --- .../client/src/components/SettingsPanel.tsx | 108 +++++++++++++++++- .../client/src/components/Terminal.tsx | 36 +++++- .../papyrus/client/src/settings.test.ts | 103 ++++++++++++++++- experimental/papyrus/client/src/settings.ts | 63 ++++++++++ .../papyrus/client/src/stores/useStore.ts | 15 +++ .../papyrus/client/src/terminal/backend.ts | 1 + .../client/src/terminal/ghosttyBackend.ts | 12 +- .../client/src/terminal/xtermBackend.ts | 5 + 8 files changed, 328 insertions(+), 15 deletions(-) diff --git a/experimental/papyrus/client/src/components/SettingsPanel.tsx b/experimental/papyrus/client/src/components/SettingsPanel.tsx index aeb53a4..ed23c5d 100644 --- a/experimental/papyrus/client/src/components/SettingsPanel.tsx +++ b/experimental/papyrus/client/src/components/SettingsPanel.tsx @@ -1,3 +1,4 @@ +import { useEffect, useState } from "react"; import { AnimatePresence, motion } from "framer-motion"; import { X, Minus, Plus, TerminalSquare, Ghost, type LucideIcon } from "lucide-react"; import { useStore } from "../stores/useStore"; @@ -5,6 +6,11 @@ import { LINE_SPACING_STEP, MIN_LINE_SPACING, MAX_LINE_SPACING, + FONT_SIZE_STEP, + MIN_FONT_SIZE, + MAX_FONT_SIZE, + clampFontSize, + fontSizeFor, TERMINAL_BACKENDS, TERMINAL_FONTS, type BackendId, @@ -44,6 +50,82 @@ function FontPicker({ value, onChange }: { value: FontId; onChange: (id: FontId) ); } +// A −/+ stepper with an editable number in the middle. `value` is the committed size; `onCommit` +// clamps and persists. The text buffer lets the field go empty/partial mid-edit; it re-syncs to +// `value` whenever that changes externally (font/emulator switch, or a clamp on commit), and +// commits on blur/Enter — reverting to `value` on empty or non-numeric input. +function FontSizeStepper({ value, onCommit }: { value: number; onCommit: (n: number) => void }) { + const [text, setText] = useState(String(value)); + useEffect(() => { + setText(String(value)); + }, [value]); + + const atMin = value <= MIN_FONT_SIZE; + const atMax = value >= MAX_FONT_SIZE; + + // Commit the field's live DOM value (not the `text` closure, which can lag a blur that lands in + // the same tick as the edit). Always re-sync the buffer to the committed value: clamping here + // (not just in onCommit) keeps the field honest even when the clamped result equals the current + // `value` (so the re-sync effect below wouldn't fire). Empty/NaN reverts to the current value. + const commit = (raw: string) => { + const n = Number(raw); + if (raw.trim() !== "" && Number.isFinite(n)) { + const clamped = clampFontSize(n); + onCommit(clamped); + setText(String(clamped)); + } else { + setText(String(value)); + } + }; + + return ( +
+ + setText(e.target.value)} + onBlur={(e) => commit(e.currentTarget.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + commit(e.currentTarget.value); + e.currentTarget.blur(); + } + }} + className="w-10 text-center font-mono text-sm text-content tabular-nums bg-transparent outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none" + /> + +
+ ); +} + // Global appearance settings, docked to the right edge and toggled by the header gear. // Mirrors Sidebar's slide-in; sits above it (z-[60]) so the two never collide. Holds the // emulator backend selector, then a section of options for the selected emulator only — a @@ -60,6 +142,8 @@ export function SettingsPanel() { const setXtermFont = useStore((s) => s.setXtermFont); const ghosttyFont = useStore((s) => s.ghosttyFont); const setGhosttyFont = useStore((s) => s.setGhosttyFont); + const fontSizes = useStore((s) => s.fontSizes); + const setFontSize = useStore((s) => s.setFontSize); const atMin = lineSpacing <= MIN_LINE_SPACING; const atMax = lineSpacing >= MAX_LINE_SPACING; @@ -133,6 +217,13 @@ export function SettingsPanel() { Font +
+ Font size for selected font + setFontSize("xterm", xtermFont, n)} + /> +
Line spacing
@@ -169,10 +260,19 @@ export function SettingsPanel() { )} {terminalBackend === "ghostty" && ( -
- Font - -
+ <> +
+ Font + +
+
+ Font size for selected font + setFontSize("ghostty", ghosttyFont, n)} + /> +
+ )}
diff --git a/experimental/papyrus/client/src/components/Terminal.tsx b/experimental/papyrus/client/src/components/Terminal.tsx index 6ea1198..2ad7d9a 100644 --- a/experimental/papyrus/client/src/components/Terminal.tsx +++ b/experimental/papyrus/client/src/components/Terminal.tsx @@ -3,7 +3,7 @@ import { useStore } from "../stores/useStore"; import { terminalWsUrl } from "./terminalWs"; import { createTerminalBackend, type TerminalBackend } from "../terminal/backend"; import { terminalTheme } from "../terminal/palette"; -import { fontStack } from "../settings"; +import { fontStack, fontSizeFor } from "../settings"; interface TerminalProps { sessionId: string; @@ -25,15 +25,20 @@ export function Terminal({ sessionId, color }: TerminalProps) { const backendId = useStore((s) => s.terminalBackend); const xtermFont = useStore((s) => s.xtermFont); const ghosttyFont = useStore((s) => s.ghosttyFont); + const fontSizes = useStore((s) => s.fontSizes); // Font is per-emulator; the active pane renders the current backend's font. const activeFont = backendId === "xterm" ? xtermFont : ghosttyFont; + // Font size is per (emulator, font) pair; resolve the current pair's size. + const activeFontSize = fontSizeFor(fontSizes, backendId, activeFont); - // Backends that can't mutate theme/line-height/font live (ghostty) fold those into a + // Backends that can't mutate theme/line-height/font/size live (ghostty) fold those into a // remount token, so changing any of them rebuilds the pane at the right settings. xterm - // applies all three live, so its token is constant and its rebuild triggers stay the old deps. + // applies them all live, so its token is constant and its rebuild triggers stay the old deps. const remountToken = - backendId === "xterm" ? "" : `${resolvedTheme}:${lineSpacing}:${ghosttyFont}`; + backendId === "xterm" + ? "" + : `${resolvedTheme}:${lineSpacing}:${ghosttyFont}:${activeFontSize}`; useEffect(() => { const container = terminalRef.current; @@ -56,7 +61,13 @@ export function Terminal({ sessionId, color }: TerminalProps) { const created = await createTerminalBackend(backendId, { cursorBlink: true, cursorStyle: "bar", - fontSize: 12, + // Read non-reactively; live size changes are handled by the setFontSize effect (xterm) or + // the remount token (ghostty), not by tearing down here. + fontSize: fontSizeFor( + useStore.getState().fontSizes, + backendId, + backendId === "xterm" ? useStore.getState().xtermFont : useStore.getState().ghosttyFont, + ), // Read non-reactively; live font changes are handled by the setFont effect (xterm) or // the remount token (ghostty), not by tearing down here. fontFamily: fontStack( @@ -195,6 +206,21 @@ export function Terminal({ sessionId, color }: TerminalProps) { } }, [activeFont]); + // Apply font-size changes live for the active (emulator, font) pair. Like font/line-spacing, a + // new size changes cell metrics → row/col count, so refit and tell the PTY. Backends without + // live support (ghostty) return false and are handled by the remount token instead. + useEffect(() => { + const backend = backendRef.current; + if (backend && backend.setFontSize(activeFontSize)) { + backend.fit(); + const ws = wsRef.current; + const size = backend.size; + if (ws?.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: "resize", cols: size.cols, rows: size.rows })); + } + } + }, [activeFontSize]); + return (
{ expect(fontStack(DEFAULT_FONT)).toBe('"JetBrains Mono", monospace'); }); }); + +describe("clampFontSize", () => { + test("clamps below the minimum up to MIN", () => { + expect(clampFontSize(1)).toBe(MIN_FONT_SIZE); + }); + + test("clamps above the maximum down to MAX", () => { + expect(clampFontSize(999)).toBe(MAX_FONT_SIZE); + }); + + test("rounds to a whole pixel", () => { + expect(clampFontSize(13.4)).toBe(13); + expect(clampFontSize(13.6)).toBe(14); + }); + + test("passes a valid whole value through unchanged", () => { + expect(clampFontSize(16)).toBe(16); + }); + + test("non-finite input falls back to the default", () => { + expect(clampFontSize(NaN)).toBe(DEFAULT_FONT_SIZE); + expect(clampFontSize(Infinity)).toBe(DEFAULT_FONT_SIZE); + }); +}); + +describe("parseFontSizes", () => { + test("null (nothing stored) yields an empty map", () => { + expect(parseFontSizes(null)).toEqual({}); + }); + + test("corrupt JSON or a non-object yields an empty map rather than throwing", () => { + expect(parseFontSizes("not json")).toEqual({}); + expect(parseFontSizes("42")).toEqual({}); + expect(parseFontSizes("null")).toEqual({}); + }); + + test("drops entries whose value isn't a finite number", () => { + expect(parseFontSizes('{"xterm:iosevka":"big","ghostty:iosevka":15}')).toEqual({ + "ghostty:iosevka": 15, + }); + }); + + test("clamps surviving values into range", () => { + expect(parseFontSizes('{"xterm:iosevka":999}')).toEqual({ + "xterm:iosevka": MAX_FONT_SIZE, + }); + }); +}); + +describe("loadFontSizes / saveFontSizes", () => { + beforeEach(() => { + localStorage.clear(); + }); + + test("defaults to an empty map when nothing is stored", () => { + expect(loadFontSizes()).toEqual({}); + }); + + test("persists the whole map under the papyrus:fontSizes key and reads back", () => { + const map = { "xterm:iosevka": 14, "ghostty:jetbrains-mono": 18 }; + saveFontSizes(map); + expect(localStorage.getItem(FONT_SIZE_STORAGE_KEY)).toBe(JSON.stringify(map)); + expect(loadFontSizes()).toEqual(map); + }); + + test("a corrupt stored value loads as an empty map", () => { + localStorage.setItem(FONT_SIZE_STORAGE_KEY, "{bogus"); + expect(loadFontSizes()).toEqual({}); + }); +}); + +describe("fontSizeFor / fontSizeKey", () => { + test("keys a pair as `${backend}:${font}`", () => { + expect(fontSizeKey("xterm", "iosevka")).toBe("xterm:iosevka"); + }); + + test("an unset pair falls back to the default size", () => { + expect(fontSizeFor({}, "xterm", "iosevka")).toBe(DEFAULT_FONT_SIZE); + }); + + test("each (emulator, font) pair resolves its own stored size", () => { + const map = { "xterm:iosevka": 14, "ghostty:iosevka": 20, "xterm:jetbrains-mono": 11 }; + expect(fontSizeFor(map, "xterm", "iosevka")).toBe(14); + expect(fontSizeFor(map, "ghostty", "iosevka")).toBe(20); + expect(fontSizeFor(map, "xterm", "jetbrains-mono")).toBe(11); + // A pair absent from the map still defaults. + expect(fontSizeFor(map, "ghostty", "jetbrains-mono")).toBe(DEFAULT_FONT_SIZE); + }); +}); diff --git a/experimental/papyrus/client/src/settings.ts b/experimental/papyrus/client/src/settings.ts index 1a7abed..0d3cd47 100644 --- a/experimental/papyrus/client/src/settings.ts +++ b/experimental/papyrus/client/src/settings.ts @@ -135,3 +135,66 @@ export function saveFont(storageKey: string, id: FontId): void { export function fontStack(id: FontId): string { return (TERMINAL_FONTS.find((f) => f.id === id) ?? TERMINAL_FONTS[0]).stack; } + +// --- Terminal font size --- +// +// Font size in px, remembered independently per (emulator, font) pair — different fonts render +// at different apparent sizes, so each combination keeps its own value. Persisted as a single +// JSON map under one key, keyed by `${backend}:${font}`; a missing pair falls back to +// DEFAULT_FONT_SIZE. Values are clamped to a sane px range and rounded to whole pixels. + +export const FONT_SIZE_STORAGE_KEY = "papyrus:fontSizes"; + +export const MIN_FONT_SIZE = 6; +export const MAX_FONT_SIZE = 40; +export const FONT_SIZE_STEP = 1; +// 12 is what xterm/ghostty panes were previously hardcoded to. +export const DEFAULT_FONT_SIZE = 12; + +// The map key for an (emulator, font) pair. +export function fontSizeKey(backend: BackendId, font: FontId): string { + return `${backend}:${font}`; +} + +// Clamp to [MIN, MAX] and round to a whole pixel. Non-finite input falls back to the default. +export function clampFontSize(n: number): number { + if (!Number.isFinite(n)) return DEFAULT_FONT_SIZE; + return Math.round(Math.min(MAX_FONT_SIZE, Math.max(MIN_FONT_SIZE, n))); +} + +// A lenient parse: corrupt JSON, a non-object, or entries whose value isn't a finite number are +// dropped (they fall back to DEFAULT_FONT_SIZE at read). Surviving values are clamped. +export function parseFontSizes(raw: string | null): Record { + if (raw === null) return {}; + try { + const parsed: unknown = JSON.parse(raw); + if (!parsed || typeof parsed !== "object") return {}; + const out: Record = {}; + for (const [k, v] of Object.entries(parsed as Record)) { + if (typeof v === "number" && Number.isFinite(v)) out[k] = clampFontSize(v); + } + return out; + } catch { + return {}; + } +} + +export function loadFontSizes(): Record { + if (typeof localStorage === "undefined") return {}; + return parseFontSizes(localStorage.getItem(FONT_SIZE_STORAGE_KEY)); +} + +export function saveFontSizes(map: Record): void { + if (typeof localStorage === "undefined") return; + localStorage.setItem(FONT_SIZE_STORAGE_KEY, JSON.stringify(map)); +} + +// The effective size for an (emulator, font) pair, defaulting when unset. +export function fontSizeFor( + map: Record, + backend: BackendId, + font: FontId, +): number { + const v = map[fontSizeKey(backend, font)]; + return typeof v === "number" && Number.isFinite(v) ? clampFontSize(v) : DEFAULT_FONT_SIZE; +} diff --git a/experimental/papyrus/client/src/stores/useStore.ts b/experimental/papyrus/client/src/stores/useStore.ts index d458c85..410bb4d 100644 --- a/experimental/papyrus/client/src/stores/useStore.ts +++ b/experimental/papyrus/client/src/stores/useStore.ts @@ -13,11 +13,15 @@ import { type FontId, GHOSTTY_FONT_STORAGE_KEY, XTERM_FONT_STORAGE_KEY, + clampFontSize, clampLineSpacing, + fontSizeKey, loadFont, + loadFontSizes, loadLineSpacing, loadTerminalBackend, saveFont, + saveFontSizes, saveLineSpacing, saveTerminalBackend, } from "../settings"; @@ -136,6 +140,9 @@ interface AppState { setXtermFont: (id: FontId) => void; ghosttyFont: FontId; setGhosttyFont: (id: FontId) => void; + // Font size (px) is per (emulator, font) pair, keyed by `${backend}:${font}` in this map. + fontSizes: Record; + setFontSize: (backend: BackendId, font: FontId, size: number) => void; } export const useStore = create((set) => ({ @@ -236,4 +243,12 @@ export const useStore = create((set) => ({ saveFont(GHOSTTY_FONT_STORAGE_KEY, id); set({ ghosttyFont: id }); }, + // Terminal reads the active (emulator, font) pair's size live (xterm) or via a remount (ghostty). + fontSizes: loadFontSizes(), + setFontSize: (backend, font, size) => + set((state) => { + const next = { ...state.fontSizes, [fontSizeKey(backend, font)]: clampFontSize(size) }; + saveFontSizes(next); + return { fontSizes: next }; + }), })); diff --git a/experimental/papyrus/client/src/terminal/backend.ts b/experimental/papyrus/client/src/terminal/backend.ts index 39b8c1a..112da5b 100644 --- a/experimental/papyrus/client/src/terminal/backend.ts +++ b/experimental/papyrus/client/src/terminal/backend.ts @@ -46,6 +46,7 @@ export interface TerminalBackend { setTheme(themeName: ThemeName, cursorColor: string): boolean; setLineHeight(value: number): boolean; setFont(fontFamily: string): boolean; + setFontSize(px: number): boolean; dispose(): void; } diff --git a/experimental/papyrus/client/src/terminal/ghosttyBackend.ts b/experimental/papyrus/client/src/terminal/ghosttyBackend.ts index 3f6b5c6..f5b3569 100644 --- a/experimental/papyrus/client/src/terminal/ghosttyBackend.ts +++ b/experimental/papyrus/client/src/terminal/ghosttyBackend.ts @@ -101,10 +101,10 @@ class GhosttyBackend implements TerminalBackend { return true; } - // Line-height and font can't mutate live: Terminal.tsx folds them into its remount token, - // rebuilding the pane with the new settings baked into the constructor. A font remount re-runs - // createGhosttyBackend (awaits document.fonts.ready) and open() (re-measures the cell via - // roundCellWidth), so the new font is sized correctly. + // Line-height, font, and font size can't mutate live: Terminal.tsx folds them into its remount + // token, rebuilding the pane with the new settings baked into the constructor. A font/size + // remount re-runs createGhosttyBackend (awaits document.fonts.ready) and open() (re-measures the + // cell via roundCellWidth), so the new font is sized correctly. setLineHeight(): boolean { return false; } @@ -113,6 +113,10 @@ class GhosttyBackend implements TerminalBackend { return false; } + setFontSize(): boolean { + return false; + } + dispose() { this.term.dispose(); } diff --git a/experimental/papyrus/client/src/terminal/xtermBackend.ts b/experimental/papyrus/client/src/terminal/xtermBackend.ts index 914f4a4..60fd2af 100644 --- a/experimental/papyrus/client/src/terminal/xtermBackend.ts +++ b/experimental/papyrus/client/src/terminal/xtermBackend.ts @@ -68,6 +68,11 @@ class XtermBackend implements TerminalBackend { return true; } + setFontSize(px: number): boolean { + this.term.options.fontSize = px; + return true; + } + dispose() { this.term.dispose(); } -- 2.51.2