mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
Themes
This commit is contained in:
@@ -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 () {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { execFile } from 'child_process'
|
||||
|
||||
let cachedFonts: string[] | null = null
|
||||
let fontsPromise: Promise<string[]> | null = null
|
||||
|
||||
export async function listSystemFontFamilies(): Promise<string[]> {
|
||||
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<string[]> {
|
||||
if (process.platform === 'darwin') return listMacFonts()
|
||||
if (process.platform === 'win32') return listWindowsFonts()
|
||||
return listLinuxFonts()
|
||||
}
|
||||
|
||||
function listMacFonts(): Promise<string[]> {
|
||||
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<string[]> {
|
||||
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<string[]> {
|
||||
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<string> {
|
||||
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 | undefined>): 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'
|
||||
]
|
||||
}
|
||||
Vendored
+2
@@ -53,6 +53,7 @@ interface GhApi {
|
||||
interface SettingsApi {
|
||||
get: () => Promise<GlobalSettings>
|
||||
set: (args: Partial<GlobalSettings>) => Promise<GlobalSettings>
|
||||
listFonts: () => Promise<string[]>
|
||||
}
|
||||
|
||||
interface ShellApi {
|
||||
@@ -85,6 +86,7 @@ interface SessionApi {
|
||||
interface UIApi {
|
||||
get: () => Promise<PersistedUIState>
|
||||
set: (args: Partial<PersistedUIState>) => Promise<void>
|
||||
onOpenSettings: (callback: () => void) => () => void
|
||||
}
|
||||
|
||||
interface Api {
|
||||
|
||||
@@ -100,7 +100,9 @@ const api = {
|
||||
get: (): Promise<unknown> => ipcRenderer.invoke('settings:get'),
|
||||
|
||||
set: (args: Record<string, unknown>): Promise<unknown> =>
|
||||
ipcRenderer.invoke('settings:set', args)
|
||||
ipcRenderer.invoke('settings:set', args),
|
||||
|
||||
listFonts: (): Promise<string[]> => ipcRenderer.invoke('settings:listFonts')
|
||||
},
|
||||
|
||||
shell: {
|
||||
@@ -126,7 +128,12 @@ const api = {
|
||||
|
||||
ui: {
|
||||
get: (): Promise<unknown> => ipcRenderer.invoke('ui:get'),
|
||||
set: (args: Record<string, unknown>): Promise<void> => ipcRenderer.invoke('ui:set', args)
|
||||
set: (args: Record<string, unknown>): Promise<void> => 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-sm">{label}</Label>
|
||||
<p className="text-xs text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(e) => onQueryChange(e.target.value)}
|
||||
placeholder="Search builtin themes"
|
||||
/>
|
||||
<div className="rounded-lg border">
|
||||
<div className="flex items-center justify-between border-b px-3 py-2 text-xs text-muted-foreground">
|
||||
<span>Selected: {selectedTheme}</span>
|
||||
<span>
|
||||
Showing {filteredThemes.length}
|
||||
{normalizedQuery
|
||||
? ` matching "${query.trim()}"`
|
||||
: ` of ${BUILTIN_TERMINAL_THEME_NAMES.length}`}
|
||||
</span>
|
||||
</div>
|
||||
<ScrollArea className="h-64">
|
||||
<div className="space-y-1 p-2">
|
||||
{filteredThemes.map((theme) => (
|
||||
<button
|
||||
key={theme}
|
||||
onClick={() => onSelectTheme(theme)}
|
||||
className={`flex w-full items-center justify-between rounded-md px-3 py-2 text-left text-sm transition-colors ${
|
||||
selectedTheme === theme
|
||||
? 'bg-accent font-medium text-accent-foreground'
|
||||
: 'hover:bg-muted/60'
|
||||
}`}
|
||||
>
|
||||
<span className="truncate">{theme}</span>
|
||||
{selectedTheme === theme ? (
|
||||
<span className="ml-3 shrink-0 text-[11px] uppercase tracking-[0.16em]">
|
||||
Current
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
))}
|
||||
{filteredThemes.length === 0 ? (
|
||||
<div className="px-3 py-6 text-sm text-muted-foreground">No themes found.</div>
|
||||
) : null}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ColorField({
|
||||
label,
|
||||
description,
|
||||
value,
|
||||
fallback,
|
||||
onChange
|
||||
}: ColorFieldProps): React.JSX.Element {
|
||||
const normalized = normalizeColor(value, fallback)
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-sm">{label}</Label>
|
||||
<p className="text-xs text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="color"
|
||||
value={normalized}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="h-9 w-12 rounded-md border border-input bg-transparent p-1"
|
||||
/>
|
||||
<Input
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={fallback}
|
||||
className="max-w-xs font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-sm">{label}</Label>
|
||||
<p className="text-xs text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Input
|
||||
type="number"
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
value={Number.isFinite(value) ? String(value) : ''}
|
||||
onChange={(e) => {
|
||||
const next = Number(e.target.value)
|
||||
if (!Number.isFinite(next)) return
|
||||
onChange(next)
|
||||
}}
|
||||
className="number-input-clean w-28 tabular-nums"
|
||||
/>
|
||||
{suffix ? <span className="text-xs text-muted-foreground">{suffix}</span> : null}
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Current: {value}
|
||||
{defaultValue !== undefined ? ` · Default: ${defaultValue}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FontAutocomplete({
|
||||
value,
|
||||
suggestions,
|
||||
onChange
|
||||
}: FontAutocompleteProps): React.JSX.Element {
|
||||
const [query, setQuery] = useState(value)
|
||||
const [open, setOpen] = useState(false)
|
||||
const rootRef = useRef<HTMLDivElement | null>(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 (
|
||||
<div ref={rootRef} className="relative max-w-sm">
|
||||
<div className="relative">
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(e) => {
|
||||
const next = e.target.value
|
||||
setQuery(next)
|
||||
onChange(next)
|
||||
setOpen(true)
|
||||
}}
|
||||
onFocus={() => setOpen(true)}
|
||||
placeholder="SF Mono"
|
||||
className="pr-18"
|
||||
/>
|
||||
<div className="absolute inset-y-0 right-2 flex items-center gap-1">
|
||||
{query ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setQuery('')
|
||||
onChange('')
|
||||
setOpen(true)
|
||||
}}
|
||||
className="rounded-sm p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
aria-label="Clear font selection"
|
||||
title="Clear"
|
||||
>
|
||||
<CircleX className="size-3.5" />
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((current) => !current)}
|
||||
className="rounded-sm p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
aria-label="Toggle font suggestions"
|
||||
title="Fonts"
|
||||
>
|
||||
<ChevronsUpDown className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{open ? (
|
||||
<div className="absolute top-full z-20 mt-2 w-full overflow-hidden rounded-md border bg-popover shadow-md">
|
||||
<ScrollArea className="max-h-64">
|
||||
<div className="p-1">
|
||||
{filteredSuggestions.length > 0 ? (
|
||||
filteredSuggestions.map((font) => (
|
||||
<button
|
||||
key={font}
|
||||
type="button"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => commitValue(font)}
|
||||
className={`flex w-full items-center justify-between rounded-sm px-3 py-2 text-left text-sm transition-colors ${
|
||||
font === value ? 'bg-accent text-accent-foreground' : 'hover:bg-muted/60'
|
||||
}`}
|
||||
>
|
||||
<span className="truncate">{font}</span>
|
||||
{font === value ? <Check className="ml-3 size-4 shrink-0" /> : null}
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<div className="px-3 py-3 text-sm text-muted-foreground">No matching fonts.</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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<string[]>([])
|
||||
const [isSearchingBaseRefs, setIsSearchingBaseRefs] = useState(false)
|
||||
const [themeSearchDark, setThemeSearchDark] = useState('')
|
||||
const [themeSearchLight, setThemeSearchLight] = useState('')
|
||||
const [systemPrefersDark, setSystemPrefersDark] = useState(getSystemPrefersDark())
|
||||
const [terminalFontSuggestions, setTerminalFontSuggestions] = useState<string[]>(
|
||||
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<void> => {
|
||||
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 (
|
||||
<div className="settings-view-shell flex min-h-0 flex-1 overflow-hidden bg-background">
|
||||
<aside className="flex w-[260px] shrink-0 flex-col border-r bg-card/40">
|
||||
@@ -461,7 +847,7 @@ function Settings(): React.JSX.Element {
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-2xl font-semibold">Terminal</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Default terminal typography for new panes.
|
||||
Terminal appearance, previews, and defaults for new panes.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -469,7 +855,7 @@ function Settings(): React.JSX.Element {
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-sm font-semibold">Typography</h2>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Default terminal typography for new panes.
|
||||
Default terminal typography for new panes and live updates.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -517,14 +903,166 @@ function Settings(): React.JSX.Element {
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm">Font Family</Label>
|
||||
<Input
|
||||
<FontAutocomplete
|
||||
value={settings.terminalFontFamily}
|
||||
onChange={(e) => updateSettings({ terminalFontFamily: e.target.value })}
|
||||
placeholder="SF Mono"
|
||||
className="max-w-xs"
|
||||
suggestions={terminalFontSuggestions}
|
||||
onChange={(value) => updateSettings({ terminalFontFamily: value })}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Separator />
|
||||
|
||||
<section className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-sm font-semibold">Pane Styling</h2>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Control inactive pane dimming, divider thickness, and transition timing.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<NumberField
|
||||
label="Inactive Pane Opacity"
|
||||
description="Opacity applied to panes that are not currently active."
|
||||
value={paneStyleOptions.inactivePaneOpacity}
|
||||
defaultValue={0.8}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
suffix="0 to 1"
|
||||
onChange={(value) =>
|
||||
updateSettings({
|
||||
terminalInactivePaneOpacity: clampNumber(value, 0, 1)
|
||||
})
|
||||
}
|
||||
/>
|
||||
<NumberField
|
||||
label="Divider Thickness"
|
||||
description="Thickness of the pane divider line."
|
||||
value={paneStyleOptions.dividerThicknessPx}
|
||||
defaultValue={1}
|
||||
min={1}
|
||||
max={32}
|
||||
step={1}
|
||||
suffix="px"
|
||||
onChange={(value) =>
|
||||
updateSettings({
|
||||
terminalDividerThicknessPx: clampNumber(value, 1, 32)
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Separator />
|
||||
|
||||
<section className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_360px]">
|
||||
<div className="space-y-6">
|
||||
<ThemePicker
|
||||
label="Dark Theme"
|
||||
description="Choose the terminal theme used in dark mode."
|
||||
selectedTheme={settings.terminalThemeDark}
|
||||
query={themeSearchDark}
|
||||
onQueryChange={setThemeSearchDark}
|
||||
onSelectTheme={(theme) => updateSettings({ terminalThemeDark: theme })}
|
||||
/>
|
||||
|
||||
<ColorField
|
||||
label="Dark Divider Color"
|
||||
description="Controls the split divider line between panes in dark mode."
|
||||
value={settings.terminalDividerColorDark}
|
||||
fallback="#3f3f46"
|
||||
onChange={(value) => updateSettings({ terminalDividerColorDark: value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<TerminalThemePreview
|
||||
title="Dark Mode Preview"
|
||||
description={
|
||||
settings.theme === 'system'
|
||||
? `System mode is currently ${systemPrefersDark ? 'Dark' : 'Light'}.`
|
||||
: `Orca is currently in ${settings.theme} mode.`
|
||||
}
|
||||
appearance={darkPreviewAppearance}
|
||||
dividerThicknessPx={paneStyleOptions.dividerThicknessPx}
|
||||
inactivePaneOpacity={paneStyleOptions.inactivePaneOpacity}
|
||||
activePaneOpacity={paneStyleOptions.activePaneOpacity}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<Separator />
|
||||
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center justify-between gap-4 px-1 py-2">
|
||||
<div className="space-y-0.5">
|
||||
<Label className="text-sm">Use Separate Theme In Light Mode</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
When disabled, light mode reuses the dark terminal theme.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
role="switch"
|
||||
aria-checked={settings.terminalUseSeparateLightTheme}
|
||||
onClick={() =>
|
||||
updateSettings({
|
||||
terminalUseSeparateLightTheme: !settings.terminalUseSeparateLightTheme
|
||||
})
|
||||
}
|
||||
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-colors ${
|
||||
settings.terminalUseSeparateLightTheme
|
||||
? 'bg-foreground'
|
||||
: 'bg-muted-foreground/30'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`pointer-events-none block size-3.5 rounded-full bg-background shadow-sm transition-transform ${
|
||||
settings.terminalUseSeparateLightTheme ? 'translate-x-4' : 'translate-x-0.5'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`grid overflow-hidden transition-all duration-300 ease-out ${
|
||||
settings.terminalUseSeparateLightTheme
|
||||
? 'grid-rows-[1fr] opacity-100'
|
||||
: 'grid-rows-[0fr] opacity-0'
|
||||
}`}
|
||||
>
|
||||
<div className="min-h-0">
|
||||
<div className="grid gap-6 pt-2 xl:grid-cols-[minmax(0,1fr)_360px]">
|
||||
<div className="space-y-6">
|
||||
<ThemePicker
|
||||
label="Light Theme"
|
||||
description="Choose the theme used when Orca is in light mode."
|
||||
selectedTheme={settings.terminalThemeLight}
|
||||
query={themeSearchLight}
|
||||
onQueryChange={setThemeSearchLight}
|
||||
onSelectTheme={(theme) => updateSettings({ terminalThemeLight: theme })}
|
||||
/>
|
||||
|
||||
<ColorField
|
||||
label="Light Divider Color"
|
||||
description="Controls the split divider line between panes in light mode."
|
||||
value={settings.terminalDividerColorLight}
|
||||
fallback="#d4d4d8"
|
||||
onChange={(value) => updateSettings({ terminalDividerColorLight: value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<TerminalThemePreview
|
||||
title="Light Mode Preview"
|
||||
description="Updates live as you change the light theme or divider color."
|
||||
appearance={lightPreviewAppearance}
|
||||
dividerThicknessPx={paneStyleOptions.dividerThicknessPx}
|
||||
inactivePaneOpacity={paneStyleOptions.inactivePaneOpacity}
|
||||
activePaneOpacity={paneStyleOptions.activePaneOpacity}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
) : selectedRepo ? (
|
||||
<div className="space-y-8">
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="absolute inset-0 min-h-0 min-w-0"
|
||||
style={{ display: isActive ? 'flex' : 'none' }}
|
||||
style={terminalContainerStyle}
|
||||
onContextMenuCapture={(event) => {
|
||||
event.preventDefault()
|
||||
window.dispatchEvent(new Event(CLOSE_ALL_CONTEXT_MENUS_EVENT))
|
||||
|
||||
@@ -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 (
|
||||
<Card className="gap-4 overflow-hidden py-0">
|
||||
<CardHeader className="gap-1 border-b py-4">
|
||||
<CardTitle className="text-sm">{title}</CardTitle>
|
||||
<CardDescription>{description}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 px-4 pb-4">
|
||||
<div className="flex items-center justify-between rounded-md border bg-muted/40 px-3 py-2 text-xs">
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium text-foreground">{appearance.themeName}</p>
|
||||
<p className="text-muted-foreground">
|
||||
{appearance.sourceTheme === 'system'
|
||||
? `System mode, currently ${appearance.systemPrefersDark ? 'Dark' : 'Light'}`
|
||||
: `${appearance.mode === 'dark' ? 'Dark' : 'Light'} mode`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground">Divider</span>
|
||||
<span
|
||||
className="size-4 rounded-sm border"
|
||||
style={{ backgroundColor: appearance.dividerColor }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="overflow-hidden rounded-lg border"
|
||||
style={{ backgroundColor: appearance.dividerColor }}
|
||||
>
|
||||
<div className="flex min-h-[220px]">
|
||||
<div
|
||||
className="flex-1 p-3 font-mono text-[12px] leading-6"
|
||||
style={{ backgroundColor: background, color: foreground, opacity: activePaneOpacity }}
|
||||
>
|
||||
<div className="flex items-center gap-2 text-[11px] opacity-70">
|
||||
<span className="size-2 rounded-full bg-emerald-500" />
|
||||
<span>orca preview</span>
|
||||
</div>
|
||||
<div className="mt-3">$ git status --short</div>
|
||||
<div style={{ color: palette[1] ?? foreground }}>
|
||||
M src/renderer/src/components/Settings.tsx
|
||||
</div>
|
||||
<div style={{ color: palette[2] ?? foreground }}>
|
||||
A src/renderer/src/lib/terminal-theme.ts
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<span style={{ backgroundColor: selection }}>theme preview selected text</span>
|
||||
</div>
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<span>$</span>
|
||||
<span>echo "cursor"</span>
|
||||
<span
|
||||
className="inline-block h-[1.1em] w-[0.6ch] align-middle"
|
||||
style={{ backgroundColor: cursor }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="shrink-0"
|
||||
style={{ width: `${dividerThicknessPx}px`, backgroundColor: appearance.dividerColor }}
|
||||
/>
|
||||
<div
|
||||
className="w-[38%] p-3 font-mono text-[12px] leading-6"
|
||||
style={{
|
||||
backgroundColor: background,
|
||||
color: foreground,
|
||||
opacity: inactivePaneOpacity
|
||||
}}
|
||||
>
|
||||
<div className="opacity-70">palette</div>
|
||||
<div className="mt-3 grid grid-cols-4 gap-2">
|
||||
{(palette.length ? palette : [foreground]).map((swatch, index) => (
|
||||
<div
|
||||
key={`${swatch}-${index}`}
|
||||
className="h-6 rounded-sm border border-black/10"
|
||||
style={{ backgroundColor: swatch }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -17,6 +17,12 @@ export function useIpcEvents(): void {
|
||||
})
|
||||
)
|
||||
|
||||
unsubs.push(
|
||||
window.api.ui.onOpenSettings(() => {
|
||||
useAppStore.getState().setActiveView('settings')
|
||||
})
|
||||
)
|
||||
|
||||
return () => unsubs.forEach((fn) => fn())
|
||||
}, [])
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+10
-1
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user