feat(theme): add Theme Editor with live preview, import/export
- ThemeEditor.svelte: 26 color pickers (14 accents + 12 neutrals) with native <input type="color"> and hex text input, live CSS preview - custom-themes.ts: persistence layer (SQLite JSON blob), validation, import/export as JSON files, clone from any built-in theme - theme.svelte.ts: previewPalette/clearPreview for live editing, setCustomTheme for persistence, initTheme loads custom themes on startup - themes.ts: applyPaletteDirect + buildXtermThemeFromPalette + PALETTE_KEYS - AppearanceSettings.svelte: custom themes list with edit/delete, "New Custom Theme" button, ThemeEditor toggle - All files under 300 lines (296 + 227 + 98)
This commit is contained in:
parent
c1149561c7
commit
0953395423
5 changed files with 454 additions and 8 deletions
227
src/lib/settings/ThemeEditor.svelte
Normal file
227
src/lib/settings/ThemeEditor.svelte
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { type ThemeId, type ThemePalette, THEME_LIST, PALETTE_KEYS } from '../styles/themes';
|
||||
import { previewPalette, clearPreview, setCustomTheme } from '../stores/theme.svelte';
|
||||
import {
|
||||
type CustomTheme, createFromBase, exportThemeJson, importThemeJson,
|
||||
loadCustomThemes, saveCustomThemes
|
||||
} from '../styles/custom-themes';
|
||||
import { handleError } from '../utils/handle-error';
|
||||
|
||||
let { editingTheme = $bindable<CustomTheme | null>(null), onclose }: {
|
||||
editingTheme?: CustomTheme | null;
|
||||
onclose: () => void;
|
||||
} = $props();
|
||||
|
||||
let name = $state('');
|
||||
let baseTheme = $state<ThemeId>('mocha');
|
||||
let palette = $state<ThemePalette>({} as ThemePalette);
|
||||
let baseOpen = $state(false);
|
||||
|
||||
const ACCENT_KEYS: (keyof ThemePalette)[] = [
|
||||
'rosewater','flamingo','pink','mauve','red','maroon','peach',
|
||||
'yellow','green','teal','sky','sapphire','blue','lavender',
|
||||
];
|
||||
const NEUTRAL_KEYS: (keyof ThemePalette)[] = [
|
||||
'text','subtext1','subtext0','overlay2','overlay1','overlay0',
|
||||
'surface2','surface1','surface0','base','mantle','crust',
|
||||
];
|
||||
|
||||
onMount(() => {
|
||||
if (editingTheme) {
|
||||
name = editingTheme.label;
|
||||
baseTheme = editingTheme.baseTheme;
|
||||
palette = { ...editingTheme.palette };
|
||||
} else {
|
||||
name = 'My Theme';
|
||||
palette = { ...createFromBase('', baseTheme).palette };
|
||||
}
|
||||
previewPalette(palette);
|
||||
});
|
||||
|
||||
function updateColor(key: keyof ThemePalette, value: string) {
|
||||
palette[key] = value;
|
||||
previewPalette({ ...palette });
|
||||
}
|
||||
|
||||
function handleHexInput(key: keyof ThemePalette, value: string) {
|
||||
const v = value.startsWith('#') ? value : `#${value}`;
|
||||
if (/^#[0-9a-fA-F]{6}$/.test(v)) updateColor(key, v);
|
||||
}
|
||||
|
||||
function cloneFrom(themeId: ThemeId) {
|
||||
baseTheme = themeId;
|
||||
const fresh = createFromBase(name, themeId);
|
||||
palette = { ...fresh.palette };
|
||||
baseOpen = false;
|
||||
previewPalette({ ...palette });
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!name.trim()) return;
|
||||
const customs = await loadCustomThemes();
|
||||
const theme: CustomTheme = {
|
||||
id: editingTheme?.id ?? `custom:${Date.now()}`,
|
||||
label: name.trim(),
|
||||
baseTheme,
|
||||
isDark: true,
|
||||
palette: { ...palette },
|
||||
};
|
||||
const idx = customs.findIndex(c => c.id === theme.id);
|
||||
if (idx >= 0) customs[idx] = theme; else customs.push(theme);
|
||||
await saveCustomThemes(customs);
|
||||
await setCustomTheme(theme.id, theme.palette);
|
||||
editingTheme = null;
|
||||
onclose();
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
clearPreview();
|
||||
editingTheme = null;
|
||||
onclose();
|
||||
}
|
||||
|
||||
function handleExport() {
|
||||
const theme: CustomTheme = {
|
||||
id: editingTheme?.id ?? 'export',
|
||||
label: name, baseTheme, isDark: true, palette: { ...palette },
|
||||
};
|
||||
const json = exportThemeJson(theme);
|
||||
const blob = new Blob([json], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url; a.download = `${name.replace(/\s+/g, '-').toLowerCase()}.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function handleImport() {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file'; input.accept = '.json';
|
||||
input.onchange = async () => {
|
||||
const file = input.files?.[0];
|
||||
if (!file) return;
|
||||
try {
|
||||
const text = await file.text();
|
||||
const imported = importThemeJson(text);
|
||||
name = imported.label;
|
||||
palette = { ...imported.palette };
|
||||
previewPalette({ ...palette });
|
||||
} catch (e) {
|
||||
handleError(e, 'themeEditor.import', 'import the theme');
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="editor">
|
||||
<div class="header">
|
||||
<h3>Theme Editor</h3>
|
||||
<div class="name-row">
|
||||
<label class="lbl">Name</label>
|
||||
<input type="text" class="name-input" bind:value={name} placeholder="Theme name" />
|
||||
</div>
|
||||
<div class="base-row">
|
||||
<label class="lbl">Base</label>
|
||||
<div class="dropdown" class:open={baseOpen}>
|
||||
<button class="dropdown-btn" onclick={(e) => { e.stopPropagation(); baseOpen = !baseOpen; }}>
|
||||
{THEME_LIST.find(t => t.id === baseTheme)?.label ?? baseTheme}
|
||||
</button>
|
||||
{#if baseOpen}
|
||||
<div class="dropdown-menu">
|
||||
{#each THEME_LIST as t}
|
||||
<button class="dropdown-item" onclick={() => cloneFrom(t.id)}>{t.label}</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="colors-scroll">
|
||||
<details class="group" open>
|
||||
<summary>Accents ({ACCENT_KEYS.length})</summary>
|
||||
<div class="color-grid">
|
||||
{#each ACCENT_KEYS as key}
|
||||
<div class="color-row">
|
||||
<label class="color-label">{key}</label>
|
||||
<input type="color" value={palette[key] ?? '#000000'}
|
||||
oninput={(e) => updateColor(key, (e.target as HTMLInputElement).value)} />
|
||||
<input type="text" class="hex-input" value={palette[key] ?? ''}
|
||||
onchange={(e) => handleHexInput(key, (e.target as HTMLInputElement).value)} />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="group" open>
|
||||
<summary>Neutrals ({NEUTRAL_KEYS.length})</summary>
|
||||
<div class="color-grid">
|
||||
{#each NEUTRAL_KEYS as key}
|
||||
<div class="color-row">
|
||||
<label class="color-label">{key}</label>
|
||||
<input type="color" value={palette[key] ?? '#000000'}
|
||||
oninput={(e) => updateColor(key, (e.target as HTMLInputElement).value)} />
|
||||
<input type="text" class="hex-input" value={palette[key] ?? ''}
|
||||
onchange={(e) => handleHexInput(key, (e.target as HTMLInputElement).value)} />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<div class="footer-left">
|
||||
<button class="btn" onclick={handleImport}>Import</button>
|
||||
<button class="btn" onclick={handleExport}>Export</button>
|
||||
</div>
|
||||
<div class="footer-right">
|
||||
<button class="btn" onclick={handleCancel}>Cancel</button>
|
||||
<button class="btn primary" onclick={handleSave} disabled={!name.trim()}>Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.editor { display: flex; flex-direction: column; height: 100%; gap: 0.75rem; }
|
||||
.header { display: flex; flex-direction: column; gap: 0.5rem; }
|
||||
h3 { margin: 0; color: var(--ctp-text); font-size: 1rem; }
|
||||
.name-row, .base-row { display: flex; align-items: center; gap: 0.5rem; }
|
||||
.lbl { color: var(--ctp-subtext0); font-size: 0.75rem; text-transform: uppercase; width: 3rem; flex-shrink: 0; }
|
||||
.name-input { flex: 1; background: var(--ctp-surface0); border: 1px solid var(--ctp-surface1);
|
||||
color: var(--ctp-text); padding: 0.35rem 0.5rem; border-radius: 0.25rem; font-size: 0.85rem; }
|
||||
.name-input:focus { outline: none; border-color: var(--ctp-blue); }
|
||||
.dropdown { position: relative; flex: 1; }
|
||||
.dropdown-btn { width: 100%; text-align: left; background: var(--ctp-surface0); border: 1px solid var(--ctp-surface1);
|
||||
color: var(--ctp-text); padding: 0.35rem 0.5rem; border-radius: 0.25rem; cursor: pointer; font-size: 0.85rem; }
|
||||
.dropdown-menu { position: absolute; top: 100%; left: 0; right: 0; z-index: 50;
|
||||
background: var(--ctp-surface0); border: 1px solid var(--ctp-surface1); border-radius: 0.25rem;
|
||||
max-height: 12rem; overflow-y: auto; }
|
||||
.dropdown-item { display: block; width: 100%; text-align: left; padding: 0.3rem 0.5rem;
|
||||
background: none; border: none; color: var(--ctp-text); cursor: pointer; font-size: 0.8rem; }
|
||||
.dropdown-item:hover { background: var(--ctp-surface1); }
|
||||
.colors-scroll { flex: 1; overflow-y: auto; min-height: 0; }
|
||||
.group { margin-bottom: 0.5rem; }
|
||||
.group summary { color: var(--ctp-subtext0); font-size: 0.75rem; text-transform: uppercase;
|
||||
cursor: pointer; padding: 0.25rem 0; user-select: none; }
|
||||
.color-grid { display: flex; flex-direction: column; gap: 0.25rem; padding: 0.25rem 0; }
|
||||
.color-row { display: flex; align-items: center; gap: 0.5rem; }
|
||||
.color-label { width: 5.5rem; color: var(--ctp-subtext1); font-size: 0.8rem; flex-shrink: 0; }
|
||||
input[type="color"] { width: 2rem; height: 1.5rem; border: 1px solid var(--ctp-surface1);
|
||||
border-radius: 0.2rem; cursor: pointer; padding: 0; background: none; }
|
||||
input[type="color"]::-webkit-color-swatch-wrapper { padding: 1px; }
|
||||
input[type="color"]::-webkit-color-swatch { border: none; border-radius: 0.15rem; }
|
||||
.hex-input { width: 5rem; background: var(--ctp-surface0); border: 1px solid var(--ctp-surface1);
|
||||
color: var(--ctp-text); padding: 0.2rem 0.35rem; border-radius: 0.2rem; font-family: monospace; font-size: 0.8rem; }
|
||||
.hex-input:focus { outline: none; border-color: var(--ctp-blue); }
|
||||
.footer { display: flex; justify-content: space-between; gap: 0.5rem; padding-top: 0.5rem;
|
||||
border-top: 1px solid var(--ctp-surface0); }
|
||||
.footer-left, .footer-right { display: flex; gap: 0.35rem; }
|
||||
.btn { padding: 0.35rem 0.75rem; border-radius: 0.25rem; border: 1px solid var(--ctp-surface1);
|
||||
background: var(--ctp-surface0); color: var(--ctp-text); cursor: pointer; font-size: 0.8rem; }
|
||||
.btn:hover { background: var(--ctp-surface1); }
|
||||
.btn.primary { background: var(--ctp-blue); color: var(--ctp-crust); border-color: var(--ctp-blue); }
|
||||
.btn.primary:hover { opacity: 0.9; }
|
||||
.btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
</style>
|
||||
|
|
@ -1,9 +1,11 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { getCurrentTheme, setTheme } from '../../stores/theme.svelte';
|
||||
import { getCurrentTheme, setTheme, setCustomTheme } from '../../stores/theme.svelte';
|
||||
import { THEME_LIST, getPalette, type ThemeId } from '../../styles/themes';
|
||||
import { getSetting, setSetting } from '../../adapters/settings-bridge';
|
||||
import { handleError } from '../../utils/handle-error';
|
||||
import { type CustomTheme, loadCustomThemes, deleteCustomTheme as deleteCustom, saveCustomThemes } from '../../styles/custom-themes';
|
||||
import ThemeEditor from '../ThemeEditor.svelte';
|
||||
|
||||
const UI_FONTS = [
|
||||
{ value: '', label: 'System Default' },
|
||||
|
|
@ -41,6 +43,9 @@
|
|||
let themeOpen = $state(false);
|
||||
let uiFontOpen = $state(false);
|
||||
let termFontOpen = $state(false);
|
||||
let customThemes = $state<CustomTheme[]>([]);
|
||||
let editorOpen = $state(false);
|
||||
let editingTheme = $state<CustomTheme | null>(null);
|
||||
|
||||
let themeGroups = $derived.by(() => {
|
||||
const map = new Map<string, typeof THEME_LIST>();
|
||||
|
|
@ -72,9 +77,17 @@
|
|||
if (results[5].status === 'fulfilled') cursorBlink = results[5].value !== 'false'; else failCount++;
|
||||
if (results[6].status === 'fulfilled' && results[6].value) scrollbackLines = results[6].value; else failCount++;
|
||||
if (failCount === results.length) loadError = 'Could not load settings. Displaying defaults.';
|
||||
customThemes = await loadCustomThemes();
|
||||
});
|
||||
|
||||
function pickTheme(id: ThemeId) { selectedTheme = id; themeOpen = false; setTheme(id); }
|
||||
function pickCustomTheme(ct: CustomTheme) { themeOpen = false; setCustomTheme(ct.id, ct.palette); }
|
||||
function startNewTheme() { editingTheme = null; editorOpen = true; closeDropdowns(); }
|
||||
function startEditTheme(ct: CustomTheme) { editingTheme = ct; editorOpen = true; closeDropdowns(); }
|
||||
async function removeCustomTheme(id: string) {
|
||||
customThemes = await deleteCustom(customThemes, id);
|
||||
}
|
||||
async function closeEditor() { editorOpen = false; customThemes = await loadCustomThemes(); }
|
||||
async function save(key: string, value: string) {
|
||||
try { await setSetting(key, value); } catch (e) { handleError(e, `settings.save.${key}`, 'save your settings'); }
|
||||
}
|
||||
|
|
@ -154,6 +167,27 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{#if editorOpen}
|
||||
<ThemeEditor bind:editingTheme onclose={closeEditor} />
|
||||
{:else}
|
||||
{#if customThemes.length > 0}
|
||||
<div class="custom-themes">
|
||||
{#each customThemes as ct}
|
||||
<div class="custom-theme-row">
|
||||
<button class="custom-theme-btn" onclick={() => pickCustomTheme(ct)}>
|
||||
<span class="color-dot" style="background: {ct.palette.blue};"></span>
|
||||
<span class="color-dot" style="background: {ct.palette.green};"></span>
|
||||
{ct.label}
|
||||
</button>
|
||||
<button class="icon-btn" onclick={() => startEditTheme(ct)} aria-label="Edit {ct.label}">✎</button>
|
||||
<button class="icon-btn danger" onclick={() => removeCustomTheme(ct.id)} aria-label="Delete {ct.label}">✕</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<button class="new-theme-btn" onclick={startNewTheme}>+ New Custom Theme</button>
|
||||
{/if}
|
||||
|
||||
<h3>UI Font</h3>
|
||||
<div class="field row" id="setting-ui-font">
|
||||
<div class="custom-dropdown flex1">
|
||||
|
|
@ -245,4 +279,18 @@
|
|||
input[type="number"] { width: 5rem; padding: 0.375rem 0.5rem; background: var(--ctp-mantle); border: 1px solid var(--ctp-surface0); border-radius: 0.25rem; color: var(--ctp-text); font-size: 0.8125rem; }
|
||||
input[type="number"]:focus { border-color: var(--ctp-blue); outline: none; }
|
||||
.hint { font-size: 0.6875rem; color: var(--ctp-overlay0); margin-left: 0.5rem; }
|
||||
.custom-themes { display: flex; flex-direction: column; gap: 0.25rem; margin-top: 0.25rem; }
|
||||
.custom-theme-row { display: flex; align-items: center; gap: 0.25rem; }
|
||||
.custom-theme-btn { flex: 1; display: flex; align-items: center; gap: 0.375rem; padding: 0.3rem 0.5rem;
|
||||
background: var(--ctp-mantle); border: 1px solid var(--ctp-surface0); border-radius: 0.25rem;
|
||||
color: var(--ctp-text); font-size: 0.8rem; cursor: pointer; text-align: left; }
|
||||
.custom-theme-btn:hover { border-color: var(--ctp-surface1); }
|
||||
.icon-btn { background: none; border: none; color: var(--ctp-overlay0); cursor: pointer; font-size: 0.85rem;
|
||||
padding: 0.2rem; border-radius: 0.15rem; }
|
||||
.icon-btn:hover { color: var(--ctp-text); background: var(--ctp-surface0); }
|
||||
.icon-btn.danger:hover { color: var(--ctp-red); }
|
||||
.new-theme-btn { margin-top: 0.25rem; padding: 0.3rem 0.5rem; background: none;
|
||||
border: 1px dashed var(--ctp-surface1); border-radius: 0.25rem; color: var(--ctp-overlay1);
|
||||
font-size: 0.8rem; cursor: pointer; }
|
||||
.new-theme-btn:hover { border-color: var(--ctp-blue); color: var(--ctp-blue); }
|
||||
</style>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue