mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-06 08:07:49 +00:00
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:
Generated
+12
@@ -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",
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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"] }
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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()))
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -2038,6 +2038,8 @@
|
||||
"Template name": "模板名称",
|
||||
"Template text": "模板文本",
|
||||
"Terminal": "终端",
|
||||
"Terminal fallback font": "终端备用字体",
|
||||
"Terminal font": "终端字体",
|
||||
"Terminal font size": "终端字体大小",
|
||||
"Terminal line height": "终端行高",
|
||||
"Terminal sessions": "终端会话",
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user