agent-orchestrator/ui-electrobun/src/mainview/settings/AppearanceSettings.svelte
Hibryda 1de6c93e01 feat(electrobun): settings overhaul — fonts, shells, providers, retention, chords
- 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
2026-03-25 01:42:34 +01:00

297 lines
12 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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>