diff --git a/src/main/index.ts b/src/main/index.ts index 4fd922fcff2..05ece8ccde8 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -13,6 +13,7 @@ import { registerSettingsHandlers } from './ipc/settings' import { registerShellHandlers } from './ipc/shell' import { registerSessionHandlers } from './ipc/session' import { registerUIHandlers } from './ipc/ui' +import { warmSystemFontFamilies } from './system-fonts' let mainWindow: BrowserWindow | null = null let store: Store | null = null @@ -85,6 +86,13 @@ app.whenReady().then(() => { label: app.name, submenu: [ { role: 'about' }, + { + label: 'Settings', + accelerator: 'CmdOrCtrl+,', + click: () => { + mainWindow?.webContents.send('ui:openSettings') + } + }, { type: 'separator' }, { role: 'services' }, { type: 'separator' }, @@ -143,6 +151,7 @@ app.whenReady().then(() => { registerShellHandlers() registerSessionHandlers(store) registerUIHandlers(store) + warmSystemFontFamilies() // macOS re-activate app.on('activate', function () { diff --git a/src/main/ipc/settings.ts b/src/main/ipc/settings.ts index 59a50eb5500..e57955e52a0 100644 --- a/src/main/ipc/settings.ts +++ b/src/main/ipc/settings.ts @@ -1,6 +1,7 @@ import { ipcMain } from 'electron' import type { Store } from '../persistence' import type { GlobalSettings, PersistedState } from '../../shared/types' +import { listSystemFontFamilies } from '../system-fonts' export function registerSettingsHandlers(store: Store): void { ipcMain.handle('settings:get', () => { @@ -11,6 +12,10 @@ export function registerSettingsHandlers(store: Store): void { return store.updateSettings(args) }) + ipcMain.handle('settings:listFonts', () => { + return listSystemFontFamilies() + }) + ipcMain.handle('cache:getGitHub', () => { return store.getGitHubCache() }) diff --git a/src/main/system-fonts.ts b/src/main/system-fonts.ts new file mode 100644 index 00000000000..ed664e23647 --- /dev/null +++ b/src/main/system-fonts.ts @@ -0,0 +1,126 @@ +import { execFile } from 'child_process' + +let cachedFonts: string[] | null = null +let fontsPromise: Promise | null = null + +export async function listSystemFontFamilies(): Promise { + if (cachedFonts) return cachedFonts + if (fontsPromise) return fontsPromise + + fontsPromise = loadSystemFontFamilies() + .then((fonts) => { + cachedFonts = fonts.length > 0 ? fonts : fallbackFonts() + return cachedFonts + }) + .catch(() => { + cachedFonts = fallbackFonts() + return cachedFonts + }) + .finally(() => { + fontsPromise = null + }) + + return fontsPromise +} + +export function warmSystemFontFamilies(): void { + void listSystemFontFamilies() +} + +function loadSystemFontFamilies(): Promise { + if (process.platform === 'darwin') return listMacFonts() + if (process.platform === 'win32') return listWindowsFonts() + return listLinuxFonts() +} + +function listMacFonts(): Promise { + return execFileText('system_profiler', ['SPFontsDataType', '-json'], 32 * 1024 * 1024).then( + (output) => { + const parsed = JSON.parse(output) as { + SPFontsDataType?: Array<{ + typefaces?: Array<{ + family?: string + }> + }> + } + + return uniqueSorted( + (parsed.SPFontsDataType ?? []).flatMap((font) => + (font.typefaces ?? []).map((typeface) => typeface.family) + ) + ) + } + ) +} + +function listLinuxFonts(): Promise { + return execFileText('fc-list', [':', 'family'], 8 * 1024 * 1024).then((output) => + uniqueSorted( + output + .split('\n') + .flatMap((line) => line.split(',')) + .map((name) => name.trim()) + .filter(Boolean) + ) + ) +} + +function listWindowsFonts(): Promise { + const script = ` +Add-Type -AssemblyName System.Drawing +$fonts = New-Object System.Drawing.Text.InstalledFontCollection +$fonts.Families | ForEach-Object { $_.Name } +` + + return execFileText( + 'powershell.exe', + ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', script], + 8 * 1024 * 1024 + ).then((output) => + uniqueSorted( + output + .split('\n') + .map((name) => name.trim()) + .filter(Boolean) + ) + ) +} + +function execFileText(command: string, args: string[], maxBuffer: number): Promise { + return new Promise((resolve, reject) => { + execFile(command, args, { encoding: 'utf8', maxBuffer }, (error, stdout) => { + if (error) { + reject(error) + return + } + resolve(stdout) + }) + }) +} + +function uniqueSorted(values: Array): string[] { + return Array.from( + new Set( + values + .map((value) => value?.trim() ?? '') + .filter((value) => value.length > 0 && !value.startsWith('.')) + ) + ).sort((a, b) => a.localeCompare(b)) +} + +function fallbackFonts(): string[] { + if (process.platform === 'darwin') { + return ['SF Mono', 'Menlo', 'Monaco', 'JetBrains Mono', 'Fira Code'] + } + if (process.platform === 'win32') { + return ['Cascadia Mono', 'Consolas', 'Lucida Console', 'JetBrains Mono', 'Fira Code'] + } + return [ + 'JetBrains Mono', + 'Fira Code', + 'DejaVu Sans Mono', + 'Liberation Mono', + 'Ubuntu Mono', + 'Noto Sans Mono' + ] +} diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index 85c965f2c0c..c02765c5ae8 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -53,6 +53,7 @@ interface GhApi { interface SettingsApi { get: () => Promise set: (args: Partial) => Promise + listFonts: () => Promise } interface ShellApi { @@ -85,6 +86,7 @@ interface SessionApi { interface UIApi { get: () => Promise set: (args: Partial) => Promise + onOpenSettings: (callback: () => void) => () => void } interface Api { diff --git a/src/preload/index.ts b/src/preload/index.ts index 25cc6dff3a3..dc70cd85400 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -100,7 +100,9 @@ const api = { get: (): Promise => ipcRenderer.invoke('settings:get'), set: (args: Record): Promise => - ipcRenderer.invoke('settings:set', args) + ipcRenderer.invoke('settings:set', args), + + listFonts: (): Promise => ipcRenderer.invoke('settings:listFonts') }, shell: { @@ -126,7 +128,12 @@ const api = { ui: { get: (): Promise => ipcRenderer.invoke('ui:get'), - set: (args: Record): Promise => ipcRenderer.invoke('ui:set', args) + set: (args: Record): Promise => ipcRenderer.invoke('ui:set', args), + onOpenSettings: (callback: () => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent) => callback() + ipcRenderer.on('ui:openSettings', listener) + return () => ipcRenderer.removeListener('ui:openSettings', listener) + } } } diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index 64c8951ffa7..120aea80ba1 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -403,6 +403,31 @@ height: 100% !important; } +.restty-pane-root .pane-divider.is-vertical, +.restty-pane-root .pane-divider.is-horizontal { + background: var(--orca-terminal-divider-color, transparent) !important; +} + +.restty-pane-root .pane-divider.is-vertical:hover, +.restty-pane-root .pane-divider.is-vertical.is-dragging, +.restty-pane-root .pane-divider.is-horizontal:hover, +.restty-pane-root .pane-divider.is-horizontal.is-dragging { + background: var( + --orca-terminal-divider-color-strong, + var(--orca-terminal-divider-color, transparent) + ) !important; +} + +.number-input-clean { + appearance: textfield; +} + +.number-input-clean::-webkit-outer-spin-button, +.number-input-clean::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; +} + /* ── Landing ─────────────────────────────────────────── */ .landing { diff --git a/src/renderer/src/components/Settings.tsx b/src/renderer/src/components/Settings.tsx index 6435c5623a4..004589c93b6 100644 --- a/src/renderer/src/components/Settings.tsx +++ b/src/renderer/src/components/Settings.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState, useCallback } from 'react' +import { useEffect, useState, useCallback, useMemo, useRef } from 'react' import type { OrcaHooks, Repo, RepoHookSettings } from '../../../shared/types' import { REPO_COLORS, getDefaultRepoHookSettings } from '../../../shared/constants' import { useAppStore } from '../store' @@ -7,8 +7,20 @@ import { Button } from './ui/button' import { Input } from './ui/input' import { Label } from './ui/label' import { Separator } from './ui/separator' +import { TerminalThemePreview } from './settings/TerminalThemePreview' +import { + BUILTIN_TERMINAL_THEME_NAMES, + clampNumber, + getSystemPrefersDark, + normalizeColor, + resolvePaneStyleOptions, + resolveEffectiveTerminalAppearance +} from '@/lib/terminal-theme' import { ArrowLeft, + Check, + ChevronsUpDown, + CircleX, FolderOpen, Minus, Plus, @@ -19,6 +31,324 @@ import { type HookName = keyof OrcaHooks['scripts'] const DEFAULT_REPO_HOOK_SETTINGS = getDefaultRepoHookSettings() +const MAX_THEME_RESULTS = 80 +const MAX_FONT_RESULTS = 12 + +function getFallbackTerminalFonts(): string[] { + const nav = + typeof navigator !== 'undefined' + ? (navigator as Navigator & { userAgentData?: { platform?: string } }) + : null + const platform = nav ? (nav.userAgentData?.platform ?? nav.platform ?? '') : '' + const normalizedPlatform = platform.toLowerCase() + + if (normalizedPlatform.includes('mac')) { + return ['SF Mono', 'Menlo', 'Monaco', 'JetBrains Mono', 'Fira Code'] + } + + if (normalizedPlatform.includes('win')) { + return ['Cascadia Mono', 'Consolas', 'Lucida Console', 'JetBrains Mono', 'Fira Code'] + } + + return [ + 'JetBrains Mono', + 'Fira Code', + 'DejaVu Sans Mono', + 'Liberation Mono', + 'Ubuntu Mono', + 'Noto Sans Mono' + ] +} + +type ThemePickerProps = { + label: string + description: string + selectedTheme: string + query: string + onQueryChange: (value: string) => void + onSelectTheme: (theme: string) => void +} + +type ColorFieldProps = { + label: string + description: string + value: string + fallback: string + onChange: (value: string) => void +} + +function ThemePicker({ + label, + description, + selectedTheme, + query, + onQueryChange, + onSelectTheme +}: ThemePickerProps): React.JSX.Element { + const normalizedQuery = query.trim().toLowerCase() + const filteredThemes = BUILTIN_TERMINAL_THEME_NAMES.filter((theme) => + theme.toLowerCase().includes(normalizedQuery) + ).slice(0, MAX_THEME_RESULTS) + + return ( +
+
+ +

{description}

+
+ onQueryChange(e.target.value)} + placeholder="Search builtin themes" + /> +
+
+ Selected: {selectedTheme} + + Showing {filteredThemes.length} + {normalizedQuery + ? ` matching "${query.trim()}"` + : ` of ${BUILTIN_TERMINAL_THEME_NAMES.length}`} + +
+ +
+ {filteredThemes.map((theme) => ( + + ))} + {filteredThemes.length === 0 ? ( +
No themes found.
+ ) : null} +
+
+
+
+ ) +} + +function ColorField({ + label, + description, + value, + fallback, + onChange +}: ColorFieldProps): React.JSX.Element { + const normalized = normalizeColor(value, fallback) + + return ( +
+
+ +

{description}

+
+
+ onChange(e.target.value)} + className="h-9 w-12 rounded-md border border-input bg-transparent p-1" + /> + onChange(e.target.value)} + placeholder={fallback} + className="max-w-xs font-mono text-xs" + /> +
+
+ ) +} + +type NumberFieldProps = { + label: string + description: string + value: number + defaultValue?: number + min: number + max: number + step?: number + onChange: (value: number) => void + suffix?: string +} + +type FontAutocompleteProps = { + value: string + suggestions: string[] + onChange: (value: string) => void +} + +function NumberField({ + label, + description, + value, + defaultValue, + min, + max, + step = 1, + onChange, + suffix +}: NumberFieldProps): React.JSX.Element { + return ( +
+
+ +

{description}

+
+
+ { + const next = Number(e.target.value) + if (!Number.isFinite(next)) return + onChange(next) + }} + className="number-input-clean w-28 tabular-nums" + /> + {suffix ? {suffix} : null} +
+

+ Current: {value} + {defaultValue !== undefined ? ` · Default: ${defaultValue}` : ''} +

+
+ ) +} + +function FontAutocomplete({ + value, + suggestions, + onChange +}: FontAutocompleteProps): React.JSX.Element { + const [query, setQuery] = useState(value) + const [open, setOpen] = useState(false) + const rootRef = useRef(null) + + useEffect(() => { + setQuery(value) + }, [value]) + + useEffect(() => { + if (!open) return + + const handlePointerDown = (event: MouseEvent): void => { + if (!rootRef.current?.contains(event.target as Node)) { + setOpen(false) + } + } + + document.addEventListener('mousedown', handlePointerDown) + return () => document.removeEventListener('mousedown', handlePointerDown) + }, [open]) + + const normalizedQuery = query.trim().toLowerCase() + const filteredSuggestions = useMemo(() => { + const startsWith = suggestions.filter((font) => font.toLowerCase().startsWith(normalizedQuery)) + const includes = suggestions.filter( + (font) => + !font.toLowerCase().startsWith(normalizedQuery) && + font.toLowerCase().includes(normalizedQuery) + ) + const ordered = normalizedQuery ? [...startsWith, ...includes] : suggestions + return ordered.slice(0, MAX_FONT_RESULTS) + }, [suggestions, normalizedQuery]) + + const commitValue = (nextValue: string): void => { + setQuery(nextValue) + onChange(nextValue) + setOpen(false) + } + + return ( +
+
+ { + const next = e.target.value + setQuery(next) + onChange(next) + setOpen(true) + }} + onFocus={() => setOpen(true)} + placeholder="SF Mono" + className="pr-18" + /> +
+ {query ? ( + + ) : null} + +
+
+ + {open ? ( +
+ +
+ {filteredSuggestions.length > 0 ? ( + filteredSuggestions.map((font) => ( + + )) + ) : ( +
No matching fonts.
+ )} +
+
+
+ ) : null} +
+ ) +} function Settings(): React.JSX.Element { const settings = useAppStore((s) => s.settings) @@ -39,11 +369,51 @@ function Settings(): React.JSX.Element { const [baseRefQuery, setBaseRefQuery] = useState('') const [baseRefResults, setBaseRefResults] = useState([]) const [isSearchingBaseRefs, setIsSearchingBaseRefs] = useState(false) + const [themeSearchDark, setThemeSearchDark] = useState('') + const [themeSearchLight, setThemeSearchLight] = useState('') + const [systemPrefersDark, setSystemPrefersDark] = useState(getSystemPrefersDark()) + const [terminalFontSuggestions, setTerminalFontSuggestions] = useState( + getFallbackTerminalFonts() + ) + const terminalFontsLoadedRef = useRef(false) useEffect(() => { fetchSettings() }, [fetchSettings]) + useEffect(() => { + const media = window.matchMedia('(prefers-color-scheme: dark)') + const handleChange = (event: MediaQueryListEvent): void => { + setSystemPrefersDark(event.matches) + } + setSystemPrefersDark(media.matches) + media.addEventListener('change', handleChange) + return () => media.removeEventListener('change', handleChange) + }, []) + + useEffect(() => { + if (selectedPane !== 'terminal' || terminalFontsLoadedRef.current) return + + let stale = false + + const loadFontSuggestions = async (): Promise => { + try { + const fonts = await window.api.settings.listFonts() + if (stale || fonts.length === 0) return + terminalFontsLoadedRef.current = true + setTerminalFontSuggestions((prev) => Array.from(new Set([...fonts, ...prev])).slice(0, 320)) + } catch { + // Fall back to curated cross-platform suggestions. + } + } + + void loadFontSuggestions() + + return () => { + stale = true + } + }, [selectedPane]) + useEffect(() => { let stale = false const checkHooks = async () => { @@ -229,6 +599,22 @@ function Settings(): React.JSX.Element { ) } + const darkPreviewAppearance = resolveEffectiveTerminalAppearance( + { + ...settings, + theme: 'dark' + }, + systemPrefersDark + ) + const lightPreviewAppearance = resolveEffectiveTerminalAppearance( + { + ...settings, + theme: 'light' + }, + systemPrefersDark + ) + const paneStyleOptions = resolvePaneStyleOptions(settings) + return (
) : selectedRepo ? (
diff --git a/src/renderer/src/components/TerminalPane.tsx b/src/renderer/src/components/TerminalPane.tsx index 90c350c60e9..2f62c4f5f90 100644 --- a/src/renderer/src/components/TerminalPane.tsx +++ b/src/renderer/src/components/TerminalPane.tsx @@ -1,5 +1,6 @@ import { useEffect, useRef, useState } from 'react' import { Restty, getBuiltinTheme } from 'restty' +import type { CSSProperties } from 'react' import { Clipboard, Copy, @@ -25,6 +26,14 @@ import type { TerminalPaneSplitDirection } from '../../../shared/types' import { useAppStore } from '../store' +import { + DEFAULT_TERMINAL_DIVIDER_DARK, + buildTerminalFontMatchers, + colorToCss, + normalizeColor, + resolvePaneStyleOptions, + resolveEffectiveTerminalAppearance +} from '@/lib/terminal-theme' type PtyTransport = { connect: (options: { @@ -223,6 +232,22 @@ function paneLeafId(paneId: number): string { return `pane:${paneId}` } +function buildTerminalFontSources(fontFamily: string) { + return [ + { + type: 'local' as const, + label: fontFamily || 'Preferred terminal font', + matchers: buildTerminalFontMatchers(fontFamily), + required: true + }, + { + type: 'local' as const, + label: 'Menlo', + matchers: ['menlo', 'menlo regular'] + } + ] +} + function getLayoutChildNodes(split: HTMLElement): HTMLElement[] { return Array.from(split.children).filter( (child): child is HTMLElement => @@ -485,11 +510,54 @@ export default function TerminalPane({ const updateTabPtyId = useAppStore((s) => s.updateTabPtyId) const clearTabPtyId = useAppStore((s) => s.clearTabPtyId) const markWorktreeUnreadFromBell = useAppStore((s) => s.markWorktreeUnreadFromBell) + const settings = useAppStore((s) => s.settings) + const [systemPrefersDark, setSystemPrefersDark] = useState(() => + typeof window !== 'undefined' && typeof window.matchMedia === 'function' + ? window.matchMedia('(prefers-color-scheme: dark)').matches + : true + ) + const settingsRef = useRef(settings) + settingsRef.current = settings // Use a ref so the Restty closure always calls the latest onPtyExit const onPtyExitRef = useRef(onPtyExit) onPtyExitRef.current = onPtyExit + useEffect(() => { + const media = window.matchMedia('(prefers-color-scheme: dark)') + const handleChange = (event: MediaQueryListEvent): void => { + setSystemPrefersDark(event.matches) + } + setSystemPrefersDark(media.matches) + media.addEventListener('change', handleChange) + return () => media.removeEventListener('change', handleChange) + }, []) + + const applyTerminalAppearance = (restty: Restty): void => { + const currentSettings = settingsRef.current + if (!currentSettings) return + + const appearance = resolveEffectiveTerminalAppearance(currentSettings, systemPrefersDark) + const paneStyles = resolvePaneStyleOptions(currentSettings) + const theme = appearance.theme ?? getBuiltinTheme(appearance.themeName) + const paneBackground = colorToCss(theme?.colors.background, '#000000') + if (theme) { + for (const pane of restty.getPanes()) { + pane.app.applyTheme(theme, appearance.themeName) + pane.app.setFontSize(currentSettings.terminalFontSize) + } + } + + restty.setPaneStyleOptions({ + splitBackground: paneBackground, + paneBackground, + inactivePaneOpacity: paneStyles.inactivePaneOpacity, + activePaneOpacity: paneStyles.activePaneOpacity, + opacityTransitionMs: paneStyles.opacityTransitionMs, + dividerThicknessPx: paneStyles.dividerThicknessPx + }) + } + // Initialize Restty instance once useEffect(() => { const container = containerRef.current @@ -529,6 +597,7 @@ export default function TerminalPane({ shortcuts: { enabled: false }, defaultContextMenu: false, appOptions: ({ id }) => { + const currentSettings = settingsRef.current const onExit = (ptyId: string): void => { // Schedule close via parent const panes = restty.getPanes() @@ -541,7 +610,7 @@ export default function TerminalPane({ } return { renderer: 'webgpu', - fontSize: 14, + fontSize: currentSettings?.terminalFontSize ?? 14, fontSizeMode: 'em', alphaBlending: 'native', ptyTransport: createIpcPtyTransport( @@ -551,25 +620,12 @@ export default function TerminalPane({ onPtySpawn, onBell ) as never, - fontSources: [ - { - type: 'local' as const, - label: 'SF Mono', - matchers: ['sf mono', 'sfmono-regular'], - required: true - }, - { - type: 'local' as const, - label: 'Menlo', - matchers: ['menlo', 'menlo regular'] - } - ] + fontSources: buildTerminalFontSources(currentSettings?.terminalFontFamily ?? 'SF Mono') } }, onPaneCreated: async (pane) => { await pane.app.init() - const theme = getBuiltinTheme('Aizen Dark') - if (theme) pane.app.applyTheme(theme, 'Aizen Dark') + applyTerminalAppearance(restty) pane.app.updateSize(true) pane.app.connectPty('') pane.canvas.focus({ preventScroll: true }) @@ -610,6 +666,7 @@ export default function TerminalPane({ } shouldPersistLayout = true syncCanExpandState() + applyTerminalAppearance(restty) queueResizeAll(isActive) persistLayoutSnapshot() @@ -624,6 +681,19 @@ export default function TerminalPane({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [tabId, cwd]) + useEffect(() => { + const restty = resttyRef.current + if (!restty || !settings) return + applyTerminalAppearance(restty) + void Promise.all( + restty + .getPanes() + .map((pane) => + pane.app.setFontSources(buildTerminalFontSources(settings.terminalFontFamily)) + ) + ) + }, [settings, systemPrefersDark]) + // Handle focus and resize when tab becomes active useEffect(() => { const restty = resttyRef.current @@ -824,13 +894,25 @@ export default function TerminalPane({ const canExpandPane = paneCount > 1 const menuPaneId = resolveMenuPane()?.id ?? null const menuPaneIsExpanded = menuPaneId !== null && menuPaneId === expandedPaneId + const effectiveAppearance = settings + ? resolveEffectiveTerminalAppearance(settings, systemPrefersDark) + : null + const terminalContainerStyle: CSSProperties = { + display: isActive ? 'flex' : 'none', + ['--orca-terminal-divider-color' as string]: + effectiveAppearance?.dividerColor ?? DEFAULT_TERMINAL_DIVIDER_DARK, + ['--orca-terminal-divider-color-strong' as string]: normalizeColor( + effectiveAppearance?.dividerColor, + DEFAULT_TERMINAL_DIVIDER_DARK + ) + } return ( <>
{ event.preventDefault() window.dispatchEvent(new Event(CLOSE_ALL_CONTEXT_MENUS_EVENT)) diff --git a/src/renderer/src/components/settings/TerminalThemePreview.tsx b/src/renderer/src/components/settings/TerminalThemePreview.tsx new file mode 100644 index 00000000000..857ee79312c --- /dev/null +++ b/src/renderer/src/components/settings/TerminalThemePreview.tsx @@ -0,0 +1,125 @@ +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { + colorToCss, + terminalPalettePreview, + type EffectiveTerminalAppearance +} from '@/lib/terminal-theme' + +type TerminalThemePreviewProps = { + title: string + description: string + appearance: EffectiveTerminalAppearance + dividerThicknessPx?: number + inactivePaneOpacity?: number + activePaneOpacity?: number +} + +export function TerminalThemePreview({ + title, + description, + appearance, + dividerThicknessPx = 1, + inactivePaneOpacity = 0.9, + activePaneOpacity = 1 +}: TerminalThemePreviewProps): React.JSX.Element { + const background = colorToCss( + appearance.theme?.colors.background, + appearance.mode === 'light' ? '#f8fafc' : '#09090b' + ) + const foreground = colorToCss( + appearance.theme?.colors.foreground, + appearance.mode === 'light' ? '#111827' : '#f4f4f5' + ) + const cursor = colorToCss(appearance.theme?.colors.cursor, foreground) + const selection = colorToCss( + appearance.theme?.colors.selectionBackground, + appearance.mode === 'light' ? 'rgba(59, 130, 246, 0.18)' : 'rgba(148, 163, 184, 0.22)' + ) + const palette = terminalPalettePreview(appearance.theme) + + return ( + + + {title} + {description} + + +
+
+

{appearance.themeName}

+

+ {appearance.sourceTheme === 'system' + ? `System mode, currently ${appearance.systemPrefersDark ? 'Dark' : 'Light'}` + : `${appearance.mode === 'dark' ? 'Dark' : 'Light'} mode`} +

+
+
+ Divider + +
+
+ +
+
+
+
+ + orca preview +
+
$ git status --short
+
+ M src/renderer/src/components/Settings.tsx +
+
+ A src/renderer/src/lib/terminal-theme.ts +
+
+ theme preview selected text +
+
+ $ + echo "cursor" + +
+
+
+
+
palette
+
+ {(palette.length ? palette : [foreground]).map((swatch, index) => ( +
+ ))} +
+
+
+
+ + + ) +} diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index 365d84a019b..bbef287f24e 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -17,6 +17,12 @@ export function useIpcEvents(): void { }) ) + unsubs.push( + window.api.ui.onOpenSettings(() => { + useAppStore.getState().setActiveView('settings') + }) + ) + return () => unsubs.forEach((fn) => fn()) }, []) } diff --git a/src/renderer/src/lib/terminal-theme.ts b/src/renderer/src/lib/terminal-theme.ts new file mode 100644 index 00000000000..2d3c84e030e --- /dev/null +++ b/src/renderer/src/lib/terminal-theme.ts @@ -0,0 +1,116 @@ +import { getBuiltinTheme, listBuiltinThemeNames, type GhosttyTheme } from 'restty' +import type { GlobalSettings } from '../../../shared/types' + +export const BUILTIN_TERMINAL_THEME_NAMES = listBuiltinThemeNames() + +export const DEFAULT_TERMINAL_THEME_DARK = 'Ghostty Default Style Dark' +export const DEFAULT_TERMINAL_THEME_LIGHT = 'Builtin Tango Light' +export const DEFAULT_TERMINAL_DIVIDER_DARK = '#3f3f46' +export const DEFAULT_TERMINAL_DIVIDER_LIGHT = '#d4d4d8' + +export type EffectiveTerminalAppearance = { + mode: 'dark' | 'light' + sourceTheme: 'system' | 'dark' | 'light' + themeName: string + dividerColor: string + theme: GhosttyTheme | null + systemPrefersDark: boolean +} + +export function getSystemPrefersDark(): boolean { + if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return true + return window.matchMedia('(prefers-color-scheme: dark)').matches +} + +export function getTerminalThemePreview(name: string): GhosttyTheme | null { + const theme = getBuiltinTheme(name) + if (theme) return theme + return getBuiltinTheme(DEFAULT_TERMINAL_THEME_DARK) +} + +export function resolveEffectiveTerminalAppearance( + settings: Pick< + GlobalSettings, + | 'theme' + | 'terminalThemeDark' + | 'terminalDividerColorDark' + | 'terminalUseSeparateLightTheme' + | 'terminalThemeLight' + | 'terminalDividerColorLight' + >, + systemPrefersDark = getSystemPrefersDark() +): EffectiveTerminalAppearance { + const sourceTheme = + settings.theme === 'system' ? (systemPrefersDark ? 'dark' : 'light') : settings.theme + const useLightVariant = sourceTheme === 'light' && settings.terminalUseSeparateLightTheme + const themeName = useLightVariant + ? settings.terminalThemeLight || DEFAULT_TERMINAL_THEME_LIGHT + : settings.terminalThemeDark || DEFAULT_TERMINAL_THEME_DARK + const dividerColor = useLightVariant + ? normalizeColor(settings.terminalDividerColorLight, DEFAULT_TERMINAL_DIVIDER_LIGHT) + : normalizeColor(settings.terminalDividerColorDark, DEFAULT_TERMINAL_DIVIDER_DARK) + + return { + mode: sourceTheme, + sourceTheme: settings.theme, + themeName, + dividerColor, + theme: getTerminalThemePreview(themeName), + systemPrefersDark + } +} + +export function normalizeColor(value: string | undefined, fallback: string): string { + const trimmed = value?.trim() + if (!trimmed) return fallback + return trimmed +} + +export function buildTerminalFontMatchers(fontFamily: string): string[] { + const trimmed = fontFamily.trim() + const normalized = trimmed.toLowerCase() + const matchers = trimmed ? [trimmed, normalized] : [] + return Array.from(new Set([...matchers, 'sf mono', 'sfmono-regular', 'menlo', 'menlo regular'])) +} + +export function clampNumber(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)) +} + +export function resolvePaneStyleOptions( + settings: Pick< + GlobalSettings, + | 'terminalInactivePaneOpacity' + | 'terminalActivePaneOpacity' + | 'terminalPaneOpacityTransitionMs' + | 'terminalDividerThicknessPx' + > +) { + return { + inactivePaneOpacity: clampNumber(settings.terminalInactivePaneOpacity, 0, 1), + activePaneOpacity: clampNumber(settings.terminalActivePaneOpacity, 0, 1), + opacityTransitionMs: clampNumber(settings.terminalPaneOpacityTransitionMs, 0, 5000), + dividerThicknessPx: clampNumber(settings.terminalDividerThicknessPx, 1, 32) + } +} + +export function colorToCss( + color: { r: number; g: number; b: number; a?: number } | string | undefined, + fallback: string +): string { + if (!color || typeof color === 'string') return fallback + const alpha = typeof color.a === 'number' ? color.a / 255 : 1 + return `rgba(${color.r}, ${color.g}, ${color.b}, ${alpha})` +} + +export function terminalPalettePreview(theme: GhosttyTheme | null): string[] { + if (!theme) return [] + const colors = theme.colors.palette + const swatches: string[] = [] + for (let i = 0; i < 16; i += 1) { + const color = colors[i] + if (!color) continue + swatches.push(colorToCss(color, '#000000')) + } + return swatches +} diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 5c617bf6625..b3c7a16af81 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -27,7 +27,16 @@ export function getDefaultSettings(homedir: string): GlobalSettings { branchPrefixCustom: '', theme: 'system', terminalFontSize: 14, - terminalFontFamily: 'SF Mono' + terminalFontFamily: 'SF Mono', + terminalThemeDark: 'Ghostty Default Style Dark', + terminalDividerColorDark: '#3f3f46', + terminalUseSeparateLightTheme: false, + terminalThemeLight: 'Builtin Tango Light', + terminalDividerColorLight: '#d4d4d8', + terminalInactivePaneOpacity: 0.8, + terminalActivePaneOpacity: 1, + terminalPaneOpacityTransitionMs: 140, + terminalDividerThicknessPx: 1 } } diff --git a/src/shared/types.ts b/src/shared/types.ts index 50c7f966f6a..400b1043b8e 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -129,6 +129,15 @@ export interface GlobalSettings { theme: 'system' | 'dark' | 'light' terminalFontSize: number terminalFontFamily: string + terminalThemeDark: string + terminalDividerColorDark: string + terminalUseSeparateLightTheme: boolean + terminalThemeLight: string + terminalDividerColorLight: string + terminalInactivePaneOpacity: number + terminalActivePaneOpacity: number + terminalPaneOpacityTransitionMs: number + terminalDividerThicknessPx: number } export interface PersistedUIState {