diff --git a/src/App.tsx b/src/App.tsx index ca6382e..633279a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -53,7 +53,9 @@ export function App() { // My Shows page returns panel renderers const myShows = MyShowsPage({ - get focused() { return activeTab() === "shows" && layerDepth() > 0 }, + get focused() { + return activeTab() === "shows" && layerDepth() > 0; + }, onPlayEpisode: (episode, feed) => { handlePlayEpisode(episode); }, @@ -69,8 +71,12 @@ export function App() { setActiveTab(tab); setInputFocused(false); }, - get inputFocused() { return inputFocused() }, - get navigationEnabled() { return layerDepth() === 0 }, + get inputFocused() { + return inputFocused(); + }, + get navigationEnabled() { + return layerDepth() === 0; + }, layerDepth, onLayerChange: (newDepth) => { setLayerDepth(newDepth); @@ -94,18 +100,20 @@ export function App() { // Copy selected text to clipboard when selection ends (mouse release) useSelectionHandler((selection: any) => { - if (!selection) return - const text = selection.getSelectedText?.() - if (!text || text.trim().length === 0) return + if (!selection) return; + const text = selection.getSelectedText?.(); + if (!text || text.trim().length === 0) return; - Clipboard.copy(text).then(() => { - emit("toast.show", { - message: "Copied to clipboard", - variant: "info", - duration: 1500, + Clipboard.copy(text) + .then(() => { + emit("toast.show", { + message: "Copied to clipboard", + variant: "info", + duration: 1500, + }); }) - }).catch(() => {}) - }) + .catch(() => {}); + }); const getPanels = createMemo(() => { const tab = activeTab(); @@ -156,19 +164,21 @@ export function App() { if (showAuthPanel()) { if (auth.isAuthenticated) { return { - panels: [{ - title: "Account", - content: ( - 0} - onLogout={() => { - auth.logout(); - setShowAuthPanel(false); - }} - onManageSync={() => setShowAuthPanel(false)} - /> - ), - }], + panels: [ + { + title: "Account", + content: ( + 0} + onLogout={() => { + auth.logout(); + setShowAuthPanel(false); + }} + onManageSync={() => setShowAuthPanel(false)} + /> + ), + }, + ], activePanelIndex: 0, hint: "Esc back", }; @@ -203,104 +213,121 @@ export function App() { }; return { - panels: [{ - title: "Sign In", - content: authContent(), - }], + panels: [ + { + title: "Sign In", + content: authContent(), + }, + ], activePanelIndex: 0, hint: "Esc back", }; } return { - panels: [{ - title: "Settings", - content: ( - setShowAuthPanel(true)} - accountLabel={ - auth.isAuthenticated - ? `Signed in as ${auth.user?.email}` - : "Not signed in" - } - accountStatus={auth.isAuthenticated ? "signed-in" : "signed-out"} - onExit={() => setLayerDepth(0)} - /> - ), - }], + panels: [ + { + title: "Settings", + content: ( + setShowAuthPanel(true)} + accountLabel={ + auth.isAuthenticated + ? `Signed in as ${auth.user?.email}` + : "Not signed in" + } + accountStatus={ + auth.isAuthenticated ? "signed-in" : "signed-out" + } + onExit={() => setLayerDepth(0)} + /> + ), + }, + ], activePanelIndex: 0, hint: "j/k navigate | Enter select | Esc back", }; case "discover": return { - panels: [{ - title: "Discover", - content: ( - 0} - onExit={() => setLayerDepth(0)} - /> - ), - }], + panels: [ + { + title: "Discover", + content: ( + 0} + onExit={() => setLayerDepth(0)} + /> + ), + }, + ], activePanelIndex: 0, hint: "Tab switch focus | j/k navigate | Enter subscribe | r refresh | Esc back", }; case "search": return { - panels: [{ - title: "Search", - content: ( - 0} - onInputFocusChange={setInputFocused} - onExit={() => setLayerDepth(0)} - onSubscribe={(result) => { - const feeds = feedStore.feeds(); - const alreadySubscribed = feeds.some( - (feed) => - feed.podcast.id === result.podcast.id || - feed.podcast.feedUrl === result.podcast.feedUrl, - ); - - if (!alreadySubscribed) { - feedStore.addFeed( - { ...result.podcast, isSubscribed: true }, - result.sourceId, - FeedVisibility.PUBLIC, + panels: [ + { + title: "Search", + content: ( + 0} + onInputFocusChange={setInputFocused} + onExit={() => setLayerDepth(0)} + onSubscribe={(result) => { + const feeds = feedStore.feeds(); + const alreadySubscribed = feeds.some( + (feed) => + feed.podcast.id === result.podcast.id || + feed.podcast.feedUrl === result.podcast.feedUrl, ); - } - }} - /> - ), - }], + + if (!alreadySubscribed) { + feedStore.addFeed( + { ...result.podcast, isSubscribed: true }, + result.sourceId, + FeedVisibility.PUBLIC, + ); + } + }} + /> + ), + }, + ], activePanelIndex: 0, hint: "Tab switch focus | / search | Enter select | Esc back", }; case "player": return { - panels: [{ - title: "Player", - content: ( - 0} onExit={() => setLayerDepth(0)} /> - ), - }], + panels: [ + { + title: "Player", + content: ( + 0} + onExit={() => setLayerDepth(0)} + /> + ), + }, + ], activePanelIndex: 0, hint: "Space play/pause | Esc back", }; default: return { - panels: [{ - title: tab, - content: ( - - Coming soon - - ), - }], + panels: [ + { + title: tab, + content: ( + + Coming soon + + ), + }, + ], activePanelIndex: 0, hint: "", }; @@ -308,24 +335,21 @@ export function App() { }); return ( - ( - - - Error: {err?.message ?? String(err)}{"\n"} - Press a number key (1-6) to switch tabs. - - - )}> + ( + + + Error: {err?.message ?? String(err)} + {"\n"} + Press a number key (1-6) to switch tabs. + + + )} + > } - footer={ - - - {getPanels().hint} - - } panels={getPanels().panels} activePanelIndex={getPanels().activePanelIndex} /> diff --git a/src/components/Tab.tsx b/src/components/Tab.tsx index 6797fd5..fc4d225 100644 --- a/src/components/Tab.tsx +++ b/src/components/Tab.tsx @@ -1,11 +1,17 @@ -import { useTheme } from "../context/ThemeContext" +import { useTheme } from "../context/ThemeContext"; -export type TabId = "feed" | "shows" | "discover" | "search" | "player" | "settings" +export type TabId = + | "feed" + | "shows" + | "discover" + | "search" + | "player" + | "settings"; export type TabDefinition = { - id: TabId - label: string -} + id: TabId; + label: string; +}; export const tabs: TabDefinition[] = [ { id: "feed", label: "Feed" }, @@ -14,27 +20,31 @@ export const tabs: TabDefinition[] = [ { id: "search", label: "Search" }, { id: "player", label: "Player" }, { id: "settings", label: "Settings" }, -] +]; type TabProps = { - tab: TabDefinition - active: boolean - onSelect: (tab: TabId) => void -} + tab: TabDefinition; + active: boolean; + onSelect: (tab: TabId) => void; +}; export function Tab(props: TabProps) { - const { theme } = useTheme() + const { theme } = useTheme(); return ( props.onSelect(props.tab.id)} - style={{ padding: 1, backgroundColor: props.active ? theme.primary : "transparent" }} + style={{ + padding: 1, + backgroundColor: props.active ? theme.primary : "transparent", + }} > - + {props.active ? "[" : " "} {props.tab.label} {props.active ? "]" : " "} - ) + ); } diff --git a/src/context/ThemeContext.tsx b/src/context/ThemeContext.tsx index 3179506..f5bd625 100644 --- a/src/context/ThemeContext.tsx +++ b/src/context/ThemeContext.tsx @@ -1,80 +1,91 @@ -import { createEffect, createMemo, onMount, onCleanup } from "solid-js" -import { createStore, produce } from "solid-js/store" -import { useRenderer } from "@opentui/solid" -import type { ThemeName } from "../types/settings" -import type { ThemeJson } from "../types/theme-schema" -import { useAppStore } from "../stores/app" -import { THEME_JSON } from "../constants/themes" -import { generateSyntax, generateSubtleSyntax } from "../utils/syntax-highlighter" -import { resolveTerminalTheme, loadThemes } from "../utils/theme" -import { createSimpleContext } from "./helper" -import { setupThemeSignalHandler, emitThemeChanged, emitThemeModeChanged } from "../utils/theme-observer" -import { createTerminalPalette, type RGBA, type TerminalColors } from "@opentui/core" +import { createEffect, createMemo, onMount, onCleanup } from "solid-js"; +import { createStore, produce } from "solid-js/store"; +import { useRenderer } from "@opentui/solid"; +import type { ThemeName } from "../types/settings"; +import type { ThemeJson } from "../types/theme-schema"; +import { useAppStore } from "../stores/app"; +import { THEME_JSON } from "../constants/themes"; +import { + generateSyntax, + generateSubtleSyntax, +} from "../utils/syntax-highlighter"; +import { resolveTerminalTheme, loadThemes } from "../utils/theme"; +import { createSimpleContext } from "./helper"; +import { + setupThemeSignalHandler, + emitThemeChanged, + emitThemeModeChanged, +} from "../utils/theme-observer"; +import { + createTerminalPalette, + type RGBA, + type 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 + 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 -} + layer0: RGBA; + layer1: RGBA; + layer2: RGBA; + layer3: RGBA; + }; + _hasSelectedListItemText?: boolean; + thinkingOpacity?: number; +}; /** * Theme context using the createSimpleContext pattern. @@ -89,88 +100,104 @@ type ThemeResolved = { export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ name: "Theme", init: (props: { mode: "dark" | "light" }) => { - const appStore = useAppStore() - const renderer = useRenderer() + const appStore = useAppStore(); + const renderer = useRenderer(); const [store, setStore] = createStore({ themes: THEME_JSON as Record, mode: props.mode, active: appStore.state().settings.theme as string, system: undefined as undefined | TerminalColors, ready: false, - }) + }); function init() { - resolveSystemTheme() + resolveSystemTheme(); loadThemes() .then((custom) => { setStore( produce((draft) => { - Object.assign(draft.themes, custom) - }) - ) + Object.assign(draft.themes, custom); + }), + ); }) .catch(() => { // If custom themes fail to load, fall back to opencode theme - setStore("active", "opencode") + setStore("active", "opencode"); }) .finally(() => { // Only set ready if not waiting for system theme if (store.active !== "system") { - setStore("ready", true) + setStore("ready", true); } - }) + }); } async function waitForCapabilities(timeoutMs = 300) { - if (renderer.capabilities) return + if (renderer.capabilities) return; await new Promise((resolve) => { - let done = false + let done = false; const onCaps = () => { - if (done) return - done = true - renderer.off("capabilities", onCaps) - clearTimeout(timer) - resolve() - } + if (done) return; + done = true; + renderer.off("capabilities", onCaps); + clearTimeout(timer); + resolve(); + }; const timer = setTimeout(() => { - if (done) return - done = true - renderer.off("capabilities", onCaps) - resolve() - }, timeoutMs) - renderer.on("capabilities", onCaps) - }) + if (done) return; + done = true; + renderer.off("capabilities", onCaps); + resolve(); + }, timeoutMs); + renderer.on("capabilities", onCaps); + }); } async function resolveSystemTheme() { if (process.env.TMUX) { - await waitForCapabilities() + await waitForCapabilities(); } - let colors: TerminalColors | null = null + let colors: TerminalColors | null = null; try { - colors = await renderer.getPalette({ size: 16 }) + colors = await renderer.getPalette({ size: 16 }); } catch { - colors = null + colors = null; } if (!colors?.palette?.[0] && process.env.TMUX) { - const writeOut = (renderer as unknown as { writeOut?: (data: string | Buffer) => boolean }).writeOut - const writeFn = typeof writeOut === "function" ? writeOut.bind(renderer) : process.stdout.write.bind(process.stdout) - const detector = createTerminalPalette(process.stdin, process.stdout, writeFn, true) + const writeOut = ( + renderer as unknown as { + writeOut?: (data: string | Buffer) => boolean; + } + ).writeOut; + const writeFn = + typeof writeOut === "function" + ? writeOut.bind(renderer) + : process.stdout.write.bind(process.stdout); + const detector = createTerminalPalette( + process.stdin, + process.stdout, + writeFn, + true, + ); try { - const tmuxColors = await detector.detect({ size: 16, timeout: 1200 }) + const tmuxColors = await detector.detect({ size: 16, timeout: 1200 }); if (tmuxColors?.palette?.[0]) { - colors = tmuxColors + colors = tmuxColors; } } finally { - detector.cleanup() + detector.cleanup(); } } - const hasPalette = Boolean(colors?.palette?.some((value) => Boolean(value))) - const hasDefaultColors = Boolean(colors?.defaultBackground || colors?.defaultForeground) + const hasPalette = Boolean( + colors?.palette?.some((value) => Boolean(value)), + ); + const hasDefaultColors = Boolean( + colors?.defaultBackground || colors?.defaultForeground, + ); if (!hasPalette && !hasDefaultColors) { // No system colors available, fall back to default @@ -179,89 +206,100 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ if (store.active === "system") { setStore( produce((draft) => { - draft.active = "opencode" - draft.ready = true - }) - ) + draft.active = "opencode"; + draft.ready = true; + }), + ); } - return + return; } if (colors) { setStore( produce((draft) => { - draft.system = colors + draft.system = colors; if (store.active === "system") { - draft.ready = true + draft.ready = true; } - }) - ) + }), + ); } } - onMount(init) + onMount(init); // Setup SIGUSR2 signal handler for dynamic theme reload // This allows external tools to trigger a theme refresh by sending: // `kill -USR2 ` const cleanupSignalHandler = setupThemeSignalHandler(() => { - renderer.clearPaletteCache() - init() - }) - onCleanup(cleanupSignalHandler) + renderer.clearPaletteCache(); + init(); + }); + onCleanup(cleanupSignalHandler); // Sync active theme with app store settings createEffect(() => { - const theme = appStore.state().settings.theme - if (theme) setStore("active", theme) - }) + const theme = appStore.state().settings.theme; + if (theme) setStore("active", theme); + }); // Emit theme change events for observers createEffect(() => { - const theme = store.active - const mode = store.mode + const theme = store.active; + const mode = store.mode; if (store.ready) { - emitThemeChanged(theme, mode) + emitThemeChanged(theme, mode); } - }) + }); const values = createMemo(() => { - return resolveTerminalTheme(store.themes, store.active, store.mode, store.system) - }) + return resolveTerminalTheme( + store.themes, + store.active, + store.mode, + store.system, + ); + }); - const syntax = createMemo(() => generateSyntax(values() as unknown as Record)) + const syntax = createMemo(() => + generateSyntax(values() as unknown as Record), + ); const subtleSyntax = createMemo(() => - generateSubtleSyntax(values() as unknown as Record & { thinkingOpacity?: number }) - ) + generateSubtleSyntax( + values() as unknown as Record & { + thinkingOpacity?: number; + }, + ), + ); return { theme: new Proxy(values(), { get(_target, prop) { // @ts-expect-error - dynamic property access - return values()[prop] + return values()[prop]; }, }) as ThemeResolved, get selected() { - return store.active + return store.active; }, all() { - return store.themes + return store.themes; }, syntax, subtleSyntax, mode() { - return store.mode + return store.mode; }, setMode(mode: "dark" | "light") { - setStore("mode", mode) - emitThemeModeChanged(mode) + setStore("mode", mode); + emitThemeModeChanged(mode); }, set(theme: string) { - appStore.setTheme(theme as ThemeName) + appStore.setTheme(theme as ThemeName); }, get ready() { - return store.ready + return store.ready; }, - } + }; }, -}) +}); diff --git a/src/utils/system-theme.ts b/src/utils/system-theme.ts index 2a0f06d..d9d4ee9 100644 --- a/src/utils/system-theme.ts +++ b/src/utils/system-theme.ts @@ -1,36 +1,49 @@ -import { RGBA, type TerminalColors } from "@opentui/core" -import { ansiToRgba } from "./ansi-to-rgba" -import { generateGrayScale, generateMutedTextColor, tint } from "./color-generation" -import type { ThemeJson } from "../types/theme-schema" +import { RGBA, type TerminalColors } from "@opentui/core"; +import { ansiToRgba } from "./ansi-to-rgba"; +import { + generateGrayScale, + generateMutedTextColor, + tint, +} from "./color-generation"; +import type { ThemeJson } from "../types/theme-schema"; -let cached: TerminalColors | null = null +let cached: TerminalColors | null = null; export function clearPaletteCache() { - cached = null + 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 } + 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"): ThemeJson { - cached = colors - const bg = RGBA.fromHex(colors.defaultBackground ?? colors.palette[0] ?? "#000000") - const fg = RGBA.fromHex(colors.defaultForeground ?? colors.palette[7] ?? "#ffffff") - const transparent = RGBA.fromInts(0, 0, 0, 0) - const isDark = mode === "dark" +export function generateSystemTheme( + colors: TerminalColors, + mode: "dark" | "light", +): ThemeJson { + cached = colors; + const bg = RGBA.fromHex( + colors.defaultBackground ?? colors.palette[0] ?? "#000000", + ); + const fg = RGBA.fromHex( + colors.defaultForeground ?? colors.palette[7] ?? "#ffffff", + ); + const transparent = RGBA.fromInts(0, 0, 0, 0); + const isDark = mode === "dark"; const col = (i: number) => { - const value = colors.palette[i] - if (value) return RGBA.fromHex(value) - return ansiToRgba(i) - } + const value = colors.palette[i]; + if (value) return RGBA.fromHex(value); + return ansiToRgba(i); + }; - const grays = generateGrayScale(bg, isDark) - const textMuted = generateMutedTextColor(bg, isDark) + const grays = generateGrayScale(bg, isDark); + const textMuted = generateMutedTextColor(bg, isDark); const ansi = { black: col(0), @@ -43,13 +56,13 @@ export function generateSystemTheme(colors: TerminalColors, mode: "dark" | "ligh white: col(7), redBright: col(9), greenBright: col(10), - } + }; - const diffAlpha = isDark ? 0.22 : 0.14 - const diffAddedBg = tint(bg, ansi.green, diffAlpha) - const diffRemovedBg = tint(bg, ansi.red, diffAlpha) - const diffAddedLineNumberBg = tint(grays[3], ansi.green, diffAlpha) - const diffRemovedLineNumberBg = tint(grays[3], ansi.red, diffAlpha) + const diffAlpha = isDark ? 0.22 : 0.14; + const diffAddedBg = tint(bg, ansi.green, diffAlpha); + const diffRemovedBg = tint(bg, ansi.red, diffAlpha); + const diffAddedLineNumberBg = tint(grays[3], ansi.green, diffAlpha); + const diffRemovedLineNumberBg = tint(grays[3], ansi.red, diffAlpha); return { theme: { @@ -68,7 +81,7 @@ export function generateSystemTheme(colors: TerminalColors, mode: "dark" | "ligh backgroundElement: grays[3], backgroundMenu: grays[3], borderSubtle: grays[6], - border: grays[7], + border: fg, borderActive: grays[8], diffAdded: ansi.green, diffRemoved: ansi.red, @@ -106,5 +119,5 @@ export function generateSystemTheme(colors: TerminalColors, mode: "dark" | "ligh syntaxOperator: ansi.cyan, syntaxPunctuation: fg, }, - } + }; }