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] },