fix(settings): List every installed font family in the font pickers (#225)

The font pickers never read the installed fonts: they probed a hard-coded list of ~30 names with canvas width checks, so anything outside the list never appeared and the monospaced filter changed nothing. A new ide-fonts crate asks the platform font system for families and their fixed-pitch flag (CoreText on macOS, DirectWrite on Windows, fc-list on Linux/BSD), exposed as system_font_families (spawn_blocking, cached). Every font picker — editor font and fallback, terminal font and fallback, UI custom font — uses one FontSelect with prefix type-ahead, a real monospaced filter, and keeps an uninstalled selection visible.

Not verified on Windows or Linux machines.

Fixes #199

Claude-Session: https://claude.ai/code/session_01Dq6jQQn8ZhRfkkyj3PRutA
This commit is contained in:
l0ng-ai
2026-10-29 12:05:46 +08:00
committed by GitHub
parent e537dd43a0
commit aea0b33585
21 changed files with 647 additions and 124 deletions
Generated
+12
View File
@@ -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",
+1
View File
@@ -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" }
+17
View File
@@ -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"] }
+264
View File
@@ -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<Vec<FontFamily>> = 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<Item = (String, bool)>) -> Vec<FontFamily> {
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<FontFamily> = 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::<u32>().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<CTFontDescriptor> = descriptors.cast_unchecked();
descriptors
.iter()
.filter_map(|d| {
let name = d.attribute(kCTFontFamilyNameAttribute)?.downcast::<CFString>().ok()?.to_string();
let traits = d
.attribute(kCTFontTraitsAttribute)
.and_then(|t| t.downcast::<CFDictionary>().ok())
.and_then(|t| t.cast_unchecked::<CFString, CFType>().get(kCTFontSymbolicTrait))
.and_then(|n| n.downcast::<CFNumber>().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<Vec<(String, bool)>> {
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::<IDWriteFont1>().ok())
.is_some_and(|f| unsafe { f.IsMonospacedFont() }.as_bool());
out.push((name, mono));
}
Ok(out)
}
unsafe fn english(names: &IDWriteLocalizedStrings) -> Option<String> {
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<String> = 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));
}
}
}
+1
View File
@@ -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
+14
View File
@@ -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<Vec<FontFamily>> {
tauri::async_runtime::spawn_blocking(|| ide_fonts::families().to_vec())
.await
.map_err(|e| IdeError::internal(e.to_string()))
}
+2
View File
@@ -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<tauri::Wry> {
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,
+14
View File
@@ -152,6 +152,11 @@ export const commands = {
* false when the clipboard stayed busy; the caller keeps working either way.
*/
clipboardSetFileList: (paths: string[]) => typedError<boolean, IdeError>(__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<FontFamily[], IdeError>(__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,
@@ -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<typeof import("@/platform/ipc")>();
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(<FontSelect value="Menlo" onChange={() => {}} 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(
<FontSelect value="PingFang SC" onChange={() => {}} 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(<FontSelect value="Fira Code" onChange={() => {}} 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(
<FontSelect value="" onChange={() => {}} 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(<FontSelect value="Arial" onChange={onChange} aria-label="Font" />, "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(
<>
<FontSelect value="Menlo" onChange={() => {}} aria-label="A" />
<FontSelect value="Menlo" onChange={() => {}} aria-label="B" />
</>,
);
await act(async () => {});
expect(systemFontFamilies).toHaveBeenCalledTimes(1);
});
});
+2 -2
View File
@@ -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);
@@ -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<SelectOption[]>(() => {
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 (
<Select value={value} options={options} onChange={onChange} disabled={disabled} aria-label={aria["aria-label"]} />
);
}
@@ -23,6 +23,11 @@
}
/* "[x] Limit history to: [1000] rows" */
.fontFamily {
display: flex;
align-items: center;
gap: 8px;
}
.inlineRow {
display: flex;
align-items: center;
+6 -19
View File
@@ -14,6 +14,8 @@ import { Checkbox, IconButton, Input, Popup, type SelectOption, T } from "@/ui";
import { NOTIFICATION_GROUPS } from "@/workbench/notificationStore";
import { ColorSwatches, NumberInput, Row, SearchLabel, Select, Toggle } from "../components/Form";
import { useLabelMatch, useSetting } from "../components/context";
import { FontSelect } from "../components/FontSelect";
import { BUNDLED_MONO_FAMILY, BUNDLED_UI_FAMILY } from "@/platform/fonts";
import {
ZOOM_LIMITS,
IDLE_LIMITS,
@@ -174,35 +176,20 @@ function ZoomRow() {
);
}
export const UI_FONTS = ["Inter", "SF Pro Text", "Helvetica Neue", "PingFang SC", "Arial", "Verdana", "JetBrains Mono"];
const UI_BUNDLED = [BUNDLED_UI_FAMILY, BUNDLED_MONO_FAMILY];
function CustomFontRow() {
const [on, setOn] = useSetting<boolean>("appearance.useCustomFont");
const [family, setFamily] = useSetting<string>("appearance.uiFontFamily");
const [size, setSize] = useSetting<number>("appearance.uiFontSize");
const [draft, setDraft] = useState<string | null>(null);
const id = useId();
const matched = useLabelMatch("Use custom font");
return (
<div className={clsx(styles.fontRow, matched && styles.matched)}>
<Checkbox checked={on} onChange={setOn} label={<SearchLabel text="Use custom font:" />} />
<Input
list={`${id}-fonts`}
className={styles.fontInput}
aria-label="UI font"
disabled={!on}
value={draft ?? family}
onChange={(e) => {
setDraft(e.target.value);
if (e.target.value.trim()) setFamily(e.target.value.trim());
}}
onBlur={() => setDraft(null)}
/>
<datalist id={`${id}-fonts`}>
{UI_FONTS.map((f) => (
<option key={f} value={f} />
))}
</datalist>
<span className={styles.fontInput}>
<FontSelect value={family} onChange={setFamily} bundled={UI_BUNDLED} disabled={!on} aria-label="UI font" />
</span>
<label className={clsx(styles.sizeLabel, !on && styles.disabled)} htmlFor={`${id}-size`}>
<SearchLabel text="Size:" />
</label>
+4 -80
View File
@@ -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<string>("editor.fontFamily");
const [monoOnly, setMonoOnly] = useState(true);
const matched = useLabelMatch("Show only monospaced fonts");
const options = useMemo<SelectOption[]>(() => {
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 (
<Row label="Font">
<span className={styles.fontFamily}>
<span className={styles.fontSelect}>
<Select value={family} options={options} onChange={setFamily} aria-label="Font" />
<FontSelect value={family} onChange={setFamily} monospacedOnly={monoOnly} aria-label="Font" />
</span>
<span className={matched ? styles.matched : undefined}>
<Checkbox
@@ -125,13 +53,9 @@ function SizeRow() {
function FallbackRow() {
const [fallback, setFallback] = useSetting<string>("editor.fallbackFont");
const options = useMemo<SelectOption[]>(
() => [{ value: "", label: "None" }, ...installedFonts().map((f) => ({ value: f.name, label: f.name }))],
[],
);
return (
<Row label="Fallback font" hint="For symbols not supported by the main font">
<Select value={fallback} options={options} onChange={setFallback} aria-label="Fallback font" />
<FontSelect value={fallback} onChange={setFallback} none aria-label="Fallback font" />
</Row>
);
}
+24 -19
View File
@@ -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<string>("terminal.fallbackFontFamily");
const [columnWidth, setColumnWidth] = useSetting<number>("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 (
<>
<div className={clsx(styles.inlineRow, matched && styles.matched)}>
@@ -245,14 +246,24 @@ function TerminalFont() {
</span>
</div>
<Indented>
<Row label="Font" htmlFor={familyId}>
<Input
id={familyId}
className={styles.medium}
disabled={useEditor}
value={family}
onChange={(e) => e.target.value.trim() && setFamily(e.target.value)}
/>
<Row label="Font">
<span className={styles.fontFamily}>
<FontSelect
value={family}
onChange={setFamily}
monospacedOnly={monoOnly}
disabled={useEditor}
aria-label="Terminal font"
/>
<span className={monoMatched ? styles.matched : undefined}>
<Checkbox
checked={monoOnly}
onChange={setMonoOnly}
disabled={useEditor}
label={<SearchLabel text="Show only monospaced fonts" />}
/>
</span>
</span>
</Row>
<Row label="Size">
<NumberInput
@@ -274,14 +285,8 @@ function TerminalFont() {
aria-label="Terminal line height"
/>
</Row>
<Row label="Fallback font" htmlFor={fallbackId} hint="Used for characters the main font lacks.">
<Input
id={fallbackId}
className={styles.medium}
value={fallback}
placeholder="None"
onChange={(e) => setFallback(e.target.value)}
/>
<Row label="Fallback font" hint="Used for characters the main font lacks.">
<FontSelect value={fallback} onChange={setFallback} none aria-label="Terminal fallback font" />
</Row>
<Row label="Column width">
<NumberInput
@@ -588,7 +593,7 @@ export const TOOLS_PAGES: PageSpec[] = [
{
kind: "custom",
label: "Use editor font",
keywords: ["Font", "Size", "Line height", "Fallback font", "Column width"],
keywords: ["Font", "Show only monospaced fonts", "Size", "Line height", "Fallback font", "Column width"],
component: TerminalFont,
},
],
+11 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { monoFontFamily } from "./fonts";
import { monoFontFamily, uiFontFamily } from "./fonts";
const BASE = "var(--font-mono)";
@@ -22,3 +22,13 @@ describe("monoFontFamily", () => {
);
});
});
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);
});
});
+14
View File
@@ -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.
+2
View File
@@ -2038,6 +2038,8 @@
"Template name": "模板名称",
"Template text": "模板文本",
"Terminal": "终端",
"Terminal fallback font": "终端备用字体",
"Terminal font": "终端字体",
"Terminal font size": "终端字体大小",
"Terminal line height": "终端行高",
"Terminal sessions": "终端会话",
+32
View File
@@ -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"]);
});
});
+57
View File
@@ -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<FontFamily[]> | null = null;
/** The installed families; empty when the font system can't be read. */
export function loadSystemFonts(): Promise<FontFamily[]> {
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<FontFamily[]>(() => 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<string, string>();
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;
}
+11 -3
View File
@@ -28,6 +28,8 @@ export interface SelectProps<V extends string = string> {
renderValue?: (option: SelectOption<V> | undefined) => ReactNode;
}
const TYPE_AHEAD_MS = 800;
/** Dropdown select with a listbox popup (↑↓ Enter Esc, type-ahead). */
export function Select<V extends string = string>({
value,
@@ -45,6 +47,7 @@ export function Select<V extends string = string>({
const triggerRef = useRef<HTMLButtonElement>(null);
const current = options.find((o) => o.value === value);
const [active, setActive] = useState(0);
const typeAhead = useRef({ text: "", at: -Infinity });
const isSection = (o: SelectOption<V>) => !!o.disabled;
const openPopup = () => {
@@ -76,11 +79,16 @@ export function Select<V extends string = string>({
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;
}