mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
Merge pull request #417 from Ark0N/feat/terminal-font-weight
feat(terminal): configurable normal and bold font weight (#403)
This commit is contained in:
@@ -0,0 +1,225 @@
|
||||
/**
|
||||
* @fileoverview Terminal font weight: live apply, and the plumbing around it.
|
||||
*
|
||||
* Bold text on the theme's default foreground carries exactly ONE cue, the
|
||||
* weight step. Claude Code marks its markdown bold with a bare `ESC[1m` and no
|
||||
* colour change, and xterm substitutes a bright colour for bold only when the
|
||||
* foreground is a palette index below 8, so nothing else distinguishes it. A
|
||||
* family that ships only a regular and a bold face keeps that step small, and
|
||||
* 400 stays 400 whatever family is picked — which is why the NORMAL slot is
|
||||
* settable at all.
|
||||
*
|
||||
* Three things are pinned here because each fails silently:
|
||||
*
|
||||
* - A live save reaches the echo overlays and the Agent Teams panes. Both
|
||||
* cache the weight (the overlays paint it into their spans, the panes read
|
||||
* their options at construction), so without the propagation the characters
|
||||
* being typed, or a pane left open across the save, keep the old weight
|
||||
* beside a repainted terminal.
|
||||
* - An unchanged save is a no-op, so opening and closing App Settings does not
|
||||
* churn the terminal.
|
||||
* - The bundled face is declared over its full axis. The `@font-face`
|
||||
* descriptor, not the file, is what the browser synthesizes from: at
|
||||
* `400 700` every weight below 400 renders identically to 400, so the
|
||||
* setting would be inert for anyone without Fira Code or Cascadia Code
|
||||
* installed.
|
||||
*
|
||||
* Loaded via `vm` with a stubbed context (no jsdom — jsdom is broken on this
|
||||
* box; see connection-indicator.test.ts), matching terminal-font-settle.test.ts.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { performance } from 'node:perf_hooks';
|
||||
import { resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const publicDir = resolve(import.meta.dirname, '../src/web/public');
|
||||
|
||||
function loadTerminalMixin(): Record<string, unknown> {
|
||||
const FakeCodemanApp = function () {} as unknown as { prototype: Record<string, unknown> };
|
||||
const context = vm.createContext({
|
||||
console,
|
||||
performance,
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
setInterval: vi.fn(),
|
||||
clearInterval: vi.fn(),
|
||||
requestAnimationFrame: vi.fn(),
|
||||
CodemanApp: FakeCodemanApp,
|
||||
window: { addEventListener: vi.fn(), removeEventListener: vi.fn() },
|
||||
document: undefined,
|
||||
});
|
||||
const constants = readFileSync(resolve(publicDir, 'constants.js'), 'utf8');
|
||||
const source = readFileSync(resolve(publicDir, 'terminal-ui.js'), 'utf8');
|
||||
vm.runInContext(`${constants}\n${source}`, context);
|
||||
// constants.js publishes CodemanTerminalFont onto the context's window, which
|
||||
// is the one the mixin closes over.
|
||||
return FakeCodemanApp.prototype;
|
||||
}
|
||||
|
||||
const mixin = loadTerminalMixin();
|
||||
|
||||
function fakeTerminal(options: Record<string, unknown> = {}) {
|
||||
return { options: { fontFamily: '"JetBrains Mono"', fontSize: 14, ...options } };
|
||||
}
|
||||
|
||||
function makeApp(opts: { teammates?: number; terminal?: ReturnType<typeof fakeTerminal> | null } = {}) {
|
||||
const fit = vi.fn();
|
||||
const teammateFits: ReturnType<typeof vi.fn>[] = [];
|
||||
const teammateTerminals = new Map<string, { terminal: ReturnType<typeof fakeTerminal>; fitAddon: unknown }>();
|
||||
for (let i = 0; i < (opts.teammates ?? 0); i++) {
|
||||
const teammateFit = vi.fn();
|
||||
teammateFits.push(teammateFit);
|
||||
teammateTerminals.set(`agent-${i}`, { terminal: fakeTerminal(), fitAddon: { fit: teammateFit } });
|
||||
}
|
||||
const app = {
|
||||
applyTerminalFontWeights: mixin.applyTerminalFontWeights,
|
||||
_awaitTerminalFont: vi.fn(() => Promise.resolve()),
|
||||
terminal: opts.terminal === undefined ? fakeTerminal() : opts.terminal,
|
||||
fitAddon: { fit },
|
||||
teammateTerminals,
|
||||
_localEchoOverlay: { refreshFont: vi.fn() },
|
||||
_predictiveEcho: { refreshFont: vi.fn() },
|
||||
_terminalFontReady: null as unknown,
|
||||
};
|
||||
return { app, fit, teammateFits, teammateTerminals };
|
||||
}
|
||||
|
||||
describe('applyTerminalFontWeights', () => {
|
||||
it('writes both slots to the live terminal', () => {
|
||||
const { app, fit } = makeApp();
|
||||
|
||||
(app as unknown as { applyTerminalFontWeights: (s: unknown) => void }).applyTerminalFontWeights({
|
||||
terminalFontWeight: '300',
|
||||
terminalFontWeightBold: '800',
|
||||
});
|
||||
|
||||
expect(app.terminal?.options.fontWeight).toBe(300);
|
||||
expect(app.terminal?.options.fontWeightBold).toBe(800);
|
||||
expect(fit).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refreshes the echo overlays, which cache the weight and paint it', () => {
|
||||
// Without this the characters being typed keep the old weight while the
|
||||
// rest of the screen changes — most visible on a phone, where local echo
|
||||
// is on by default.
|
||||
const { app } = makeApp();
|
||||
|
||||
(app as unknown as { applyTerminalFontWeights: (s: unknown) => void }).applyTerminalFontWeights({
|
||||
terminalFontWeight: '300',
|
||||
});
|
||||
|
||||
expect(app._localEchoOverlay.refreshFont).toHaveBeenCalledTimes(1);
|
||||
expect(app._predictiveEcho.refreshFont).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('reaches Agent Teams panes, which read their options at construction', () => {
|
||||
const { app, teammateTerminals, teammateFits } = makeApp({ teammates: 2 });
|
||||
|
||||
(app as unknown as { applyTerminalFontWeights: (s: unknown) => void }).applyTerminalFontWeights({
|
||||
terminalFontWeight: '300',
|
||||
terminalFontWeightBold: '800',
|
||||
});
|
||||
|
||||
for (const [, entry] of teammateTerminals) {
|
||||
expect(entry.terminal.options.fontWeight).toBe(300);
|
||||
expect(entry.terminal.options.fontWeightBold).toBe(800);
|
||||
}
|
||||
for (const teammateFit of teammateFits) expect(teammateFit).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('restores xterm’s own defaults when the setting is cleared', () => {
|
||||
const { app } = makeApp({ terminal: fakeTerminal({ fontWeight: 300, fontWeightBold: 800 }) });
|
||||
|
||||
(app as unknown as { applyTerminalFontWeights: (s: unknown) => void }).applyTerminalFontWeights({});
|
||||
|
||||
expect(app.terminal?.options.fontWeight).toBe('normal');
|
||||
expect(app.terminal?.options.fontWeightBold).toBe('bold');
|
||||
});
|
||||
|
||||
it('does nothing when neither slot changed', () => {
|
||||
// saveAppSettings runs on every close of the modal.
|
||||
const { app, fit } = makeApp({ terminal: fakeTerminal({ fontWeight: 300, fontWeightBold: 'bold' }) });
|
||||
|
||||
(app as unknown as { applyTerminalFontWeights: (s: unknown) => void }).applyTerminalFontWeights({
|
||||
terminalFontWeight: 300,
|
||||
});
|
||||
|
||||
expect(fit).not.toHaveBeenCalled();
|
||||
expect(app._localEchoOverlay.refreshFont).not.toHaveBeenCalled();
|
||||
expect(app._awaitTerminalFont).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('re-arms the font wait, so a fit lands once the face is rasterized', () => {
|
||||
const { app } = makeApp();
|
||||
|
||||
(app as unknown as { applyTerminalFontWeights: (s: unknown) => void }).applyTerminalFontWeights({
|
||||
terminalFontWeight: '300',
|
||||
});
|
||||
|
||||
expect(app._awaitTerminalFont).toHaveBeenCalledTimes(1);
|
||||
expect(app._terminalFontReady).toBeInstanceOf(Promise);
|
||||
});
|
||||
|
||||
it('survives a terminal that does not exist yet', () => {
|
||||
const { app } = makeApp({ terminal: null });
|
||||
|
||||
expect(() =>
|
||||
(app as unknown as { applyTerminalFontWeights: (s: unknown) => void }).applyTerminalFontWeights({
|
||||
terminalFontWeight: '300',
|
||||
})
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('bundled terminal face', () => {
|
||||
const styles = readFileSync(resolve(publicDir, 'styles.css'), 'utf8');
|
||||
|
||||
it('is declared over its full weight axis, not xterm’s default span', () => {
|
||||
// The woff2 carries a `wght` axis of 100 to 800. A narrower @font-face
|
||||
// descriptor CLAMPS it: at `400 700`, 100/200/300 all render identically to
|
||||
// 400 and 800 identically to 700, so the settings above would be a no-op
|
||||
// for every install without Fira Code or Cascadia Code.
|
||||
const face = styles.slice(styles.indexOf("font-family: 'JetBrains Mono'"));
|
||||
const declared = /font-weight:\s*(\d+)\s+(\d+)/.exec(face.slice(0, face.indexOf('}')));
|
||||
expect(declared, 'the bundled mono face must declare a weight RANGE').not.toBeNull();
|
||||
expect(Number(declared![1])).toBeLessThanOrEqual(100);
|
||||
expect(Number(declared![2])).toBeGreaterThanOrEqual(800);
|
||||
});
|
||||
});
|
||||
|
||||
describe('terminal font weight settings plumbing', () => {
|
||||
const settingsUi = readFileSync(resolve(publicDir, 'settings-ui.js'), 'utf8');
|
||||
const html = readFileSync(resolve(publicDir, 'index.html'), 'utf8');
|
||||
const keys = ['terminalFontWeight', 'terminalFontWeightBold'] as const;
|
||||
|
||||
it('offers both selects with a Default entry and the 100-900 steps', () => {
|
||||
for (const id of ['appSettingsTerminalFontWeight', 'appSettingsTerminalFontWeightBold']) {
|
||||
const start = html.indexOf(`<select id="${id}"`);
|
||||
expect(start, `${id} missing from index.html`).toBeGreaterThan(-1);
|
||||
const select = html.slice(start, html.indexOf('</select>', start));
|
||||
expect(select).toContain('<option value="">');
|
||||
for (let w = 100; w <= 900; w += 100) expect(select).toContain(`<option value="${w}">`);
|
||||
}
|
||||
});
|
||||
|
||||
it('treats both as per-device, which is TWO separate decisions', () => {
|
||||
// Membership in displayKeys keeps one device from overwriting another's
|
||||
// value; the strip before the PUT is what stops the .strict() schema from
|
||||
// 400-ing the whole settings save.
|
||||
const displayKeys = settingsUi.slice(settingsUi.indexOf('const displayKeys = new Set(['));
|
||||
const listed = displayKeys.slice(0, displayKeys.indexOf(']);'));
|
||||
const stripped = settingsUi.slice(settingsUi.indexOf('const {', settingsUi.indexOf('async saveAppSettings()')));
|
||||
for (const key of keys) {
|
||||
expect(listed, `${key} must be a display key`).toContain(`'${key}'`);
|
||||
expect(stripped.slice(0, stripped.indexOf('} = settings;')), `${key} must be stripped from the PUT`).toContain(
|
||||
`${key}: _`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('applies the save to the live terminal', () => {
|
||||
const save = settingsUi.slice(settingsUi.indexOf('async saveAppSettings()'));
|
||||
expect(save.slice(0, save.indexOf('\n },'))).toContain('this.applyTerminalFontWeights?.(settings)');
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,8 @@ function loadFontHelper() {
|
||||
CodemanTerminalFont: {
|
||||
DEFAULT_STACK: string;
|
||||
resolve: (custom?: unknown) => string;
|
||||
WEIGHT_DEFAULTS: { fontWeight: string; fontWeightBold: string };
|
||||
resolveWeights: (settings?: unknown) => { fontWeight: string | number; fontWeightBold: string | number };
|
||||
};
|
||||
}
|
||||
).CodemanTerminalFont;
|
||||
@@ -56,3 +58,60 @@ describe('CodemanTerminalFont', () => {
|
||||
expect(font.resolve('Hack, monospace')).toBe(`Hack, ${font.DEFAULT_STACK}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CodemanTerminalFont.resolveWeights', () => {
|
||||
const DEFAULTS = { fontWeight: 'normal', fontWeightBold: 'bold' };
|
||||
|
||||
it("leaves an untouched install on xterm's own defaults", () => {
|
||||
// The whole feature has to be invisible until someone asks for it.
|
||||
expect(font.resolveWeights(undefined)).toEqual(DEFAULTS);
|
||||
expect(font.resolveWeights({})).toEqual(DEFAULTS);
|
||||
expect(font.resolveWeights('nonsense')).toEqual(DEFAULTS);
|
||||
expect(font.WEIGHT_DEFAULTS).toEqual(DEFAULTS);
|
||||
});
|
||||
|
||||
it('resolves each slot independently', () => {
|
||||
expect(font.resolveWeights({ terminalFontWeight: 300 })).toEqual({ fontWeight: 300, fontWeightBold: 'bold' });
|
||||
expect(font.resolveWeights({ terminalFontWeightBold: 800 })).toEqual({ fontWeight: 'normal', fontWeightBold: 800 });
|
||||
expect(font.resolveWeights({ terminalFontWeight: 300, terminalFontWeightBold: 800 })).toEqual({
|
||||
fontWeight: 300,
|
||||
fontWeightBold: 800,
|
||||
});
|
||||
});
|
||||
|
||||
it('never hands one slot the other slot default', () => {
|
||||
// A shared fallback would turn an unset bold weight into a visible change.
|
||||
for (const bad of [null, '', ' ', 'heavy', NaN, {}, [], true]) {
|
||||
expect(font.resolveWeights({ terminalFontWeight: bad, terminalFontWeightBold: bad })).toEqual(DEFAULTS);
|
||||
}
|
||||
});
|
||||
|
||||
it('accepts the string values the select stores', () => {
|
||||
expect(font.resolveWeights({ terminalFontWeight: '300', terminalFontWeightBold: '900' })).toEqual({
|
||||
fontWeight: 300,
|
||||
fontWeightBold: 900,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps a hand-set weight the picker does not offer', () => {
|
||||
expect(font.resolveWeights({ terminalFontWeight: '350' }).fontWeight).toBe(350);
|
||||
});
|
||||
|
||||
it("passes through xterm's own keywords unchanged", () => {
|
||||
expect(font.resolveWeights({ terminalFontWeight: 'bold', terminalFontWeightBold: 'normal' })).toEqual({
|
||||
fontWeight: 'bold',
|
||||
fontWeightBold: 'normal',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects what xterm would reject, rather than letting it silently reset the slot', () => {
|
||||
// OptionsService accepts a number in 1..1000 and falls back otherwise, so
|
||||
// anything outside that range must resolve to the default here instead of
|
||||
// reaching the terminal and being swapped out underneath the setting.
|
||||
expect(font.resolveWeights({ terminalFontWeight: 0 }).fontWeight).toBe('normal');
|
||||
expect(font.resolveWeights({ terminalFontWeight: -400 }).fontWeight).toBe('normal');
|
||||
expect(font.resolveWeights({ terminalFontWeight: 1001 }).fontWeight).toBe('normal');
|
||||
expect(font.resolveWeights({ terminalFontWeight: 1000 }).fontWeight).toBe(1000);
|
||||
expect(font.resolveWeights({ terminalFontWeight: 1 }).fontWeight).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user