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

@ -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)}
/>
</div>
</div>

View file

@ -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" });

View file

@ -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++) {