feat: custom IDE Font (#1428)
* Add an Appearance setting for the app/workbench UI font family. It will be independent from terminal typography and will not change Monaco, diffs, markdown editor text, or terminal panes. * Update IDE font copy
This commit is contained in:
parent
479f5c488d
commit
4497b5fa3d
|
|
@ -30,6 +30,7 @@ vi.mock('node:os', async () => {
|
|||
})
|
||||
|
||||
function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings {
|
||||
const appFontFamily = overrides.appFontFamily ?? 'Geist'
|
||||
return {
|
||||
workspaceDir: testState.fakeHomeDir,
|
||||
nestWorkspaces: false,
|
||||
|
|
@ -100,7 +101,8 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
|
|||
terminalWindowsShell: 'powershell.exe',
|
||||
terminalWindowsPowerShellImplementation: 'powershell.exe',
|
||||
enableGitHubAttribution: true,
|
||||
...overrides
|
||||
...overrides,
|
||||
appFontFamily
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ vi.mock('node:os', async () => {
|
|||
})
|
||||
|
||||
function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings {
|
||||
const appFontFamily = overrides.appFontFamily ?? 'Geist'
|
||||
return {
|
||||
workspaceDir: testState.fakeHomeDir,
|
||||
nestWorkspaces: false,
|
||||
|
|
@ -94,7 +95,8 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
|
|||
terminalWindowsShell: 'powershell.exe',
|
||||
terminalWindowsPowerShellImplementation: 'powershell.exe',
|
||||
enableGitHubAttribution: true,
|
||||
...overrides
|
||||
...overrides,
|
||||
appFontFamily
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -72,6 +72,7 @@ describe('Store', () => {
|
|||
expect(settings.branchPrefix).toBe('git-username')
|
||||
expect(settings.refreshLocalBaseRefOnWorktreeCreate).toBe(false)
|
||||
expect(settings.theme).toBe('system')
|
||||
expect(settings.appFontFamily).toBe('Geist')
|
||||
expect(settings.editorAutoSave).toBe(false)
|
||||
expect(settings.editorAutoSaveDelayMs).toBe(1000)
|
||||
expect(settings.terminalFontSize).toBe(14)
|
||||
|
|
@ -310,12 +311,14 @@ describe('Store', () => {
|
|||
theme: 'dark',
|
||||
editorAutoSave: true,
|
||||
editorAutoSaveDelayMs: 1500,
|
||||
appFontFamily: 'Inter',
|
||||
terminalFontSize: 16,
|
||||
terminalFontWeight: 600
|
||||
})
|
||||
expect(updated.theme).toBe('dark')
|
||||
expect(updated.editorAutoSave).toBe(true)
|
||||
expect(updated.editorAutoSaveDelayMs).toBe(1500)
|
||||
expect(updated.appFontFamily).toBe('Inter')
|
||||
expect(updated.terminalFontSize).toBe(16)
|
||||
expect(updated.terminalFontWeight).toBe(600)
|
||||
// Other fields preserved
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
TOGGLE_TERMINAL_PANE_EXPAND_EVENT
|
||||
} from '@/constants/terminal'
|
||||
import { syncZoomCSSVar } from '@/lib/ui-zoom'
|
||||
import { buildAppFontFamily } from '@/lib/app-font-family'
|
||||
import { toast } from 'sonner'
|
||||
import { Toaster } from '@/components/ui/sonner'
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
|
|
@ -518,6 +519,13 @@ function App(): React.JSX.Element {
|
|||
}
|
||||
}, [settings])
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.style.setProperty(
|
||||
'--app-font-family',
|
||||
buildAppFontFamily(settings?.appFontFamily)
|
||||
)
|
||||
}, [settings?.appFontFamily])
|
||||
|
||||
// Refresh GitHub data (PR/issue status) when window regains focus
|
||||
useEffect(() => {
|
||||
const handler = (): void => {
|
||||
|
|
|
|||
|
|
@ -61,6 +61,8 @@
|
|||
/* ── Light Mode ──────────────────────────────────────── */
|
||||
|
||||
:root {
|
||||
--app-font-family: 'Geist', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
--font-sans: var(--app-font-family);
|
||||
--font-mono:
|
||||
'SF Mono', SFMono-Regular, ui-monospace, 'Cascadia Code', Menlo, Consolas, 'Liberation Mono',
|
||||
monospace;
|
||||
|
|
@ -165,12 +167,14 @@
|
|||
padding: 0;
|
||||
overflow: hidden;
|
||||
height: 100vh;
|
||||
font-family:
|
||||
font-family: var(
|
||||
--app-font-family,
|
||||
'Geist',
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
'Segoe UI',
|
||||
sans-serif;
|
||||
sans-serif
|
||||
);
|
||||
letter-spacing: 0.01em;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
|
|
|
|||
|
|
@ -5,11 +5,14 @@ import { UIZoomControl } from './UIZoomControl'
|
|||
import { SearchableSetting } from './SearchableSetting'
|
||||
import { matchesSettingsSearch, type SettingsSearchEntry } from './settings-search'
|
||||
import { useAppStore } from '../../store'
|
||||
import { FontAutocomplete } from './SettingsFormControls'
|
||||
import { DEFAULT_APP_FONT_FAMILY } from '../../../../shared/constants'
|
||||
|
||||
type AppearancePaneProps = {
|
||||
settings: GlobalSettings
|
||||
updateSettings: (updates: Partial<GlobalSettings>) => void
|
||||
applyTheme: (theme: 'system' | 'dark' | 'light') => void
|
||||
fontSuggestions: string[]
|
||||
}
|
||||
|
||||
const STATUS_BAR_TOGGLES: readonly {
|
||||
|
|
@ -87,6 +90,14 @@ const ZOOM_ENTRIES: SettingsSearchEntry[] = [
|
|||
}
|
||||
]
|
||||
|
||||
const TYPOGRAPHY_ENTRIES: SettingsSearchEntry[] = [
|
||||
{
|
||||
title: 'IDE Font',
|
||||
description: 'Choose the font used by the Orca interface.',
|
||||
keywords: ['font', 'typeface', 'typography', 'ide', 'orca', 'interface', 'app', 'ui']
|
||||
}
|
||||
]
|
||||
|
||||
const LAYOUT_ENTRIES: SettingsSearchEntry[] = [
|
||||
{
|
||||
title: 'Open Right Sidebar by Default',
|
||||
|
|
@ -117,6 +128,7 @@ const SIDEBAR_ENTRIES: SettingsSearchEntry[] = [
|
|||
|
||||
export const APPEARANCE_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [
|
||||
...THEME_ENTRIES,
|
||||
...TYPOGRAPHY_ENTRIES,
|
||||
...ZOOM_ENTRIES,
|
||||
...LAYOUT_ENTRIES,
|
||||
...TITLEBAR_ENTRIES,
|
||||
|
|
@ -127,7 +139,8 @@ export const APPEARANCE_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [
|
|||
export function AppearancePane({
|
||||
settings,
|
||||
updateSettings,
|
||||
applyTheme
|
||||
applyTheme,
|
||||
fontSuggestions
|
||||
}: AppearancePaneProps): React.JSX.Element {
|
||||
const searchQuery = useAppStore((state) => state.settingsSearchQuery)
|
||||
const isMac = navigator.userAgent.includes('Mac')
|
||||
|
|
@ -191,6 +204,33 @@ export function AppearancePane({
|
|||
</SearchableSetting>
|
||||
</section>
|
||||
) : null,
|
||||
matchesSettingsSearch(searchQuery, TYPOGRAPHY_ENTRIES) ? (
|
||||
<section key="typography" className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-semibold">Typography</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Choose the font used by the Orca interface.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<SearchableSetting
|
||||
title="IDE Font"
|
||||
description="Choose the font used by the Orca interface."
|
||||
keywords={['font', 'typeface', 'typography', 'ide', 'orca', 'interface', 'app', 'ui']}
|
||||
className="space-y-2"
|
||||
>
|
||||
<Label>IDE Font</Label>
|
||||
<FontAutocomplete
|
||||
value={settings.appFontFamily}
|
||||
suggestions={fontSuggestions}
|
||||
placeholder={DEFAULT_APP_FONT_FAMILY}
|
||||
onChange={(value) =>
|
||||
updateSettings({ appFontFamily: value.trim() || DEFAULT_APP_FONT_FAMILY })
|
||||
}
|
||||
/>
|
||||
</SearchableSetting>
|
||||
</section>
|
||||
) : null,
|
||||
matchesSettingsSearch(searchQuery, LAYOUT_ENTRIES) ? (
|
||||
<section key="layout" className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import { useAppStore } from '../../store'
|
|||
import { useSystemPrefersDark } from '@/components/terminal-pane/use-system-prefers-dark'
|
||||
import { isMacUserAgent, isWindowsUserAgent } from '@/components/terminal-pane/pane-helpers'
|
||||
import { SCROLLBACK_PRESETS_MB, getFallbackTerminalFonts } from './SettingsConstants'
|
||||
import { DEFAULT_APP_FONT_FAMILY } from '../../../../shared/constants'
|
||||
import { GeneralPane, GENERAL_PANE_SEARCH_ENTRIES } from './GeneralPane'
|
||||
import { BrowserPane, BROWSER_PANE_SEARCH_ENTRIES } from './BrowserPane'
|
||||
import { AppearancePane, APPEARANCE_PANE_SEARCH_ENTRIES } from './AppearancePane'
|
||||
|
|
@ -180,8 +181,8 @@ function Settings(): React.JSX.Element {
|
|||
void window.api.wsl.isAvailable().then(setWslAvailable)
|
||||
void window.api.pwsh.isAvailable().then(setPwshAvailable)
|
||||
}, [isWindows])
|
||||
const [terminalFontSuggestions, setTerminalFontSuggestions] = useState<string[]>(
|
||||
getFallbackTerminalFonts()
|
||||
const [fontSuggestions, setFontSuggestions] = useState<string[]>(
|
||||
Array.from(new Set([DEFAULT_APP_FONT_FAMILY, ...getFallbackTerminalFonts()]))
|
||||
)
|
||||
const [activeSectionId, setActiveSectionId] = useState('general')
|
||||
// Why: the hidden-experimental group is an unlock — Shift-clicking the
|
||||
|
|
@ -255,7 +256,9 @@ function Settings(): React.JSX.Element {
|
|||
return
|
||||
}
|
||||
terminalFontsLoadedRef.current = true
|
||||
setTerminalFontSuggestions((prev) => Array.from(new Set([...fonts, ...prev])).slice(0, 320))
|
||||
setFontSuggestions((prev) =>
|
||||
Array.from(new Set([DEFAULT_APP_FONT_FAMILY, ...fonts, ...prev])).slice(0, 320)
|
||||
)
|
||||
} catch {
|
||||
// Fall back to curated cross-platform suggestions.
|
||||
}
|
||||
|
|
@ -684,6 +687,7 @@ function Settings(): React.JSX.Element {
|
|||
settings={settings}
|
||||
updateSettings={updateSettings}
|
||||
applyTheme={applyTheme}
|
||||
fontSuggestions={fontSuggestions}
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
|
|
@ -708,7 +712,7 @@ function Settings(): React.JSX.Element {
|
|||
settings={settings}
|
||||
updateSettings={updateSettings}
|
||||
systemPrefersDark={systemPrefersDark}
|
||||
terminalFontSuggestions={terminalFontSuggestions}
|
||||
terminalFontSuggestions={fontSuggestions}
|
||||
scrollbackMode={scrollbackMode}
|
||||
setScrollbackMode={setScrollbackMode}
|
||||
ghostty={ghostty}
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ type FontAutocompleteProps = {
|
|||
value: string
|
||||
suggestions: string[]
|
||||
onChange: (value: string) => void
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
export function ThemePicker({
|
||||
|
|
@ -214,7 +215,8 @@ export function NumberField({
|
|||
export function FontAutocomplete({
|
||||
value,
|
||||
suggestions,
|
||||
onChange
|
||||
onChange,
|
||||
placeholder = 'SF Mono'
|
||||
}: FontAutocompleteProps): React.JSX.Element {
|
||||
const [query, setQuery] = useState(value)
|
||||
const [prevValue, setPrevValue] = useState(value)
|
||||
|
|
@ -353,7 +355,7 @@ export function FontAutocomplete({
|
|||
}
|
||||
}
|
||||
}}
|
||||
placeholder="SF Mono"
|
||||
placeholder={placeholder}
|
||||
className="pr-18"
|
||||
role="combobox"
|
||||
aria-autocomplete="list"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,22 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { buildAppFontFamily } from './app-font-family'
|
||||
|
||||
describe('buildAppFontFamily', () => {
|
||||
it('defaults to the bundled app font', () => {
|
||||
expect(buildAppFontFamily('')).toBe(
|
||||
'"Geist", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'
|
||||
)
|
||||
})
|
||||
|
||||
it('places a custom UI font before the fallback chain', () => {
|
||||
expect(buildAppFontFamily('Inter')).toBe(
|
||||
'"Inter", "Geist", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'
|
||||
)
|
||||
})
|
||||
|
||||
it('does not duplicate the bundled font when selected explicitly', () => {
|
||||
expect(buildAppFontFamily('Geist')).toBe(
|
||||
'"Geist", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
import { DEFAULT_APP_FONT_FAMILY } from '../../../shared/constants'
|
||||
|
||||
const APP_FONT_FALLBACKS = [
|
||||
DEFAULT_APP_FONT_FAMILY,
|
||||
'-apple-system',
|
||||
'BlinkMacSystemFont',
|
||||
'Segoe UI',
|
||||
'sans-serif'
|
||||
] as const
|
||||
|
||||
const CSS_FONT_KEYWORDS = new Set([
|
||||
'serif',
|
||||
'sans-serif',
|
||||
'monospace',
|
||||
'cursive',
|
||||
'fantasy',
|
||||
'system-ui',
|
||||
'blinkmacsystemfont'
|
||||
])
|
||||
|
||||
function quoteFontFamily(fontFamily: string): string {
|
||||
if (fontFamily.startsWith('-') || CSS_FONT_KEYWORDS.has(fontFamily.toLowerCase())) {
|
||||
return fontFamily
|
||||
}
|
||||
return JSON.stringify(fontFamily)
|
||||
}
|
||||
|
||||
export function buildAppFontFamily(fontFamily: string | null | undefined): string {
|
||||
const trimmed = fontFamily?.trim() || DEFAULT_APP_FONT_FAMILY
|
||||
const lowerTrimmed = trimmed.toLowerCase()
|
||||
const parts = [
|
||||
trimmed,
|
||||
...APP_FONT_FALLBACKS.filter((fallback) => fallback.toLowerCase() !== lowerTrimmed)
|
||||
]
|
||||
return parts.map(quoteFontFamily).join(', ')
|
||||
}
|
||||
|
|
@ -11,6 +11,7 @@ import type {
|
|||
import { DEFAULT_TERMINAL_FONT_WEIGHT } from './terminal-fonts'
|
||||
|
||||
export const SCHEMA_VERSION = 1
|
||||
export const DEFAULT_APP_FONT_FAMILY = 'Geist'
|
||||
|
||||
export const ORCA_BROWSER_PARTITION = 'persist:orca-browser'
|
||||
// Why: blank browser tabs must start from an inert guest URL that does not
|
||||
|
|
@ -119,6 +120,7 @@ export function getDefaultSettings(homedir: string): GlobalSettings {
|
|||
branchPrefixCustom: '',
|
||||
enableGitHubAttribution: false,
|
||||
theme: 'system',
|
||||
appFontFamily: DEFAULT_APP_FONT_FAMILY,
|
||||
editorAutoSave: false,
|
||||
editorAutoSaveDelayMs: DEFAULT_EDITOR_AUTO_SAVE_DELAY_MS,
|
||||
editorMinimapEnabled: false,
|
||||
|
|
|
|||
|
|
@ -1020,6 +1020,7 @@ export type GlobalSettings = {
|
|||
branchPrefixCustom: string
|
||||
enableGitHubAttribution: boolean
|
||||
theme: 'system' | 'dark' | 'light'
|
||||
appFontFamily: string
|
||||
editorAutoSave: boolean
|
||||
editorAutoSaveDelayMs: number
|
||||
editorMinimapEnabled: boolean
|
||||
|
|
|
|||
Loading…
Reference in New Issue