web: add terminal font size setting (#324)

This commit is contained in:
dotblue
2026-03-20 08:43:36 +08:00
committed by GitHub
parent 2e2727835e
commit ea45797b6f
7 changed files with 227 additions and 5 deletions
+3 -1
View File
@@ -5,6 +5,7 @@ import { WebLinksAddon } from '@xterm/addon-web-links'
import { CanvasAddon } from '@xterm/addon-canvas'
import '@xterm/xterm/css/xterm.css'
import { ensureBuiltinFontLoaded, getFontProvider } from '@/lib/terminalFont'
import { getInitialTerminalFontSize } from '@/hooks/useTerminalFontSize'
function resolveThemeColors(): { background: string; foreground: string; selectionBackground: string } {
const styles = getComputedStyle(document.documentElement)
@@ -38,11 +39,12 @@ export function TerminalView(props: {
const abortController = new AbortController()
const fontProvider = getFontProvider()
const fontSize = getInitialTerminalFontSize()
const { background, foreground, selectionBackground } = resolveThemeColors()
const terminal = new Terminal({
cursorBlink: true,
fontFamily: fontProvider.getFontFamily(),
fontSize: 13,
fontSize,
theme: {
background,
foreground,
+40
View File
@@ -0,0 +1,40 @@
import { beforeEach, describe, expect, it } from 'vitest'
import {
DEFAULT_TERMINAL_FONT_SIZE,
getInitialTerminalFontSize,
getTerminalFontSizeOptions,
} from './useTerminalFontSize'
describe('useTerminalFontSize helpers', () => {
beforeEach(() => {
window.localStorage.clear()
})
it('returns the allowed raw px options', () => {
const options = getTerminalFontSizeOptions()
expect(options).toEqual([
{ value: 9, label: '9px' },
{ value: 11, label: '11px' },
{ value: 13, label: '13px' },
{ value: 15, label: '15px' },
{ value: 17, label: '17px' },
])
})
it('falls back to the default size for missing or invalid storage values', () => {
expect(getInitialTerminalFontSize()).toBe(DEFAULT_TERMINAL_FONT_SIZE)
window.localStorage.setItem('hapi-terminal-font-size', 'not-a-number')
expect(getInitialTerminalFontSize()).toBe(DEFAULT_TERMINAL_FONT_SIZE)
window.localStorage.setItem('hapi-terminal-font-size', '19')
expect(getInitialTerminalFontSize()).toBe(DEFAULT_TERMINAL_FONT_SIZE)
})
it('reads a valid stored terminal font size', () => {
window.localStorage.setItem('hapi-terminal-font-size', '17')
expect(getInitialTerminalFontSize()).toBe(17)
})
})
+96
View File
@@ -0,0 +1,96 @@
import { useCallback, useEffect, useState } from 'react'
const TERMINAL_FONT_SIZES = [9, 11, 13, 15, 17] as const
export type TerminalFontSize = typeof TERMINAL_FONT_SIZES[number]
export const DEFAULT_TERMINAL_FONT_SIZE: TerminalFontSize = 13
export function getTerminalFontSizeOptions(): ReadonlyArray<{ value: TerminalFontSize; label: string }> {
return TERMINAL_FONT_SIZES.map(value => ({ value, label: `${value}px` }))
}
function getTerminalFontSizeStorageKey(): string {
return 'hapi-terminal-font-size'
}
function isBrowser(): boolean {
return typeof window !== 'undefined' && typeof document !== 'undefined'
}
function safeGetItem(key: string): string | null {
if (!isBrowser()) {
return null
}
try {
return localStorage.getItem(key)
} catch {
return null
}
}
function safeSetItem(key: string, value: string): void {
if (!isBrowser()) {
return
}
try {
localStorage.setItem(key, value)
} catch {
// Ignore storage errors
}
}
function safeRemoveItem(key: string): void {
if (!isBrowser()) {
return
}
try {
localStorage.removeItem(key)
} catch {
// Ignore storage errors
}
}
function parseTerminalFontSize(raw: string | null): TerminalFontSize {
const value = Number(raw)
return TERMINAL_FONT_SIZES.find(size => size === value) ?? DEFAULT_TERMINAL_FONT_SIZE
}
export function getInitialTerminalFontSize(): TerminalFontSize {
return parseTerminalFontSize(safeGetItem(getTerminalFontSizeStorageKey()))
}
export function useTerminalFontSize(): {
terminalFontSize: TerminalFontSize
setTerminalFontSize: (size: TerminalFontSize) => void
} {
const [terminalFontSize, setTerminalFontSizeState] = useState<TerminalFontSize>(getInitialTerminalFontSize)
useEffect(() => {
if (!isBrowser()) {
return
}
const onStorage = (event: StorageEvent) => {
if (event.key !== getTerminalFontSizeStorageKey()) {
return
}
setTerminalFontSizeState(parseTerminalFontSize(event.newValue))
}
window.addEventListener('storage', onStorage)
return () => window.removeEventListener('storage', onStorage)
}, [])
const setTerminalFontSize = useCallback((size: TerminalFontSize) => {
setTerminalFontSizeState(size)
if (size === DEFAULT_TERMINAL_FONT_SIZE) {
safeRemoveItem(getTerminalFontSizeStorageKey())
} else {
safeSetItem(getTerminalFontSizeStorageKey(), String(size))
}
}, [])
return { terminalFontSize, setTerminalFontSize }
}
+1
View File
@@ -258,6 +258,7 @@ export default {
'settings.display.appearance.dark': 'Dark',
'settings.display.appearance.light': 'Light',
'settings.display.fontSize': 'Font Size',
'settings.display.terminalFontSize': 'Terminal Font Size',
'settings.voice.title': 'Voice Assistant',
'settings.voice.language': 'Voice Language',
'settings.voice.autoDetect': 'Auto-detect',
+1
View File
@@ -260,6 +260,7 @@ export default {
'settings.display.appearance.dark': '深色',
'settings.display.appearance.light': '浅色',
'settings.display.fontSize': '字体大小',
'settings.display.terminalFontSize': '终端字体大小',
'settings.voice.title': '语音助手',
'settings.voice.language': '语音语言',
'settings.voice.autoDetect': '自动检测',
+19
View File
@@ -5,6 +5,10 @@ import { en } from '@/lib/locales'
import { PROTOCOL_VERSION } from '@hapi/protocol'
import SettingsPage from './index'
vi.mock('@hapi/protocol', () => ({
PROTOCOL_VERSION: 1,
}))
// Mock the router hooks
vi.mock('@tanstack/react-router', () => ({
useNavigate: () => vi.fn(),
@@ -22,6 +26,15 @@ vi.mock('@/hooks/useFontScale', () => ({
],
}))
vi.mock('@/hooks/useTerminalFontSize', () => ({
useTerminalFontSize: () => ({ terminalFontSize: 13, setTerminalFontSize: vi.fn() }),
getTerminalFontSizeOptions: () => [
{ value: 9, label: '9px' },
{ value: 13, label: '13px' },
{ value: 17, label: '17px' },
],
}))
// Mock useTheme hook
vi.mock('@/hooks/useTheme', () => ({
useAppearance: () => ({ appearance: 'system', setAppearance: vi.fn() }),
@@ -121,4 +134,10 @@ describe('SettingsPage', () => {
expect(calledKeys).toContain('settings.display.appearance')
expect(calledKeys).toContain('settings.display.appearance.system')
})
it('renders the Terminal Font Size setting', () => {
renderWithProviders(<SettingsPage />)
expect(screen.getAllByText('Terminal Font Size').length).toBeGreaterThanOrEqual(1)
expect(screen.getAllByText('13px').length).toBeGreaterThanOrEqual(1)
})
})
+67 -4
View File
@@ -3,6 +3,7 @@ import { useTranslation, type Locale } from '@/lib/use-translation'
import { useAppGoBack } from '@/hooks/useAppGoBack'
import { getElevenLabsSupportedLanguages, getLanguageDisplayName, type Language } from '@/lib/languages'
import { getFontScaleOptions, useFontScale, type FontScale } from '@/hooks/useFontScale'
import { getTerminalFontSizeOptions, useTerminalFontSize, type TerminalFontSize } from '@/hooks/useTerminalFontSize'
import { useAppearance, getAppearanceOptions, type AppearancePreference } from '@/hooks/useTheme'
import { PROTOCOL_VERSION } from '@hapi/protocol'
@@ -76,12 +77,15 @@ export default function SettingsPage() {
const [isOpen, setIsOpen] = useState(false)
const [isAppearanceOpen, setIsAppearanceOpen] = useState(false)
const [isFontOpen, setIsFontOpen] = useState(false)
const [isTerminalFontOpen, setIsTerminalFontOpen] = useState(false)
const [isVoiceOpen, setIsVoiceOpen] = useState(false)
const containerRef = useRef<HTMLDivElement>(null)
const appearanceContainerRef = useRef<HTMLDivElement>(null)
const fontContainerRef = useRef<HTMLDivElement>(null)
const terminalFontContainerRef = useRef<HTMLDivElement>(null)
const voiceContainerRef = useRef<HTMLDivElement>(null)
const { fontScale, setFontScale } = useFontScale()
const { terminalFontSize, setTerminalFontSize } = useTerminalFontSize()
const { appearance, setAppearance } = useAppearance()
// Voice language state - read from localStorage
@@ -90,10 +94,12 @@ export default function SettingsPage() {
})
const fontScaleOptions = getFontScaleOptions()
const terminalFontSizeOptions = getTerminalFontSizeOptions()
const appearanceOptions = getAppearanceOptions()
const currentLocale = locales.find((loc) => loc.value === locale)
const currentAppearanceLabel = appearanceOptions.find((opt) => opt.value === appearance)?.labelKey ?? 'settings.display.appearance.system'
const currentFontScaleLabel = fontScaleOptions.find((opt) => opt.value === fontScale)?.label ?? '100%'
const currentTerminalFontSizeLabel = terminalFontSizeOptions.find((opt) => opt.value === terminalFontSize)?.label ?? '13px'
const currentVoiceLanguage = voiceLanguages.find((lang) => lang.code === voiceLanguage)
const handleLocaleChange = (newLocale: Locale) => {
@@ -111,6 +117,11 @@ export default function SettingsPage() {
setIsFontOpen(false)
}
const handleTerminalFontSizeChange = (newSize: TerminalFontSize) => {
setTerminalFontSize(newSize)
setIsTerminalFontOpen(false)
}
const handleVoiceLanguageChange = (language: Language) => {
setVoiceLanguage(language.code)
if (language.code === null) {
@@ -123,7 +134,7 @@ export default function SettingsPage() {
// Close dropdown when clicking outside
useEffect(() => {
if (!isOpen && !isAppearanceOpen && !isFontOpen && !isVoiceOpen) return
if (!isOpen && !isAppearanceOpen && !isFontOpen && !isTerminalFontOpen && !isVoiceOpen) return
const handleClickOutside = (event: MouseEvent) => {
if (isOpen && containerRef.current && !containerRef.current.contains(event.target as Node)) {
@@ -135,6 +146,9 @@ export default function SettingsPage() {
if (isFontOpen && fontContainerRef.current && !fontContainerRef.current.contains(event.target as Node)) {
setIsFontOpen(false)
}
if (isTerminalFontOpen && terminalFontContainerRef.current && !terminalFontContainerRef.current.contains(event.target as Node)) {
setIsTerminalFontOpen(false)
}
if (isVoiceOpen && voiceContainerRef.current && !voiceContainerRef.current.contains(event.target as Node)) {
setIsVoiceOpen(false)
}
@@ -142,24 +156,25 @@ export default function SettingsPage() {
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [isOpen, isAppearanceOpen, isFontOpen, isVoiceOpen])
}, [isOpen, isAppearanceOpen, isFontOpen, isTerminalFontOpen, isVoiceOpen])
// Close on escape key
useEffect(() => {
if (!isOpen && !isAppearanceOpen && !isFontOpen && !isVoiceOpen) return
if (!isOpen && !isAppearanceOpen && !isFontOpen && !isTerminalFontOpen && !isVoiceOpen) return
const handleEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
setIsOpen(false)
setIsAppearanceOpen(false)
setIsFontOpen(false)
setIsTerminalFontOpen(false)
setIsVoiceOpen(false)
}
}
document.addEventListener('keydown', handleEscape)
return () => document.removeEventListener('keydown', handleEscape)
}, [isOpen, isAppearanceOpen, isFontOpen, isVoiceOpen])
}, [isOpen, isAppearanceOpen, isFontOpen, isTerminalFontOpen, isVoiceOpen])
return (
<div className="flex h-full flex-col">
@@ -334,6 +349,54 @@ export default function SettingsPage() {
</div>
)}
</div>
<div ref={terminalFontContainerRef} className="relative">
<button
type="button"
onClick={() => setIsTerminalFontOpen(!isTerminalFontOpen)}
className="flex w-full items-center justify-between px-3 py-3 text-left transition-colors hover:bg-[var(--app-subtle-bg)]"
aria-expanded={isTerminalFontOpen}
aria-haspopup="listbox"
>
<span className="text-[var(--app-fg)]">{t('settings.display.terminalFontSize')}</span>
<span className="flex items-center gap-1 text-[var(--app-hint)]">
<span>{currentTerminalFontSizeLabel}</span>
<ChevronDownIcon className={`transition-transform ${isTerminalFontOpen ? 'rotate-180' : ''}`} />
</span>
</button>
{isTerminalFontOpen && (
<div
className="absolute right-3 top-full mt-1 min-w-[140px] rounded-lg border border-[var(--app-border)] bg-[var(--app-bg)] shadow-lg overflow-hidden z-50"
role="listbox"
aria-label={t('settings.display.terminalFontSize')}
>
{terminalFontSizeOptions.map((opt) => {
const isSelected = terminalFontSize === opt.value
return (
<button
key={opt.value}
type="button"
role="option"
aria-selected={isSelected}
onClick={() => handleTerminalFontSizeChange(opt.value)}
className={`flex items-center justify-between w-full px-3 py-2 text-base text-left transition-colors ${
isSelected
? 'text-[var(--app-link)] bg-[var(--app-subtle-bg)]'
: 'text-[var(--app-fg)] hover:bg-[var(--app-subtle-bg)]'
}`}
>
<span>{opt.label}</span>
{isSelected && (
<span className="ml-2 text-[var(--app-link)]">
<CheckIcon />
</span>
)}
</button>
)
})}
</div>
)}
</div>
</div>
{/* Voice Assistant section */}