getting terminal colors working
This commit is contained in:
129
src/context/KeybindContext.tsx
Normal file
129
src/context/KeybindContext.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
import { createMemo } from "solid-js"
|
||||
import type { ParsedKey, Renderable } from "@opentui/core"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useKeyboard, useRenderer } from "@opentui/solid"
|
||||
import { createSimpleContext } from "./helper"
|
||||
import { Keybind, DEFAULT_KEYBINDS, type KeybindsConfig } from "../utils/keybind"
|
||||
|
||||
/**
|
||||
* Keybind context provider for managing keyboard shortcuts.
|
||||
*
|
||||
* Features:
|
||||
* - Leader key support (like vim's leader key)
|
||||
* - Configurable keybindings
|
||||
* - Key parsing and matching
|
||||
* - Display-friendly key representations
|
||||
*/
|
||||
export const { use: useKeybind, provider: KeybindProvider } = createSimpleContext({
|
||||
name: "Keybind",
|
||||
init: (props: { keybinds?: Partial<KeybindsConfig> }) => {
|
||||
// Merge default keybinds with custom keybinds
|
||||
const customKeybinds = props.keybinds ?? {}
|
||||
const mergedKeybinds = { ...DEFAULT_KEYBINDS, ...customKeybinds }
|
||||
|
||||
const keybinds = createMemo(() => {
|
||||
const result: Record<string, Keybind.Info[]> = {}
|
||||
for (const [key, value] of Object.entries(mergedKeybinds)) {
|
||||
result[key] = Keybind.parse(value)
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
const [store, setStore] = createStore({
|
||||
leader: false,
|
||||
})
|
||||
|
||||
const renderer = useRenderer()
|
||||
|
||||
let focus: Renderable | null = null
|
||||
let timeout: NodeJS.Timeout | undefined
|
||||
|
||||
function leader(active: boolean) {
|
||||
if (active) {
|
||||
setStore("leader", true)
|
||||
focus = renderer.currentFocusedRenderable
|
||||
focus?.blur()
|
||||
if (timeout) clearTimeout(timeout)
|
||||
timeout = setTimeout(() => {
|
||||
if (!store.leader) return
|
||||
leader(false)
|
||||
if (!focus || focus.isDestroyed) return
|
||||
focus.focus()
|
||||
}, 2000) // Leader key timeout
|
||||
return
|
||||
}
|
||||
|
||||
if (!active) {
|
||||
if (focus && !renderer.currentFocusedRenderable) {
|
||||
focus.focus()
|
||||
}
|
||||
setStore("leader", false)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle leader key
|
||||
useKeyboard(async (evt) => {
|
||||
if (!store.leader && result.match("leader", evt)) {
|
||||
leader(true)
|
||||
return
|
||||
}
|
||||
|
||||
if (store.leader && evt.name) {
|
||||
setImmediate(() => {
|
||||
if (focus && renderer.currentFocusedRenderable === focus) {
|
||||
focus.focus()
|
||||
}
|
||||
leader(false)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const result = {
|
||||
get all() {
|
||||
return keybinds()
|
||||
},
|
||||
get leader() {
|
||||
return store.leader
|
||||
},
|
||||
/**
|
||||
* Parse a keyboard event into a Keybind.Info.
|
||||
*/
|
||||
parse(evt: ParsedKey): Keybind.Info {
|
||||
// Handle special case for Ctrl+Underscore (represented as \x1F)
|
||||
if (evt.name === "\x1F") {
|
||||
return Keybind.fromParsedKey({ ...evt, name: "_", ctrl: true }, store.leader)
|
||||
}
|
||||
return Keybind.fromParsedKey(evt, store.leader)
|
||||
},
|
||||
/**
|
||||
* Check if a keyboard event matches a registered keybind.
|
||||
*/
|
||||
match(key: keyof KeybindsConfig, evt: ParsedKey): boolean {
|
||||
const keybind = keybinds()[key]
|
||||
if (!keybind) return false
|
||||
const parsed: Keybind.Info = result.parse(evt)
|
||||
for (const kb of keybind) {
|
||||
if (Keybind.match(kb, parsed)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
},
|
||||
/**
|
||||
* Get a display string for a registered keybind.
|
||||
*/
|
||||
print(key: keyof KeybindsConfig): string {
|
||||
const first = keybinds()[key]?.at(0)
|
||||
if (!first) return ""
|
||||
const display = Keybind.toString(first)
|
||||
// Replace leader placeholder with actual leader key
|
||||
const leaderKey = keybinds().leader?.[0]
|
||||
if (leaderKey) {
|
||||
return display.replace("<leader>", Keybind.toString(leaderKey))
|
||||
}
|
||||
return display
|
||||
},
|
||||
}
|
||||
return result
|
||||
},
|
||||
})
|
||||
@@ -1,13 +1,14 @@
|
||||
import { createContext, createEffect, createMemo, createSignal, Show, useContext } from "solid-js"
|
||||
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 { resolveTheme } from "../utils/theme-resolver"
|
||||
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 type { RGBA, TerminalColors } from "@opentui/core"
|
||||
|
||||
type ThemeResolved = {
|
||||
@@ -75,93 +76,154 @@ type ThemeResolved = {
|
||||
thinkingOpacity?: number
|
||||
}
|
||||
|
||||
type ThemeContextValue = {
|
||||
theme: ThemeResolved
|
||||
selected: () => string
|
||||
all: () => Record<string, ThemeJson>
|
||||
syntax: () => unknown
|
||||
subtleSyntax: () => unknown
|
||||
mode: () => "dark" | "light"
|
||||
setMode: (mode: "dark" | "light") => void
|
||||
set: (theme: string) => void
|
||||
ready: () => boolean
|
||||
}
|
||||
/**
|
||||
* Theme context using the createSimpleContext pattern.
|
||||
*
|
||||
* This ensures children are NOT rendered until the theme is ready,
|
||||
* preventing "useTheme must be used within a ThemeProvider" errors.
|
||||
*
|
||||
* The key insight from opencode's implementation is that the provider
|
||||
* uses `<Show when={ready}>` to gate rendering, so components can
|
||||
* safely call useTheme() without checking ready state.
|
||||
*/
|
||||
export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
|
||||
name: "Theme",
|
||||
init: (props: { mode: "dark" | "light" }) => {
|
||||
const appStore = useAppStore()
|
||||
const renderer = useRenderer()
|
||||
const [store, setStore] = createStore({
|
||||
themes: THEME_JSON as Record<string, ThemeJson>,
|
||||
mode: props.mode,
|
||||
active: appStore.state().settings.theme as string,
|
||||
system: undefined as undefined | TerminalColors,
|
||||
ready: false,
|
||||
})
|
||||
|
||||
const ThemeContext = createContext<ThemeContextValue>()
|
||||
function init() {
|
||||
resolveSystemTheme()
|
||||
loadThemes()
|
||||
.then((custom) => {
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
Object.assign(draft.themes, custom)
|
||||
})
|
||||
)
|
||||
})
|
||||
.catch(() => {
|
||||
// If custom themes fail to load, fall back to opencode theme
|
||||
setStore("active", "opencode")
|
||||
})
|
||||
.finally(() => {
|
||||
// Only set ready if not waiting for system theme
|
||||
if (store.active !== "system") {
|
||||
setStore("ready", true)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function ThemeProvider({ children }: { children: any }) {
|
||||
const appStore = useAppStore()
|
||||
const renderer = useRenderer()
|
||||
const [ready, setReady] = createSignal(false)
|
||||
const [store, setStore] = createStore({
|
||||
themes: {} as Record<string, ThemeJson>,
|
||||
mode: "dark" as "dark" | "light",
|
||||
active: appStore.state().settings.theme as ThemeName,
|
||||
system: undefined as undefined | TerminalColors,
|
||||
})
|
||||
function resolveSystemTheme() {
|
||||
renderer
|
||||
.getPalette({ size: 16 })
|
||||
.then((colors) => {
|
||||
if (!colors.palette[0]) {
|
||||
// No system colors available, fall back to default
|
||||
// This happens when the terminal doesn't support OSC palette queries
|
||||
// (e.g., running inside tmux, or on unsupported terminals)
|
||||
if (store.active === "system") {
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
draft.active = "opencode"
|
||||
draft.ready = true
|
||||
})
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
draft.system = colors
|
||||
if (store.active === "system") {
|
||||
draft.ready = true
|
||||
}
|
||||
})
|
||||
)
|
||||
})
|
||||
.catch(() => {
|
||||
// On error, fall back to default theme if using system
|
||||
if (store.active === "system") {
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
draft.active = "opencode"
|
||||
draft.ready = true
|
||||
})
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const init = () => {
|
||||
loadThemes()
|
||||
.then((custom) => {
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
Object.assign(draft.themes, custom)
|
||||
})
|
||||
)
|
||||
})
|
||||
.finally(() => setReady(true))
|
||||
}
|
||||
onMount(init)
|
||||
|
||||
init()
|
||||
// Setup SIGUSR2 signal handler for dynamic theme reload
|
||||
// This allows external tools to trigger a theme refresh by sending:
|
||||
// `kill -USR2 <pid>`
|
||||
const cleanupSignalHandler = setupThemeSignalHandler(() => {
|
||||
renderer.clearPaletteCache()
|
||||
init()
|
||||
})
|
||||
onCleanup(cleanupSignalHandler)
|
||||
|
||||
createEffect(() => {
|
||||
setStore("active", appStore.state().settings.theme)
|
||||
})
|
||||
// Sync active theme with app store settings
|
||||
createEffect(() => {
|
||||
const theme = appStore.state().settings.theme
|
||||
if (theme) setStore("active", theme)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
renderer
|
||||
.getPalette({ size: 16 })
|
||||
.then((colors) => setStore("system", colors))
|
||||
.catch(() => {})
|
||||
})
|
||||
// Emit theme change events for observers
|
||||
createEffect(() => {
|
||||
const theme = store.active
|
||||
const mode = store.mode
|
||||
if (store.ready) {
|
||||
emitThemeChanged(theme, mode)
|
||||
}
|
||||
})
|
||||
|
||||
const values = createMemo(() => {
|
||||
const themes = Object.keys(store.themes).length ? store.themes : THEME_JSON
|
||||
return resolveTerminalTheme(themes, store.active, store.mode, store.system)
|
||||
})
|
||||
const values = createMemo(() => {
|
||||
return resolveTerminalTheme(store.themes, store.active, store.mode, store.system)
|
||||
})
|
||||
|
||||
const syntax = createMemo(() => generateSyntax(values() as unknown as Record<string, RGBA>))
|
||||
const subtleSyntax = createMemo(() =>
|
||||
generateSubtleSyntax(values() as unknown as Record<string, RGBA> & { thinkingOpacity?: number })
|
||||
)
|
||||
const syntax = createMemo(() => generateSyntax(values() as unknown as Record<string, RGBA>))
|
||||
const subtleSyntax = createMemo(() =>
|
||||
generateSubtleSyntax(values() as unknown as Record<string, RGBA> & { thinkingOpacity?: number })
|
||||
)
|
||||
|
||||
const context: ThemeContextValue = {
|
||||
theme: new Proxy(values(), {
|
||||
get(_target, prop) {
|
||||
return values()[prop as keyof typeof values]
|
||||
},
|
||||
}) as ThemeResolved,
|
||||
selected: () => store.active,
|
||||
all: () => store.themes,
|
||||
syntax,
|
||||
subtleSyntax,
|
||||
mode: () => store.mode,
|
||||
setMode: (mode) => setStore("mode", mode),
|
||||
set: (theme) => appStore.setTheme(theme as ThemeName),
|
||||
ready,
|
||||
}
|
||||
|
||||
return (
|
||||
<Show when={ready()}>
|
||||
<ThemeContext.Provider value={context}>{children}</ThemeContext.Provider>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const context = useContext(ThemeContext)
|
||||
if (!context) {
|
||||
throw new Error("useTheme must be used within a ThemeProvider")
|
||||
}
|
||||
return context
|
||||
}
|
||||
return {
|
||||
theme: new Proxy(values(), {
|
||||
get(_target, prop) {
|
||||
// @ts-expect-error - dynamic property access
|
||||
return values()[prop]
|
||||
},
|
||||
}) as ThemeResolved,
|
||||
get selected() {
|
||||
return store.active
|
||||
},
|
||||
all() {
|
||||
return store.themes
|
||||
},
|
||||
syntax,
|
||||
subtleSyntax,
|
||||
mode() {
|
||||
return store.mode
|
||||
},
|
||||
setMode(mode: "dark" | "light") {
|
||||
setStore("mode", mode)
|
||||
emitThemeModeChanged(mode)
|
||||
},
|
||||
set(theme: string) {
|
||||
appStore.setTheme(theme as ThemeName)
|
||||
},
|
||||
get ready() {
|
||||
return store.ready
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
53
src/context/helper.tsx
Normal file
53
src/context/helper.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { createContext, Show, useContext, type ParentProps } from "solid-js"
|
||||
|
||||
/**
|
||||
* Creates a simple context with automatic ready-state handling.
|
||||
*
|
||||
* This pattern ensures that child components are NOT rendered until the
|
||||
* context's `ready` property is true (or undefined, meaning no ready check needed).
|
||||
*
|
||||
* This prevents the "useX must be used within a XProvider" errors that occur
|
||||
* when child components try to use context values before the provider has
|
||||
* finished async initialization.
|
||||
*
|
||||
* Usage:
|
||||
* ```tsx
|
||||
* export const { use: useMyContext, provider: MyProvider } = createSimpleContext({
|
||||
* name: "MyContext",
|
||||
* init: (props: { someProp: string }) => {
|
||||
* const [ready, setReady] = createSignal(false)
|
||||
* // ... async initialization ...
|
||||
* return {
|
||||
* get ready() { return ready() },
|
||||
* // ... other values
|
||||
* }
|
||||
* },
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
export function createSimpleContext<T, Props extends Record<string, any>>(input: {
|
||||
name: string
|
||||
init: ((input: Props) => T) | (() => T)
|
||||
}) {
|
||||
const ctx = createContext<T>()
|
||||
|
||||
return {
|
||||
provider: (props: ParentProps<Props>) => {
|
||||
const init = input.init(props)
|
||||
// Use an arrow function accessor for the ready check to maintain reactivity.
|
||||
// The getter `init.ready` reads from a store, so wrapping it in an
|
||||
// accessor allows Solid to track changes reactively.
|
||||
return (
|
||||
// @ts-expect-error - ready may not exist on all context types
|
||||
<Show when={init.ready === undefined || init.ready}>
|
||||
<ctx.Provider value={init}>{props.children}</ctx.Provider>
|
||||
</Show>
|
||||
)
|
||||
},
|
||||
use() {
|
||||
const value = useContext(ctx)
|
||||
if (!value) throw new Error(`${input.name} context must be used within a context provider`)
|
||||
return value
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user