diff --git a/Cargo.lock b/Cargo.lock index e1c269ec..e7455c99 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1077,6 +1077,28 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +[[package]] +name = "fontconfig-parser" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646" +dependencies = [ + "roxmltree", +] + +[[package]] +name = "fontdb" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2660c5e9157bf76d2db1294e4a9feba604ef610819a3b591088d0d8392a3290f" +dependencies = [ + "fontconfig-parser", + "log", + "memmap2", + "slotmap", + "tinyvec", +] + [[package]] name = "foreign-types" version = "0.5.0" @@ -2170,6 +2192,15 @@ version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "memmap2" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" +dependencies = [ + "libc", +] + [[package]] name = "memoffset" version = "0.9.1" @@ -2218,6 +2249,7 @@ version = "0.1.42" dependencies = [ "base64 0.22.1", "block2", + "fontdb", "libc", "notify-rust", "objc2", @@ -3128,6 +3160,12 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "roxmltree" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" + [[package]] name = "rusqlite" version = "0.40.2" @@ -3623,6 +3661,15 @@ version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + [[package]] name = "smallvec" version = "1.15.2" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index d023c040..847d0512 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -22,6 +22,7 @@ serde.workspace = true serde_json.workspace = true base64 = "0.22" tauri-plugin-dialog = "2" +fontdb = "0.24" rusqlite = { version = "0.40.2", features = ["bundled"], default-features = false } ureq = { version = "2.12.1", default-features = false, features = ["tls", "gzip"] } tauri-plugin-process = "2" diff --git a/src-tauri/src/fonts.rs b/src-tauri/src/fonts.rs new file mode 100644 index 00000000..54a8100d --- /dev/null +++ b/src-tauri/src/fonts.rs @@ -0,0 +1,125 @@ +use std::collections::BTreeMap; + +#[derive(Debug, Clone, serde::Serialize)] +pub struct SystemFont { + pub family: String, + pub monospace: bool, +} + +fn collect_system_fonts_sync() -> Vec { + let mut db = fontdb::Database::new(); + db.load_system_fonts(); + + // Family -> monospace. A family counts as monospace if any of its faces + // is marked monospaced (covers regular/bold/italic splits). + let mut by_family: BTreeMap = BTreeMap::new(); + // Case-insensitive dedupe while preserving the first-seen display name. + let mut seen_lower: std::collections::HashMap = + std::collections::HashMap::new(); + + for face in db.faces() { + for (name, _) in face.families.iter() { + let trimmed = name.trim(); + if trimmed.is_empty() { + continue; + } + let lower = trimmed.to_lowercase(); + let display = seen_lower + .entry(lower.clone()) + .or_insert_with(|| trimmed.to_string()) + .clone(); + let entry = by_family.entry(display).or_insert(false); + if face.monospaced { + *entry = true; + } + } + } + + let mut out: Vec = by_family + .into_iter() + .map(|(family, monospace)| SystemFont { family, monospace }) + .collect(); + out.sort_by(|a, b| { + a.family + .to_lowercase() + .cmp(&b.family.to_lowercase()) + .then_with(|| a.family.cmp(&b.family)) + }); + out +} + +#[tauri::command] +pub async fn list_system_fonts() -> Result, String> { + tauri::async_runtime::spawn_blocking(collect_system_fonts_sync) + .await + .map_err(|e| e.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn dedupes_families_case_insensitively_and_sorts() { + // Simulate the merge logic without touching the real system fonts. + let mut by_family: BTreeMap = BTreeMap::new(); + let mut seen_lower: std::collections::HashMap = + std::collections::HashMap::new(); + for (name, mono) in [ + ("JetBrains Mono", true), + ("jetbrains mono", true), + ("Inter", false), + (" Inter ", false), + ("", false), + ] { + let trimmed = name.trim(); + if trimmed.is_empty() { + continue; + } + let lower = trimmed.to_lowercase(); + let display = seen_lower + .entry(lower) + .or_insert_with(|| trimmed.to_string()) + .clone(); + let entry = by_family.entry(display).or_insert(false); + if mono { + *entry = true; + } + } + let mut out: Vec = by_family + .into_iter() + .map(|(family, monospace)| SystemFont { family, monospace }) + .collect(); + out.sort_by(|a, b| { + a.family + .to_lowercase() + .cmp(&b.family.to_lowercase()) + .then_with(|| a.family.cmp(&b.family)) + }); + assert_eq!(out.len(), 2); + assert_eq!(out[0].family, "Inter"); + assert!(!out[0].monospace); + assert_eq!(out[1].family, "JetBrains Mono"); + assert!(out[1].monospace); + } + + #[test] + fn system_scan_returns_sorted_unique_families() { + let fonts = collect_system_fonts_sync(); + // An empty result is valid (minimal containers may have no fonts); + // uniqueness and ordering must hold regardless. + let mut last = String::new(); + let mut seen = std::collections::HashSet::new(); + for font in &fonts { + assert!(!font.family.trim().is_empty()); + assert!(seen.insert(font.family.to_lowercase()), "duplicate family"); + assert!( + font.family.to_lowercase() >= last, + "fonts not sorted: {} after {}", + font.family, + last + ); + last = font.family.to_lowercase(); + } + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index bfeca204..0b230c6b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -3,6 +3,7 @@ use tauri::Manager; mod chat_background; mod checkpoint; mod cursor_store; +mod fonts; mod fs; mod gitlab; mod harness; @@ -338,6 +339,7 @@ pub fn run() { project_logo::save_project_logo, project_logo::remove_project_logo, project_logo::forget_logo_file, + fonts::list_system_fonts, ]) .build(tauri::generate_context!()) .expect("error while building MonoCode"); diff --git a/src/index.css b/src/index.css index 4d483a00..a0e45c0f 100644 --- a/src/index.css +++ b/src/index.css @@ -21,6 +21,9 @@ --font-sans: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif; + --font-mono-size: 13px; + --font-mono-weight: 400; + --font-sans-weight: 400; } :root { @@ -38,6 +41,15 @@ html { color-scheme: dark; + font-weight: var(--font-sans-weight); +} + +/* A picked interface font applies on every platform. Without it, macOS + keeps its native text rendering (see the `html:not(.is-mac)` rule below). */ +html.has-custom-ui-font, +html.has-custom-ui-font body, +html.has-custom-ui-font #root { + font-family: var(--font-sans); } html.theme-light { diff --git a/src/lib/fonts.test.ts b/src/lib/fonts.test.ts new file mode 100644 index 00000000..ab4941e5 --- /dev/null +++ b/src/lib/fonts.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it, beforeEach } from "vitest"; +import { + CODE_FONT_SIZE_DEFAULT, + CODE_FONT_SIZE_MAX, + CODE_FONT_SIZE_MIN, + UI_FONT_WEIGHT_DEFAULT, + codeFontStack, + loadCodeFontSize, + loadCodeFontFamily, + loadCodeFontWeight, + loadUiFontFamily, + loadUiFontWeight, + normalizeCodeFontSize, + normalizeFontFamily, + normalizeFontWeight, + saveCodeFontFamily, + saveCodeFontSize, + saveCodeFontWeight, + saveUiFontFamily, + saveUiFontWeight, + uiFontStack, +} from "./fonts"; + +function mockLocalStorage() { + const data = new Map(); + const storage = { + getItem: (key: string) => data.get(key) ?? null, + setItem: (key: string, value: string) => { + data.set(key, value); + }, + removeItem: (key: string) => { + data.delete(key); + }, + clear: () => { + data.clear(); + }, + key: (index: number) => [...data.keys()][index] ?? null, + get length() { + return data.size; + }, + }; + Object.defineProperty(globalThis, "localStorage", { + value: storage, + configurable: true, + }); +} + +describe("font family settings", () => { + beforeEach(mockLocalStorage); + + it("defaults to the system stacks", () => { + expect(loadUiFontFamily()).toBe(""); + expect(loadCodeFontFamily()).toBe(""); + expect(uiFontStack("")).toContain("system-ui"); + expect(codeFontStack("")).toContain("ui-monospace"); + }); + + it("prefixes a picked family ahead of the fallback", () => { + expect(uiFontStack("Inter")).toBe( + `"Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif`, + ); + expect(codeFontStack("JetBrains Mono")).toContain('"JetBrains Mono"'); + expect(codeFontStack("JetBrains Mono")).toContain("ui-monospace"); + }); + + it("escapes quotes in family names for the CSS stack", () => { + const stack = codeFontStack('Evil "Font"'); + expect(stack).toContain('Evil \\"Font\\"'); + }); + + it("trims and drops control characters", () => { + expect(normalizeFontFamily(" Inter\n")).toBe("Inter"); + expect(normalizeFontFamily(42)).toBe(""); + }); + + it("persists and clears the picked families", () => { + saveUiFontFamily("Inter"); + expect(loadUiFontFamily()).toBe("Inter"); + saveUiFontFamily(""); + expect(loadUiFontFamily()).toBe(""); + saveCodeFontFamily("JetBrains Mono"); + expect(loadCodeFontFamily()).toBe("JetBrains Mono"); + saveCodeFontFamily(" "); + expect(loadCodeFontFamily()).toBe(""); + }); +}); + +describe("code size / weight settings", () => { + beforeEach(mockLocalStorage); + + it("defaults to the current editor metrics", () => { + expect(loadCodeFontSize()).toBe(CODE_FONT_SIZE_DEFAULT); + expect(loadUiFontWeight()).toBe(UI_FONT_WEIGHT_DEFAULT); + expect(loadCodeFontWeight()).toBe(400); + }); + + it("clamps size into range", () => { + expect(normalizeCodeFontSize(4)).toBe(CODE_FONT_SIZE_MIN); + expect(normalizeCodeFontSize(99)).toBe(CODE_FONT_SIZE_MAX); + expect(normalizeCodeFontSize("junk")).toBe(CODE_FONT_SIZE_DEFAULT); + saveCodeFontSize(15); + expect(loadCodeFontSize()).toBe(15); + }); + + it("snaps weights to hundreds inside 400-700", () => { + expect(normalizeFontWeight(450, 400)).toBe(500); + expect(normalizeFontWeight(100, 400)).toBe(400); + expect(normalizeFontWeight(900, 400)).toBe(700); + expect(normalizeFontWeight("junk", 400)).toBe(400); + saveUiFontWeight(600); + expect(loadUiFontWeight()).toBe(600); + saveCodeFontWeight(500); + expect(loadCodeFontWeight()).toBe(500); + }); +}); diff --git a/src/lib/fonts.ts b/src/lib/fonts.ts new file mode 100644 index 00000000..20c554bb --- /dev/null +++ b/src/lib/fonts.ts @@ -0,0 +1,272 @@ +import { invoke } from "@tauri-apps/api/core"; + +export type SystemFont = { + family: string; + monospace: boolean; +}; + +/** Fired on `window` whenever any font setting changes. */ +export const FONTS_CHANGE_EVENT = "monocode:fontschange"; + +const UI_FAMILY_KEY = "monocode.uiFontFamily"; +const UI_WEIGHT_KEY = "monocode.uiFontWeight"; +const CODE_FAMILY_KEY = "monocode.codeFontFamily"; +const CODE_SIZE_KEY = "monocode.codeFontSize"; +const CODE_WEIGHT_KEY = "monocode.codeFontWeight"; + +export const UI_FONT_WEIGHT_DEFAULT = 400; +export const CODE_FONT_SIZE_DEFAULT = 13; +export const CODE_FONT_SIZE_MIN = 11; +export const CODE_FONT_SIZE_MAX = 18; +export const FONT_WEIGHT_MIN = 400; +export const FONT_WEIGHT_MAX = 700; +export const CODE_FONT_WEIGHT_DEFAULT = 400; + +const SANS_FALLBACK = + 'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif'; +const MONO_FALLBACK = + 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace'; + +function clamp(value: number, min: number, max: number) { + return Math.min(max, Math.max(min, value)); +} + +export function normalizeFontFamily(value: unknown): string { + if (typeof value !== "string") return ""; + return value.replace(/[\0-\x1f\x7f]/g, "").trim().slice(0, 120); +} + +function escapeFamilyName(family: string): string { + return family.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); +} + +/** `"Picked Font", `, or the bare fallback when unset. */ +export function buildFamilyStack(family: string, fallback: string): string { + const clean = normalizeFontFamily(family); + if (!clean) return fallback; + return `"${escapeFamilyName(clean)}", ${fallback}`; +} + +export function uiFontStack(family: string): string { + return buildFamilyStack(family, SANS_FALLBACK); +} + +export function codeFontStack(family: string): string { + return buildFamilyStack(family, MONO_FALLBACK); +} + +export function normalizeFontWeight(value: unknown, fallback: number): number { + const parsed = typeof value === "number" ? value : Number(value); + if (!Number.isFinite(parsed)) return fallback; + return Math.round(clamp(parsed, FONT_WEIGHT_MIN, FONT_WEIGHT_MAX) / 100) * 100; +} + +export function normalizeCodeFontSize(value: unknown): number { + const parsed = typeof value === "number" ? value : Number(value); + if (!Number.isFinite(parsed)) return CODE_FONT_SIZE_DEFAULT; + return Math.round(clamp(parsed, CODE_FONT_SIZE_MIN, CODE_FONT_SIZE_MAX)); +} + +function readFamily(key: string): string { + try { + return normalizeFontFamily(localStorage.getItem(key)); + } catch { + return ""; + } +} + +function writeFamily(key: string, value: string) { + const next = normalizeFontFamily(value); + try { + if (next) localStorage.setItem(key, next); + else localStorage.removeItem(key); + } catch { + // private mode / quota + } + return next; +} + +function readNumber(key: string): number | null { + try { + const raw = localStorage.getItem(key); + if (raw == null) return null; + const parsed = Number(raw); + return Number.isFinite(parsed) ? parsed : null; + } catch { + return null; + } +} + +function notifyFontsChanged() { + if (typeof window === "undefined") return; + window.dispatchEvent(new Event(FONTS_CHANGE_EVENT)); +} + +export function subscribeFonts(onStoreChange: () => void) { + if (typeof window === "undefined") return () => {}; + window.addEventListener(FONTS_CHANGE_EVENT, onStoreChange); + return () => window.removeEventListener(FONTS_CHANGE_EVENT, onStoreChange); +} + +function setVar(name: string, value: string) { + try { + document.documentElement.style.setProperty(name, value); + } catch { + // non-DOM (tests) + } +} + +// — Interface knob (sans) — + +export function loadUiFontFamily(): string { + return readFamily(UI_FAMILY_KEY); +} + +export function loadUiFontWeight(): number { + const raw = readNumber(UI_WEIGHT_KEY); + return normalizeFontWeight(raw ?? UI_FONT_WEIGHT_DEFAULT, UI_FONT_WEIGHT_DEFAULT); +} + +export function applyUiFontFamily(family: string): string { + const next = normalizeFontFamily(family); + setVar("--font-sans", uiFontStack(next)); + try { + document.documentElement.classList.toggle( + "has-custom-ui-font", + next !== "", + ); + } catch { + // non-DOM (tests) + } + return next; +} + +export function applyUiFontWeight(weight: number): number { + const next = normalizeFontWeight(weight, UI_FONT_WEIGHT_DEFAULT); + setVar("--font-sans-weight", String(next)); + return next; +} + +export function saveUiFontFamily(family: string): string { + const next = writeFamily(UI_FAMILY_KEY, family); + applyUiFontFamily(next); + notifyFontsChanged(); + return next; +} + +export function saveUiFontWeight(weight: number): number { + const next = normalizeFontWeight(weight, UI_FONT_WEIGHT_DEFAULT); + try { + localStorage.setItem(UI_WEIGHT_KEY, String(next)); + } catch { + // private mode / quota + } + applyUiFontWeight(next); + notifyFontsChanged(); + return next; +} + +// — Code knob (mono: editor + terminal + code blocks) — + +export function loadCodeFontFamily(): string { + return readFamily(CODE_FAMILY_KEY); +} + +export function loadCodeFontSize(): number { + return normalizeCodeFontSize(readNumber(CODE_SIZE_KEY) ?? CODE_FONT_SIZE_DEFAULT); +} + +export function loadCodeFontWeight(): number { + const raw = readNumber(CODE_WEIGHT_KEY); + return normalizeFontWeight(raw ?? CODE_FONT_WEIGHT_DEFAULT, CODE_FONT_WEIGHT_DEFAULT); +} + +export function applyCodeFontFamily(family: string): string { + const next = normalizeFontFamily(family); + setVar("--font-mono", codeFontStack(next)); + return next; +} + +export function applyCodeFontSize(size: number): number { + const next = normalizeCodeFontSize(size); + setVar("--font-mono-size", `${next}px`); + return next; +} + +export function applyCodeFontWeight(weight: number): number { + const next = normalizeFontWeight(weight, CODE_FONT_WEIGHT_DEFAULT); + setVar("--font-mono-weight", String(next)); + return next; +} + +export function saveCodeFontFamily(family: string): string { + const next = writeFamily(CODE_FAMILY_KEY, family); + applyCodeFontFamily(next); + notifyFontsChanged(); + return next; +} + +export function saveCodeFontSize(size: number): number { + const next = normalizeCodeFontSize(size); + try { + localStorage.setItem(CODE_SIZE_KEY, String(next)); + } catch { + // private mode / quota + } + applyCodeFontSize(next); + notifyFontsChanged(); + return next; +} + +export function saveCodeFontWeight(weight: number): number { + const next = normalizeFontWeight(weight, CODE_FONT_WEIGHT_DEFAULT); + try { + localStorage.setItem(CODE_WEIGHT_KEY, String(next)); + } catch { + // private mode / quota + } + applyCodeFontWeight(next); + notifyFontsChanged(); + return next; +} + +/** Apply every persisted font setting to `:root`. Called once at boot. */ +export function initFonts() { + applyUiFontFamily(loadUiFontFamily()); + applyUiFontWeight(loadUiFontWeight()); + applyCodeFontFamily(loadCodeFontFamily()); + applyCodeFontSize(loadCodeFontSize()); + applyCodeFontWeight(loadCodeFontWeight()); +} + +/** Reset both knobs to the system defaults. */ +export function resetFontsToDefaults() { + saveUiFontFamily(""); + saveUiFontWeight(UI_FONT_WEIGHT_DEFAULT); + saveCodeFontFamily(""); + saveCodeFontSize(CODE_FONT_SIZE_DEFAULT); + saveCodeFontWeight(CODE_FONT_WEIGHT_DEFAULT); +} + +let cachedFonts: Promise | null = null; + +/** + * Enumerate installed families via the Rust backend. Cached in-memory so + * opening Settings repeatedly does not re-scan. Falls back to [] outside + * Tauri (plain `vite dev`) or when the scan fails. + */ +export function listSystemFonts(): Promise { + if (!cachedFonts) { + cachedFonts = invoke("list_system_fonts") + .then((fonts) => + Array.isArray(fonts) + ? fonts.filter( + (font) => + font && typeof font.family === "string" && font.family.trim(), + ) + : [], + ) + .catch(() => []); + } + return cachedFonts; +} diff --git a/src/lib/settings.ts b/src/lib/settings.ts index 7d910e5a..4630e932 100644 --- a/src/lib/settings.ts +++ b/src/lib/settings.ts @@ -18,7 +18,7 @@ export const SETTINGS_SECTIONS: { { id: "appearance", label: "Appearance", - description: "Theme, translucency, and the tint applied to the chrome.", + description: "Theme, translucency, tint, and fonts applied to the chrome.", }, { id: "keybindings", diff --git a/src/main.tsx b/src/main.tsx index 9330f2f8..682b900f 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -3,12 +3,14 @@ import ReactDOM from "react-dom/client"; import { listen } from "@tauri-apps/api/event"; import App from "./App"; import { activateWindowAppearance, initAppearance } from "./lib/appearance"; +import { initFonts } from "./lib/fonts"; import { initSounds } from "./lib/sounds"; import { handleQuitRequested, loadBootWorkspace } from "./lib/appLifecycle"; import { consumeInstalledUpdate } from "./lib/updateNotice"; import "./index.css"; initAppearance(); +initFonts(); initSounds(); function dismissBootSplash() { diff --git a/src/surfaces/SettingsView.tsx b/src/surfaces/SettingsView.tsx index b8949838..eaa51514 100644 --- a/src/surfaces/SettingsView.tsx +++ b/src/surfaces/SettingsView.tsx @@ -21,8 +21,11 @@ import { } from "react"; import { HarnessIcon } from "../chrome/HarnessIcon"; import { Popover } from "../chrome/Popover"; +import { Terminal } from "@xterm/xterm"; +import { FitAddon } from "@xterm/addon-fit"; import { InboxProviderMark } from "../chrome/InboxProviderMark"; import { RemoveProjectDialog } from "../chrome/RemoveProjectDialog"; +import { terminalTheme } from "./TerminalView"; import { WindowControls } from "../chrome/WindowControls"; import { useLockOverscroll } from "../hooks/useLockOverscroll"; import { useColorScheme } from "../hooks/useColorScheme"; @@ -42,6 +45,7 @@ import { CHAT_BACKGROUND_SCOPE_DEFAULT, THEME_PREFERENCE_DEFAULT, chatBackgroundSrc, + isLightScheme, loadBodyGlass, loadChatBackgroundOpacity, loadChatBackgroundPath, @@ -64,6 +68,7 @@ import { saveThemeSaturation, saveTranscriptLayout, saveTranscriptAnchor, + SCHEME_CHANGE_EVENT, TRANSCRIPT_ANCHOR_CHANGE_EVENT, SIDEBAR_BLUR_DEFAULT, SIDEBAR_BLUR_MAX, @@ -94,6 +99,27 @@ import { UI_SCALE_MAX, UI_SCALE_MIN, } from "../lib/uiScale"; +import { + CODE_FONT_SIZE_MAX, + CODE_FONT_SIZE_MIN, + FONT_WEIGHT_MAX, + FONT_WEIGHT_MIN, + FONTS_CHANGE_EVENT, + codeFontStack, + listSystemFonts, + loadCodeFontFamily, + loadCodeFontSize, + loadCodeFontWeight, + loadUiFontFamily, + loadUiFontWeight, + resetFontsToDefaults, + saveCodeFontFamily, + saveCodeFontSize, + saveCodeFontWeight, + saveUiFontFamily, + saveUiFontWeight, + type SystemFont, +} from "../lib/fonts"; import { getHarnessAvailabilitySnapshot, harnessUnavailableHint, @@ -944,6 +970,11 @@ function useAppearanceSettings() { null, ); const [uiScale, setUiScale] = useState(loadUiScale); + const [uiFontFamily, setUiFontFamily] = useState(loadUiFontFamily); + const [uiFontWeight, setUiFontWeight] = useState(loadUiFontWeight); + const [codeFontFamily, setCodeFontFamily] = useState(loadCodeFontFamily); + const [codeFontSize, setCodeFontSize] = useState(loadCodeFontSize); + const [codeFontWeight, setCodeFontWeight] = useState(loadCodeFontWeight); useEffect(() => subscribeUiScale(() => setUiScale(loadUiScale())), []); @@ -1032,6 +1063,26 @@ function useAppearanceSettings() { void applyUiScale(next); }, []); + const onUiFontFamily = useCallback((next: string) => { + setUiFontFamily(saveUiFontFamily(next)); + }, []); + + const onUiFontWeight = useCallback((next: number) => { + setUiFontWeight(saveUiFontWeight(next)); + }, []); + + const onCodeFontFamily = useCallback((next: string) => { + setCodeFontFamily(saveCodeFontFamily(next)); + }, []); + + const onCodeFontSize = useCallback((next: number) => { + setCodeFontSize(saveCodeFontSize(next)); + }, []); + + const onCodeFontWeight = useCallback((next: number) => { + setCodeFontWeight(saveCodeFontWeight(next)); + }, []); + const restoreDefaults = useCallback(() => { onThemePreference(THEME_PREFERENCE_DEFAULT); onOpacity(Math.round(SIDEBAR_OPACITY_DEFAULT * 100)); @@ -1042,6 +1093,12 @@ function useAppearanceSettings() { onChatBackgroundScope(CHAT_BACKGROUND_SCOPE_DEFAULT); if (chatBackgroundPath) void onClearChatBackground(); onUiScale(Math.round(UI_SCALE_DEFAULT * 100)); + resetFontsToDefaults(); + setUiFontFamily(loadUiFontFamily()); + setUiFontWeight(loadUiFontWeight()); + setCodeFontFamily(loadCodeFontFamily()); + setCodeFontSize(loadCodeFontSize()); + setCodeFontWeight(loadCodeFontWeight()); }, [ chatBackgroundPath, onBlur, @@ -1053,6 +1110,11 @@ function useAppearanceSettings() { onOpacity, onTint, onUiScale, + onUiFontFamily, + onUiFontWeight, + onCodeFontFamily, + onCodeFontSize, + onCodeFontWeight, ]); return { @@ -1068,6 +1130,11 @@ function useAppearanceSettings() { chatBackgroundBusy, chatBackgroundError, uiScale, + uiFontFamily, + uiFontWeight, + codeFontFamily, + codeFontSize, + codeFontWeight, onThemePreference, onOpacity, onBlur, @@ -1078,6 +1145,11 @@ function useAppearanceSettings() { onChatBackgroundOpacity, onChatBackgroundScope, onUiScale, + onUiFontFamily, + onUiFontWeight, + onCodeFontFamily, + onCodeFontSize, + onCodeFontWeight, restoreDefaults, }; } @@ -1194,10 +1266,457 @@ function AppearancePage({ appearance }: { appearance: AppearanceSettings }) { onChange={appearance.onUiScale} /> + + + + + + + + + + + + + + + + + ); } +const CODE_TERMINAL_SAMPLE = [ + "\x1b[1;32m$\x1b[0m pnpm vitest run src/lib/fonts.test.ts", + " \x1b[32m✓\x1b[0m 9 passed (6ms)", + "\x1b[1;32m$\x1b[0m echo \"a => b != c → € £ ¥\"", + "a => b != c → € £ ¥", + "\x1b[1;32m$\x1b[0m git diff --stat", + " src/lib/fonts.ts \x1b[33m| 120 +++++++++++\x1b[0m", +]; + +/** Live in-place example of the code knob: a real (read-only) xterm.js + * terminal running the same family, size, and weight as the workspace + * terminals, so every picker and slider change shows here immediately. */ +function CodeFontPreview() { + const hostRef = useRef(null); + + useEffect(() => { + const host = hostRef.current; + if (!host) return; + let disposed = false; + const term = new Terminal({ + cursorBlink: false, + disableStdin: true, + fontFamily: codeFontStack(loadCodeFontFamily()), + fontSize: loadCodeFontSize(), + fontWeight: loadCodeFontWeight(), + lineHeight: 1, + letterSpacing: 0, + scrollback: 100, + allowTransparency: true, + theme: terminalTheme(isLightScheme()), + }); + const fit = new FitAddon(); + term.loadAddon(fit); + term.open(host); + fit.fit(); + // Static preview: hide the cursor, then play a tiny session. + term.write("\x1b[?25l"); + for (const line of CODE_TERMINAL_SAMPLE) term.writeln(line); + const onFontsChange = () => { + if (disposed) return; + term.options.fontFamily = codeFontStack(loadCodeFontFamily()); + term.options.fontSize = loadCodeFontSize(); + term.options.fontWeight = loadCodeFontWeight(); + // New metrics change the cell grid: re-fit to the host. + fit.fit(); + }; + const onSchemeChange = () => { + if (disposed) return; + term.options.theme = terminalTheme(isLightScheme()); + }; + window.addEventListener(FONTS_CHANGE_EVENT, onFontsChange); + window.addEventListener(SCHEME_CHANGE_EVENT, onSchemeChange); + const observer = new ResizeObserver(() => { + if (!disposed) fit.fit(); + }); + observer.observe(host); + return () => { + disposed = true; + observer.disconnect(); + window.removeEventListener(FONTS_CHANGE_EVENT, onFontsChange); + window.removeEventListener(SCHEME_CHANGE_EVENT, onSchemeChange); + term.dispose(); + }; + }, []); + + return ( +
+
Code preview
+

+ A real terminal running your code font: same typeface, size, and + weight as the workspace terminals. +

+
+
+
+
+ ); +} + +function FontWeightSegmented({ + label, + value, + onChange, +}: { + label: string; + value: number; + onChange: (value: number) => void; +}) { + const asOption = (weight: number) => + weight >= FONT_WEIGHT_MAX + ? String(FONT_WEIGHT_MAX) + : weight <= FONT_WEIGHT_MIN + ? String(FONT_WEIGHT_MIN) + : String(Math.round(weight / 100) * 100); + return ( + onChange(Number(next))} + /> + ); +} + +function FontPicker({ + label, + value, + onChange, + previewText, + monospaceFilter = false, +}: { + label: string; + value: string; + onChange: (value: string) => void; + previewText: string; + monospaceFilter?: boolean; +}) { + const [open, setOpen] = useState(false); + const [fonts, setFonts] = useState(null); + const [failed, setFailed] = useState(false); + const [query, setQuery] = useState(""); + const [monoOnly, setMonoOnly] = useState(monospaceFilter); + const [active, setActive] = useState(0); + const root = useRef(null); + const trigger = useRef(null); + const searchId = useId(); + const listId = useId(); + + useEffect(() => { + if (!open || fonts || failed) return; + let cancelled = false; + void listSystemFonts() + .then((next) => { + if (!cancelled) setFonts(next); + }) + .catch(() => { + if (!cancelled) setFailed(true); + }); + return () => { + cancelled = true; + }; + }, [open, fonts, failed]); + + useEffect(() => { + if (open) { + setQuery(""); + setActive(0); + } + }, [open]); + + const needle = query.trim().toLowerCase(); + const visible = useMemo(() => { + const all = fonts ?? []; + return all.filter((font) => { + if (monoOnly && !font.monospace) return false; + if (!needle) return true; + return font.family.toLowerCase().includes(needle); + }); + }, [fonts, monoOnly, needle]); + + // Index 0 is always "System default"; font rows follow it. + useEffect(() => { + setActive(0); + }, [query, monoOnly]); + + const pick = (next: string) => { + onChange(next); + setOpen(false); + trigger.current?.focus(); + }; + + const onListKey = (e: ReactKeyboardEvent) => { + // Typing in the search field must not pick a font: Enter there would + // otherwise reset to "System default" while the user is still filtering. + if ( + e.key === "Enter" && + (e.target as HTMLElement | null)?.tagName === "INPUT" + ) { + return; + } + if (e.key === "ArrowDown") { + e.preventDefault(); + setActive((i) => Math.min(visible.length, i + 1)); + return; + } + if (e.key === "ArrowUp") { + e.preventDefault(); + setActive((i) => Math.max(0, i - 1)); + return; + } + if (e.key === "Enter") { + e.preventDefault(); + pick(active === 0 ? "" : (visible[active - 1]?.family ?? value)); + } + }; + + return ( +
+ + {open ? ( + { + setOpen(false); + if (reason === "escape") trigger.current?.focus(); + }} + role="listbox" + aria-label={label} + aria-activedescendant={`${listId}-opt-${active}`} + tabIndex={-1} + onKeyDown={onListKey} + className="flex flex-col overflow-hidden p-1" + > +
+ +
+ {monospaceFilter ? ( + + ) : null} +
+ {fonts == null && !failed ? ( +

+ + Loading system fonts… +

+ ) : failed || (fonts != null && fonts.length === 0) ? ( +

+ {failed + ? "Could not list system fonts. Your pick still applies if the family is installed." + : "No system fonts found. Your pick still applies if the family is installed."} +

+ ) : ( + <> + setActive(0)} + onPick={() => pick("")} + /> + {visible.map((font, index) => { + const row = index + 1; + return ( + setActive(row)} + onPick={() => pick(font.family)} + /> + ); + })} + {visible.length === 0 ? ( +

+ No matching fonts +

+ ) : null} + + )} +
+
+ ) : null} +
+ ); +} + +function FontPickerOption({ + id, + name, + preview, + badge, + selected, + highlighted, + onEnter, + onPick, +}: { + id: string; + name: string; + preview: string | null; + badge?: string | null; + selected: boolean; + highlighted: boolean; + onEnter: () => void; + onPick: () => void; +}) { + return ( + + ); +} + function ChatBackgroundCard({ appearance, }: { diff --git a/src/surfaces/TerminalView.tsx b/src/surfaces/TerminalView.tsx index 12034f5e..ee9e8383 100644 --- a/src/surfaces/TerminalView.tsx +++ b/src/surfaces/TerminalView.tsx @@ -15,6 +15,13 @@ import { type TerminalMetaPatch, } from "../lib/terminalTab"; import { isLightScheme, SCHEME_CHANGE_EVENT } from "../lib/appearance"; +import { + codeFontStack, + FONTS_CHANGE_EVENT, + loadCodeFontFamily, + loadCodeFontSize, + loadCodeFontWeight, +} from "../lib/fonts"; import { applyTerminalChrome, fitTerminal, @@ -79,7 +86,7 @@ const ANSI_LIGHT = { brightWhite: "#ffffff", }; -function terminalTheme(light: boolean) { +export function terminalTheme(light: boolean) { return { background: "#00000000", foreground: cssColor("var(--color-content)", light ? "#2e2e2e" : "#e8eef2"), @@ -93,11 +100,8 @@ function terminalTheme(light: boolean) { }; } -function monoFont(): string { - const fromCss = getComputedStyle(document.documentElement) - .getPropertyValue("--font-mono") - .trim(); - return fromCss || "ui-monospace, SFMono-Regular, Menlo, Monaco, monospace"; +function codeFont(): string { + return codeFontStack(loadCodeFontFamily()); } // OSC 10/11/12 replies so CLIs (vim, tmux, …) pick matching colors. @@ -126,8 +130,9 @@ export function TerminalView({ id, cwd, active, onMetaChange }: Props) { const term = new Terminal({ cursorBlink: true, cursorStyle: "bar", - fontFamily: monoFont(), - fontSize: 13, + fontFamily: codeFont(), + fontSize: loadCodeFontSize(), + fontWeight: loadCodeFontWeight(), lineHeight: 1, letterSpacing: 0, scrollback: 5000, @@ -289,6 +294,16 @@ export function TerminalView({ id, cwd, active, onMetaChange }: Props) { }; applySizeRef.current = applySize; + const onFontsChange = () => { + term.options.fontFamily = codeFont(); + term.options.fontSize = loadCodeFontSize(); + term.options.fontWeight = loadCodeFontWeight(); + // New metrics change the cell grid: force a re-fit + pty resize. + lastCols = 0; + lastRows = 0; + schedule(); + }; + window.addEventListener(FONTS_CHANGE_EVENT, onFontsChange); const renderSub = term.onRender(() => { if (!spawned.current) applySize(); }); @@ -308,6 +323,7 @@ export function TerminalView({ id, cwd, active, onMetaChange }: Props) { host.removeEventListener("copy", onCopy); host.removeEventListener("paste", onPaste); window.removeEventListener(SCHEME_CHANGE_EVENT, onSchemeChange); + window.removeEventListener(FONTS_CHANGE_EVENT, onFontsChange); dataSub.dispose(); oscFg.dispose(); oscBg.dispose(); diff --git a/src/surfaces/editorChrome.ts b/src/surfaces/editorChrome.ts index 9a054690..1146abc7 100644 --- a/src/surfaces/editorChrome.ts +++ b/src/surfaces/editorChrome.ts @@ -17,7 +17,8 @@ function editorThemeStyles(dark: boolean) { height: "100%", backgroundColor: "transparent", color: "var(--color-content)", - fontSize: "13px", + fontSize: "var(--font-mono-size, 13px)", + fontWeight: "var(--font-mono-weight, 400)", userSelect: "text", }, "&.cm-focused": {