Add appearance settings for theme, fonts, and syntax highlighting (#1236)
* Add appearance settings for theme, fonts, and syntax highlighting New Appearance section in Settings to configure the app theme, UI and mono font family + size, and a syntax-highlighting theme chosen independently of light/dark. Font family fields default to empty and show the active system stack as the placeholder; a live split-diff preview reflects the choices as they change. The theme picker moves here from General. Preferences are client-only (AsyncStorage) and applied at runtime by patching every registered Unistyles theme via UnistylesRuntime.updateTheme, so existing StyleSheet consumers repaint with no per-component preference reads and no useUnistyles. No daemon/protocol change. * Expand syntax themes and move the preview under the chooser Replace the small fixed syntax-theme set with eight popular themes — GitHub, Catppuccin, Dracula, Tokyo Night, One, Nord, Gruvbox, Solarized — built from a compact per-theme role palette. The app theme and syntax theme stay separate; the only coupling is the light/dark axis: a theme with both variants uses its light palette on a light app and its dark palette on a dark app, while the code frame (gutter, line numbers, background) keeps following the app theme. Default is now GitHub (GitHub keeps its existing hand-tuned maps for an exact match). Move the live preview directly under the syntax-theme chooser, drop the "preview.ts" filename header, and use a clearer code snippet for it. * Update settings e2e for the moved theme picker and Appearance section The theme picker moved from General to the new Appearance section, so the General-content assertion no longer finds "Theme" — point it at "Default send" (which stays in General) and add Appearance to the e2e section map plus an expectAppearanceContent check, and exercise the Appearance section in the sidebar-navigation test. * Render the appearance preview as a unified diff Switch the live preview from side-by-side columns to a single unified diff: unchanged context lines plus "-"/"+" rows for the change, matching how diffs read elsewhere in the app. Same snippet, mono font, syntax colors, and live code-font draft behavior. * Fix code font size change shrinking all UI text applyAppearance scaled the live (already-patched) theme ramp instead of the authored FONT_SIZE ramp, so it was not idempotent: every appearance change re-scaled the current sizes, compounding the whole-UI font scale. With any non-default UI size, changing the code size — or any setting — shrank (or grew) all app text, and it persisted into the theme. Build the ramp from the canonical FONT_SIZE ramp each apply (code size still set absolutely), so applies are idempotent and a code-size change never touches the UI ramp. * Apply the interface font across the app on web react-native-web stamps a default font onto every text element, so the interface font only reached the few components that set a font explicitly. Inject one rule that points all text at a CSS variable (updated live as the setting changes), with high specificity (1,2,0) so it deterministically beats RN-web's base font and Unistyles' generated classes — no stylesheet-order fragility. Code, diff, and monospace surfaces carry a `data-pmono` marker (and their subtrees are excluded), so they keep their code font. Web-only: React Native has no global font cascade, so native still applies the UI font only where components opt in. Inline `code` within chat prose is a known minor gap (its render paths aren't tagged yet). * Keep preview code in the code font * Apply code font settings to terminals * Fix settings host picker import after rebase * Format terminal webview bundle
This commit is contained in:
@@ -86,7 +86,7 @@ interface StubTerminal {
|
||||
resize?: (cols: number, rows: number) => void;
|
||||
focus: () => void;
|
||||
refresh?: (start: number, end: number) => void;
|
||||
options?: { theme?: unknown; scrollback?: number };
|
||||
options?: { theme?: unknown; scrollback?: number; fontFamily?: string; fontSize?: number };
|
||||
rows?: number;
|
||||
cols?: number;
|
||||
}
|
||||
@@ -384,6 +384,31 @@ describe("terminal-emulator-runtime", () => {
|
||||
expect(refresh).toHaveBeenCalledWith(0, 11);
|
||||
});
|
||||
|
||||
it("updates terminal font without remounting", () => {
|
||||
const runtime = new TerminalEmulatorRuntime();
|
||||
const refresh = vi.fn();
|
||||
const fitAndEmitResize = vi.fn();
|
||||
const terminal: StubTerminal = {
|
||||
write: () => {},
|
||||
reset: () => {},
|
||||
focus: () => {},
|
||||
refresh,
|
||||
options: { fontFamily: "before", fontSize: 13 },
|
||||
rows: 12,
|
||||
cols: 40,
|
||||
};
|
||||
(runtime as unknown as { terminal: StubTerminal }).terminal = terminal;
|
||||
(runtime as unknown as { fitAndEmitResize: (force: boolean) => void }).fitAndEmitResize =
|
||||
fitAndEmitResize;
|
||||
|
||||
runtime.setFont({ fontFamily: " Menlo ", fontSize: 18 });
|
||||
|
||||
expect(terminal.options?.fontFamily).toBe("Menlo");
|
||||
expect(terminal.options?.fontSize).toBe(18);
|
||||
expect(fitAndEmitResize).toHaveBeenCalledWith({ force: true });
|
||||
expect(refresh).toHaveBeenCalledWith(0, 11);
|
||||
});
|
||||
|
||||
it("passively refits when the page becomes visible again", () => {
|
||||
const runtime = new TerminalEmulatorRuntime();
|
||||
const fitAndEmitResize = vi.fn();
|
||||
|
||||
@@ -37,6 +37,8 @@ export interface TerminalEmulatorRuntimeMountInput {
|
||||
initialSnapshot: TerminalState | null;
|
||||
scrollback: number;
|
||||
theme: ITheme;
|
||||
fontFamily?: string;
|
||||
fontSize?: number;
|
||||
}
|
||||
|
||||
export interface TerminalEmulatorRuntimeCallbacks {
|
||||
@@ -107,6 +109,7 @@ const isAppleHandheld =
|
||||
});
|
||||
|
||||
const DEFAULT_TOUCH_SCROLL_LINE_HEIGHT_PX = 18;
|
||||
const DEFAULT_TERMINAL_FONT_SIZE = 13;
|
||||
const FIT_TIMEOUT_DELAYS_MS = [0, 16, 48, 120, 250, 500, 1_000, 2_000];
|
||||
const OUTPUT_OPERATION_TIMEOUT_MS = 5_000;
|
||||
const EMPTY_TERMINAL_OUTPUT = new Uint8Array(0);
|
||||
@@ -147,6 +150,17 @@ const DEFAULT_TERMINAL_FONT_FAMILY = [
|
||||
"monospace",
|
||||
].join(", ");
|
||||
|
||||
function resolveTerminalFontFamily(fontFamily: string | undefined): string {
|
||||
const trimmed = fontFamily?.trim();
|
||||
return trimmed && trimmed.length > 0 ? trimmed : DEFAULT_TERMINAL_FONT_FAMILY;
|
||||
}
|
||||
|
||||
function resolveTerminalFontSize(fontSize: number | undefined): number {
|
||||
return typeof fontSize === "number" && Number.isFinite(fontSize) && fontSize > 0
|
||||
? fontSize
|
||||
: DEFAULT_TERMINAL_FONT_SIZE;
|
||||
}
|
||||
|
||||
function withOverviewRulerBorderHidden(theme: ITheme): ITheme {
|
||||
return {
|
||||
...theme,
|
||||
@@ -210,8 +224,8 @@ export class TerminalEmulatorRuntime {
|
||||
convertEol: false,
|
||||
cursorBlink: true,
|
||||
cursorStyle: "bar",
|
||||
fontFamily: DEFAULT_TERMINAL_FONT_FAMILY,
|
||||
fontSize: 13,
|
||||
fontFamily: resolveTerminalFontFamily(input.fontFamily),
|
||||
fontSize: resolveTerminalFontSize(input.fontSize),
|
||||
lineHeight: 1.0,
|
||||
macOptionIsMeta: true,
|
||||
minimumContrastRatio: 1,
|
||||
@@ -668,6 +682,24 @@ export class TerminalEmulatorRuntime {
|
||||
this.refreshVisibleRows();
|
||||
}
|
||||
|
||||
setFont(input: { fontFamily?: string; fontSize?: number }): void {
|
||||
const terminal = this.terminal;
|
||||
if (!terminal) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
terminal.options.fontFamily = resolveTerminalFontFamily(input.fontFamily);
|
||||
terminal.options.fontSize = resolveTerminalFontSize(input.fontSize);
|
||||
} catch {
|
||||
// ignore
|
||||
return;
|
||||
}
|
||||
|
||||
this.fitAndEmitResize?.({ force: true });
|
||||
this.refreshVisibleRows();
|
||||
}
|
||||
|
||||
focus(input?: { forceRefocus?: boolean }): void {
|
||||
const terminal = this.terminal;
|
||||
if (!terminal) {
|
||||
|
||||
@@ -18,6 +18,8 @@ interface MountMessage {
|
||||
initialSnapshot: TerminalState | null;
|
||||
scrollbackLines: number;
|
||||
theme: ITheme;
|
||||
fontFamily?: string;
|
||||
fontSize?: number;
|
||||
pendingModifiers: PendingTerminalModifiers;
|
||||
swipeGesturesEnabled: boolean;
|
||||
}
|
||||
@@ -33,6 +35,7 @@ type InboundMessage =
|
||||
| { type: "resize"; streamKey: string; shouldClaim?: boolean }
|
||||
| { type: "setTheme"; streamKey: string; theme: ITheme }
|
||||
| { type: "setScrollback"; streamKey: string; lines: number }
|
||||
| { type: "setFont"; streamKey: string; fontFamily?: string; fontSize?: number }
|
||||
| { type: "setPendingModifiers"; streamKey: string; pendingModifiers: PendingTerminalModifiers }
|
||||
| { type: "setSwipeGesturesEnabled"; streamKey: string; enabled: boolean }
|
||||
| {
|
||||
@@ -227,6 +230,8 @@ class TerminalWebViewBridge {
|
||||
MountMessage | { type: "unmount" } | { type: "resolveLocalFileLinkResponse" }
|
||||
>,
|
||||
): void {
|
||||
if (this.receiveConfigurationMessage(message)) return;
|
||||
|
||||
switch (message.type) {
|
||||
case "writeOutput":
|
||||
this.runtime?.write({ data: encodeTerminalOutput(message.text) });
|
||||
@@ -246,19 +251,34 @@ class TerminalWebViewBridge {
|
||||
case "resize":
|
||||
this.runtime?.resize({ force: true, shouldClaim: message.shouldClaim !== false });
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private receiveConfigurationMessage(
|
||||
message: Exclude<
|
||||
InboundMessage,
|
||||
MountMessage | { type: "unmount" } | { type: "resolveLocalFileLinkResponse" }
|
||||
>,
|
||||
): boolean {
|
||||
switch (message.type) {
|
||||
case "setTheme":
|
||||
this.applyThemeBackground(message.theme);
|
||||
this.runtime?.setTheme({ theme: message.theme });
|
||||
break;
|
||||
return true;
|
||||
case "setScrollback":
|
||||
this.runtime?.setScrollback({ lines: message.lines });
|
||||
break;
|
||||
return true;
|
||||
case "setFont":
|
||||
this.runtime?.setFont({ fontFamily: message.fontFamily, fontSize: message.fontSize });
|
||||
return true;
|
||||
case "setPendingModifiers":
|
||||
this.runtime?.setPendingModifiers({ pendingModifiers: message.pendingModifiers });
|
||||
break;
|
||||
return true;
|
||||
case "setSwipeGesturesEnabled":
|
||||
this.swipeGesturesEnabled = message.enabled;
|
||||
break;
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -300,6 +320,8 @@ class TerminalWebViewBridge {
|
||||
initialSnapshot: message.initialSnapshot,
|
||||
scrollback: message.scrollbackLines,
|
||||
theme: message.theme,
|
||||
fontFamily: message.fontFamily,
|
||||
fontSize: message.fontSize,
|
||||
});
|
||||
sendToNative({ type: "rendererReady", streamKey: message.streamKey, isReady: true });
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user