From aab36afce440f1c4a23c1171c4846f6466e42a2d Mon Sep 17 00:00:00 2001 From: megaproxy Date: Fri, 22 May 2026 22:48:35 +0100 Subject: [PATCH] Per-pane and global terminal zoom via keyboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- README.md | 4 ++ src/App.tsx | 19 +++++++ src/components/XtermPane.tsx | 54 +++++++++++++++---- src/lib/layout/LeafPane.tsx | 3 +- src/lib/layout/tree.test.ts | 101 +++++++++++++++++++++++++++++++++++ src/lib/layout/tree.ts | 67 +++++++++++++++++++++++ 6 files changed, 237 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index e277854..c6cc0f7 100644 --- a/README.md +++ b/README.md @@ -32,9 +32,13 @@ A Windows desktop app for running and arranging many WSL terminals at once. Buil | `Ctrl+Shift+B` | toggle broadcast on active pane | | `Ctrl+Shift+Alt+B` | toggle broadcast on ALL panes (same as the titlebar 📡 button) | | `Ctrl+Shift+←` / `→` / `↑` / `↓` | focus neighbour pane in that direction | +| `Ctrl+=` / `Ctrl+-` / `Ctrl+0` | zoom the active pane in / out / back to default | +| `Ctrl+Shift+=` / `Ctrl+Shift+-` / `Ctrl+Shift+0` | same, applied to **every** pane (shift = "to all") | Shortcuts work while a terminal is focused — we capture the key before xterm.js sees it. They don't fire while you're typing into a label edit or the palette input, so those still work normally. `Ctrl` and `⌘` (Cmd) are interchangeable. +Font size persists per pane in `workspace.json`, so a zoomed pane stays zoomed across restarts. + ### Mouse + toolbar - **Split panes** — `⇥` in the pane toolbar splits right, `⇣` splits down. The new pane inherits the parent's distro; the cwd defaults to `~` in the WSL distro. diff --git a/src/App.tsx b/src/App.tsx index 6cab616..9c40c98 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -22,6 +22,8 @@ import { changeLabel, toggleBroadcast as toggleBroadcastInTree, setAllBroadcast, + adjustFontSize, + adjustAllFontSizes, reshapeToPreset, flattenLayout, updateSplitRatio, @@ -278,6 +280,23 @@ export default function App() { return; } + // Ctrl[+Shift]+= / - / 0 — terminal font size. Browser convention: + // unshifted touches the active pane, Shift escalates to every pane. + // Match on e.code so the bindings work the same across layouts (and + // regardless of whether Shift turns "=" into "+" etc.). + if (ctrl && !alt && (e.code === "Equal" || e.code === "Minus" || e.code === "Digit0")) { + e.preventDefault(); + e.stopPropagation(); + const delta = + e.code === "Equal" ? 1 : e.code === "Minus" ? -1 : null; + if (shift) { + setTree((t) => adjustAllFontSizes(t, delta)); + } else if (activeLeafId) { + setTree((t) => adjustFontSize(t, activeLeafId, delta)); + } + return; + } + // All remaining shortcuts require Ctrl+Shift with no Alt. if (!ctrl || !shift || alt) return; diff --git a/src/components/XtermPane.tsx b/src/components/XtermPane.tsx index 910a3de..17a715f 100644 --- a/src/components/XtermPane.tsx +++ b/src/components/XtermPane.tsx @@ -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(null); + const termRef = useRef(null); + const fitRef = useRef(null); + const paneIdRef = useRef(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(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( @@ -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
; } diff --git a/src/lib/layout/LeafPane.tsx b/src/lib/layout/LeafPane.tsx index 71babf5..e6308f2 100644 --- a/src/lib/layout/LeafPane.tsx +++ b/src/lib/layout/LeafPane.tsx @@ -7,7 +7,7 @@ import { type MouseEvent, type PointerEvent as ReactPointerEvent, } from "react"; -import type { LeafNode } from "./tree"; +import { type LeafNode, resolveFontSize } from "./tree"; import { useOrchestration } from "./orchestration"; import XtermPane from "../../components/XtermPane"; import "./LeafPane.css"; @@ -365,6 +365,7 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) { onDataReceived={onDataReceived} onFocus={onXtermFocus} focusTrigger={focusTrigger} + fontSize={resolveFontSize(leaf.fontSizeOffset)} />
diff --git a/src/lib/layout/tree.test.ts b/src/lib/layout/tree.test.ts index 9dae5b8..031642f 100644 --- a/src/lib/layout/tree.test.ts +++ b/src/lib/layout/tree.test.ts @@ -11,6 +11,12 @@ import { changeDistro, changeLabel, toggleBroadcast, + adjustFontSize, + adjustAllFontSizes, + resolveFontSize, + DEFAULT_FONT_SIZE, + MIN_FONT_SIZE, + MAX_FONT_SIZE, serialize, deserialize, presetSingle, @@ -298,6 +304,101 @@ describe("toggleBroadcast", () => { }); }); +describe("resolveFontSize", () => { + it("returns the default when offset is undefined or 0", () => { + expect(resolveFontSize(undefined)).toBe(DEFAULT_FONT_SIZE); + expect(resolveFontSize(0)).toBe(DEFAULT_FONT_SIZE); + }); + + it("clamps to [MIN_FONT_SIZE, MAX_FONT_SIZE]", () => { + expect(resolveFontSize(-9999)).toBe(MIN_FONT_SIZE); + expect(resolveFontSize(9999)).toBe(MAX_FONT_SIZE); + }); +}); + +describe("adjustFontSize", () => { + it("bumps a leaf's offset by delta", () => { + const leaf = newLeaf(); + const next = adjustFontSize(leaf, leaf.id, 2) as LeafNode; + expect(next.fontSizeOffset).toBe(2); + }); + + it("MUST NOT swap the leaf id (metadata-only — pane should not respawn)", () => { + const leaf = newLeaf(); + const next = adjustFontSize(leaf, leaf.id, 1) as LeafNode; + expect(next.id).toBe(leaf.id); + }); + + it("clamps the offset so the resolved font size stays within bounds", () => { + const leaf = newLeaf(); + const bigUp = adjustFontSize(leaf, leaf.id, 999) as LeafNode; + expect(resolveFontSize(bigUp.fontSizeOffset)).toBe(MAX_FONT_SIZE); + const bigDown = adjustFontSize(leaf, leaf.id, -999) as LeafNode; + expect(resolveFontSize(bigDown.fontSizeOffset)).toBe(MIN_FONT_SIZE); + }); + + it("strips the offset field entirely when the result is 0", () => { + const leaf = newLeaf({ fontSizeOffset: 1 }); + const next = adjustFontSize(leaf, leaf.id, -1) as LeafNode; + expect(next.fontSizeOffset).toBeUndefined(); + expect("fontSizeOffset" in next).toBe(false); + }); + + it("delta=null resets to default", () => { + const leaf = newLeaf({ fontSizeOffset: 5 }); + const next = adjustFontSize(leaf, leaf.id, null) as LeafNode; + expect(next.fontSizeOffset).toBeUndefined(); + }); + + it("only touches the targeted leaf", () => { + const target = newLeaf({ label: "a" }); + const sibling = newLeaf({ label: "b", fontSizeOffset: 3 }); + const root = newSplit("h", target, sibling); + const next = adjustFontSize(root, target.id, 2) as SplitNode; + expect((next.a as LeafNode).fontSizeOffset).toBe(2); + expect((next.b as LeafNode).fontSizeOffset).toBe(3); + }); +}); + +describe("adjustAllFontSizes", () => { + it("shifts every leaf by the same delta and preserves independence", () => { + const a = newLeaf({ fontSizeOffset: 0 }); + const b = newLeaf({ fontSizeOffset: 2 }); + const c = newLeaf({ fontSizeOffset: -1 }); + const root = newSplit("h", a, newSplit("v", b, c)); + const next = adjustAllFontSizes(root, 1); + const offsets = Array.from(walkLeaves(next)).map((l) => l.fontSizeOffset ?? 0); + expect(offsets).toEqual([1, 3, 0]); + }); + + it("delta=null resets every leaf to default", () => { + const a = newLeaf({ fontSizeOffset: 4 }); + const b = newLeaf({ fontSizeOffset: -3 }); + const root = newSplit("h", a, b); + const next = adjustAllFontSizes(root, null); + for (const leaf of walkLeaves(next)) { + expect(leaf.fontSizeOffset).toBeUndefined(); + } + }); + + it("MUST NOT swap any leaf id", () => { + const a = newLeaf({ fontSizeOffset: 1 }); + const b = newLeaf(); + const root = newSplit("h", a, b); + const idsBefore = leafIds(root); + const next = adjustAllFontSizes(root, 1); + expect(leafIds(next)).toEqual(idsBefore); + }); + + it("returns the same root reference when nothing changes (e.g. all at min, delta < 0)", () => { + const minOffset = MIN_FONT_SIZE - DEFAULT_FONT_SIZE; + const a = newLeaf({ fontSizeOffset: minOffset }); + const b = newLeaf({ fontSizeOffset: minOffset }); + const root = newSplit("h", a, b); + expect(adjustAllFontSizes(root, -1)).toBe(root); + }); +}); + describe("presets", () => { it("presetSingle returns a single leaf with the provided distro", () => { const t = presetSingle({ distro: "Ubuntu" }); diff --git a/src/lib/layout/tree.ts b/src/lib/layout/tree.ts index 4d60116..383deff 100644 --- a/src/lib/layout/tree.ts +++ b/src/lib/layout/tree.ts @@ -25,8 +25,21 @@ export interface LeafNode { * pane toolbar. */ broadcast?: boolean; + /** + * Per-pane font-size delta from the default ({@link DEFAULT_FONT_SIZE}). + * Bumped by Ctrl+Shift+= / Ctrl+Shift+- / reset by Ctrl+Shift+0. + * Stored as an offset (not absolute) so changing the base default + * later doesn't require migrating saved workspaces. + */ + fontSizeOffset?: number; } +/** Base xterm.js font size in px. Per-leaf offset adds on top of this. */ +export const DEFAULT_FONT_SIZE = 13; +/** Hard clamps on `DEFAULT_FONT_SIZE + offset`. */ +export const MIN_FONT_SIZE = 6; +export const MAX_FONT_SIZE = 40; + export interface SplitNode { kind: "split"; id: NodeId; @@ -198,6 +211,59 @@ export function toggleBroadcast(root: TreeNode, leafId: NodeId): TreeNode { }); } +/** Compute the actual pixel font size from a leaf's offset, clamped to + * [MIN_FONT_SIZE, MAX_FONT_SIZE]. */ +export function resolveFontSize(offset: number | undefined): number { + const px = DEFAULT_FONT_SIZE + (offset ?? 0); + return Math.max(MIN_FONT_SIZE, Math.min(MAX_FONT_SIZE, px)); +} + +/** Apply a font-size change to one leaf. Internal helper; returns the + * same reference when nothing changes so callers can short-circuit. */ +function adjustOneFontSize(leaf: LeafNode, delta: number | null): LeafNode { + if (delta === null) { + if (leaf.fontSizeOffset === undefined) return leaf; + const next: LeafNode = { ...leaf }; + delete next.fontSizeOffset; + return next; + } + const cur = leaf.fontSizeOffset ?? 0; + const nextPx = resolveFontSize(cur + delta); + const nextOffset = nextPx - DEFAULT_FONT_SIZE; + if (nextOffset === cur) return leaf; + if (nextOffset === 0) { + const next: LeafNode = { ...leaf }; + delete next.fontSizeOffset; + return next; + } + return { ...leaf, fontSizeOffset: nextOffset }; +} + +/** Adjust a single leaf's font-size offset by `delta` (positive = bigger). + * Pass `delta = null` to reset back to the default. Metadata-only — does + * NOT swap the id, so the PTY keeps running. */ +export function adjustFontSize( + root: TreeNode, + leafId: NodeId, + delta: number | null, +): TreeNode { + return replaceById(root, leafId, (node) => { + if (node.kind !== "leaf") return node; + return adjustOneFontSize(node, delta); + }); +} + +/** Adjust EVERY leaf's font-size offset by the same `delta` (or reset all + * to default with `delta = null`). Independent per-pane offsets stay + * independent — we just shift each by the same amount. */ +export function adjustAllFontSizes(root: TreeNode, delta: number | null): TreeNode { + if (root.kind === "leaf") return adjustOneFontSize(root, delta); + const a = adjustAllFontSizes(root.a, delta); + const b = adjustAllFontSizes(root.b, delta); + if (a === root.a && b === root.b) return root; + return { ...root, a, b }; +} + /** * Reshape the tree into the structure produced by `preset`, but PRESERVE * existing leaves (and their PTYs) by copying their id/distro/cwd/label/ @@ -231,6 +297,7 @@ export function reshapeToPreset( if (src.cwd !== undefined) slot.cwd = src.cwd; if (src.label !== undefined) slot.label = src.label; if (src.broadcast !== undefined) slot.broadcast = src.broadcast; + if (src.fontSizeOffset !== undefined) slot.fontSizeOffset = src.fontSizeOffset; } for (let i = slots.length; i < existingLeaves.length; i++) {