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:
parent
8f9667b218
commit
aab36afce4
6 changed files with 237 additions and 11 deletions
|
|
@ -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.
|
||||
|
|
|
|||
19
src/App.tsx
19
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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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%" }} />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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" });
|
||||
|
|
|
|||
|
|
@ -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++) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue