From ea45797b6fe376bd4a49b6266cc46fb8e2bf8612 Mon Sep 17 00:00:00 2001 From: dotblue Date: Fri, 20 Mar 2026 08:43:36 +0800 Subject: [PATCH] web: add terminal font size setting (#324) --- web/src/components/Terminal/TerminalView.tsx | 4 +- web/src/hooks/useTerminalFontSize.test.ts | 40 ++++++++ web/src/hooks/useTerminalFontSize.ts | 96 ++++++++++++++++++++ web/src/lib/locales/en.ts | 1 + web/src/lib/locales/zh-CN.ts | 1 + web/src/routes/settings/index.test.tsx | 19 ++++ web/src/routes/settings/index.tsx | 71 ++++++++++++++- 7 files changed, 227 insertions(+), 5 deletions(-) create mode 100644 web/src/hooks/useTerminalFontSize.test.ts create mode 100644 web/src/hooks/useTerminalFontSize.ts diff --git a/web/src/components/Terminal/TerminalView.tsx b/web/src/components/Terminal/TerminalView.tsx index cf17111f..51b19572 100644 --- a/web/src/components/Terminal/TerminalView.tsx +++ b/web/src/components/Terminal/TerminalView.tsx @@ -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, diff --git a/web/src/hooks/useTerminalFontSize.test.ts b/web/src/hooks/useTerminalFontSize.test.ts new file mode 100644 index 00000000..9fd06681 --- /dev/null +++ b/web/src/hooks/useTerminalFontSize.test.ts @@ -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) + }) +}) diff --git a/web/src/hooks/useTerminalFontSize.ts b/web/src/hooks/useTerminalFontSize.ts new file mode 100644 index 00000000..ee2bcc96 --- /dev/null +++ b/web/src/hooks/useTerminalFontSize.ts @@ -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(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 } +} diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index d957a70f..5e283070 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -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', diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index 32b203e0..93129df4 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -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': '自动检测', diff --git a/web/src/routes/settings/index.test.tsx b/web/src/routes/settings/index.test.tsx index 22486831..c837cd72 100644 --- a/web/src/routes/settings/index.test.tsx +++ b/web/src/routes/settings/index.test.tsx @@ -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() + expect(screen.getAllByText('Terminal Font Size').length).toBeGreaterThanOrEqual(1) + expect(screen.getAllByText('13px').length).toBeGreaterThanOrEqual(1) + }) }) diff --git a/web/src/routes/settings/index.tsx b/web/src/routes/settings/index.tsx index 3c7be672..8b1dae55 100644 --- a/web/src/routes/settings/index.tsx +++ b/web/src/routes/settings/index.tsx @@ -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(null) const appearanceContainerRef = useRef(null) const fontContainerRef = useRef(null) + const terminalFontContainerRef = useRef(null) const voiceContainerRef = useRef(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 (
@@ -334,6 +349,54 @@ export default function SettingsPage() {
)} +
+ + + {isTerminalFontOpen && ( +
+ {terminalFontSizeOptions.map((opt) => { + const isSelected = terminalFontSize === opt.value + return ( + + ) + })} +
+ )} +
{/* Voice Assistant section */}