- Settings drawer: responsive width clamp(24rem, 45vw, 50rem) - System font detection: fc-list for UI fonts (preferred sans-serif starred) and mono fonts (Nerd Fonts starred), fallback to hardcoded lists - Scrollback: default 5000, min 1000, step 500 - Shell detection: system.shells RPC, pre-selects $SHELL login shell - Provider enablement: provider.scan gates toggle, unavailable shown as N/A - Session retention: count 0-100 (0=Keep all), age 0-365 (0=Forever) - Chord keybindings: Ctrl+K → Ctrl+S style multi-key sequences, 1s prefix wait, arrow separator display, 26 tests passing
297 lines
12 KiB
Svelte
297 lines
12 KiB
Svelte
<script lang="ts">
|
||
import { onMount } from 'svelte';
|
||
import { appRpc } from '../rpc.ts';
|
||
import { THEMES, THEME_GROUPS, getPalette, type ThemeId, type ThemeMeta } from '../themes.ts';
|
||
import { themeStore } from '../theme-store.svelte.ts';
|
||
import { fontStore } from '../font-store.svelte.ts';
|
||
import { t, getLocale, setLocale, AVAILABLE_LOCALES } from '../i18n.svelte.ts';
|
||
import ThemeEditor from './ThemeEditor.svelte';
|
||
import CustomDropdown from '../ui/CustomDropdown.svelte';
|
||
|
||
// Fallback lists — replaced by system detection on mount
|
||
const FALLBACK_UI_FONTS = [
|
||
{ value: '', label: 'System Default' },
|
||
{ value: 'Inter', label: 'Inter' },
|
||
{ value: 'IBM Plex Sans', label: 'IBM Plex Sans' },
|
||
{ value: 'Noto Sans', label: 'Noto Sans' },
|
||
{ value: 'Roboto', label: 'Roboto' },
|
||
{ value: 'Ubuntu', label: 'Ubuntu' },
|
||
];
|
||
|
||
const FALLBACK_TERM_FONTS = [
|
||
{ value: '', label: 'Default (JetBrains Mono)' },
|
||
{ value: 'JetBrains Mono', label: 'JetBrains Mono' },
|
||
{ value: 'Fira Code', label: 'Fira Code' },
|
||
{ value: 'Cascadia Code', label: 'Cascadia Code' },
|
||
{ value: 'Source Code Pro', label: 'Source Code Pro' },
|
||
{ value: 'IBM Plex Mono', label: 'IBM Plex Mono' },
|
||
{ value: 'monospace', label: 'monospace' },
|
||
];
|
||
|
||
let uiFontItems = $state(FALLBACK_UI_FONTS.map(f => ({ value: f.value, label: f.label })));
|
||
let termFontItems = $state(FALLBACK_TERM_FONTS.map(f => ({ value: f.value, label: f.label })));
|
||
|
||
// ── Local reactive state ───────────────────────────────────────────────────
|
||
let themeId = $state<ThemeId>(themeStore.currentTheme);
|
||
let uiFont = $state(fontStore.uiFontFamily);
|
||
let uiFontSize = $state(fontStore.uiFontSize);
|
||
let termFont = $state(fontStore.termFontFamily);
|
||
let termFontSize = $state(fontStore.termFontSize);
|
||
let cursorStyle = $state('block');
|
||
let cursorBlink = $state(true);
|
||
let scrollback = $state(5000);
|
||
|
||
interface CustomThemeMeta { id: string; name: string; }
|
||
let customThemes = $state<CustomThemeMeta[]>([]);
|
||
let showEditor = $state(false);
|
||
|
||
// Keep local state in sync when store changes
|
||
$effect(() => { themeId = themeStore.currentTheme; });
|
||
$effect(() => { uiFont = fontStore.uiFontFamily; });
|
||
$effect(() => { uiFontSize = fontStore.uiFontSize; });
|
||
$effect(() => { termFont = fontStore.termFontFamily; });
|
||
$effect(() => { termFontSize = fontStore.termFontSize; });
|
||
|
||
// ── Dropdown open state (managed by CustomDropdown now) ────────────────────
|
||
|
||
// ── Language ──────────────────────────────────────────────────────────────
|
||
let currentLocale = $derived(getLocale());
|
||
let langLabel = $derived(AVAILABLE_LOCALES.find(l => l.tag === currentLocale)?.nativeLabel ?? 'English');
|
||
|
||
function selectLang(tag: string): void {
|
||
setLocale(tag);
|
||
}
|
||
|
||
// ── All themes (built-in + custom) ────────────────────────────────────────
|
||
let allThemes = $derived<ThemeMeta[]>([
|
||
...THEMES,
|
||
...customThemes.map(t => ({ id: t.id as ThemeId, label: t.name, group: 'Custom', isDark: true })),
|
||
]);
|
||
let allGroups = $derived([...THEME_GROUPS, ...(customThemes.length > 0 ? ['Custom'] : [])]);
|
||
|
||
// ── Dropdown items for CustomDropdown ──────────────────────────────────────
|
||
let themeItems = $derived(allThemes.map(t => ({ value: t.id, label: t.label, group: t.group })));
|
||
// uiFontItems and termFontItems are $state — populated by system.fonts on mount
|
||
let langItems = AVAILABLE_LOCALES.map(l => ({ value: l.tag, label: l.nativeLabel }));
|
||
|
||
// ── Actions ────────────────────────────────────────────────────────────────
|
||
|
||
function selectTheme(id: ThemeId): void {
|
||
themeId = id;
|
||
themeStore.setTheme(id);
|
||
appRpc?.request['settings.set']({ key: 'theme', value: id }).catch(console.error);
|
||
}
|
||
|
||
function selectUIFont(value: string): void {
|
||
uiFont = value;
|
||
fontStore.setUIFont(value, uiFontSize);
|
||
}
|
||
|
||
function selectTermFont(value: string): void {
|
||
termFont = value;
|
||
fontStore.setTermFont(value, termFontSize);
|
||
}
|
||
|
||
function adjustUISize(delta: number): void {
|
||
uiFontSize = Math.max(8, Math.min(24, uiFontSize + delta));
|
||
fontStore.setUIFont(uiFont, uiFontSize);
|
||
}
|
||
|
||
function adjustTermSize(delta: number): void {
|
||
termFontSize = Math.max(8, Math.min(24, termFontSize + delta));
|
||
fontStore.setTermFont(termFont, termFontSize);
|
||
}
|
||
|
||
function persistCursorStyle(v: string) {
|
||
cursorStyle = v;
|
||
appRpc?.request['settings.set']({ key: 'cursor_style', value: v }).catch(console.error);
|
||
}
|
||
|
||
function persistCursorBlink(v: boolean) {
|
||
cursorBlink = v;
|
||
appRpc?.request['settings.set']({ key: 'cursor_blink', value: String(v) }).catch(console.error);
|
||
}
|
||
|
||
function persistScrollback(v: number) {
|
||
scrollback = v;
|
||
appRpc?.request['settings.set']({ key: 'scrollback', value: String(v) }).catch(console.error);
|
||
}
|
||
|
||
// CustomDropdown handles its own open/close state
|
||
|
||
async function deleteCustomTheme(id: string) {
|
||
await appRpc?.request['themes.deleteCustom']({ id }).catch(console.error);
|
||
customThemes = customThemes.filter(t => t.id !== id);
|
||
if (themeId === id) selectTheme('mocha');
|
||
}
|
||
|
||
function onEditorSave(id: string, name: string) {
|
||
customThemes = [...customThemes, { id, name }];
|
||
showEditor = false;
|
||
selectTheme(id as ThemeId);
|
||
}
|
||
|
||
function onEditorCancel() { showEditor = false; }
|
||
|
||
onMount(async () => {
|
||
if (!appRpc) return;
|
||
const { settings } = await appRpc.request['settings.getAll']({}).catch(() => ({ settings: {} }));
|
||
if (settings['cursor_style']) cursorStyle = settings['cursor_style'];
|
||
if (settings['cursor_blink']) cursorBlink = settings['cursor_blink'] !== 'false';
|
||
if (settings['scrollback']) scrollback = parseInt(settings['scrollback'], 10) || 5000;
|
||
|
||
const res = await appRpc.request['themes.getCustom']({}).catch(() => ({ themes: [] }));
|
||
customThemes = res.themes.map(t => ({ id: t.id, name: t.name }));
|
||
|
||
// Detect system fonts
|
||
try {
|
||
const fontRes = await appRpc.request['system.fonts']({});
|
||
if (fontRes.uiFonts.length > 0) {
|
||
uiFontItems = [
|
||
{ value: '', label: 'System Default' },
|
||
...fontRes.uiFonts.map(f => ({
|
||
value: f.family,
|
||
label: f.preferred ? `\u2605 ${f.family}` : f.family,
|
||
})),
|
||
];
|
||
}
|
||
if (fontRes.monoFonts.length > 0) {
|
||
termFontItems = [
|
||
{ value: '', label: 'Default (JetBrains Mono)' },
|
||
...fontRes.monoFonts.map(f => ({
|
||
value: f.family,
|
||
label: f.isNerdFont ? `\u2B50 ${f.family}` : f.family,
|
||
})),
|
||
];
|
||
}
|
||
} catch {
|
||
// Keep fallback font lists
|
||
}
|
||
});
|
||
</script>
|
||
|
||
<div class="section">
|
||
|
||
{#if showEditor}
|
||
<ThemeEditor
|
||
baseThemeId={themeId}
|
||
initialPalette={getPalette(themeId)}
|
||
onSave={onEditorSave}
|
||
onCancel={onEditorCancel}
|
||
/>
|
||
{:else}
|
||
|
||
<h3 class="sh">{t('settings.theme')}</h3>
|
||
<div class="field">
|
||
<CustomDropdown
|
||
items={themeItems}
|
||
selected={themeId}
|
||
onSelect={v => selectTheme(v as ThemeId)}
|
||
groupBy={true}
|
||
/>
|
||
<div class="theme-actions">
|
||
<button class="theme-action-btn" onclick={() => showEditor = true}>Edit Theme</button>
|
||
<button class="theme-action-btn" onclick={() => showEditor = true}>+ Custom</button>
|
||
</div>
|
||
</div>
|
||
|
||
<h3 class="sh">{t('settings.uiFont')}</h3>
|
||
<div class="field row">
|
||
<div class="flex1">
|
||
<CustomDropdown
|
||
items={uiFontItems}
|
||
selected={uiFont}
|
||
onSelect={v => selectUIFont(v)}
|
||
placeholder="System Default"
|
||
/>
|
||
</div>
|
||
<div class="stepper">
|
||
<button onclick={() => adjustUISize(-1)} aria-label="Decrease UI font size">−</button>
|
||
<span>{uiFontSize}px</span>
|
||
<button onclick={() => adjustUISize(1)} aria-label="Increase UI font size">+</button>
|
||
</div>
|
||
</div>
|
||
|
||
<h3 class="sh">{t('settings.termFont')}</h3>
|
||
<div class="field row">
|
||
<div class="flex1">
|
||
<CustomDropdown
|
||
items={termFontItems}
|
||
selected={termFont}
|
||
onSelect={v => selectTermFont(v)}
|
||
placeholder="Default (JetBrains Mono)"
|
||
/>
|
||
</div>
|
||
<div class="stepper">
|
||
<button onclick={() => adjustTermSize(-1)} aria-label="Decrease terminal font size">−</button>
|
||
<span>{termFontSize}px</span>
|
||
<button onclick={() => adjustTermSize(1)} aria-label="Increase terminal font size">+</button>
|
||
</div>
|
||
</div>
|
||
|
||
<h3 class="sh">{t('settings.termCursor')}</h3>
|
||
<div class="field row">
|
||
<div class="seg">
|
||
{#each ['block', 'line', 'underline'] as s}
|
||
<button class:active={cursorStyle === s} onclick={() => persistCursorStyle(s)}>{s[0].toUpperCase() + s.slice(1)}</button>
|
||
{/each}
|
||
</div>
|
||
<label class="toggle-row">
|
||
<span>Blink</span>
|
||
<button class="toggle" class:on={cursorBlink} onclick={() => persistCursorBlink(!cursorBlink)}>{cursorBlink ? 'On' : 'Off'}</button>
|
||
</label>
|
||
</div>
|
||
|
||
<h3 class="sh">{t('settings.scrollback')}</h3>
|
||
<div class="field row">
|
||
<input type="number" class="num-in" min="1000" max="100000" step="500" value={scrollback}
|
||
onchange={e => persistScrollback(parseInt((e.target as HTMLInputElement).value, 10) || 5000)}
|
||
aria-label="Scrollback lines" />
|
||
<span class="hint">{t('settings.scrollbackHint')}</span>
|
||
</div>
|
||
|
||
<h3 class="sh">{t('settings.language')}</h3>
|
||
<div class="field">
|
||
<CustomDropdown
|
||
items={langItems}
|
||
selected={currentLocale}
|
||
onSelect={v => selectLang(v)}
|
||
/>
|
||
</div>
|
||
|
||
{/if}
|
||
</div>
|
||
|
||
<style>
|
||
.section { display: flex; flex-direction: column; gap: 0.5rem; }
|
||
.sh { margin: 0.375rem 0 0.125rem; font-size: 0.6875rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; color: var(--ctp-overlay0); }
|
||
.field { position: relative; }
|
||
.row { display: flex; align-items: center; gap: 0.5rem; }
|
||
.flex1 { flex: 1; min-width: 0; }
|
||
|
||
.theme-actions { display: flex; gap: 0.375rem; margin-top: 0.25rem; }
|
||
.theme-action-btn {
|
||
padding: 0.2rem 0.625rem; background: var(--ctp-surface0); border: 1px solid var(--ctp-surface1);
|
||
border-radius: 0.2rem; color: var(--ctp-subtext1); font-size: 0.75rem; cursor: pointer;
|
||
font-family: var(--ui-font-family);
|
||
}
|
||
.theme-action-btn:hover { background: var(--ctp-surface1); color: var(--ctp-text); }
|
||
|
||
.stepper { display: flex; align-items: center; gap: 0.25rem; flex-shrink: 0; }
|
||
.stepper button { width: 1.375rem; height: 1.375rem; background: var(--ctp-surface0); border: 1px solid var(--ctp-surface1); border-radius: 0.2rem; color: var(--ctp-text); font-size: 0.875rem; cursor: pointer; display: flex; align-items: center; justify-content: center; }
|
||
.stepper button:hover { background: var(--ctp-surface1); }
|
||
.stepper span { font-size: 0.8125rem; color: var(--ctp-text); min-width: 2.5rem; text-align: center; }
|
||
|
||
.seg { display: flex; border-radius: 0.25rem; overflow: hidden; border: 1px solid var(--ctp-surface1); }
|
||
.seg button { flex: 1; padding: 0.25rem 0.5rem; background: var(--ctp-surface0); border: none; color: var(--ctp-overlay1); font-size: 0.75rem; cursor: pointer; }
|
||
.seg button:hover { background: var(--ctp-surface1); color: var(--ctp-subtext1); }
|
||
.seg button.active { background: var(--ctp-blue); color: var(--ctp-base); }
|
||
|
||
.toggle-row { display: flex; align-items: center; gap: 0.375rem; font-size: 0.8125rem; color: var(--ctp-subtext0); cursor: pointer; }
|
||
.toggle { padding: 0.1875rem 0.5rem; background: var(--ctp-surface0); border: none; border-radius: 0.2rem; color: var(--ctp-subtext0); cursor: pointer; font-size: 0.75rem; }
|
||
.toggle.on { background: var(--ctp-green); color: var(--ctp-base); }
|
||
|
||
.num-in { width: 5rem; padding: 0.3rem 0.5rem; background: var(--ctp-surface0); border: 1px solid var(--ctp-surface1); border-radius: 0.25rem; color: var(--ctp-text); font-size: 0.8125rem; }
|
||
.num-in:focus { outline: none; border-color: var(--ctp-blue); }
|
||
.hint { font-size: 0.6875rem; color: var(--ctp-overlay0); }
|
||
</style>
|