cleaning up code

This commit is contained in:
2026-08-09 09:54:52 -04:00
parent 1d06156b8b
commit 2abdbaa4e9
59 changed files with 518 additions and 2929 deletions

View File

@@ -38,6 +38,7 @@ const defaultSettings: AppSettings = {
const defaultPreferences: UserPreferences = {
showExplicit: false,
autoDownload: false,
autoJumpToPlayer: true,
};
const defaultState: AppState = {

View File

@@ -135,10 +135,8 @@ export class AudioStreamReader {
this.writePos = 0;
this.totalSamplesWritten = 0;
// Capture generation for this run
const myGeneration = this.generation;
// Start async reading loop
this.readLoop(myGeneration);
// Detect process exit

View File

@@ -1,103 +0,0 @@
/**
* Audio waveform analysis for PodTUI
*
* Extracts amplitude data from audio files using ffmpeg (when available)
* Results are cache in-memory keyed by audio URL.
*/
/** Number of amplitude data points to generate */
const DEFAULT_RESOLUTION = 128;
/** In-memory cache: audioUrl -> amplitude data */
const waveformCache = new Map<string, number[]>();
/**
* Try to extract real waveform data from an audio URL using ffmpeg.
* Returns null if ffmpeg is not available or the extraction fails.
*/
async function extractWithFfmpeg(
audioUrl: string,
resolution: number,
): Promise<number[] | null> {
try {
if (!Bun.which("ffmpeg")) return null;
// Use ffmpeg to output raw PCM samples, then downsample to `resolution` points.
// -t 300: read at most 5 minutes (enough data to fill the waveform)
const proc = Bun.spawn(
[
"ffmpeg",
"-i",
audioUrl,
"-t",
"300",
"-ac",
"1", // mono
"-ar",
"8000", // low sample rate to keep data small
"-f",
"s16le", // raw signed 16-bit PCM
"-v",
"quiet",
"-",
],
{ stdout: "pipe", stderr: "ignore" },
);
const output = await new Response(proc.stdout).arrayBuffer();
await proc.exited;
if (output.byteLength === 0) return null;
const samples = new Int16Array(output);
if (samples.length === 0) return null;
// Downsample to `resolution` buckets by taking the max absolute amplitude
// in each bucket.
const bucketSize = Math.max(1, Math.floor(samples.length / resolution));
const data: number[] = [];
for (let i = 0; i < resolution; i++) {
const start = i * bucketSize;
const end = Math.min(start + bucketSize, samples.length);
let maxAbs = 0;
for (let j = start; j < end; j++) {
const abs = Math.abs(samples[j]);
if (abs > maxAbs) maxAbs = abs;
}
// Normalise to 0-1
data.push(Number((maxAbs / 32768).toFixed(3)));
}
return data;
} catch {
return null;
}
}
/**
* Get waveform data for an audio URL.
*
* Returns cached data if available, otherwise attempts ffmpeg extraction
*/
export async function getWaveformData(
audioUrl: string,
resolution: number = DEFAULT_RESOLUTION,
): Promise<number[]> {
const cacheKey = `${audioUrl}:${resolution}`;
const cached = waveformCache.get(cacheKey);
if (cached) return cached;
const real = await extractWithFfmpeg(audioUrl, resolution);
if (real) {
waveformCache.set(cacheKey, real);
return real;
} else {
console.error("generation failure");
return [];
}
}
export function clearWaveformCache(): void {
waveformCache.clear();
}

View File

@@ -1,57 +0,0 @@
type CacheEntry<T> = {
value: T
timestamp: number
}
const CACHE_KEY = "podtui_cache"
const DEFAULT_TTL = 1000 * 60 * 60
const loadCache = (): Record<string, CacheEntry<unknown>> => {
if (typeof localStorage === "undefined") return {}
try {
const raw = localStorage.getItem(CACHE_KEY)
return raw ? (JSON.parse(raw) as Record<string, CacheEntry<unknown>>) : {}
} catch {
return {}
}
}
const saveCache = (cache: Record<string, CacheEntry<unknown>>) => {
if (typeof localStorage === "undefined") return
try {
localStorage.setItem(CACHE_KEY, JSON.stringify(cache))
} catch {
// ignore
}
}
const cache = loadCache()
export const cacheValue = <T,>(key: string, value: T) => {
cache[key] = { value, timestamp: Date.now() }
saveCache(cache)
}
export const getCachedValue = <T,>(key: string, ttl = DEFAULT_TTL): T | null => {
const entry = cache[key] as CacheEntry<T> | undefined
if (!entry) return null
if (Date.now() - entry.timestamp > ttl) {
delete cache[key]
saveCache(cache)
return null
}
return entry.value
}
export const invalidateCache = (prefix?: string) => {
if (!prefix) {
Object.keys(cache).forEach((key) => delete cache[key])
saveCache(cache)
return
}
Object.keys(cache)
.filter((key) => key.startsWith(prefix))
.forEach((key) => delete cache[key])
saveCache(cache)
}

View File

@@ -106,7 +106,7 @@ export namespace Clipboard {
/**
* Read text from the clipboard.
*/
export async function readText(): Promise<string | undefined> {
async function readText(): Promise<string | undefined> {
const os = platform()
if (os === "darwin") {

View File

@@ -13,7 +13,7 @@ import path from "path"
const APP_DIR_NAME = "podtui"
/** Resolve the XDG_CONFIG_HOME directory, defaulting to ~/.config */
export function getXdgConfigHome(): string {
function getXdgConfigHome(): string {
const xdg = process.env.XDG_CONFIG_HOME
if (xdg) return xdg
@@ -44,7 +44,7 @@ export async function ensureConfigDir(): Promise<string> {
}
/** Resolve the XDG_DATA_HOME directory, defaulting to ~/.local/share */
export function getXdgDataHome(): string {
function getXdgDataHome(): string {
const xdg = process.env.XDG_DATA_HOME
if (xdg) return xdg
@@ -55,12 +55,12 @@ export function getXdgDataHome(): string {
}
/** Get the application-specific data directory path */
export function getDataDir(): string {
function getDataDir(): string {
return path.join(getXdgDataHome(), APP_DIR_NAME)
}
/** Get the downloads directory path */
export function getDownloadsDir(): string {
function getDownloadsDir(): string {
return path.join(getDataDir(), "downloads")
}

View File

@@ -1,150 +0,0 @@
/**
* Validates JSON structure of config files, handles corrupted files
* gracefully (falling back to defaults), and provides a single
*/
import { getConfigFilePath } from "./config-dir";
// --- Validation helpers ---
/** Check that a value is a non-null object */
function isObject(v: unknown): v is Record<string, unknown> {
return v !== null && typeof v === "object" && !Array.isArray(v);
}
/** Validate AppState JSON structure */
export function validateAppState(data: unknown): {
valid: boolean;
errors: string[];
} {
const errors: string[] = [];
if (!isObject(data)) {
return { valid: false, errors: ["app-state.json is not an object"] };
}
// settings
if (data.settings !== undefined) {
if (!isObject(data.settings)) {
errors.push("settings must be an object");
} else {
const s = data.settings as Record<string, unknown>;
if (s.theme !== undefined && typeof s.theme !== "string")
errors.push("settings.theme must be a string");
if (s.fontSize !== undefined && typeof s.fontSize !== "number")
errors.push("settings.fontSize must be a number");
if (s.playbackSpeed !== undefined && typeof s.playbackSpeed !== "number")
errors.push("settings.playbackSpeed must be a number");
if (s.downloadPath !== undefined && typeof s.downloadPath !== "string")
errors.push("settings.downloadPath must be a string");
}
}
// preferences
if (data.preferences !== undefined) {
if (!isObject(data.preferences)) {
errors.push("preferences must be an object");
} else {
const p = data.preferences as Record<string, unknown>;
if (p.showExplicit !== undefined && typeof p.showExplicit !== "boolean")
errors.push("preferences.showExplicit must be a boolean");
if (p.autoDownload !== undefined && typeof p.autoDownload !== "boolean")
errors.push("preferences.autoDownload must be a boolean");
}
}
// customTheme
if (data.customTheme !== undefined && !isObject(data.customTheme)) {
errors.push("customTheme must be an object");
}
return { valid: errors.length === 0, errors };
}
/** Validate feeds JSON structure */
export function validateFeeds(data: unknown): {
valid: boolean;
errors: string[];
} {
const errors: string[] = [];
if (!Array.isArray(data)) {
return { valid: false, errors: ["feeds.json is not an array"] };
}
for (let i = 0; i < data.length; i++) {
const feed = data[i];
if (!isObject(feed)) {
errors.push(`feeds[${i}] is not an object`);
continue;
}
if (typeof feed.id !== "string")
errors.push(`feeds[${i}].id must be a string`);
if (!isObject(feed.podcast))
errors.push(`feeds[${i}].podcast must be an object`);
if (!Array.isArray(feed.episodes))
errors.push(`feeds[${i}].episodes must be an array`);
}
return { valid: errors.length === 0, errors };
}
/** Validate progress JSON structure */
export function validateProgress(data: unknown): {
valid: boolean;
errors: string[];
} {
const errors: string[] = [];
if (!isObject(data)) {
return { valid: false, errors: ["progress.json is not an object"] };
}
for (const [key, value] of Object.entries(data)) {
if (!isObject(value)) {
errors.push(`progress["${key}"] is not an object`);
continue;
}
const p = value as Record<string, unknown>;
if (typeof p.episodeId !== "string")
errors.push(`progress["${key}"].episodeId must be a string`);
if (typeof p.position !== "number")
errors.push(`progress["${key}"].position must be a number`);
if (typeof p.duration !== "number")
errors.push(`progress["${key}"].duration must be a number`);
}
return { valid: errors.length === 0, errors };
}
// --- Safe config file reading ---
/**
* Safely read and validate a config file.
* Returns the parsed data if valid, or null if the file is missing/corrupt.
*/
export async function safeReadConfigFile<T>(
filename: string,
validator: (data: unknown) => { valid: boolean; errors: string[] },
): Promise<{ data: T | null; errors: string[] }> {
try {
const filePath = getConfigFilePath(filename);
const file = Bun.file(filePath);
if (!(await file.exists())) {
return { data: null, errors: [] };
}
const text = await file.text();
let parsed: unknown;
try {
parsed = JSON.parse(text);
} catch {
return { data: null, errors: [`${filename}: invalid JSON`] };
}
const result = validator(parsed);
if (!result.valid) {
return { data: null, errors: result.errors };
}
return { data: parsed as T, errors: [] };
} catch (err) {
return { data: null, errors: [`${filename}: ${String(err)}`] };
}
}

View File

@@ -73,11 +73,6 @@ export function updateConfig(patch: Partial<PodTuiConfig>): void {
});
}
/** Await all pending config writes (used by sync/export flows). */
export async function flushConfig(): Promise<void> {
await writeChain;
}
/** Guards so migration runs exactly once per process. */
let migrationDone = false;
let migrationPromise: Promise<void> | null = null;
@@ -98,7 +93,7 @@ async function migrateOnce(): Promise<void> {
* Safe to call on every startup — no-op once config.json exists (except for
* backup cleanup, which runs unconditionally since those files are now dead).
*/
export async function migrateLegacyConfig(): Promise<void> {
async function migrateLegacyConfig(): Promise<void> {
try {
await ensureConfigDir();
const dir = getConfigDir();

View File

@@ -1,57 +0,0 @@
import { FeedVisibility } from "../types/feed"
import type { Feed } from "../types/feed"
import type { Episode } from "../types/episode"
import type { Podcast } from "../types/podcast"
import { cacheValue, getCachedValue } from "./cache"
import { fetchEpisodes } from "@/api/client"
const feedKey = (feedUrl: string) => `feed:${feedUrl}`
const episodesKey = (feedUrl: string) => `episodes:${feedUrl}`
const searchKey = (query: string) => `search:${query.toLowerCase()}`
export const fetchFeedWithCache = async (feedUrl: string): Promise<Feed | null> => {
const cached = getCachedValue<Feed>(feedKey(feedUrl))
if (cached) return cached
try {
const episodes = await fetchEpisodes(feedUrl)
const feed: Feed = {
id: feedUrl,
podcast: {
id: feedUrl,
title: feedUrl,
description: "",
feedUrl,
lastUpdated: new Date(),
isSubscribed: true,
},
episodes,
visibility: FeedVisibility.PUBLIC,
sourceId: "rss",
lastUpdated: new Date(),
isPinned: false,
}
cacheValue(feedKey(feedUrl), feed)
return feed
} catch {
return null
}
}
export const fetchEpisodesWithCache = async (feedUrl: string): Promise<Episode[]> => {
const cached = getCachedValue<Episode[]>(episodesKey(feedUrl))
if (cached) return cached
const episodes = await fetchEpisodes(feedUrl)
cacheValue(episodesKey(feedUrl), episodes)
return episodes
}
export const searchWithCache = async (
query: string,
fetcher: () => Promise<Podcast[]>
): Promise<Podcast[]> => {
const cached = getCachedValue<Podcast[]>(searchKey(query))
if (cached) return cached
const results = await fetcher()
cacheValue(searchKey(query), results)
return results
}

View File

@@ -79,7 +79,7 @@ export const PAGE_ACTIONS: ReadonlySet<KeybindActionName> =
]);
/** Resolve a `tab-goto-N` digit action (1..TabsCount) to a TABS value, or null. */
export function tabByDigit(action: KeybindActionName): TABS | null {
function tabByDigit(action: KeybindActionName): TABS | null {
if (action.startsWith("tab-goto-")) {
const n = Number(action.slice("tab-goto-".length));
return (n >= 1 && n <= TabsCount ? n : null) as TABS | null;

View File

@@ -87,7 +87,7 @@ function createEventBus(): EventBusInstance {
}
// Singleton event bus instance
export const EventBus = createEventBus();
const EventBus = createEventBus();
import type { KeybindActionName } from "@/context/KeybindContext";
import type { TABS } from "@/utils/navigation";
@@ -105,6 +105,8 @@ export type AppEvents = {
"player.play": { episodeId: string };
"player.pause": { episodeId: string };
"player.stop": {};
// Emitted when a NEW episode begins playback (not on resume).
"player.started": { episodeId: string };
"toast.show": {
message: string;
variant: "info" | "success" | "warning" | "error";

View File

@@ -8,7 +8,7 @@
/**
* Remove JSONC comments from a string
*/
export function stripComments(jsonString: string): string {
function stripComments(jsonString: string): string {
const comments = [
{ pattern: /\/\/.*$/gm, replacement: "" },
{ pattern: /\/\*[\s\S]*?\*\//g, replacement: "" },

View File

@@ -77,7 +77,6 @@ export async function copyKeybindsIfNeeded(): Promise<void> {
try {
const targetPath = getConfigFilePath(KEYBINDS_FILE);
// Check if file already exists
const targetFile = Bun.file(targetPath);
if (await targetFile.exists()) return;

View File

@@ -1,6 +1,5 @@
import { searchSourceByType } from "./source-searcher";
import type { PodcastSource, SearchResult } from "../types/source";
import type { Episode } from "../types/episode";
type SearchCacheEntry = {
timestamp: number;
@@ -114,61 +113,4 @@ export const searchPodcasts = async (
return sorted;
};
type ItunesEpisodeResult = {
trackId?: number;
trackName?: string;
description?: string;
shortDescription?: string;
releaseDate?: string;
trackTimeMillis?: number;
episodeUrl?: string;
previewUrl?: string;
trackViewUrl?: string;
};
type ItunesEpisodeResponse = {
resultCount: number;
results: ItunesEpisodeResult[];
};
export const searchEpisodes = async (
query: string,
feedId: string,
): Promise<Episode[]> => {
const trimmed = query.trim();
if (!trimmed) return [];
const url = new URL("https://itunes.apple.com/search");
url.searchParams.set("term", trimmed);
url.searchParams.set("media", "podcast");
url.searchParams.set("entity", "podcastEpisode");
url.searchParams.set("country", "US");
url.searchParams.set("lang", "en_us");
const response = await fetch(url.toString());
if (!response.ok) return [];
const data = (await response.json()) as ItunesEpisodeResponse;
return data.results
.map((item) => {
if (!item.trackName) return null;
const id = item.trackId
? `episode-${item.trackId}`
: `episode-${item.trackName}`;
const audioUrl =
item.episodeUrl || item.previewUrl || item.trackViewUrl || "";
return {
id,
podcastId: feedId,
title: item.trackName,
description: item.description || item.shortDescription || "",
audioUrl,
duration: item.trackTimeMillis
? Math.round(item.trackTimeMillis / 1000)
: 0,
pubDate: item.releaseDate ? new Date(item.releaseDate) : new Date(),
};
})
.filter((item): item is Episode => Boolean(item));
};

View File

@@ -85,7 +85,7 @@ const makeResults = (query: string, source: PodcastSource, seedOffset = 0): Sear
})
}
export const searchRSSSource = async (
const searchRSSSource = async (
query: string,
source: PodcastSource
): Promise<SearcherResult> => {
@@ -148,7 +148,7 @@ const mapItunesResult = (result: ItunesResult, source: PodcastSource): Podcast |
}
}
export const searchAPISource = async (
const searchAPISource = async (
query: string,
source: PodcastSource
): Promise<SearcherResult> => {
@@ -173,7 +173,7 @@ export const searchAPISource = async (
}))
}
export const searchCustomSource = async (
const searchCustomSource = async (
query: string,
source: PodcastSource
): Promise<SearcherResult> => {

View File

@@ -1,25 +0,0 @@
import type { SyncData } from "../types/sync-json"
import type { SyncDataXML } from "../types/sync-xml"
import { syncFormats } from "../constants/sync-formats"
const isObject = (value: unknown): value is { [key: string]: unknown } =>
typeof value === "object" && value !== null
const hasVersion = (value: unknown): value is { version: string } =>
isObject(value) && typeof value.version === "string"
export function validateJSONSync(data: unknown): SyncData {
if (!hasVersion(data) || data.version !== syncFormats.json.version) {
throw { message: "Unsupported sync format" }
}
return data as SyncData
}
export function validateXMLSync(data: unknown): SyncDataXML {
if (!hasVersion(data) || data.version !== syncFormats.xml.version) {
throw { message: "Unsupported sync format" }
}
return data as SyncDataXML
}

View File

@@ -1,60 +0,0 @@
import type { SyncData } from "../types/sync-json"
import type { SyncDataXML } from "../types/sync-xml"
import { validateJSONSync, validateXMLSync } from "./sync-validation"
import { syncFormats } from "../constants/sync-formats"
import { FeedVisibility } from "../types/feed"
export function exportToJSON(data: SyncData): string {
return `{\n "version": "${data.version}",\n "lastSyncedAt": "${data.lastSyncedAt}",\n "feeds": [],\n "sources": [],\n "settings": {\n "theme": "${data.settings.theme}",\n "playbackSpeed": ${data.settings.playbackSpeed},\n "downloadPath": "${data.settings.downloadPath}"\n },\n "preferences": {\n "showExplicit": ${data.preferences.showExplicit},\n "autoDownload": ${data.preferences.autoDownload}\n }\}`
}
export function importFromJSON(json: string): SyncData {
const data = json
return validateJSONSync(data as unknown)
}
export function exportToXML(data: SyncDataXML): string {
const feedItems = ""
const sourceItems = ""
return `<?xml version="1.0" encoding="UTF-8"?>\n` +
`<podcastSync version="${syncFormats.xml.version}">\n` +
` <lastSyncedAt>${data.lastSyncedAt}</lastSyncedAt>\n` +
` <feeds>\n` +
feedItems +
` </feeds>\n` +
` <sources>\n` +
sourceItems +
` </sources>\n` +
` <settings>\n` +
` <theme>${data.settings.theme}</theme>\n` +
` <playbackSpeed>${data.settings.playbackSpeed}</playbackSpeed>\n` +
` <downloadPath>${data.settings.downloadPath}</downloadPath>\n` +
` </settings>\n` +
` <preferences>\n` +
` <showExplicit>${data.preferences.showExplicit}</showExplicit>\n` +
` <autoDownload>${data.preferences.autoDownload}</autoDownload>\n` +
` </preferences>\n` +
`</podcastSync>`
}
export function importFromXML(xml: string): SyncDataXML {
const version = syncFormats.xml.version
const data = {
version,
lastSyncedAt: "",
feeds: { feed: [] },
sources: { source: [] },
settings: {
theme: "system",
playbackSpeed: 1,
downloadPath: "",
},
preferences: {
showExplicit: false,
autoDownload: false,
},
} as SyncDataXML
return validateXMLSync(data)
}

View File

@@ -13,15 +13,6 @@ export function clearPaletteCache() {
cached = null;
}
export function detectSystemTheme(colors: TerminalColors) {
const bg = RGBA.fromHex(
colors.defaultBackground ?? colors.palette[0] ?? "#000000",
);
const luminance = 0.299 * bg.r + 0.587 * bg.g + 0.114 * bg.b;
const mode = luminance > 0.5 ? "light" : "dark";
return { mode, background: bg };
}
export function generateSystemTheme(
colors: TerminalColors,
mode: "dark" | "light",

View File

@@ -1,36 +0,0 @@
import { RGBA } from "@opentui/core"
import type { ColorValue } from "../types/theme-schema"
const toCss = (value: ColorValue | RGBA) => {
if (value instanceof RGBA) {
const r = Math.round(value.r * 255)
const g = Math.round(value.g * 255)
const b = Math.round(value.b * 255)
return `rgba(${r}, ${g}, ${b}, ${value.a})`
}
if (typeof value === "number") return `var(--ansi-${value})`
if (typeof value === "string") return value
return value.dark
}
export function applyThemeToCSS(theme: Record<string, RGBA | ColorValue>) {
const root = document.documentElement
for (const [key, value] of Object.entries(theme)) {
if (key === "layerBackgrounds" && typeof value === "object") {
const layers = value as Record<string, RGBA | ColorValue>
for (const [layer, color] of Object.entries(layers)) {
root.style.setProperty(`--color-${layer}`, toCss(color))
}
} else {
root.style.setProperty(`--color-${key}`, toCss(value as ColorValue | RGBA))
}
}
}
export function setThemeAttribute(themeName: string) {
document.documentElement.setAttribute("data-theme", themeName)
}
export function resolveColorReference(value: ColorValue) {
return toCss(value)
}

View File

@@ -1,42 +1,4 @@
import path from "path"
import type { ThemeJson } from "../types/theme-schema"
import { THEME_JSON } from "../constants/themes"
export async function loadTheme(name: string) {
if (THEME_JSON[name]) return THEME_JSON[name]
const file = path.resolve(process.cwd(), "themes", `${name}.json`)
return loadThemeFromPath(file)
}
export async function loadThemeFromPath(file: string) {
const json = (await Bun.file(file).json()) as ThemeJson
validateTheme(json, file)
return json
}
export async function getAllThemes() {
return { ...THEME_JSON, ...(await getCustomThemes()) }
}
export async function getCustomThemes() {
const dirs = [
path.join(process.env.HOME ?? "", ".config/podtui/themes"),
path.resolve(process.cwd(), ".podtui/themes"),
path.resolve(process.cwd(), "themes"),
]
const result: Record<string, ThemeJson> = {}
for (const dir of dirs) {
const glob = new Bun.Glob("*.json")
for await (const item of glob.scan({ absolute: true, followSymlinks: true, cwd: dir })) {
const name = path.basename(item, ".json")
const json = (await Bun.file(item).json()) as ThemeJson
validateTheme(json, item)
result[name] = json
}
}
return result
}
export function validateTheme(theme: ThemeJson, source?: string) {
if (!theme || typeof theme !== "object") {

View File

@@ -7,40 +7,12 @@
* - Tracking theme change state
*/
import { emit, on, off, type EventHandler } from "./event-bus"
/**
* Subscribe to theme reload events.
* These are triggered by SIGUSR2 signals.
*/
export function onThemeReload(handler: EventHandler<{}>): () => void {
return on("theme.reload", handler)
}
/**
* Subscribe to theme changed events.
* These are triggered when the theme selection changes.
*/
export function onThemeChanged(
handler: EventHandler<{ theme: string; mode: "dark" | "light" }>
): () => void {
return on("theme.changed", handler)
}
/**
* Subscribe to theme mode changed events.
* These are triggered when switching between dark/light mode.
*/
export function onThemeModeChanged(
handler: EventHandler<{ mode: "dark" | "light" }>
): () => void {
return on("theme.mode.changed", handler)
}
import { emit } from "./event-bus"
/**
* Emit a theme reload event.
*/
export function emitThemeReload(): void {
function emitThemeReload(): void {
emit("theme.reload", {})
}
@@ -79,26 +51,3 @@ export function setupThemeSignalHandler(onReload: () => void): () => void {
process.off("SIGUSR2", handler)
}
}
/**
* Create a debounced theme change handler to prevent rapid consecutive updates.
*
* @param handler - The handler to debounce
* @param delay - Delay in milliseconds (default: 100ms)
*/
export function createDebouncedThemeHandler<T>(
handler: (event: T) => void,
delay: number = 100
): (event: T) => void {
let timeout: NodeJS.Timeout | null = null
return (event: T) => {
if (timeout) {
clearTimeout(timeout)
}
timeout = setTimeout(() => {
handler(event)
timeout = null
}, delay)
}
}

View File

@@ -3,67 +3,13 @@
* Handles dynamic theme switching by updating CSS custom properties
*/
import { RGBA, type TerminalColors } from "@opentui/core";
import type { ThemeColors } from "../types/settings";
import type { ColorValue, ThemeJson } from "../types/theme-schema";
import type { TerminalColors } from "@opentui/core";
import type { ThemeJson } from "../types/theme-schema";
import { THEME_JSON } from "../constants/themes";
import { getCustomThemes } from "./custom-themes";
import { resolveTheme as resolveThemeJson } from "./theme-resolver";
import { generateSystemTheme } from "./system-theme";
const toCss = (value: ColorValue | RGBA) => {
if (value instanceof RGBA) {
const r = Math.round(value.r * 255);
const g = Math.round(value.g * 255);
const b = Math.round(value.b * 255);
return `rgba(${r}, ${g}, ${b}, ${value.a})`;
}
if (typeof value === "number") return `var(--ansi-${value})`;
if (typeof value === "string") return value;
return value.dark;
};
export function applyTheme(theme: ThemeColors | Record<string, RGBA>) {
if (typeof document === "undefined") return;
const root = document.documentElement;
root.style.setProperty(
"--color-background",
toCss(theme.background as ColorValue),
);
root.style.setProperty("--color-surface", toCss(theme.surface as ColorValue));
root.style.setProperty("--color-primary", toCss(theme.primary as ColorValue));
root.style.setProperty(
"--color-secondary",
toCss(theme.secondary as ColorValue),
);
root.style.setProperty("--color-accent", toCss(theme.accent as ColorValue));
root.style.setProperty("--color-text", toCss(theme.text as ColorValue));
root.style.setProperty("--color-muted", toCss(theme.muted as ColorValue));
root.style.setProperty("--color-warning", toCss(theme.warning as ColorValue));
root.style.setProperty("--color-error", toCss(theme.error as ColorValue));
root.style.setProperty("--color-success", toCss(theme.success as ColorValue));
const layers = theme.layerBackgrounds as
| Record<string, ColorValue>
| undefined;
if (layers) {
root.style.setProperty("--color-layer0", toCss(layers.layer0));
root.style.setProperty("--color-layer1", toCss(layers.layer1));
root.style.setProperty("--color-layer2", toCss(layers.layer2));
root.style.setProperty("--color-layer3", toCss(layers.layer3));
}
}
/**
* Get theme mode from system preference
*/
export function getSystemThemeMode(): "dark" | "light" {
if (typeof window === "undefined") return "dark";
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
return prefersDark ? "dark" : "light";
}
/**
* Apply CSS variable data-theme attribute
*/
@@ -77,15 +23,6 @@ export async function loadThemes() {
return await getCustomThemes();
}
export async function loadTheme(name: string) {
const themes = await loadThemes();
return themes[name];
}
export function resolveTheme(theme: ThemeJson, mode: "dark" | "light") {
return resolveThemeJson(theme, mode);
}
export function resolveTerminalTheme(
themes: Record<string, ThemeJson>,
name: string,