Per-pane and global terminal zoom via keyboard

Each leaf now carries an optional fontSizeOffset, persisted in
workspace.json alongside everything else. Ctrl+= / Ctrl+- / Ctrl+0
adjust the active pane; adding Shift escalates to every pane (the
mirror of the broadcast Shift+Alt convention, with shift alone since
the keys are otherwise unused). Bindings match on e.code so layouts
that don't have "=" / "-" / "0" in the same spot still work.

XtermPane gained a fontSize prop. A secondary effect reacts to changes:
set term.options.fontSize, fit() to recompute cols/rows for the new
cell size, refresh(), then resizePane so bash redraws the prompt at
the right width. No remount, so PTY + scrollback survive zoom changes.

The new tree helpers (resolveFontSize / adjustFontSize /
adjustAllFontSizes) are metadata-only — they don't swap leaf ids, so
nothing respawns. reshapeToPreset also carries the offset across when
splicing existing leaves into a new layout. 12 new vitest cases pin
those invariants plus the clamp and reset-to-default behaviour.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
megaproxy 2026-05-22 22:48:35 +01:00
parent 8f9667b218
commit aab36afce4
6 changed files with 237 additions and 11 deletions

View file

@ -52,8 +52,12 @@ interface XtermPaneProps {
onFocus?: () => void;
/** Increment to refocus the terminal programmatically (palette etc.). */
focusTrigger?: number;
/** Absolute font size in px. Changes are applied live (fit + PTY resize). */
fontSize?: number;
}
const DEFAULT_XTERM_FONT_SIZE = 13;
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
@ -67,8 +71,16 @@ export default function XtermPane({
onDataReceived,
onFocus,
focusTrigger = 0,
fontSize,
}: XtermPaneProps) {
const containerRef = useRef<HTMLDivElement>(null);
const termRef = useRef<Terminal | null>(null);
const fitRef = useRef<FitAddon | null>(null);
const paneIdRef = useRef<PaneId | null>(null);
// Stash the most recent `fontSize` prop so the mount effect can pick
// up the initial value without re-running when it changes (the secondary
// effect below handles dynamic updates).
const initialFontSizeRef = useRef(fontSize);
// Stable refs for callbacks so the mount effect doesn't need to re-run when
// parents pass new inline functions, while still always calling the latest version.
@ -93,7 +105,7 @@ export default function XtermPane({
let term: Terminal | null = new Terminal({
fontFamily: '"Cascadia Mono", "JetBrains Mono", "Consolas", monospace',
fontSize: 13,
fontSize: initialFontSizeRef.current ?? DEFAULT_XTERM_FONT_SIZE,
cursorBlink: true,
theme: {
background: "#0c0c0c",
@ -103,8 +115,10 @@ export default function XtermPane({
convertEol: false,
allowProposedApi: true,
});
termRef.current = term;
const fit = new FitAddon();
fitRef.current = fit;
term.loadAddon(fit);
term.open(container);
@ -127,6 +141,7 @@ export default function XtermPane({
void killPane(paneId);
return;
}
paneIdRef.current = paneId;
onStatusRef.current?.(`pane ${paneId} alive`, true);
onSpawnRef.current?.(paneId);
} catch (e) {
@ -219,6 +234,9 @@ export default function XtermPane({
if (paneId != null) void killPane(paneId);
term?.dispose();
term = null;
termRef.current = null;
fitRef.current = null;
paneIdRef.current = null;
};
// distro/cwd are only used at spawn time; intentionally omitted from deps
// so remounting doesn't happen if a parent re-renders with the same values.
@ -228,13 +246,6 @@ export default function XtermPane({
// -------------------------------------------------------------------------
// focusTrigger: programmatic refocus from parent (palette navigation etc.)
// -------------------------------------------------------------------------
const termRef = useRef<Terminal | null>(null);
// Keep termRef in sync via a second effect that runs after mount.
// We can't easily share the Terminal instance across the two effects without
// a ref, so we store it on termRef inside the mount effect instead.
// Actually, let's just wire focusTrigger by querying the textarea directly —
// that avoids the cross-effect coupling problem entirely.
useEffect(() => {
if (focusTrigger > 0 && containerRef.current) {
const ta = containerRef.current.querySelector<HTMLTextAreaElement>(
@ -244,8 +255,31 @@ export default function XtermPane({
}
}, [focusTrigger]);
// Suppress unused ref warning
void termRef;
// -------------------------------------------------------------------------
// Live font-size changes (Ctrl+Shift+= / - / 0).
//
// Setting term.options.fontSize re-rasterises glyphs immediately, but the
// cols/rows the terminal thinks it has are still based on the OLD cell
// size — so we have to fit() to recompute, refresh() to repaint, then
// ship the new dimensions to the PTY so bash redraws the prompt at the
// right width.
// -------------------------------------------------------------------------
useEffect(() => {
const term = termRef.current;
const fit = fitRef.current;
if (!term || !fit) return;
const target = fontSize ?? DEFAULT_XTERM_FONT_SIZE;
if (term.options.fontSize === target) return;
try {
term.options.fontSize = target;
fit.fit();
term.refresh(0, term.rows - 1);
const paneId = paneIdRef.current;
if (paneId != null) void resizePane(paneId, term.cols, term.rows);
} catch (e) {
console.warn("font-size apply failed", e);
}
}, [fontSize]);
return <div ref={containerRef} style={{ width: "100%", height: "100%" }} />;
}