diff --git a/Cargo.lock b/Cargo.lock index 3f179876..53f18e19 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4104,6 +4104,17 @@ dependencies = [ "which", ] +[[package]] +name = "ide-fonts" +version = "0.1.23" +dependencies = [ + "objc2-core-foundation", + "objc2-core-text", + "serde", + "specta", + "windows", +] + [[package]] name = "ide-github" version = "0.1.23" @@ -5988,6 +5999,7 @@ dependencies = [ "ide-dap", "ide-db", "ide-docker", + "ide-fonts", "ide-github", "ide-host", "ide-http", diff --git a/Cargo.toml b/Cargo.toml index d191dac1..18b58723 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,6 +36,7 @@ ide-web = { path = "crates/ide-web" } ide-spell = { path = "crates/ide-spell" } ide-agent = { path = "crates/ide-agent" } ide-clipboard = { path = "crates/ide-clipboard" } +ide-fonts = { path = "crates/ide-fonts" } ide-ssh = { path = "crates/ide-ssh" } ide-remote = { path = "crates/ide-remote" } ide-host = { path = "crates/ide-host" } diff --git a/crates/ide-fonts/Cargo.toml b/crates/ide-fonts/Cargo.toml new file mode 100644 index 00000000..5c669527 --- /dev/null +++ b/crates/ide-fonts/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "ide-fonts" +description = "Font families installed on this machine, for the font pickers: CoreText, DirectWrite or fontconfig" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +serde.workspace = true +specta.workspace = true + +[target.'cfg(target_os = "macos")'.dependencies] +objc2-core-foundation = { version = "0.3", default-features = false, features = ["std", "CFArray", "CFDictionary", "CFNumber", "CFString"] } +objc2-core-text = { version = "0.3", default-features = false, features = ["std", "CTFontCollection", "CTFontDescriptor", "CTFontTraits"] } + +[target.'cfg(windows)'.dependencies] +windows = { version = "0.62", features = ["Win32_Graphics_DirectWrite"] } diff --git a/crates/ide-fonts/src/lib.rs b/crates/ide-fonts/src/lib.rs new file mode 100644 index 00000000..92a6f521 --- /dev/null +++ b/crates/ide-fonts/src/lib.rs @@ -0,0 +1,264 @@ +//! The font families installed on this machine, for the font pickers in +//! Settings (editor, typography fallback, terminal, UI font). +//! +//! Each platform asks the font system the WebView itself resolves CSS +//! `font-family` names against, so every listed name renders: CoreText on +//! macOS, DirectWrite on Windows, fontconfig (`fc-list`) on Linux and the BSDs. +//! The list is read once per run and cached; the first read can take a few +//! hundred milliseconds, so callers run it off the UI and command threads. + +use serde::{Deserialize, Serialize}; +use specta::Type; +use std::collections::BTreeMap; +use std::sync::OnceLock; + +/// One installed family. `monospaced` is the font system's own flag (fixed +/// pitch; on Linux also fontconfig's dual-width CJK monospace). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct FontFamily { + pub name: String, + pub monospaced: bool, +} + +/// The installed families, sorted case-insensitively, read on first use. +pub fn families() -> &'static [FontFamily] { + static CACHE: OnceLock> = OnceLock::new(); + CACHE.get_or_init(|| normalize(imp::list())) +} + +/// Merges the per-face records a font system reports into one entry per +/// family: trimmed, without empty or hidden (`.`-prefixed system UI) names, +/// monospaced when any face is, sorted case-insensitively. +pub fn normalize(faces: impl IntoIterator) -> Vec { + let mut by_key: BTreeMap<(String, String), bool> = BTreeMap::new(); + for (name, mono) in faces { + let name = name.trim(); + if name.is_empty() || name.starts_with('.') { + continue; + } + *by_key.entry((name.to_lowercase(), name.to_owned())).or_default() |= mono; + } + let mut out: Vec = Vec::with_capacity(by_key.len()); + for ((key, name), monospaced) in by_key { + // Same family under two casings (one font file each): keep the first. + if let Some(last) = out.last_mut() + && last.name.to_lowercase() == key + { + last.monospaced |= monospaced; + continue; + } + out.push(FontFamily { name, monospaced }); + } + out +} + +/// Parses `fc-list -f '%{family[0]}\t%{spacing}\n'`: one line per face, the +/// spacing empty (proportional) or fontconfig's number or name for it. +pub fn parse_fc_list(output: &str) -> Vec<(String, bool)> { + output + .lines() + .filter_map(|line| { + let (family, spacing) = line.split_once('\t').unwrap_or((line, "")); + let family = family.replace("\\-", "-"); + let mono = match spacing.trim() { + "dual" | "mono" | "charcell" => true, + s => s.parse::().is_ok_and(|n| n >= 90), + }; + (!family.trim().is_empty()).then_some((family, mono)) + }) + .collect() +} + +#[cfg(target_os = "macos")] +mod imp { + use objc2_core_foundation::{CFArray, CFDictionary, CFNumber, CFString, CFType}; + use objc2_core_text::{ + CTFontCollection, CTFontDescriptor, CTFontSymbolicTraits, kCTFontFamilyNameAttribute, kCTFontSymbolicTrait, + kCTFontTraitsAttribute, + }; + + /// Every face CoreText makes available to this process, as (family, fixed pitch). + pub(crate) fn list() -> Vec<(String, bool)> { + // SAFETY: CoreText's documented types: the matching descriptors array + // holds CTFontDescriptors and the traits dictionary maps CFString keys. + unsafe { + let collection = CTFontCollection::from_available_fonts(None); + let Some(descriptors) = collection.matching_font_descriptors() else { + return Vec::new(); + }; + let descriptors: &CFArray = descriptors.cast_unchecked(); + descriptors + .iter() + .filter_map(|d| { + let name = d.attribute(kCTFontFamilyNameAttribute)?.downcast::().ok()?.to_string(); + let traits = d + .attribute(kCTFontTraitsAttribute) + .and_then(|t| t.downcast::().ok()) + .and_then(|t| t.cast_unchecked::().get(kCTFontSymbolicTrait)) + .and_then(|n| n.downcast::().ok()) + .and_then(|n| n.as_i64()) + .unwrap_or(0); + Some((name, traits as u32 & CTFontSymbolicTraits::TraitMonoSpace.0 != 0)) + }) + .collect() + } + } +} + +#[cfg(windows)] +mod imp { + use windows::Win32::Graphics::DirectWrite::{ + DWRITE_FACTORY_TYPE_SHARED, DWRITE_FONT_STRETCH_NORMAL, DWRITE_FONT_STYLE_NORMAL, DWRITE_FONT_WEIGHT_NORMAL, + DWriteCreateFactory, IDWriteFactory, IDWriteFont1, IDWriteLocalizedStrings, + }; + use windows::core::{BOOL, Interface, w}; + + /// The system font collection's families, named in English when the + /// family has an English name (what CSS matches), else its first name. + pub(crate) fn list() -> Vec<(String, bool)> { + // SAFETY: plain DirectWrite COM calls on interfaces we own. + unsafe { read() }.unwrap_or_default() + } + + unsafe fn read() -> windows::core::Result> { + let factory: IDWriteFactory = unsafe { DWriteCreateFactory(DWRITE_FACTORY_TYPE_SHARED) }?; + let mut collection = None; + unsafe { factory.GetSystemFontCollection(&mut collection, false) }?; + let Some(collection) = collection else { + return Ok(Vec::new()); + }; + let mut out = Vec::new(); + for i in 0..unsafe { collection.GetFontFamilyCount() } { + let Ok(family) = (unsafe { collection.GetFontFamily(i) }) else { + continue; + }; + let Some(name) = unsafe { family.GetFamilyNames() }.ok().and_then(|n| unsafe { english(&n) }) else { + continue; + }; + let mono = unsafe { + family.GetFirstMatchingFont( + DWRITE_FONT_WEIGHT_NORMAL, + DWRITE_FONT_STRETCH_NORMAL, + DWRITE_FONT_STYLE_NORMAL, + ) + } + .ok() + .and_then(|f| f.cast::().ok()) + .is_some_and(|f| unsafe { f.IsMonospacedFont() }.as_bool()); + out.push((name, mono)); + } + Ok(out) + } + + unsafe fn english(names: &IDWriteLocalizedStrings) -> Option { + let mut index = 0u32; + let mut exists = BOOL(0); + unsafe { names.FindLocaleName(w!("en-us"), &mut index, &mut exists) }.ok()?; + if !exists.as_bool() { + index = 0; + } + let len = unsafe { names.GetStringLength(index) }.ok()? as usize; + let mut buf = vec![0u16; len + 1]; + unsafe { names.GetString(index, &mut buf) }.ok()?; + Some(String::from_utf16_lossy(&buf[..len])) + } +} + +#[cfg(all(unix, not(target_os = "macos")))] +mod imp { + use std::process::{Command, Stdio}; + + /// fontconfig's view, which WebKitGTK resolves names with. Empty when + /// `fc-list` isn't installed. + pub(crate) fn list() -> Vec<(String, bool)> { + Command::new("fc-list") + .args(["-f", "%{family[0]}\t%{spacing}\n"]) + .stdin(Stdio::null()) + .stderr(Stdio::null()) + .output() + .ok() + .filter(|o| o.status.success()) + .map(|o| super::parse_fc_list(&String::from_utf8_lossy(&o.stdout))) + .unwrap_or_default() + } +} + +#[cfg(not(any(unix, windows)))] +mod imp { + pub(crate) fn list() -> Vec<(String, bool)> { + Vec::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn fam(name: &str, monospaced: bool) -> FontFamily { + FontFamily { name: name.into(), monospaced } + } + + #[test] + fn normalize_merges_faces_sorts_and_drops_hidden() { + let faces = [ + ("Menlo", true), + ("Arial", false), + ("Menlo", true), + (".SF NS Mono", true), + (" ", false), + ("arial", false), + ("PingFang SC", false), + ("Courier New", false), + ("Courier New", true), + ("andale mono", true), + ] + .map(|(n, m)| (n.to_owned(), m)); + assert_eq!( + normalize(faces), + vec![ + fam("andale mono", true), + fam("Arial", false), + fam("Courier New", true), + fam("Menlo", true), + fam("PingFang SC", false), + ] + ); + } + + #[test] + fn fc_list_spacing_marks_monospace() { + let out = "DejaVu Sans Mono\t100\nDejaVu Sans\t\nNoto Sans Mono CJK SC\t90\nTerminus\t110\nCantarell\nFira\\-Code\tmono\n\t100\n"; + assert_eq!( + parse_fc_list(out), + [ + ("DejaVu Sans Mono", true), + ("DejaVu Sans", false), + ("Noto Sans Mono CJK SC", true), + ("Terminus", true), + ("Cantarell", false), + ("Fira-Code", true), + ] + .map(|(n, m)| (n.to_owned(), m)) + ); + } + + /// The real font system: every desktop OS ships some fonts, at least one + /// of them monospaced, and the list comes back sorted without duplicates. + #[cfg(any(target_os = "macos", windows))] + #[test] + fn lists_installed_families() { + let list = families(); + assert!(list.len() > 20, "only {} families", list.len()); + assert!(list.iter().any(|f| f.monospaced)); + assert!(list.iter().any(|f| !f.monospaced)); + let keys: Vec = list.iter().map(|f| f.name.to_lowercase()).collect(); + assert!(keys.windows(2).all(|w| w[0] < w[1])); + #[cfg(target_os = "macos")] + { + let menlo = list.iter().find(|f| f.name == "Menlo").expect("Menlo ships with macOS"); + assert!(menlo.monospaced); + assert!(list.iter().any(|f| f.name == "Helvetica Neue" && !f.monospaced)); + } + } +} diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 4fcbc9e7..f63ec5b8 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -36,6 +36,7 @@ dirs.workspace = true ide-core.workspace = true dunce.workspace = true ide-clipboard.workspace = true +ide-fonts.workspace = true ide-text.workspace = true ide-vfs.workspace = true ide-workspace.workspace = true diff --git a/src-tauri/src/commands/fonts.rs b/src-tauri/src/commands/fonts.rs new file mode 100644 index 00000000..5b15079c --- /dev/null +++ b/src-tauri/src/commands/fonts.rs @@ -0,0 +1,14 @@ +//! Font families installed on this machine, for the font pickers in Settings. + +use ide_core::{IdeError, IdeResult}; +use ide_fonts::FontFamily; + +/// Every installed family, sorted, each flagged monospaced or not. Read once +/// per run (off the command threads) and cached. +#[tauri::command] +#[specta::specta] +pub async fn system_font_families() -> IdeResult> { + tauri::async_runtime::spawn_blocking(|| ide_fonts::families().to_vec()) + .await + .map_err(|e| IdeError::internal(e.to_string())) +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index c89190cf..f130d387 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -20,6 +20,7 @@ pub mod deploy; pub mod doc; pub mod docker; pub mod env; +pub mod fonts; pub mod fs; pub mod github; pub mod history; @@ -109,6 +110,7 @@ pub fn builder() -> tauri_specta::Builder { clipboard::clipboard_file_list, clipboard::clipboard_has_files, clipboard::clipboard_set_file_list, + fonts::system_font_families, doc::doc_open, doc::doc_open_virtual, doc::doc_close, diff --git a/src/bindings.ts b/src/bindings.ts index 61687ac2..bbe05593 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -152,6 +152,11 @@ export const commands = { * false when the clipboard stayed busy; the caller keeps working either way. */ clipboardSetFileList: (paths: string[]) => typedError(__TAURI_INVOKE("clipboard_set_file_list", { paths })), + /** + * Every installed family, sorted, each flagged monospaced or not. Read once + * per run (off the command threads) and cached. + */ + systemFontFamilies: () => typedError(__TAURI_INVOKE("system_font_families")), /** * Opens a document. Async: reading and decoding a large file must not block * the main thread (synchronous commands run on it). @@ -2979,6 +2984,15 @@ export type FoldingSettings = { docComments?: boolean, }; +/** + * One installed family. `monospaced` is the font system's own flag (fixed + * pitch; on Linux also fontconfig's dual-width CJK monospace). + */ +export type FontFamily = { + name: string, + monospaced: boolean, +}; + export type FormatOptions = { tabSize: number, insertSpaces: boolean, diff --git a/src/features/settings/__tests__/fontSelect.test.tsx b/src/features/settings/__tests__/fontSelect.test.tsx new file mode 100644 index 00000000..6e0fd87d --- /dev/null +++ b/src/features/settings/__tests__/fontSelect.test.tsx @@ -0,0 +1,108 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { act, fireEvent, render, screen } from "@testing-library/react"; +import type { FontFamily } from "@/platform/ipc"; + +const installed: FontFamily[] = [ + { name: "Andale Mono", monospaced: true }, + { name: "Arial", monospaced: false }, + { name: "Courier New", monospaced: true }, + { name: "Georgia", monospaced: false }, + { name: "Helvetica Neue", monospaced: false }, + { name: "Maple Mono NF CN", monospaced: true }, + { name: "Menlo", monospaced: true }, + { name: "Monaco", monospaced: true }, + { name: "PingFang SC", monospaced: false }, + { name: "Sarasa Term SC", monospaced: true }, +]; +const systemFontFamilies = vi.fn(async () => ({ status: "ok" as const, data: installed })); + +vi.mock("@/platform/ipc", async (importOriginal) => { + const real = await importOriginal(); + return { ...real, commands: { ...real.commands, systemFontFamilies: () => systemFontFamilies() } }; +}); + +import { resetSystemFontsForTests } from "@/platform/systemFonts"; +import { FontSelect } from "../components/FontSelect"; + +afterEach(() => resetSystemFontsForTests()); + +async function open(ui: React.ReactElement, label: string) { + render(ui); + await act(async () => {}); + fireEvent.click(screen.getByRole("button", { name: label })); + return screen.getAllByRole("option").map((o) => o.textContent); +} + +describe("FontSelect", () => { + it("lists every installed family plus the bundled one, sorted", async () => { + const names = await open( {}} aria-label="Font" />, "Font"); + expect(names).toEqual([ + "Andale Mono", + "Arial", + "Courier New", + "Georgia", + "Helvetica Neue", + "JetBrains Mono", + "Maple Mono NF CN", + "Menlo", + "Monaco", + "PingFang SC", + "Sarasa Term SC", + ]); + }); + + it("filters to monospaced families when asked, keeping the current one", async () => { + const names = await open( + {}} monospacedOnly aria-label="Font" />, + "Font", + ); + expect(names).toEqual([ + "Andale Mono", + "Courier New", + "JetBrains Mono", + "Maple Mono NF CN", + "Menlo", + "Monaco", + "PingFang SC", + "Sarasa Term SC", + ]); + }); + + it("keeps a current family that isn't installed here", async () => { + render( {}} monospacedOnly aria-label="Font" />); + await act(async () => {}); + expect(screen.getByRole("button", { name: "Font" })).toHaveTextContent("Fira Code"); + }); + + it("offers None first for fallback fonts, without the bundled family", async () => { + const names = await open( + {}} none aria-label="Fallback font" />, + "Fallback font", + ); + expect(names[0]).toBe("None"); + expect(names).not.toContain("JetBrains Mono"); + expect(names).toHaveLength(installed.length + 1); + }); + + it("jumps to the family typed as a prefix", async () => { + const onChange = vi.fn(); + await open(, "Font"); + const popup = document.querySelector("[data-autofocus]")!; + fireEvent.keyDown(popup, { key: "m", timeStamp: 1000 }); + fireEvent.keyDown(popup, { key: "e", timeStamp: 1100 }); + fireEvent.keyDown(popup, { key: "Enter", timeStamp: 1200 }); + expect(onChange).toHaveBeenCalledWith("Menlo"); + }); + + it("reads the font list once", async () => { + systemFontFamilies.mockClear(); + render( + <> + {}} aria-label="A" /> + {}} aria-label="B" /> + , + ); + await act(async () => {}); + expect(systemFontFamilies).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/features/settings/appearanceRuntime.ts b/src/features/settings/appearanceRuntime.ts index 1c2734c2..6eb9ac43 100644 --- a/src/features/settings/appearanceRuntime.ts +++ b/src/features/settings/appearanceRuntime.ts @@ -6,6 +6,7 @@ * pressed only" and "Save files if the IDE is idle". */ import { executeAction, type Action } from "@/platform/actions"; +import { uiFontFamily } from "@/platform/fonts"; import { call, commands, isTauri } from "@/platform/ipc"; import { useProject } from "@/platform/project"; import { setDragRequiresAlt } from "@/ui"; @@ -32,8 +33,7 @@ export function applyAppearanceOptions(s: ValidSettings, root: HTMLElement = doc if (size === DEFAULT_SETTINGS.appearance.uiFontSize) root.style.removeProperty(name); else root.style.setProperty(name, `${size + delta}px`); } - if (a.useCustomFont) - root.style.setProperty("--font-ui", `"${a.uiFontFamily.replace(/"/g, "")}", system-ui, sans-serif`); + if (a.useCustomFont) root.style.setProperty("--font-ui", uiFontFamily(a.uiFontFamily)); else root.style.removeProperty("--font-ui"); flag(root, "compact", a.compactMode); diff --git a/src/features/settings/components/FontSelect.tsx b/src/features/settings/components/FontSelect.tsx new file mode 100644 index 00000000..c5ecb75f --- /dev/null +++ b/src/features/settings/components/FontSelect.tsx @@ -0,0 +1,46 @@ +/** + * Font-family pickers over the families installed on this machine. The current + * value always stays listed, so a family that isn't installed here (a synced + * setting, an uninstalled font) still shows as selected. + */ +import { useMemo } from "react"; +import type { SelectOption } from "@/ui"; +import { BUNDLED_MONO_FAMILY } from "@/platform/fonts"; +import { fontNames, useSystemFonts } from "@/platform/systemFonts"; +import { Select } from "./Form"; + +const NO_BUNDLED: readonly string[] = []; +const MONO_BUNDLED: readonly string[] = [BUNDLED_MONO_FAMILY]; + +export interface FontSelectProps { + value: string; + onChange: (value: string) => void; + /** List only monospaced families (the current value stays). */ + monospacedOnly?: boolean; + /** Offer "None" (empty value) first: fallback fonts. */ + none?: boolean; + /** Families the app bundles, listed even when not installed. Default: the bundled monospace (none for fallbacks). */ + bundled?: readonly string[]; + disabled?: boolean; + "aria-label": string; +} + +export function FontSelect({ + value, + onChange, + monospacedOnly, + none, + bundled = none ? NO_BUNDLED : MONO_BUNDLED, + disabled, + ...aria +}: FontSelectProps) { + const fonts = useSystemFonts(); + const options = useMemo(() => { + const names = fontNames(fonts, { monospacedOnly, include: [...bundled, value] }); + const list = names.map((n) => ({ value: n, label: n })); + return none ? [{ value: "", label: "None" }, ...list] : list; + }, [fonts, monospacedOnly, none, bundled, value]); + return ( + { - setDraft(e.target.value); - if (e.target.value.trim()) setFamily(e.target.value.trim()); - }} - onBlur={() => setDraft(null)} - /> - - {UI_FONTS.map((f) => ( - + + + diff --git a/src/features/settings/pages/editor/font.tsx b/src/features/settings/pages/editor/font.tsx index 45c63261..6992f154 100644 --- a/src/features/settings/pages/editor/font.tsx +++ b/src/features/settings/pages/editor/font.tsx @@ -2,10 +2,11 @@ * Editor › Font: family (installed fonts, monospaced filter), size, line height, * ligatures, Typography Settings (main / bold weight, fallback font), editable preview. */ -import { useMemo, useState } from "react"; +import { useState } from "react"; import { Checkbox, type SelectOption, T } from "@/ui"; import { monoFontFamily } from "@/platform/fonts"; import { NumberInput, Row, SearchLabel, Select } from "../../components/Form"; +import { FontSelect } from "../../components/FontSelect"; import { useLabelMatch, usePageContext, useSetting } from "../../components/context"; import { FONT_WEIGHTS, fontWeightOf, fontWeightVars } from "@/platform/editorSettings"; import { LIMITS } from "../../model/schema"; @@ -13,88 +14,15 @@ import { editorPage } from "./kit"; import styles from "./Editor.module.css"; import { tx } from "@/platform/i18n"; -const CANDIDATES = [ - "JetBrains Mono", - "SF Mono", - "Menlo", - "Monaco", - "Fira Code", - "Fira Mono", - "Source Code Pro", - "Cascadia Code", - "Cascadia Mono", - "IBM Plex Mono", - "Hack", - "Inconsolata", - "Roboto Mono", - "Ubuntu Mono", - "DejaVu Sans Mono", - "Consolas", - "Courier New", - "PT Mono", - "Andale Mono", - "Victor Mono", - "Iosevka", - "Sarasa Mono SC", - "Noto Sans Mono", - "Noto Sans Mono CJK SC", - "PingFang SC", - "Hiragino Sans GB", - "Microsoft YaHei", - "Helvetica Neue", - "Arial", - "Inter", - "Georgia", -]; - -interface FontInfo { - name: string; - mono: boolean; -} - -let detected: FontInfo[] | null = null; - -/** Installed candidates, detected by comparing glyph widths against generic fallbacks. */ -export function installedFonts(): FontInfo[] { - if (detected) return detected; - const canvas = typeof document !== "undefined" ? document.createElement("canvas") : null; - const ctx = canvas?.getContext?.("2d"); - if (!ctx) return (detected = CANDIDATES.map((name) => ({ name, mono: true }))); - const width = (font: string, text: string) => { - ctx.font = `32px ${font}`; - return ctx.measureText(text).width; - }; - const sample = "mmmmmmmmmmlli1WQ@"; - const out: FontInfo[] = []; - for (const name of CANDIDATES) { - const quoted = `"${name}"`; - const present = ["monospace", "serif", "sans-serif"].some( - (base) => width(`${quoted}, ${base}`, sample) !== width(base, sample), - ); - if (!present && name !== "JetBrains Mono") continue; - out.push({ - name, - mono: Math.abs(width(`${quoted}, monospace`, "iiiiiiiiii") - width(`${quoted}, monospace`, "WWWWWWWWWW")) < 0.5, - }); - } - return (detected = out); -} - function FontRow() { const [family, setFamily] = useSetting("editor.fontFamily"); const [monoOnly, setMonoOnly] = useState(true); const matched = useLabelMatch("Show only monospaced fonts"); - const options = useMemo(() => { - const list = installedFonts().filter((f) => !monoOnly || f.mono); - const names = list.map((f) => f.name); - if (!names.includes(family)) names.unshift(family); - return names.map((n) => ({ value: n, label: n })); - }, [family, monoOnly]); return ( - + ); } diff --git a/src/features/settings/pages/tools.tsx b/src/features/settings/pages/tools.tsx index f95d0447..43889169 100644 --- a/src/features/settings/pages/tools.tsx +++ b/src/features/settings/pages/tools.tsx @@ -14,6 +14,7 @@ import { } from "@/platform/workflowSettings"; import { Button, Checkbox, IconButton, Input, Menu, type SelectOption, T } from "@/ui"; import { NumberInput, Row, SearchLabel, Section, Select } from "../components/Form"; +import { FontSelect } from "../components/FontSelect"; import { useLabelMatch, useSetting } from "../components/context"; import { selectPage, setQuery } from "../model/staging"; import type { PageSpec } from "./spec"; @@ -231,8 +232,8 @@ function TerminalFont() { const [fallback, setFallback] = useSetting("terminal.fallbackFontFamily"); const [columnWidth, setColumnWidth] = useSetting("terminal.columnWidth"); const matched = useLabelMatch("Use editor font"); - const familyId = useId(); - const fallbackId = useId(); + const monoMatched = useLabelMatch("Show only monospaced fonts"); + const [monoOnly, setMonoOnly] = useState(true); return ( <>
@@ -245,14 +246,24 @@ function TerminalFont() {
- - e.target.value.trim() && setFamily(e.target.value)} - /> + + + + + } + /> + + - - setFallback(e.target.value)} - /> + + { ); }); }); + +describe("uiFontFamily", () => { + it.each([ + ["Inter", '"Inter Variable", "Inter", system-ui, sans-serif'], + ["JetBrains Mono", '"JetBrains Mono Variable", "JetBrains Mono", system-ui, sans-serif'], + [' "PingFang SC" ', '"PingFang SC", system-ui, sans-serif'], + ])("%j", (family, expected) => { + expect(uiFontFamily(family)).toBe(expected); + }); +}); diff --git a/src/platform/fonts.ts b/src/platform/fonts.ts index b5413b75..60b24620 100644 --- a/src/platform/fonts.ts +++ b/src/platform/fonts.ts @@ -30,6 +30,20 @@ export function monoFontFamily(family: string | undefined, fallback = "", base = return [...head, ...(fb ? [quote(fb)] : []), base].join(", "); } +export const BUNDLED_UI_FAMILY = "Inter"; +const BUNDLED_UI_FACES = ['"Inter Variable"', '"Inter"']; + +/** + * The `--font-ui` stack for a custom UI font. The bundled families map to their + * bundled faces, like `monoFontFamily`, so they render without a system install. + */ +export function uiFontFamily(family: string): string { + const name = family.replace(/"/g, "").trim(); + const head = + name === BUNDLED_UI_FAMILY ? BUNDLED_UI_FACES : name === BUNDLED_MONO_FAMILY ? BUNDLED_MONO_FACES : [quote(name)]; + return [...head, "system-ui", "sans-serif"].join(", "); +} + /** * Resolves once the faces `family` uses at `size` are loaded, so code that measures * glyphs (xterm's cell size) measures the real font rather than the swap fallback. diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index c416ec51..c503ad1d 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -2038,6 +2038,8 @@ "Template name": "模板名称", "Template text": "模板文本", "Terminal": "终端", + "Terminal fallback font": "终端备用字体", + "Terminal font": "终端字体", "Terminal font size": "终端字体大小", "Terminal line height": "终端行高", "Terminal sessions": "终端会话", diff --git a/src/platform/systemFonts.test.ts b/src/platform/systemFonts.test.ts new file mode 100644 index 00000000..84d29285 --- /dev/null +++ b/src/platform/systemFonts.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import { fontNames } from "./systemFonts"; + +const fonts = [ + { name: "menlo", monospaced: true }, + { name: "Arial", monospaced: false }, + { name: "Courier New", monospaced: true }, + { name: "Ångström Sans", monospaced: false }, +]; + +describe("fontNames", () => { + it("sorts installed families case- and accent-insensitively", () => { + expect(fontNames(fonts)).toEqual(["Ångström Sans", "Arial", "Courier New", "menlo"]); + }); + + it("filters to monospaced families", () => { + expect(fontNames(fonts, { monospacedOnly: true })).toEqual(["Courier New", "menlo"]); + }); + + it("always includes the extra names, spelled as given, without duplicates", () => { + expect(fontNames(fonts, { monospacedOnly: true, include: ["JetBrains Mono", "Menlo", "Arial", " "] })).toEqual([ + "Arial", + "Courier New", + "JetBrains Mono", + "Menlo", + ]); + }); + + it("keeps the current family when nothing is installed", () => { + expect(fontNames([], { include: ["Fira Code"] })).toEqual(["Fira Code"]); + }); +}); diff --git a/src/platform/systemFonts.ts b/src/platform/systemFonts.ts new file mode 100644 index 00000000..475c4606 --- /dev/null +++ b/src/platform/systemFonts.ts @@ -0,0 +1,57 @@ +/** + * The font families installed on this machine (`system_font_families`: CoreText, + * DirectWrite or fontconfig), for every font picker. Read once per window; the + * backend caches the list for the run. + */ +import { useEffect, useState } from "react"; +import { call, commands, type FontFamily } from "./ipc"; + +let cached: FontFamily[] | null = null; +let pending: Promise | null = null; + +/** The installed families; empty when the font system can't be read. */ +export function loadSystemFonts(): Promise { + pending ??= call(commands.systemFontFamilies()) + .catch(() => [] as FontFamily[]) + .then((list) => (cached = list)); + return pending; +} + +/** The installed families, empty until the first read resolves. */ +export function useSystemFonts(): FontFamily[] { + const [fonts, setFonts] = useState(() => cached ?? []); + useEffect(() => { + if (cached) return; + let live = true; + void loadSystemFonts().then((list) => live && setFonts(list)); + return () => { + live = false; + }; + }, []); + return fonts; +} + +/** + * Picker entries: the installed families (only monospaced ones when asked), + * plus `include` (the bundled faces, and the current value so a family that + * isn't installed here still shows as selected), deduplicated and sorted. + */ +export function fontNames( + fonts: readonly FontFamily[], + { monospacedOnly = false, include = [] }: { monospacedOnly?: boolean; include?: readonly string[] } = {}, +): string[] { + const seen = new Map(); + const add = (name: string) => { + const n = name.trim(); + if (n && !seen.has(n.toLowerCase())) seen.set(n.toLowerCase(), n); + }; + include.forEach(add); + for (const f of fonts) if (!monospacedOnly || f.monospaced) add(f.name); + return [...seen.values()].sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" })); +} + +/** Test hook: forget the cached list. */ +export function resetSystemFontsForTests(): void { + cached = null; + pending = null; +} diff --git a/src/ui/Select.tsx b/src/ui/Select.tsx index 20d7a8e7..48a55fb2 100644 --- a/src/ui/Select.tsx +++ b/src/ui/Select.tsx @@ -28,6 +28,8 @@ export interface SelectProps { renderValue?: (option: SelectOption | undefined) => ReactNode; } +const TYPE_AHEAD_MS = 800; + /** Dropdown select with a listbox popup (↑↓ Enter Esc, type-ahead). */ export function Select({ value, @@ -45,6 +47,7 @@ export function Select({ const triggerRef = useRef(null); const current = options.find((o) => o.value === value); const [active, setActive] = useState(0); + const typeAhead = useRef({ text: "", at: -Infinity }); const isSection = (o: SelectOption) => !!o.disabled; const openPopup = () => { @@ -76,11 +79,16 @@ export function Select({ return; } if (e.key.length === 1 && !e.metaKey && !e.ctrlKey) { - const ch = e.key.toLowerCase(); + // Keys typed in quick succession extend the prefix ("me" → Menlo); a lone key cycles. + const ahead = typeAhead.current; + ahead.text = e.timeStamp - ahead.at < TYPE_AHEAD_MS ? ahead.text + e.key.toLowerCase() : e.key.toLowerCase(); + ahead.at = e.timeStamp; + const repeat = ahead.text.length > 1 && [...ahead.text].every((c) => c === ahead.text[0]); + const prefix = repeat ? ahead.text[0]! : ahead.text; const n = options.length; - for (let s = 1; s <= n; s++) { + for (let s = prefix.length > 1 ? 0 : 1; s <= n; s++) { const i = (active + s) % n; - if (!options[i]!.disabled && tx(options[i]!.label).toLowerCase().startsWith(ch)) { + if (!options[i]!.disabled && tx(options[i]!.label).toLowerCase().startsWith(prefix)) { setActive(i); break; }