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>
|
||||
|
|
|
|||
|
|
@ -4,14 +4,18 @@ import { getSetting, setSetting } from '../adapters/settings-bridge';
|
|||
import { handleInfraError } from '../utils/handle-error';
|
||||
import {
|
||||
type ThemeId,
|
||||
type ThemePalette,
|
||||
type CatppuccinFlavor,
|
||||
ALL_THEME_IDS,
|
||||
buildXtermTheme,
|
||||
buildXtermThemeFromPalette,
|
||||
applyCssVariables,
|
||||
applyPaletteDirect,
|
||||
type XtermTheme,
|
||||
} from '../styles/themes';
|
||||
|
||||
let currentTheme = $state<ThemeId>('mocha');
|
||||
let customPalette = $state<ThemePalette | null>(null);
|
||||
|
||||
/** Registered theme-change listeners */
|
||||
const themeChangeCallbacks = new Set<() => void>();
|
||||
|
|
@ -36,9 +40,47 @@ export function getCurrentFlavor(): CatppuccinFlavor {
|
|||
}
|
||||
|
||||
export function getXtermTheme(): XtermTheme {
|
||||
if (customPalette) return buildXtermThemeFromPalette(customPalette);
|
||||
return buildXtermTheme(currentTheme);
|
||||
}
|
||||
|
||||
/** Apply an arbitrary palette for live preview (does NOT persist) */
|
||||
export function previewPalette(palette: ThemePalette): void {
|
||||
customPalette = palette;
|
||||
applyPaletteDirect(palette);
|
||||
for (const cb of themeChangeCallbacks) {
|
||||
try { cb(); } catch (e) { handleInfraError(e, 'theme.previewCallback'); }
|
||||
}
|
||||
}
|
||||
|
||||
/** Clear custom palette preview, revert to current built-in theme */
|
||||
export function clearPreview(): void {
|
||||
customPalette = null;
|
||||
applyCssVariables(currentTheme);
|
||||
for (const cb of themeChangeCallbacks) {
|
||||
try { cb(); } catch (e) { handleInfraError(e, 'theme.clearPreviewCallback'); }
|
||||
}
|
||||
}
|
||||
|
||||
/** Set a custom theme as active (persists the custom theme ID) */
|
||||
export async function setCustomTheme(id: string, palette: ThemePalette): Promise<void> {
|
||||
customPalette = palette;
|
||||
applyPaletteDirect(palette);
|
||||
for (const cb of themeChangeCallbacks) {
|
||||
try { cb(); } catch (e) { handleInfraError(e, 'theme.customCallback'); }
|
||||
}
|
||||
try {
|
||||
await setSetting('theme', id);
|
||||
} catch (e) {
|
||||
handleInfraError(e, 'theme.persistCustom');
|
||||
}
|
||||
}
|
||||
|
||||
/** Check if current theme is a custom theme */
|
||||
export function isCustomThemeActive(): boolean {
|
||||
return customPalette !== null;
|
||||
}
|
||||
|
||||
/** Change theme, apply CSS variables, and persist to settings DB */
|
||||
export async function setTheme(theme: ThemeId): Promise<void> {
|
||||
currentTheme = theme;
|
||||
|
|
@ -68,15 +110,25 @@ export async function setFlavor(flavor: CatppuccinFlavor): Promise<void> {
|
|||
export async function initTheme(): Promise<void> {
|
||||
try {
|
||||
const saved = await getSetting('theme');
|
||||
if (saved && ALL_THEME_IDS.includes(saved as ThemeId)) {
|
||||
currentTheme = saved as ThemeId;
|
||||
if (saved) {
|
||||
if (saved.startsWith('custom:')) {
|
||||
// Custom theme — load palette from custom_themes storage
|
||||
const { loadCustomThemes } = await import('../styles/custom-themes');
|
||||
const customs = await loadCustomThemes();
|
||||
const match = customs.find(c => c.id === saved);
|
||||
if (match) {
|
||||
customPalette = match.palette;
|
||||
applyPaletteDirect(match.palette);
|
||||
}
|
||||
} else if (ALL_THEME_IDS.includes(saved as ThemeId)) {
|
||||
currentTheme = saved as ThemeId;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Fall back to default (mocha) — catppuccin.css provides Mocha defaults
|
||||
}
|
||||
// Always apply to sync CSS vars with current theme
|
||||
// (skip if mocha — catppuccin.css already has Mocha values)
|
||||
if (currentTheme !== 'mocha') {
|
||||
// Always apply to sync CSS vars with current theme (skip if custom already applied)
|
||||
if (!customPalette && currentTheme !== 'mocha') {
|
||||
applyCssVariables(currentTheme);
|
||||
}
|
||||
|
||||
|
|
|
|||
98
src/lib/styles/custom-themes.ts
Normal file
98
src/lib/styles/custom-themes.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
// Custom theme persistence — store, load, validate, import/export
|
||||
|
||||
import { getSetting, setSetting } from '../adapters/settings-bridge';
|
||||
import { handleError, handleInfraError } from '../utils/handle-error';
|
||||
import { type ThemePalette, type ThemeId, getPalette, PALETTE_KEYS } from './themes';
|
||||
|
||||
export interface CustomTheme {
|
||||
id: string;
|
||||
label: string;
|
||||
baseTheme: ThemeId;
|
||||
isDark: boolean;
|
||||
palette: ThemePalette;
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'custom_themes';
|
||||
const HEX_RE = /^#[0-9a-fA-F]{6}$/;
|
||||
|
||||
// --- Persistence ---
|
||||
|
||||
export async function loadCustomThemes(): Promise<CustomTheme[]> {
|
||||
try {
|
||||
const raw = await getSetting(STORAGE_KEY);
|
||||
if (!raw) return [];
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? parsed.filter(isValidCustomTheme) : [];
|
||||
} catch (e) {
|
||||
handleInfraError(e, 'customThemes.load');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveCustomThemes(themes: CustomTheme[]): Promise<void> {
|
||||
try {
|
||||
await setSetting(STORAGE_KEY, JSON.stringify(themes));
|
||||
} catch (e) {
|
||||
handleError(e, 'customThemes.save', 'save your custom themes');
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteCustomTheme(themes: CustomTheme[], id: string): Promise<CustomTheme[]> {
|
||||
const updated = themes.filter(t => t.id !== id);
|
||||
await saveCustomThemes(updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
// --- Create from base ---
|
||||
|
||||
export function createFromBase(name: string, baseTheme: ThemeId): CustomTheme {
|
||||
const base = getPalette(baseTheme);
|
||||
return {
|
||||
id: `custom:${Date.now()}`,
|
||||
label: name,
|
||||
baseTheme,
|
||||
isDark: baseTheme !== 'latte',
|
||||
palette: { ...base },
|
||||
};
|
||||
}
|
||||
|
||||
// --- Validation ---
|
||||
|
||||
function isValidCustomTheme(obj: unknown): obj is CustomTheme {
|
||||
if (!obj || typeof obj !== 'object') return false;
|
||||
const t = obj as Record<string, unknown>;
|
||||
if (typeof t.id !== 'string' || typeof t.label !== 'string') return false;
|
||||
if (!t.palette || typeof t.palette !== 'object') return false;
|
||||
return isValidPalette(t.palette as Record<string, unknown>);
|
||||
}
|
||||
|
||||
export function isValidPalette(p: Record<string, unknown>): boolean {
|
||||
return PALETTE_KEYS.every(k => typeof p[k] === 'string' && HEX_RE.test(p[k] as string));
|
||||
}
|
||||
|
||||
// --- Import / Export ---
|
||||
|
||||
export function exportThemeJson(theme: CustomTheme): string {
|
||||
return JSON.stringify({
|
||||
name: theme.label,
|
||||
version: 1,
|
||||
isDark: theme.isDark,
|
||||
palette: theme.palette,
|
||||
}, null, 2);
|
||||
}
|
||||
|
||||
export function importThemeJson(json: string): CustomTheme {
|
||||
const data = JSON.parse(json);
|
||||
if (!data || typeof data !== 'object') throw new Error('Invalid theme JSON');
|
||||
if (typeof data.name !== 'string' || !data.name.trim()) throw new Error('Missing theme name');
|
||||
if (!data.palette || !isValidPalette(data.palette)) {
|
||||
throw new Error('Invalid palette — must have all 26 color keys as #hex values');
|
||||
}
|
||||
return {
|
||||
id: `custom:${Date.now()}`,
|
||||
label: data.name.trim(),
|
||||
baseTheme: 'mocha',
|
||||
isDark: data.isDark !== false,
|
||||
palette: data.palette,
|
||||
};
|
||||
}
|
||||
|
|
@ -361,13 +361,34 @@ const CSS_VAR_MAP: [string, keyof ThemePalette][] = [
|
|||
|
||||
/** Apply a theme's CSS custom properties to document root */
|
||||
export function applyCssVariables(theme: ThemeId): void {
|
||||
const p = palettes[theme];
|
||||
applyPaletteDirect(palettes[theme]);
|
||||
}
|
||||
|
||||
/** Apply an arbitrary palette object to CSS vars (used by custom theme editor) */
|
||||
export function applyPaletteDirect(palette: ThemePalette): void {
|
||||
const style = document.documentElement.style;
|
||||
for (const [varName, key] of CSS_VAR_MAP) {
|
||||
style.setProperty(varName, p[key]);
|
||||
style.setProperty(varName, palette[key]);
|
||||
}
|
||||
}
|
||||
|
||||
/** Build xterm.js theme from an arbitrary palette */
|
||||
export function buildXtermThemeFromPalette(p: ThemePalette): XtermTheme {
|
||||
return {
|
||||
background: p.base, foreground: p.text,
|
||||
cursor: p.rosewater, cursorAccent: p.base,
|
||||
selectionBackground: p.surface1, selectionForeground: p.text,
|
||||
black: p.surface1, red: p.red, green: p.green, yellow: p.yellow,
|
||||
blue: p.blue, magenta: p.pink, cyan: p.teal, white: p.subtext1,
|
||||
brightBlack: p.surface2, brightRed: p.red, brightGreen: p.green,
|
||||
brightYellow: p.yellow, brightBlue: p.blue, brightMagenta: p.pink,
|
||||
brightCyan: p.teal, brightWhite: p.subtext0,
|
||||
};
|
||||
}
|
||||
|
||||
/** All palette keys for iteration */
|
||||
export const PALETTE_KEYS: (keyof ThemePalette)[] = CSS_VAR_MAP.map(([, k]) => k);
|
||||
|
||||
/** @deprecated Use THEME_LIST instead */
|
||||
export const FLAVOR_LABELS: Record<CatppuccinFlavor, string> = {
|
||||
latte: 'Latte (Light)',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue