getting terminal colors working
This commit is contained in:
221
src/utils/clipboard.ts
Normal file
221
src/utils/clipboard.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
import { $ } from "bun"
|
||||
import { platform, release } from "os"
|
||||
import { tmpdir } from "os"
|
||||
import path from "path"
|
||||
|
||||
/**
|
||||
* Writes text to clipboard via OSC 52 escape sequence.
|
||||
* This allows clipboard operations to work over SSH by having
|
||||
* the terminal emulator handle the clipboard locally.
|
||||
*/
|
||||
function writeOsc52(text: string): void {
|
||||
if (!process.stdout.isTTY) return
|
||||
const base64 = Buffer.from(text).toString("base64")
|
||||
const osc52 = `\x1b]52;c;${base64}\x07`
|
||||
const passthrough = process.env["TMUX"] || process.env["STY"]
|
||||
const sequence = passthrough ? `\x1bPtmux;\x1b${osc52}\x1b\\` : osc52
|
||||
process.stdout.write(sequence)
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazy initialization for clipboard copy method.
|
||||
* Detects the best clipboard method for the current platform.
|
||||
*/
|
||||
function createLazy<T>(factory: () => T): () => T {
|
||||
let value: T | undefined
|
||||
return () => {
|
||||
if (value === undefined) {
|
||||
value = factory()
|
||||
}
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
export namespace Clipboard {
|
||||
export interface Content {
|
||||
data: string
|
||||
mime: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Read content from the clipboard.
|
||||
* Supports text and image (PNG) content on macOS, Windows, and Linux.
|
||||
*/
|
||||
export async function read(): Promise<Content | undefined> {
|
||||
const os = platform()
|
||||
|
||||
// macOS: Try to read PNG image first
|
||||
if (os === "darwin") {
|
||||
const tmpfile = path.join(tmpdir(), "podtui-clipboard.png")
|
||||
try {
|
||||
await $`osascript -e 'set imageData to the clipboard as "PNGf"' -e 'set fileRef to open for access POSIX file "${tmpfile}" with write permission' -e 'set eof fileRef to 0' -e 'write imageData to fileRef' -e 'close access fileRef'`
|
||||
.nothrow()
|
||||
.quiet()
|
||||
const file = Bun.file(tmpfile)
|
||||
const buffer = await file.arrayBuffer()
|
||||
if (buffer.byteLength > 0) {
|
||||
return { data: Buffer.from(buffer).toString("base64"), mime: "image/png" }
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors, fall through to text
|
||||
} finally {
|
||||
await $`rm -f "${tmpfile}"`.nothrow().quiet()
|
||||
}
|
||||
}
|
||||
|
||||
// Windows/WSL: Try to read PNG image
|
||||
if (os === "win32" || release().includes("WSL")) {
|
||||
const script =
|
||||
"Add-Type -AssemblyName System.Windows.Forms; $img = [System.Windows.Forms.Clipboard]::GetImage(); if ($img) { $ms = New-Object System.IO.MemoryStream; $img.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png); [System.Convert]::ToBase64String($ms.ToArray()) }"
|
||||
const base64 = await $`powershell.exe -NonInteractive -NoProfile -command "${script}"`.nothrow().text()
|
||||
if (base64) {
|
||||
const imageBuffer = Buffer.from(base64.trim(), "base64")
|
||||
if (imageBuffer.length > 0) {
|
||||
return { data: imageBuffer.toString("base64"), mime: "image/png" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Linux: Try Wayland or X11
|
||||
if (os === "linux") {
|
||||
// Try Wayland first
|
||||
const wayland = await $`wl-paste -t image/png`.nothrow().arrayBuffer()
|
||||
if (wayland && wayland.byteLength > 0) {
|
||||
return { data: Buffer.from(wayland).toString("base64"), mime: "image/png" }
|
||||
}
|
||||
// Try X11
|
||||
const x11 = await $`xclip -selection clipboard -t image/png -o`.nothrow().arrayBuffer()
|
||||
if (x11 && x11.byteLength > 0) {
|
||||
return { data: Buffer.from(x11).toString("base64"), mime: "image/png" }
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to reading text
|
||||
try {
|
||||
const text = await readText()
|
||||
if (text) {
|
||||
return { data: text, mime: "text/plain" }
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Read text from the clipboard.
|
||||
*/
|
||||
export async function readText(): Promise<string | undefined> {
|
||||
const os = platform()
|
||||
|
||||
if (os === "darwin") {
|
||||
const result = await $`pbpaste`.nothrow().text()
|
||||
return result || undefined
|
||||
}
|
||||
|
||||
if (os === "linux") {
|
||||
// Try Wayland first
|
||||
if (process.env["WAYLAND_DISPLAY"]) {
|
||||
const result = await $`wl-paste`.nothrow().text()
|
||||
if (result) return result
|
||||
}
|
||||
// Try X11
|
||||
const result = await $`xclip -selection clipboard -o`.nothrow().text()
|
||||
return result || undefined
|
||||
}
|
||||
|
||||
if (os === "win32" || release().includes("WSL")) {
|
||||
const result = await $`powershell.exe -NonInteractive -NoProfile -command "Get-Clipboard"`.nothrow().text()
|
||||
return result?.trim() || undefined
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
const getCopyMethod = createLazy(() => {
|
||||
const os = platform()
|
||||
|
||||
if (os === "darwin" && Bun.which("osascript")) {
|
||||
return async (text: string) => {
|
||||
const escaped = text.replace(/\\/g, "\\\\").replace(/"/g, '\\"')
|
||||
await $`osascript -e 'set the clipboard to "${escaped}"'`.nothrow().quiet()
|
||||
}
|
||||
}
|
||||
|
||||
if (os === "linux") {
|
||||
if (process.env["WAYLAND_DISPLAY"] && Bun.which("wl-copy")) {
|
||||
return async (text: string) => {
|
||||
const proc = Bun.spawn(["wl-copy"], { stdin: "pipe", stdout: "ignore", stderr: "ignore" })
|
||||
proc.stdin.write(text)
|
||||
proc.stdin.end()
|
||||
await proc.exited.catch(() => {})
|
||||
}
|
||||
}
|
||||
if (Bun.which("xclip")) {
|
||||
return async (text: string) => {
|
||||
const proc = Bun.spawn(["xclip", "-selection", "clipboard"], {
|
||||
stdin: "pipe",
|
||||
stdout: "ignore",
|
||||
stderr: "ignore",
|
||||
})
|
||||
proc.stdin.write(text)
|
||||
proc.stdin.end()
|
||||
await proc.exited.catch(() => {})
|
||||
}
|
||||
}
|
||||
if (Bun.which("xsel")) {
|
||||
return async (text: string) => {
|
||||
const proc = Bun.spawn(["xsel", "--clipboard", "--input"], {
|
||||
stdin: "pipe",
|
||||
stdout: "ignore",
|
||||
stderr: "ignore",
|
||||
})
|
||||
proc.stdin.write(text)
|
||||
proc.stdin.end()
|
||||
await proc.exited.catch(() => {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (os === "win32") {
|
||||
return async (text: string) => {
|
||||
// Pipe via stdin to avoid PowerShell string interpolation ($env:FOO, $(), etc.)
|
||||
const proc = Bun.spawn(
|
||||
[
|
||||
"powershell.exe",
|
||||
"-NonInteractive",
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"[Console]::InputEncoding = [System.Text.Encoding]::UTF8; Set-Clipboard -Value ([Console]::In.ReadToEnd())",
|
||||
],
|
||||
{
|
||||
stdin: "pipe",
|
||||
stdout: "ignore",
|
||||
stderr: "ignore",
|
||||
},
|
||||
)
|
||||
|
||||
proc.stdin.write(text)
|
||||
proc.stdin.end()
|
||||
await proc.exited.catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: No native clipboard support
|
||||
return async (_text: string) => {
|
||||
console.warn("No clipboard support available on this platform")
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Copy text to the clipboard.
|
||||
* Uses OSC 52 for SSH/tmux support and native clipboard for local.
|
||||
*/
|
||||
export async function copy(text: string): Promise<void> {
|
||||
// Always try OSC 52 first for SSH/tmux support
|
||||
writeOsc52(text)
|
||||
// Then use native clipboard
|
||||
await getCopyMethod()(text)
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,9 @@ import type { ThemeJson } from "../types/theme-schema"
|
||||
import { THEME_JSON } from "../constants/themes"
|
||||
import { validateTheme } from "./theme-loader"
|
||||
|
||||
// Files to exclude from theme loading (not actual themes)
|
||||
const EXCLUDED_FILES = new Set(["schema", "schema.json"])
|
||||
|
||||
export async function getCustomThemes() {
|
||||
const home = process.env.HOME ?? ""
|
||||
if (!home) return {}
|
||||
@@ -22,6 +25,10 @@ export async function getCustomThemes() {
|
||||
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")
|
||||
// Skip non-theme files
|
||||
if (EXCLUDED_FILES.has(name) || EXCLUDED_FILES.has(path.basename(item))) {
|
||||
continue
|
||||
}
|
||||
const json = (await Bun.file(item).json()) as ThemeJson
|
||||
validateTheme(json, item)
|
||||
result[name] = json
|
||||
|
||||
136
src/utils/event-bus.ts
Normal file
136
src/utils/event-bus.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* Simple event bus for inter-component communication.
|
||||
*
|
||||
* This provides a decoupled way for components to communicate without
|
||||
* direct dependencies. Components can publish events and subscribe to
|
||||
* events they're interested in.
|
||||
*
|
||||
* Usage:
|
||||
* ```tsx
|
||||
* // Subscribe to events
|
||||
* const unsub = EventBus.on("theme.changed", (data) => {
|
||||
* console.log("Theme changed to:", data.theme)
|
||||
* })
|
||||
*
|
||||
* // Publish events
|
||||
* EventBus.emit("theme.changed", { theme: "dark" })
|
||||
*
|
||||
* // Cleanup
|
||||
* unsub()
|
||||
* ```
|
||||
*/
|
||||
|
||||
type EventHandler<T = unknown> = (data: T) => void
|
||||
|
||||
// Export EventHandler type for external use
|
||||
export type { EventHandler }
|
||||
|
||||
interface EventBusInstance {
|
||||
on<T = unknown>(event: string, handler: EventHandler<T>): () => void
|
||||
once<T = unknown>(event: string, handler: EventHandler<T>): () => void
|
||||
off<T = unknown>(event: string, handler: EventHandler<T>): void
|
||||
emit<T = unknown>(event: string, data: T): void
|
||||
clear(): void
|
||||
}
|
||||
|
||||
function createEventBus(): EventBusInstance {
|
||||
const handlers = new Map<string, Set<EventHandler>>()
|
||||
|
||||
return {
|
||||
on<T = unknown>(event: string, handler: EventHandler<T>): () => void {
|
||||
if (!handlers.has(event)) {
|
||||
handlers.set(event, new Set())
|
||||
}
|
||||
handlers.get(event)!.add(handler as EventHandler)
|
||||
|
||||
// Return unsubscribe function
|
||||
return () => {
|
||||
this.off(event, handler)
|
||||
}
|
||||
},
|
||||
|
||||
once<T = unknown>(event: string, handler: EventHandler<T>): () => void {
|
||||
const wrappedHandler: EventHandler<T> = (data) => {
|
||||
this.off(event, wrappedHandler)
|
||||
handler(data)
|
||||
}
|
||||
return this.on(event, wrappedHandler)
|
||||
},
|
||||
|
||||
off<T = unknown>(event: string, handler: EventHandler<T>): void {
|
||||
const eventHandlers = handlers.get(event)
|
||||
if (eventHandlers) {
|
||||
eventHandlers.delete(handler as EventHandler)
|
||||
if (eventHandlers.size === 0) {
|
||||
handlers.delete(event)
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
emit<T = unknown>(event: string, data: T): void {
|
||||
const eventHandlers = handlers.get(event)
|
||||
if (eventHandlers) {
|
||||
for (const handler of eventHandlers) {
|
||||
try {
|
||||
handler(data)
|
||||
} catch (error) {
|
||||
console.error(`Error in event handler for "${event}":`, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
clear(): void {
|
||||
handlers.clear()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton event bus instance
|
||||
export const EventBus = createEventBus()
|
||||
|
||||
// Common event types for the application
|
||||
export type AppEvents = {
|
||||
"theme.changed": { theme: string; mode: "dark" | "light" }
|
||||
"theme.mode.changed": { mode: "dark" | "light" }
|
||||
"theme.reload": {}
|
||||
"navigation.tab.changed": { tab: string; previousTab?: string }
|
||||
"navigation.layer.changed": { depth: number; previousDepth: number }
|
||||
"feed.subscribed": { feedId: string; feedUrl: string }
|
||||
"feed.unsubscribed": { feedId: string }
|
||||
"player.play": { episodeId: string }
|
||||
"player.pause": { episodeId: string }
|
||||
"player.stop": {}
|
||||
"auth.login": { userId: string }
|
||||
"auth.logout": {}
|
||||
"toast.show": { message: string; variant: "info" | "success" | "warning" | "error"; title?: string; duration?: number }
|
||||
"dialog.open": { dialogId: string }
|
||||
"dialog.close": { dialogId?: string }
|
||||
"command.execute": { command: string; args?: unknown }
|
||||
}
|
||||
|
||||
// Type-safe emit and on functions
|
||||
export function emit<K extends keyof AppEvents>(event: K, data: AppEvents[K]): void {
|
||||
EventBus.emit(event, data)
|
||||
}
|
||||
|
||||
export function on<K extends keyof AppEvents>(
|
||||
event: K,
|
||||
handler: EventHandler<AppEvents[K]>
|
||||
): () => void {
|
||||
return EventBus.on(event, handler)
|
||||
}
|
||||
|
||||
export function once<K extends keyof AppEvents>(
|
||||
event: K,
|
||||
handler: EventHandler<AppEvents[K]>
|
||||
): () => void {
|
||||
return EventBus.once(event, handler)
|
||||
}
|
||||
|
||||
export function off<K extends keyof AppEvents>(
|
||||
event: K,
|
||||
handler: EventHandler<AppEvents[K]>
|
||||
): void {
|
||||
EventBus.off(event, handler)
|
||||
}
|
||||
187
src/utils/keybind.ts
Normal file
187
src/utils/keybind.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
import type { ParsedKey } from "@opentui/core"
|
||||
|
||||
/**
|
||||
* Keyboard shortcut parsing and matching utilities.
|
||||
*
|
||||
* Supports key combinations like:
|
||||
* - "ctrl+c" - Control + c
|
||||
* - "alt+x" - Alt + x
|
||||
* - "shift+enter" - Shift + Enter
|
||||
* - "<leader>n" - Leader key followed by n
|
||||
* - "ctrl+shift+p" - Control + Shift + p
|
||||
*/
|
||||
|
||||
export namespace Keybind {
|
||||
export interface Info {
|
||||
key: string
|
||||
ctrl: boolean
|
||||
alt: boolean
|
||||
shift: boolean
|
||||
meta: boolean
|
||||
leader: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a keybind string into a structured Info object.
|
||||
*
|
||||
* Examples:
|
||||
* - "ctrl+c" -> { key: "c", ctrl: true, ... }
|
||||
* - "<leader>n" -> { key: "n", leader: true, ... }
|
||||
* - "alt+shift+x" -> { key: "x", alt: true, shift: true, ... }
|
||||
*/
|
||||
export function parse(input: string): Info[] {
|
||||
if (!input) return []
|
||||
|
||||
// Handle multiple keybinds separated by comma or space
|
||||
const parts = input.split(/[,\s]+/).filter(Boolean)
|
||||
|
||||
return parts.map((part) => {
|
||||
const info: Info = {
|
||||
key: "",
|
||||
ctrl: false,
|
||||
alt: false,
|
||||
shift: false,
|
||||
meta: false,
|
||||
leader: false,
|
||||
}
|
||||
|
||||
// Check for leader key prefix
|
||||
if (part.startsWith("<leader>")) {
|
||||
info.leader = true
|
||||
part = part.substring(8) // Remove "<leader>"
|
||||
}
|
||||
|
||||
// Split by + for modifiers
|
||||
const tokens = part.toLowerCase().split("+")
|
||||
|
||||
for (const token of tokens) {
|
||||
switch (token) {
|
||||
case "ctrl":
|
||||
case "control":
|
||||
info.ctrl = true
|
||||
break
|
||||
case "alt":
|
||||
case "option":
|
||||
info.alt = true
|
||||
break
|
||||
case "shift":
|
||||
info.shift = true
|
||||
break
|
||||
case "meta":
|
||||
case "cmd":
|
||||
case "command":
|
||||
case "win":
|
||||
case "super":
|
||||
info.meta = true
|
||||
break
|
||||
default:
|
||||
// The last non-modifier token is the key
|
||||
info.key = token
|
||||
}
|
||||
}
|
||||
|
||||
return info
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a ParsedKey event to a Keybind.Info.
|
||||
*/
|
||||
export function fromParsedKey(evt: ParsedKey, leader: boolean = false): Info {
|
||||
// ParsedKey has ctrl, shift, meta but may not have alt directly
|
||||
// We need to check what properties are available
|
||||
const evtAny = evt as unknown as Record<string, unknown>
|
||||
return {
|
||||
key: evt.name?.toLowerCase() ?? "",
|
||||
ctrl: evt.ctrl ?? false,
|
||||
alt: (evtAny.alt as boolean) ?? false,
|
||||
shift: evt.shift ?? false,
|
||||
meta: evt.meta ?? false,
|
||||
leader,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a keybind matches a parsed key event.
|
||||
*/
|
||||
export function match(keybind: Info, evt: Info): boolean {
|
||||
return (
|
||||
keybind.key === evt.key &&
|
||||
keybind.ctrl === evt.ctrl &&
|
||||
keybind.alt === evt.alt &&
|
||||
keybind.shift === evt.shift &&
|
||||
keybind.meta === evt.meta &&
|
||||
keybind.leader === evt.leader
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a keybind Info to a display string.
|
||||
*/
|
||||
export function toString(info: Info): string {
|
||||
const parts: string[] = []
|
||||
|
||||
if (info.leader) parts.push("<leader>")
|
||||
if (info.ctrl) parts.push("Ctrl")
|
||||
if (info.alt) parts.push("Alt")
|
||||
if (info.shift) parts.push("Shift")
|
||||
if (info.meta) parts.push("Cmd")
|
||||
|
||||
if (info.key) {
|
||||
// Capitalize special keys
|
||||
const displayKey = info.key.length === 1 ? info.key.toUpperCase() : capitalize(info.key)
|
||||
parts.push(displayKey)
|
||||
}
|
||||
|
||||
return parts.join("+")
|
||||
}
|
||||
|
||||
function capitalize(str: string): string {
|
||||
return str.charAt(0).toUpperCase() + str.slice(1)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Default keybindings configuration.
|
||||
*/
|
||||
export const DEFAULT_KEYBINDS = {
|
||||
// Leader key (space by default)
|
||||
leader: "space",
|
||||
|
||||
// Navigation
|
||||
tab_next: "tab",
|
||||
tab_prev: "shift+tab",
|
||||
|
||||
// App commands
|
||||
command_list: "ctrl+p",
|
||||
help: "?",
|
||||
quit: "ctrl+c",
|
||||
|
||||
// Session/content
|
||||
session_new: "<leader>n",
|
||||
session_list: "<leader>s",
|
||||
|
||||
// Theme
|
||||
theme_list: "<leader>t",
|
||||
|
||||
// Player
|
||||
player_play: "space",
|
||||
player_pause: "space",
|
||||
player_next: "n",
|
||||
player_prev: "p",
|
||||
player_seek_forward: "l",
|
||||
player_seek_backward: "h",
|
||||
|
||||
// List navigation
|
||||
list_up: "k",
|
||||
list_down: "j",
|
||||
list_top: "g",
|
||||
list_bottom: "G",
|
||||
list_select: "enter",
|
||||
|
||||
// Search
|
||||
search_focus: "/",
|
||||
search_clear: "escape",
|
||||
}
|
||||
|
||||
export type KeybindsConfig = typeof DEFAULT_KEYBINDS
|
||||
104
src/utils/theme-observer.ts
Normal file
104
src/utils/theme-observer.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Theme observer utility for detecting and responding to theme changes.
|
||||
*
|
||||
* This module provides utilities for:
|
||||
* - Listening to SIGUSR2 signals for theme reload
|
||||
* - Emitting theme change events via the event bus
|
||||
* - 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)
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a theme reload event.
|
||||
*/
|
||||
export function emitThemeReload(): void {
|
||||
emit("theme.reload", {})
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a theme changed event.
|
||||
*/
|
||||
export function emitThemeChanged(theme: string, mode: "dark" | "light"): void {
|
||||
emit("theme.changed", { theme, mode })
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a theme mode changed event.
|
||||
*/
|
||||
export function emitThemeModeChanged(mode: "dark" | "light"): void {
|
||||
emit("theme.mode.changed", { mode })
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup SIGUSR2 signal handler for theme reload.
|
||||
* This allows external tools to trigger a theme refresh by sending SIGUSR2 to the process.
|
||||
*
|
||||
* Usage: `kill -USR2 <pid>` to trigger a theme reload
|
||||
*
|
||||
* @param onReload - Callback to execute when SIGUSR2 is received
|
||||
* @returns Cleanup function to remove the handler
|
||||
*/
|
||||
export function setupThemeSignalHandler(onReload: () => void): () => void {
|
||||
const handler = () => {
|
||||
emitThemeReload()
|
||||
onReload()
|
||||
}
|
||||
|
||||
process.on("SIGUSR2", handler)
|
||||
|
||||
return () => {
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user