diff --git a/src/App.tsx b/src/App.tsx index 2721335..b70b6fe 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -11,7 +11,6 @@ import { SearchPage } from "./components/SearchPage"; import { DiscoverPage } from "./components/DiscoverPage"; import { Player } from "./components/Player"; import { SettingsScreen } from "./components/SettingsScreen"; -import { ThemeProvider } from "./context/ThemeContext"; import { useAuthStore } from "./stores/auth"; import { useFeedStore } from "./stores/feed"; import { useAppStore } from "./stores/app"; @@ -185,16 +184,14 @@ export function App() { }; return ( - - - } - footer={} - > - {renderContent()} - - + + } + footer={} + > + {renderContent()} + ); } diff --git a/src/components/LayerIndicator.tsx b/src/components/LayerIndicator.tsx index a286041..507c871 100644 --- a/src/components/LayerIndicator.tsx +++ b/src/components/LayerIndicator.tsx @@ -1,13 +1,13 @@ -import { useRenderer } from "@opentui/solid" +import { useTheme } from "../context/ThemeContext" export function LayerIndicator({ layerDepth }: { layerDepth: number }) { - const renderer = useRenderer() + const { theme } = useTheme() const getLayerIndicator = () => { const indicators = [] for (let i = 0; i < 4; i++) { const isActive = i <= layerDepth - const color = isActive ? "var(--color-accent)" : "var(--color-muted)" + const color = isActive ? theme.accent : theme.textMuted const size = isActive ? "●" : "○" indicators.push( @@ -20,9 +20,9 @@ export function LayerIndicator({ layerDepth }: { layerDepth: number }) { return ( - Depth: + Depth: {getLayerIndicator()} - + {layerDepth} diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index 8fcc0f0..3265702 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -1,41 +1,34 @@ import type { JSX } from "solid-js" -import type { ThemeColors } from "../types/settings" -import type { ColorValue } from "../types/theme-schema" -import { resolveColorReference } from "../utils/theme-css" +import type { RGBA } from "@opentui/core" +import { useTheme } from "../context/ThemeContext" import { LayerIndicator } from "./LayerIndicator" type LayerConfig = { depth: number - background: string + background: RGBA } type LayoutProps = { header?: JSX.Element footer?: JSX.Element children?: JSX.Element - theme?: ThemeColors layerDepth?: number } export function Layout(props: LayoutProps) { - const theme = props.theme - const toColor = (value?: ColorValue) => (value ? resolveColorReference(value) : undefined) + const { theme } = useTheme() // Get layer configuration based on depth const getLayerConfig = (depth: number): LayerConfig => { - if (!theme?.layerBackgrounds) { - return { depth: 0, background: "transparent" } - } - const backgrounds = theme.layerBackgrounds const depthMap: Record = { - 0: { depth: 0, background: resolveColorReference(backgrounds.layer0) }, - 1: { depth: 1, background: resolveColorReference(backgrounds.layer1) }, - 2: { depth: 2, background: resolveColorReference(backgrounds.layer2) }, - 3: { depth: 3, background: resolveColorReference(backgrounds.layer3) }, + 0: { depth: 0, background: backgrounds?.layer0 ?? theme.background }, + 1: { depth: 1, background: backgrounds?.layer1 ?? theme.backgroundPanel }, + 2: { depth: 2, background: backgrounds?.layer2 ?? theme.backgroundElement }, + 3: { depth: 3, background: backgrounds?.layer3 ?? theme.backgroundMenu }, } - return depthMap[depth] || { depth: 0, background: "transparent" } + return depthMap[depth] || { depth: 0, background: theme.background } } // Get current layer background @@ -46,14 +39,14 @@ export function Layout(props: LayoutProps) { flexDirection="column" width="100%" height="100%" - backgroundColor={toColor(theme?.background)} + backgroundColor={theme.background} > {/* Header */} {props.header ? ( @@ -83,7 +76,7 @@ export function Layout(props: LayoutProps) { @@ -99,7 +92,7 @@ export function Layout(props: LayoutProps) { diff --git a/src/components/LoginScreen.tsx b/src/components/LoginScreen.tsx index e43c237..5628b0b 100644 --- a/src/components/LoginScreen.tsx +++ b/src/components/LoginScreen.tsx @@ -5,6 +5,7 @@ import { createSignal } from "solid-js" import { useAuthStore } from "../stores/auth" +import { useTheme } from "../context/ThemeContext" import { AUTH_CONFIG } from "../config/auth" interface LoginScreenProps { @@ -17,6 +18,7 @@ type FocusField = "email" | "password" | "submit" | "code" | "oauth" export function LoginScreen(props: LoginScreenProps) { const auth = useAuthStore() + const { theme } = useTheme() const [email, setEmail] = createSignal("") const [password, setPassword] = createSignal("") const [focusField, setFocusField] = createSignal("email") @@ -90,7 +92,7 @@ export function LoginScreen(props: LoginScreenProps) { {/* Email field */} - Email: + Email: {emailError() && ( - {emailError()} + {emailError()} )} {/* Password field */} - + Password: {passwordError() && ( - {passwordError()} + {passwordError()} )} @@ -127,9 +129,9 @@ export function LoginScreen(props: LoginScreenProps) { - + {auth.isLoading ? "Signing in..." : "[Enter] Sign In"} @@ -137,21 +139,21 @@ export function LoginScreen(props: LoginScreenProps) { {/* Auth error message */} {auth.error && ( - {auth.error.message} + {auth.error.message} )} {/* Alternative auth options */} - Or authenticate with: + Or authenticate with: - + [C] Sync Code @@ -159,9 +161,9 @@ export function LoginScreen(props: LoginScreenProps) { - + [O] OAuth Info @@ -169,7 +171,7 @@ export function LoginScreen(props: LoginScreenProps) { - Tab to navigate, Enter to select + Tab to navigate, Enter to select ) } diff --git a/src/components/PreferencesPanel.tsx b/src/components/PreferencesPanel.tsx index 509a2ea..1a79bca 100644 --- a/src/components/PreferencesPanel.tsx +++ b/src/components/PreferencesPanel.tsx @@ -1,6 +1,7 @@ import { createSignal } from "solid-js" import { useKeyboard } from "@opentui/solid" import { useAppStore } from "../stores/app" +import { useTheme } from "../context/ThemeContext" import type { ThemeName } from "../types/settings" type FocusField = "theme" | "font" | "speed" | "explicit" | "auto" @@ -16,6 +17,7 @@ const THEME_LABELS: Array<{ value: ThemeName; label: string }> = [ export function PreferencesPanel() { const appStore = useAppStore() + const { theme } = useTheme() const [focusField, setFocusField] = createSignal("theme") const settings = () => appStore.state().settings @@ -76,55 +78,55 @@ export function PreferencesPanel() { return ( - Preferences + Preferences - Theme: + Theme: - {THEME_LABELS.find((t) => t.value === settings().theme)?.label} + {THEME_LABELS.find((t) => t.value === settings().theme)?.label} - [Left/Right] + [Left/Right] - Font Size: + Font Size: - {settings().fontSize}px + {settings().fontSize}px - [Left/Right] + [Left/Right] - Playback: + Playback: - {settings().playbackSpeed}x + {settings().playbackSpeed}x - [Left/Right] + [Left/Right] - Show Explicit: + Show Explicit: - + {preferences().showExplicit ? "On" : "Off"} - [Space] + [Space] - Auto Download: + Auto Download: - + {preferences().autoDownload ? "On" : "Off"} - [Space] + [Space] - Tab to move focus, Left/Right to adjust + Tab to move focus, Left/Right to adjust ) } diff --git a/src/components/SettingsScreen.tsx b/src/components/SettingsScreen.tsx index d340aa1..3a0e8c7 100644 --- a/src/components/SettingsScreen.tsx +++ b/src/components/SettingsScreen.tsx @@ -1,6 +1,7 @@ import { createSignal } from "solid-js" import { useKeyboard } from "@opentui/solid" import { SourceManager } from "./SourceManager" +import { useTheme } from "../context/ThemeContext" import { PreferencesPanel } from "./PreferencesPanel" import { SyncPanel } from "./SyncPanel" @@ -21,6 +22,7 @@ const SECTIONS: Array<{ id: SectionId; label: string }> = [ ] export function SettingsScreen(props: SettingsScreenProps) { + const { theme } = useTheme() const [activeSection, setActiveSection] = createSignal("sync") useKeyboard((key) => { @@ -50,7 +52,7 @@ export function SettingsScreen(props: SettingsScreenProps) { Settings - [Tab] Switch section | 1-4 jump | Esc up + [Tab] Switch section | 1-4 jump | Esc up @@ -58,10 +60,10 @@ export function SettingsScreen(props: SettingsScreenProps) { setActiveSection(section.id)} > - + [{index + 1}] {section.label} @@ -74,21 +76,21 @@ export function SettingsScreen(props: SettingsScreenProps) { {activeSection() === "preferences" && } {activeSection() === "account" && ( - Account + Account - Status: - + Status: + {props.accountLabel} props.onOpenAccount?.()}> - [A] Manage Account + [A] Manage Account )} - Enter to dive | Esc up + Enter to dive | Esc up ) } diff --git a/src/components/SourceManager.tsx b/src/components/SourceManager.tsx index 903c75a..3da0177 100644 --- a/src/components/SourceManager.tsx +++ b/src/components/SourceManager.tsx @@ -5,6 +5,7 @@ import { createSignal, For } from "solid-js" import { useFeedStore } from "../stores/feed" +import { useTheme } from "../context/ThemeContext" import { SourceType } from "../types/source" import type { PodcastSource } from "../types/source" @@ -17,6 +18,7 @@ type FocusArea = "list" | "add" | "url" | "country" | "explicit" | "language" export function SourceManager(props: SourceManagerProps) { const feedStore = useFeedStore() + const { theme } = useTheme() const [selectedIndex, setSelectedIndex] = createSignal(0) const [focusArea, setFocusArea] = createSignal("list") const [newSourceUrl, setNewSourceUrl] = createSignal("") @@ -155,15 +157,15 @@ export function SourceManager(props: SourceManagerProps) { Podcast Sources - [Esc] Close + [Esc] Close - Manage where to search for podcasts + Manage where to search for podcasts {/* Source list */} - Sources: + Sources: {(source, index) => ( @@ -173,7 +175,7 @@ export function SourceManager(props: SourceManagerProps) { padding={0} backgroundColor={ focusArea() === "list" && index() === selectedIndex() - ? "var(--color-primary)" + ? theme.primary : undefined } onMouseDown={() => { @@ -184,21 +186,21 @@ export function SourceManager(props: SourceManagerProps) { > {focusArea() === "list" && index() === selectedIndex() ? ">" : " "} - + {source.enabled ? "[x]" : "[ ]"} - {getSourceIcon(source)} + {getSourceIcon(source)} @@ -208,54 +210,54 @@ export function SourceManager(props: SourceManagerProps) { )} - Space/Enter to toggle, d to delete, a to add + Space/Enter to toggle, d to delete, a to add {/* API settings */} - + {isApiSource() ? "API Settings" : "API Settings (select an API source)"} - + Country: {sourceCountry()} - + Language: {sourceLanguage() === "ja_jp" ? "Japanese" : "English"} - + Explicit: {sourceExplicit() ? "Yes" : "No"} - Enter/Space to toggle focused setting + Enter/Space to toggle focused setting {/* Add new source form */} - + Add New Source: - Name: + Name: - URL: + URL: { @@ -285,16 +287,16 @@ export function SourceManager(props: SourceManagerProps) { width={15} onMouseDown={handleAddSource} > - [+] Add Source + [+] Add Source {/* Error message */} {error() && ( - {error()} + {error()} )} - Tab to switch sections, Esc to close + Tab to switch sections, Esc to close ) } diff --git a/src/components/Tab.tsx b/src/components/Tab.tsx index 4053333..9e078b6 100644 --- a/src/components/Tab.tsx +++ b/src/components/Tab.tsx @@ -1,3 +1,5 @@ +import { useTheme } from "../context/ThemeContext" + export type TabId = "discover" | "feeds" | "search" | "player" | "settings" export type TabDefinition = { @@ -20,11 +22,12 @@ type TabProps = { } export function Tab(props: TabProps) { + const { theme } = useTheme() return ( props.onSelect(props.tab.id)} - style={{ padding: 1, backgroundColor: props.active ? "var(--color-primary)" : "transparent" }} + style={{ padding: 1, backgroundColor: props.active ? theme.primary : "transparent" }} > {props.active ? "[" : " "} diff --git a/src/context/ThemeContext.tsx b/src/context/ThemeContext.tsx index 7c31111..5da095e 100644 --- a/src/context/ThemeContext.tsx +++ b/src/context/ThemeContext.tsx @@ -1,4 +1,4 @@ -import { createContext, createEffect, createMemo, createSignal, useContext } from "solid-js" +import { createContext, createEffect, createMemo, createSignal, Show, useContext } from "solid-js" import { createStore, produce } from "solid-js/store" import { useRenderer } from "@opentui/solid" import type { ThemeName } from "../types/settings" @@ -7,13 +7,76 @@ import { useAppStore } from "../stores/app" import { THEME_JSON } from "../constants/themes" import { resolveTheme } from "../utils/theme-resolver" import { generateSyntax, generateSubtleSyntax } from "../utils/syntax-highlighter" -import { generateSystemTheme } from "../utils/system-theme" -import { getCustomThemes } from "../utils/custom-themes" -import { setThemeAttribute } from "../utils/theme" -import type { RGBA } from "@opentui/core" +import { resolveTerminalTheme, loadThemes } from "../utils/theme" +import type { RGBA, TerminalColors } from "@opentui/core" + +type ThemeResolved = { + primary: RGBA + secondary: RGBA + accent: RGBA + error: RGBA + warning: RGBA + success: RGBA + info: RGBA + text: RGBA + textMuted: RGBA + selectedListItemText: RGBA + background: RGBA + backgroundPanel: RGBA + backgroundElement: RGBA + backgroundMenu: RGBA + border: RGBA + borderActive: RGBA + borderSubtle: RGBA + diffAdded: RGBA + diffRemoved: RGBA + diffContext: RGBA + diffHunkHeader: RGBA + diffHighlightAdded: RGBA + diffHighlightRemoved: RGBA + diffAddedBg: RGBA + diffRemovedBg: RGBA + diffContextBg: RGBA + diffLineNumber: RGBA + diffAddedLineNumberBg: RGBA + diffRemovedLineNumberBg: RGBA + markdownText: RGBA + markdownHeading: RGBA + markdownLink: RGBA + markdownLinkText: RGBA + markdownCode: RGBA + markdownBlockQuote: RGBA + markdownEmph: RGBA + markdownStrong: RGBA + markdownHorizontalRule: RGBA + markdownListItem: RGBA + markdownListEnumeration: RGBA + markdownImage: RGBA + markdownImageText: RGBA + markdownCodeBlock: RGBA + syntaxComment: RGBA + syntaxKeyword: RGBA + syntaxFunction: RGBA + syntaxVariable: RGBA + syntaxString: RGBA + syntaxNumber: RGBA + syntaxType: RGBA + syntaxOperator: RGBA + syntaxPunctuation: RGBA + muted?: RGBA + surface?: RGBA + layerBackgrounds?: { + layer0: RGBA + layer1: RGBA + layer2: RGBA + layer3: RGBA + } + _hasSelectedListItemText?: boolean + thinkingOpacity?: number +} type ThemeContextValue = { - theme: Record + theme: ThemeResolved selected: () => string all: () => Record syntax: () => unknown @@ -31,13 +94,14 @@ export function ThemeProvider({ children }: { children: any }) { const renderer = useRenderer() const [ready, setReady] = createSignal(false) const [store, setStore] = createStore({ - themes: { ...THEME_JSON } as Record, + themes: {} as Record, mode: "dark" as "dark" | "light", active: appStore.state().settings.theme as ThemeName, + system: undefined as undefined | TerminalColors, }) const init = () => { - getCustomThemes() + loadThemes() .then((custom) => { setStore( produce((draft) => { @@ -52,26 +116,18 @@ export function ThemeProvider({ children }: { children: any }) { createEffect(() => { setStore("active", appStore.state().settings.theme) - setThemeAttribute(appStore.state().settings.theme) }) createEffect(() => { - if (store.active !== "system") return renderer .getPalette({ size: 16 }) - .then((colors) => { - setStore( - produce((draft) => { - draft.themes.system = generateSystemTheme(colors, store.mode) - }) - ) - }) + .then((colors) => setStore("system", colors)) .catch(() => {}) }) const values = createMemo(() => { - const theme = store.themes[store.active] ?? store.themes.opencode - return resolveTheme(theme, store.mode) + const themes = Object.keys(store.themes).length ? store.themes : THEME_JSON + return resolveTerminalTheme(themes, store.active, store.mode, store.system) }) const syntax = createMemo(() => generateSyntax(values() as unknown as Record)) @@ -80,11 +136,11 @@ export function ThemeProvider({ children }: { children: any }) { ) const context: ThemeContextValue = { - theme: new Proxy(values(), { - get(_target, prop) { - return values()[prop as keyof typeof values] - }, - }), + theme: new Proxy(values(), { + get(_target, prop) { + return values()[prop as keyof typeof values] + }, + }) as ThemeResolved, selected: () => store.active, all: () => store.themes, syntax, @@ -95,7 +151,11 @@ export function ThemeProvider({ children }: { children: any }) { ready, } - return {children} + return ( + + {children} + + ) } export function useTheme() { diff --git a/src/index.tsx b/src/index.tsx index 9d800cd..696c364 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,5 +1,10 @@ import { render } from "@opentui/solid" import { App } from "./App" +import { ThemeProvider } from "./context/ThemeContext" import "./styles/theme.css" -render(() => ) +render(() => ( + + + +)) diff --git a/src/utils/custom-themes.ts b/src/utils/custom-themes.ts index ee67acd..e5b62a5 100644 --- a/src/utils/custom-themes.ts +++ b/src/utils/custom-themes.ts @@ -1,23 +1,30 @@ import path from "path" +import { mkdir } from "fs/promises" import type { ThemeJson } from "../types/theme-schema" +import { THEME_JSON } from "../constants/themes" import { validateTheme } from "./theme-loader" 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 home = process.env.HOME ?? "" + if (!home) return {} + + const dir = path.join(home, ".config/podtui/themes") + await mkdir(dir, { recursive: true }) + + for (const [name, theme] of Object.entries(THEME_JSON)) { + const file = path.join(dir, `${name}.json`) + const exists = await Bun.file(file).exists() + if (exists) continue + await Bun.write(file, JSON.stringify(theme, null, 2)) + } const result: Record = {} - 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 - } + 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 } diff --git a/src/utils/theme-resolver.ts b/src/utils/theme-resolver.ts index 7389120..8c565b3 100644 --- a/src/utils/theme-resolver.ts +++ b/src/utils/theme-resolver.ts @@ -5,6 +5,9 @@ import { ansiToRgba } from "./ansi-to-rgba" export type ThemeMode = "dark" | "light" export function resolveTheme(theme: ThemeJson, mode: ThemeMode) { + if (!theme || !theme.theme) { + throw new Error("Invalid theme: missing theme object") + } const defs = theme.defs ?? {} function resolveColor(value: ColorValue): RGBA { @@ -38,8 +41,21 @@ export function resolveTheme(theme: ThemeJson, mode: ThemeMode) { const thinkingOpacity = theme.theme.thinkingOpacity ?? 0.6 + const background = resolved.background + const backgroundPanel = resolved.backgroundPanel ?? background + const backgroundElement = resolved.backgroundElement ?? backgroundPanel + const backgroundMenu = resolved.backgroundMenu ?? backgroundElement + return { ...resolved, + muted: resolved.textMuted ?? resolved.muted, + surface: resolved.backgroundPanel ?? resolved.surface, + layerBackgrounds: { + layer0: background, + layer1: backgroundPanel, + layer2: backgroundElement, + layer3: backgroundMenu, + }, _hasSelectedListItemText: hasSelected, thinkingOpacity, } diff --git a/src/utils/theme.ts b/src/utils/theme.ts index bc34b2e..0398986 100644 --- a/src/utils/theme.ts +++ b/src/utils/theme.ts @@ -3,9 +3,13 @@ * Handles dynamic theme switching by updating CSS custom properties */ -import { RGBA } from "@opentui/core" +import { RGBA, type TerminalColors } from "@opentui/core" import type { ThemeColors } from "../types/settings" -import type { ColorValue } from "../types/theme-schema" +import type { ColorValue, 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) { @@ -60,3 +64,32 @@ export function setThemeAttribute(themeName: string) { const root = document.documentElement root.setAttribute("data-theme", themeName) } + +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, + name: string, + mode: "dark" | "light", + system?: TerminalColors +) { + if (name === "system" && system) { + return resolveThemeJson(generateSystemTheme(system, mode), mode) + } + const theme = themes[name] ?? themes.opencode + if (!theme) { + return resolveThemeJson(THEME_JSON.opencode, mode) + } + return resolveThemeJson(theme, mode) +}