From b57219f3480f5629960be409273e44842f8fa9d3 Mon Sep 17 00:00:00 2001 From: Ap_Tx <3045929398@qq.com> Date: Fri, 2 Oct 2026 21:33:13 +0800 Subject: [PATCH 01/13] =?UTF-8?q?feat(fonts):=20=E5=90=8E=E7=AB=AF?= =?UTF-8?q?=E5=AD=97=E4=BD=93=E5=91=BD=E4=BB=A4=20fonts=5Fget=5Fsystem=5Ff?= =?UTF-8?q?onts=20=E5=8F=8A=20ui=5Ffont/mono=5Ffont=20=E8=AE=BE=E7=BD=AE?= =?UTF-8?q?=E5=AD=97=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Cargo.toml | 1 + apps/app/Cargo.toml | 1 + apps/app/build.rs | 8 ++ apps/app/src/api/fonts.rs | 91 +++++++++++++++++++ apps/app/src/api/mod.rs | 1 + apps/app/src/main.rs | 1 + .../20261002020000_add-font-settings.sql | 2 + packages/app-lib/src/state/settings.rs | 16 ++++ 8 files changed, 121 insertions(+) create mode 100644 apps/app/src/api/fonts.rs create mode 100644 packages/app-lib/migrations/20261002020000_add-font-settings.sql diff --git a/Cargo.toml b/Cargo.toml index 2293561..1338da0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -44,6 +44,7 @@ encoding_rs = "0.8.35" enumset = "1.1.10" eyre = "0.6.12" flate2 = "1.1.4" +fontdb = "0.24.0" fs4 = { version = "0.13.1", default-features = false } futures = "0.3.31" futures-lite = "2.6.1" diff --git a/apps/app/Cargo.toml b/apps/app/Cargo.toml index fe01a4b..2b66379 100644 --- a/apps/app/Cargo.toml +++ b/apps/app/Cargo.toml @@ -16,6 +16,7 @@ dashmap = { workspace = true } either = { workspace = true } enumset = { workspace = true, features = ["serde"] } flate2 = { workspace = true } +fontdb = { workspace = true } futures = { workspace = true } hyper = { workspace = true, features = ["server"] } hyper-util = { workspace = true } diff --git a/apps/app/build.rs b/apps/app/build.rs index 1ab3c38..7bd7295 100644 --- a/apps/app/build.rs +++ b/apps/app/build.rs @@ -697,6 +697,14 @@ fn main() { DefaultPermissionRule::AllowAllCommands, ), ) + .plugin( + "fonts", + InlinedPlugin::new() + .commands(&["fonts_get_system_fonts"]) + .default_permission( + DefaultPermissionRule::AllowAllCommands, + ), + ) .plugin( "drop", InlinedPlugin::new() diff --git a/apps/app/src/api/fonts.rs b/apps/app/src/api/fonts.rs new file mode 100644 index 0000000..54bfe6f --- /dev/null +++ b/apps/app/src/api/fonts.rs @@ -0,0 +1,91 @@ +use serde::Serialize; +use std::collections::HashMap; +use std::sync::OnceLock; + +use crate::api::Result; + +#[derive(Debug, Clone, Serialize)] +pub struct SystemFontFamily { + pub family: String, + pub monospaced: bool, +} + +/// Scanning every installed face costs a few hundred milliseconds, and the +/// collection only changes between sessions, so it is read once per process. +static SYSTEM_FONTS: OnceLock> = OnceLock::new(); + +type FontFamilies = [(String, fontdb::Language)]; + +/// fontdb lists the English US family first when the font has one, but the +/// lookup is explicit so a face with an unusual name table still resolves to +/// the name the webview matches against. +fn english_family_name(families: &FontFamilies) -> Option<&str> { + families + .iter() + .find(|(_, language)| { + *language == fontdb::Language::English_UnitedStates + }) + .or_else(|| families.first()) + .map(|(name, _)| name.trim()) + .filter(|name| !name.is_empty()) +} + +#[derive(Default)] +struct FamilyAccumulator { + families: HashMap, +} + +impl FamilyAccumulator { + fn add(&mut self, families: &FontFamilies, monospaced: bool) { + let Some(name) = english_family_name(families) else { + return; + }; + + self.families + .entry(name.to_owned()) + .and_modify(|flag| *flag |= monospaced) + .or_insert(monospaced); + } + + fn into_sorted_families(self) -> Vec { + let mut families: Vec = self + .families + .into_iter() + .map(|(family, monospaced)| SystemFontFamily { family, monospaced }) + .collect(); + families.sort_by_cached_key(|entry| entry.family.to_lowercase()); + families + } +} + +fn collect_system_fonts() -> Vec { + let mut database = fontdb::Database::new(); + database.load_system_fonts(); + + let mut accumulator = FamilyAccumulator::default(); + for face in database.faces() { + accumulator.add(&face.families, face.monospaced); + } + + accumulator.into_sorted_families() +} + +#[tauri::command] +pub async fn fonts_get_system_fonts() -> Result> { + if let Some(fonts) = SYSTEM_FONTS.get() { + return Ok(fonts.clone()); + } + + let fonts = tauri::async_runtime::spawn_blocking(collect_system_fonts) + .await + .map_err(std::io::Error::other)?; + + // A concurrent caller may have won the race; the scan is deterministic. + Ok(SYSTEM_FONTS.get_or_init(|| fonts).clone()) +} + +pub fn init() -> tauri::plugin::TauriPlugin { + tauri::plugin::Builder::new("fonts") + .invoke_handler(tauri::generate_handler![fonts_get_system_fonts]) + .build() +} \ No newline at end of file diff --git a/apps/app/src/api/mod.rs b/apps/app/src/api/mod.rs index 544509a..6c46a21 100644 --- a/apps/app/src/api/mod.rs +++ b/apps/app/src/api/mod.rs @@ -36,6 +36,7 @@ pub mod curseforge; pub mod datapacks; pub mod drop; pub mod files; +pub mod fonts; pub mod friends; pub mod worlds; diff --git a/apps/app/src/main.rs b/apps/app/src/main.rs index 2467aaa..cfd1486 100644 --- a/apps/app/src/main.rs +++ b/apps/app/src/main.rs @@ -947,6 +947,7 @@ fn main() { .plugin(api::datapacks::init()) .plugin(api::drop::init()) .plugin(api::files::init()) + .plugin(api::fonts::init()) .plugin(api::friends::init()) .plugin(api::worlds::init()) .plugin(api::terracotta::init()) diff --git a/packages/app-lib/migrations/20261002020000_add-font-settings.sql b/packages/app-lib/migrations/20261002020000_add-font-settings.sql new file mode 100644 index 0000000..f9ecabd --- /dev/null +++ b/packages/app-lib/migrations/20261002020000_add-font-settings.sql @@ -0,0 +1,2 @@ +ALTER TABLE settings ADD COLUMN ui_font TEXT; +ALTER TABLE settings ADD COLUMN mono_font TEXT; \ No newline at end of file diff --git a/packages/app-lib/src/state/settings.rs b/packages/app-lib/src/state/settings.rs index 019ddf2..053541e 100644 --- a/packages/app-lib/src/state/settings.rs +++ b/packages/app-lib/src/state/settings.rs @@ -145,6 +145,10 @@ pub struct Settings { #[serde(default = "default_ui_scale")] pub ui_scale: f64, #[serde(default)] + pub ui_font: Option, + #[serde(default)] + pub mono_font: Option, + #[serde(default)] pub close_behavior: String, #[serde(default)] pub auto_hide_downloads_button: bool, @@ -289,6 +293,8 @@ impl Settings { json(home_widgets) as \"home_widgets?: String\", auto_hide_downloads_button, json(terracotta_public_nodes) terracotta_public_nodes, ui_scale, + ui_font, + mono_font, version FROM settings " @@ -362,6 +368,8 @@ impl Settings { transparent_background_blur: res.transparent_background_blur == 1, sidebar_instance_count: res.sidebar_instance_count as u32, ui_scale: res.ui_scale, + ui_font: res.ui_font, + mono_font: res.mono_font, close_behavior, auto_hide_downloads_button: res.auto_hide_downloads_button == 1, home_layout: HomeLayout::from_string(&res.home_layout), @@ -665,6 +673,14 @@ impl Settings { .bind(self.ui_scale) .execute(exec) .await?; + sqlx::query("UPDATE settings SET ui_font = ? WHERE id = 0") + .bind(&self.ui_font) + .execute(exec) + .await?; + sqlx::query("UPDATE settings SET mono_font = ? WHERE id = 0") + .bind(&self.mono_font) + .execute(exec) + .await?; Ok(()) } From f490600ee2e7c82e351f958b5605fd39f6cbe721 Mon Sep 17 00:00:00 2001 From: Ap_Tx <3045929398@qq.com> Date: Fri, 2 Oct 2026 21:57:24 +0800 Subject: [PATCH 02/13] =?UTF-8?q?feat(fonts):=20=E5=89=8D=E7=AB=AF?= =?UTF-8?q?=E5=AD=97=E4=BD=93=E5=B7=A5=E5=85=B7=E5=87=BD=E6=95=B0=E3=80=81?= =?UTF-8?q?=E4=B8=BB=E9=A2=98=E5=AD=97=E4=BD=93=E7=8A=B6=E6=80=81=E4=B8=8E?= =?UTF-8?q?=E8=AE=BE=E7=BD=AE=E7=B1=BB=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/app-frontend/src/helpers/font-family.ts | 179 ++++++++++++++++++ apps/app-frontend/src/helpers/settings.ts | 4 + apps/app-frontend/src/helpers/system-fonts.ts | 19 ++ apps/app-frontend/src/store/theme.ts | 38 ++++ 4 files changed, 240 insertions(+) create mode 100644 apps/app-frontend/src/helpers/font-family.ts create mode 100644 apps/app-frontend/src/helpers/system-fonts.ts diff --git a/apps/app-frontend/src/helpers/font-family.ts b/apps/app-frontend/src/helpers/font-family.ts new file mode 100644 index 0000000..28db953 --- /dev/null +++ b/apps/app-frontend/src/helpers/font-family.ts @@ -0,0 +1,179 @@ +/** + * Font family plumbing shared by the appearance settings pickers and the theme + * store. Kept free of imports so `node --test` can load it directly. + */ + +/** Mirrors `--font-standard` in `packages/assets/styles/defaults.scss`. */ +export const DEFAULT_UI_FONT_STACK = + 'Inter, -apple-system, BlinkMacSystemFont, Segoe UI, Oxygen, Ubuntu, Roboto, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif' + +/** Mirrors `--mono-font` in `packages/assets/styles/defaults.scss`. */ +export const DEFAULT_MONO_FONT_STACK = + 'ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace' + +/** Picker value for "follow the launcher default"; persisted as `null`. */ +export const FOLLOW_DEFAULT_FONT = '' + +export interface SystemFontFamily { + family: string + monospaced: boolean +} + +export interface FontFamilyOption { + value: string + label: string + subLabel?: string + searchTerms?: string[] +} + +export type FontFamilyOptionOrDivider = FontFamilyOption | { type: 'divider' } + +export interface FontFamilyOptionsConfig { + defaultLabel: string + selected?: string | null + missingLabel?: string + monospaceLabel?: string + groupMonospaced?: boolean +} + +const MAX_FONT_FAMILY_LENGTH = 128 + +/** + * Quotes and escapes an installed family name so it cannot break out of the + * font-family declaration it is spliced into. Returns null when the name has + * nothing usable left. + */ +export function cssFontFamilyName(name: string): string | null { + const trimmed = name.replace(/\p{Cc}/gu, '').trim() + + if (!trimmed || trimmed.length > MAX_FONT_FAMILY_LENGTH) { + return null + } + + return `"${trimmed.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"` +} + +/** + * The value written into `--font-standard` / `--mono-font`. The launcher stack + * always trails the chosen family so an unresolvable name degrades to the + * previous default instead of the user agent's own font. + */ +export function resolveFontFamily( + setting: string | null | undefined, + fallbackStack: string, +): string { + if (!setting) { + return fallbackStack + } + + const family = cssFontFamilyName(setting) + + return family ? `${family}, ${fallbackStack}` : fallbackStack +} + +export function fontSettingToOptionValue(setting: string | null | undefined): string { + return setting ?? FOLLOW_DEFAULT_FONT +} + +export function optionValueToFontSetting(value: string): string | null { + const trimmed = value.trim() + + return trimmed === FOLLOW_DEFAULT_FONT ? null : trimmed +} + +function compareFamilies(first: SystemFontFamily, second: SystemFontFamily): number { + const left = first.family.toLowerCase() + const right = second.family.toLowerCase() + + if (left === right) { + return 0 + } + + return left < right ? -1 : 1 +} + +function toOption(family: SystemFontFamily, monospaceLabel?: string): FontFamilyOption { + return { + value: family.family, + label: family.family, + ...(family.monospaced && monospaceLabel + ? { subLabel: monospaceLabel, searchTerms: [monospaceLabel] } + : {}), + } +} + +/** + * The spelling the font list uses for a stored family. Font names are matched + * case-insensitively so a stored value that only differs in case still selects + * its option instead of leaving the picker blank. + */ +export function canonicalFontFamily( + fonts: SystemFontFamily[], + setting: string | null | undefined, +): string { + const selected = optionValueToFontSetting(setting ?? '') + if (!selected) { + return FOLLOW_DEFAULT_FONT + } + + const listed = fonts.find((font) => font.family.trim().toLowerCase() === selected.toLowerCase()) + + return listed ? listed.family.trim() : selected +} + +/** + * Builds the picker options for the installed font collection: the default + * entry first, the current selection re-added when it is no longer installed, + * and — for the monospace picker — monospaced families ahead of the rest. + */ +export function buildFontFamilyOptions( + fonts: SystemFontFamily[], + config: FontFamilyOptionsConfig, +): FontFamilyOptionOrDivider[] { + const families = new Map() + for (const font of fonts) { + const family = font.family.trim() + + if (!family) { + continue + } + + const key = family.toLowerCase() + const known = families.get(key) + + if (known) { + known.monospaced ||= font.monospaced + } else { + families.set(key, { family, monospaced: font.monospaced }) + } + } + + const sorted = [...families.values()].sort(compareFamilies) + const selected = optionValueToFontSetting(config.selected ?? '') + const options: FontFamilyOptionOrDivider[] = [ + { value: FOLLOW_DEFAULT_FONT, label: config.defaultLabel }, + ] + + if (selected && !families.has(selected.toLowerCase()) && config.missingLabel) { + options.push({ value: selected, label: selected, subLabel: config.missingLabel }) + } + + if (config.groupMonospaced) { + const monospaced = sorted.filter((font) => font.monospaced) + const proportional = sorted.filter((font) => !font.monospaced) + + options.push(...monospaced.map((font) => toOption(font, config.monospaceLabel))) + + if (monospaced.length > 0 && proportional.length > 0) { + options.push({ type: 'divider' }) + } + + options.push(...proportional.map((font) => toOption(font, config.monospaceLabel))) + + return options + } + + options.push(...sorted.map((font) => toOption(font))) + + return options +} \ No newline at end of file diff --git a/apps/app-frontend/src/helpers/settings.ts b/apps/app-frontend/src/helpers/settings.ts index 996687f..67f0321 100644 --- a/apps/app-frontend/src/helpers/settings.ts +++ b/apps/app-frontend/src/helpers/settings.ts @@ -174,6 +174,8 @@ export type AppSettings = { minimal_home_instance_id: string | null close_behavior: 'ask' | 'close' | 'lightweight' ui_scale: number + ui_font: string | null + mono_font: string | null home_widgets: HomeDashboardConfig | null terracotta_public_nodes: string[] @@ -216,6 +218,8 @@ type LegacyMirrorSettings = { function normalizeDownloadSettings(settings: AppSettings & LegacyMirrorSettings): AppSettings { settings.close_behavior ??= 'ask' settings.ui_scale ??= 100 + settings.ui_font ??= null + settings.mono_font ??= null const hasLegacySettings = typeof settings.use_minecraft_mirror === 'boolean' && typeof settings.use_modrinth_mirror === 'boolean' && diff --git a/apps/app-frontend/src/helpers/system-fonts.ts b/apps/app-frontend/src/helpers/system-fonts.ts new file mode 100644 index 0000000..7c87cbc --- /dev/null +++ b/apps/app-frontend/src/helpers/system-fonts.ts @@ -0,0 +1,19 @@ +import { invoke } from '@tauri-apps/api/core' + +import type { SystemFontFamily } from './font-family.ts' + +let cached: Promise | null = null + +/** + * Installed families are enumerated by the host once per session; installing a + * font while the launcher runs shows up after a restart. + */ +export function getSystemFontFamilies(): Promise { + cached ??= invoke('plugin:fonts|fonts_get_system_fonts').catch((error) => { + // A failed scan must not poison the session for a later retry. + cached = null + throw error + }) + + return cached +} \ No newline at end of file diff --git a/apps/app-frontend/src/store/theme.ts b/apps/app-frontend/src/store/theme.ts index bc6366c..e3dc50e 100644 --- a/apps/app-frontend/src/store/theme.ts +++ b/apps/app-frontend/src/store/theme.ts @@ -1,5 +1,11 @@ import { defineStore } from 'pinia' +import { + DEFAULT_MONO_FONT_STACK, + DEFAULT_UI_FONT_STACK, + resolveFontFamily, +} from '@/helpers/font-family.ts' + let systemThemeMq: MediaQueryList | null = null export const DEFAULT_FEATURE_FLAGS = { @@ -130,6 +136,9 @@ export type ThemeStore = { devMode: boolean featureFlags: FeatureFlags + + uiFont: string | null + monoFont: string | null } export const DEFAULT_THEME_STORE: ThemeStore = { @@ -154,6 +163,9 @@ export const DEFAULT_THEME_STORE: ThemeStore = { devMode: false, featureFlags: DEFAULT_FEATURE_FLAGS, + + uiFont: null, + monoFont: null, } export const useTheming = defineStore('themeStore', { @@ -249,6 +261,32 @@ export const useTheming = defineStore('themeStore', { `${Math.min(Math.max(this.transparentBackgroundOpacity, 0), 100)}%`, ) }, + /** + * The font tokens are declared on `body` in the shared defaults, so the + * launcher picks up the chosen family by overriding the CSS variable there. + */ + setUiFont() { + const body = document.body + if (this.uiFont) { + body.style.setProperty( + '--font-standard', + resolveFontFamily(this.uiFont, DEFAULT_UI_FONT_STACK), + ) + } else { + body.style.removeProperty('--font-standard') + } + }, + setMonoFont() { + const body = document.body + if (this.monoFont) { + body.style.setProperty( + '--mono-font', + resolveFontFamily(this.monoFont, DEFAULT_MONO_FONT_STACK), + ) + } else { + body.style.removeProperty('--mono-font') + } + }, getFeatureFlag(key: FeatureFlag) { return this.featureFlags[key] ?? DEFAULT_FEATURE_FLAGS[key] }, From 95a3e00b03d20935491859bceec785cd49ed6507 Mon Sep 17 00:00:00 2001 From: Ap_Tx <3045929398@qq.com> Date: Fri, 2 Oct 2026 23:04:12 +0800 Subject: [PATCH 03/13] =?UTF-8?q?feat(fonts):=20=E5=A4=96=E8=A7=82?= =?UTF-8?q?=E8=AE=BE=E7=BD=AE=E6=96=B0=E5=A2=9E=E7=95=8C=E9=9D=A2/?= =?UTF-8?q?=E7=AD=89=E5=AE=BD=E5=AD=97=E4=BD=93=E9=80=89=E6=8B=A9=E5=99=A8?= =?UTF-8?q?=EF=BC=8C=E5=90=AB=E4=B8=AD=E8=8B=B1=E7=B9=81=E4=B8=AD=E6=96=87?= =?UTF-8?q?=E6=A1=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Cargo.lock | 47 +++ .../ui/settings/AppearanceSettings.vue | 269 +++++++++++++++++- .../app-frontend/src/locales/en-US/index.json | 51 ++++ .../app-frontend/src/locales/zh-CN/index.json | 51 ++++ .../app-frontend/src/locales/zh-TW/index.json | 51 ++++ 5 files changed, 468 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index 5354349..9782bb4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1889,6 +1889,28 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +[[package]] +name = "fontconfig-parser" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646" +dependencies = [ + "roxmltree", +] + +[[package]] +name = "fontdb" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2660c5e9157bf76d2db1294e4a9feba604ef610819a3b591088d0d8392a3290f" +dependencies = [ + "fontconfig-parser", + "log", + "memmap2", + "slotmap", + "tinyvec", +] + [[package]] name = "foreign-types" version = "0.5.0" @@ -3477,6 +3499,15 @@ version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "memmap2" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" +dependencies = [ + "libc", +] + [[package]] name = "memoffset" version = "0.9.1" @@ -5102,6 +5133,12 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "roxmltree" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" + [[package]] name = "rsa" version = "0.9.10" @@ -5758,6 +5795,15 @@ version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + [[package]] name = "smallvec" version = "1.15.2" @@ -6912,6 +6958,7 @@ dependencies = [ "either", "enumset", "flate2", + "fontdb", "futures", "hyper", "hyper-util", diff --git a/apps/app-frontend/src/components/ui/settings/AppearanceSettings.vue b/apps/app-frontend/src/components/ui/settings/AppearanceSettings.vue index 0d4089e..279ed0e 100644 --- a/apps/app-frontend/src/components/ui/settings/AppearanceSettings.vue +++ b/apps/app-frontend/src/components/ui/settings/AppearanceSettings.vue @@ -22,7 +22,7 @@ import { convertFileSrc } from '@tauri-apps/api/core' import { appDataDir, join } from '@tauri-apps/api/path' import { open } from '@tauri-apps/plugin-dialog' import { exists, mkdir, readFile, remove, writeFile } from '@tauri-apps/plugin-fs' -import { computed, ref, watch } from 'vue' +import { computed, onMounted, ref, watch } from 'vue' import { get, set } from '@/helpers/settings.ts' import { getOS } from '@/helpers/utils' @@ -41,6 +41,19 @@ import { parseCustomAccentColor, } from '@/store/theme.ts' +import { + buildFontFamilyOptions, + canonicalFontFamily, + DEFAULT_MONO_FONT_STACK, + DEFAULT_UI_FONT_STACK, + type FontFamilyOption, + type FontFamilyOptionOrDivider, + optionValueToFontSetting, + resolveFontFamily, + type SystemFontFamily, +} from '@/helpers/font-family.ts' +import { getSystemFontFamilies } from '@/helpers/system-fonts.ts' + import SettingsRow from './SettingsRow.vue' import SettingsSection from './SettingsSection.vue' @@ -363,6 +376,63 @@ const messages = defineMessages({ defaultMessage: 'Hide the downloads button in the sidebar when there are no active download tasks.', }, + fontsTitle: { id: 'app.appearance-settings.fonts.title', defaultMessage: 'Fonts' }, + fontsDescription: { + id: 'app.appearance-settings.fonts.description', + defaultMessage: 'Choose the fonts the launcher interface and its logs are rendered in.', + }, + uiFontTitle: { id: 'app.appearance-settings.fonts.ui-font', defaultMessage: 'Interface font' }, + uiFontDescription: { + id: 'app.appearance-settings.fonts.ui-font-description', + defaultMessage: + 'Used across the launcher interface. Only fonts installed on this system are listed.', + }, + monoFontTitle: { + id: 'app.appearance-settings.fonts.mono-font', + defaultMessage: 'Monospace font', + }, + monoFontDescription: { + id: 'app.appearance-settings.fonts.mono-font-description', + defaultMessage: 'Used for logs, the server console, and other monospace content.', + }, + fontDefault: { + id: 'app.appearance-settings.fonts.default', + defaultMessage: 'Launcher default', + }, + fontMonospace: { + id: 'app.appearance-settings.fonts.monospace', + defaultMessage: 'Monospace', + }, + fontMissing: { + id: 'app.appearance-settings.fonts.missing', + defaultMessage: 'Not installed', + }, + fontSearchPlaceholder: { + id: 'app.appearance-settings.fonts.search-placeholder', + defaultMessage: 'Search installed fonts', + }, + fontNoResults: { + id: 'app.appearance-settings.fonts.no-results', + defaultMessage: 'No matching fonts', + }, + fontLoadFailed: { + id: 'app.appearance-settings.fonts.load-failed', + defaultMessage: "Couldn't read the list of installed fonts.", + }, + fontRetry: { id: 'app.appearance-settings.fonts.retry', defaultMessage: 'Retry' }, + fontReset: { + id: 'app.appearance-settings.fonts.reset', + defaultMessage: 'Reset', + }, + fontPreviewLabel: { id: 'app.appearance-settings.fonts.preview', defaultMessage: 'Preview' }, + fontUiPreviewSample: { + id: 'app.appearance-settings.fonts.ui-preview-sample', + defaultMessage: 'The quick brown fox jumps over the lazy dog.', + }, + fontMonoPreviewSample: { + id: 'app.appearance-settings.fonts.mono-preview-sample', + defaultMessage: 'const x = () => 0; // monospace', + }, }) const os = ref(await getOS()) @@ -373,6 +443,78 @@ const customBackgroundPreview = computed(() => : null, ) +const systemFonts = ref([]) +const systemFontsLoading = ref(true) +const systemFontsFailed = ref(false) + +async function loadSystemFonts() { + systemFontsLoading.value = true + systemFontsFailed.value = false + + try { + systemFonts.value = await getSystemFontFamilies() + } catch (error) { + systemFontsFailed.value = true + console.warn('Failed to read the list of installed fonts', error) + } finally { + systemFontsLoading.value = false + } +} + +function selectedOptionLabel(options: FontFamilyOptionOrDivider[], value: string): string { + const option = options.find( + (entry): entry is FontFamilyOption => 'value' in entry && entry.value === value, + ) + + return option?.label ?? '' +} + +const uiFontOptions = computed(() => + buildFontFamilyOptions(systemFonts.value, { + defaultLabel: formatMessage(messages.fontDefault), + missingLabel: formatMessage(messages.fontMissing), + selected: settings.value.ui_font, + }), +) + +const monoFontOptions = computed(() => + buildFontFamilyOptions(systemFonts.value, { + defaultLabel: formatMessage(messages.fontDefault), + missingLabel: formatMessage(messages.fontMissing), + monospaceLabel: formatMessage(messages.fontMonospace), + selected: settings.value.mono_font, + groupMonospaced: true, + }), +) + +const uiFontSelection = computed({ + get: () => canonicalFontFamily(systemFonts.value, settings.value.ui_font), + set: (value: string) => { + settings.value.ui_font = optionValueToFontSetting(value) + }, +}) + +const monoFontSelection = computed({ + get: () => canonicalFontFamily(systemFonts.value, settings.value.mono_font), + set: (value: string) => { + settings.value.mono_font = optionValueToFontSetting(value) + }, +}) + +const uiFontSearchValue = computed(() => + selectedOptionLabel(uiFontOptions.value, uiFontSelection.value), +) +const monoFontSearchValue = computed(() => + selectedOptionLabel(monoFontOptions.value, monoFontSelection.value), +) + +const uiFontPreview = computed(() => + resolveFontFamily(settings.value.ui_font, DEFAULT_UI_FONT_STACK), +) +const monoFontPreview = computed(() => + resolveFontFamily(settings.value.mono_font, DEFAULT_MONO_FONT_STACK), +) + function updateUiScale(value: number) { const clamped = Math.min(200, Math.max(50, Math.round(value))) setUiScale(clamped) @@ -489,6 +631,8 @@ watch( settings.value.transparent_background_blur, settings.value.sidebar_instance_count, settings.value.close_behavior, + settings.value.ui_font, + settings.value.mono_font, ] as const, ([ path, @@ -499,6 +643,8 @@ watch( transparentBlur, sidebarInstanceCount, closeBehavior, + uiFont, + monoFont, ]) => { themeStore.customBackgroundPath = path themeStore.customBackgroundBlur = blur @@ -509,10 +655,19 @@ watch( themeStore.setTransparentBackgroundClass() themeStore.sidebarInstanceCount = sidebarInstanceCount themeStore.closeBehavior = closeBehavior as CloseBehavior + themeStore.uiFont = uiFont + themeStore.monoFont = monoFont + themeStore.setUiFont() + themeStore.setMonoFont() }, { immediate: true }, ) +onMounted(() => { + if (props.scope === 'interface') { + void loadSystemFonts() + } +}) watch( settings, async () => { @@ -1148,6 +1303,118 @@ watch( + + + + + + + + + + + + +
+ {{ formatMessage(messages.fontLoadFailed) }} + +
+
+