diff --git a/.changeset/retry-theme-palette-reads.md b/.changeset/retry-theme-palette-reads.md new file mode 100644 index 0000000..aa8641e --- /dev/null +++ b/.changeset/retry-theme-palette-reads.md @@ -0,0 +1,6 @@ +--- +"@kitlangton/ghui": patch +--- + +retry terminal palette reads during theme reload signals and keep the existing +system theme when the terminal only returns incomplete palette data diff --git a/src/index.tsx b/src/index.tsx index 424286e..5604d95 100755 --- a/src/index.tsx +++ b/src/index.tsx @@ -5,7 +5,7 @@ import { createRoot, useRenderer, useTerminalDimensions } from "@opentui/react" import { Effect } from "effect" import { useEffect, useState } from "react" import { loadStoredSystemThemeAutoReload } from "./themeStore.js" -import { colors, setSystemThemeColors } from "./ui/colors.js" +import { colors, isHexColor, setSystemThemeColors } from "./ui/colors.js" import { LoadingLogoPane } from "./ui/LoadingLogo.js" import { SPINNER_INTERVAL_MS } from "./ui/spinner.js" @@ -32,6 +32,23 @@ type AppBundle = { } let notifySystemThemeReload = () => {} +let signalTimer: ReturnType | null = null +let reloadToken = 0 + +const SYSTEM_THEME_SIGNAL_DELAY_MS = 150 +const SYSTEM_THEME_RETRY_DELAYS_MS = [150, 300] as const + +const hasCompleteTerminalPalette = (terminalColors: { + readonly palette: readonly (string | null)[] + readonly defaultForeground: string | null + readonly defaultBackground: string | null +}) => + isHexColor(terminalColors.defaultForeground) && + isHexColor(terminalColors.defaultBackground) && + terminalColors.palette.length >= 16 && + terminalColors.palette.slice(0, 16).every(isHexColor) + +const sleep = (delayMs: number) => new Promise((resolve) => globalThis.setTimeout(resolve, delayMs)) const StartupLogo = () => { const startupRenderer = useRenderer() @@ -64,22 +81,55 @@ const renderer = await createCliRenderer({ }, }) -const reloadSystemThemeColors = async () => { +const readSystemThemeColors = () => { renderer.clearPaletteCache() - const terminalColors = await renderer.getPalette({ timeout: 150, size: 16 }) + return renderer.getPalette({ timeout: 150, size: 16 }) +} + +const readThemeColorsAfterRetries = async () => { + let palette: Awaited> | null = null + + const readNext = async () => { + const terminalColors = await readSystemThemeColors() + if (hasCompleteTerminalPalette(terminalColors)) palette = terminalColors + } + + await readNext() + for (const delayMs of SYSTEM_THEME_RETRY_DELAYS_MS) { + await sleep(delayMs) + await readNext() + } + + return palette +} + +const reloadSystemThemeColors = async () => { + const terminalColors = await readSystemThemeColors() setSystemThemeColors(terminalColors) renderer.setBackgroundColor(colors.background) notifySystemThemeReload() } -const reloadSystemThemeColorsFromSignal = async () => { +const reloadSystemThemeColorsFromSignal = async (token: number) => { const systemThemeAutoReload = await Effect.runPromise(loadStoredSystemThemeAutoReload) + if (token !== reloadToken) return if (!systemThemeAutoReload) return - await reloadSystemThemeColors() + const terminalColors = await readThemeColorsAfterRetries() + if (token !== reloadToken) return + if (terminalColors === null) return + setSystemThemeColors(terminalColors) + renderer.setBackgroundColor(colors.background) + notifySystemThemeReload() } process.on("SIGUSR2", () => { - void reloadSystemThemeColorsFromSignal().catch(() => {}) + reloadToken += 1 + const token = reloadToken + if (signalTimer !== null) globalThis.clearTimeout(signalTimer) + signalTimer = globalThis.setTimeout(() => { + signalTimer = null + void reloadSystemThemeColorsFromSignal(token).catch(() => {}) + }, SYSTEM_THEME_SIGNAL_DELAY_MS) }) const Bootstrap = () => { diff --git a/src/ui/colors.ts b/src/ui/colors.ts index 6a767ba..a29fe3f 100644 --- a/src/ui/colors.ts +++ b/src/ui/colors.ts @@ -85,7 +85,11 @@ interface TerminalThemeColors { readonly highlightForeground: string | null } -const readableHex = (value: string | null | undefined, fallback: string) => (typeof value === "string" && /^#[0-9a-fA-F]{6}(?:[0-9a-fA-F]{2})?$/.test(value) ? value : fallback) +const HEX_COLOR_RE = /^#[0-9a-fA-F]{6}(?:[0-9a-fA-F]{2})?$/ + +export const isHexColor = (value: string | null | undefined): value is string => typeof value === "string" && HEX_COLOR_RE.test(value) + +const readableHex = (value: string | null | undefined, fallback: string) => (isHexColor(value) ? value : fallback) const hexToRgb = (hex: string) => { const value = hex.replace(/^#/, "").slice(0, 6)