| 1 | import type { ITheme } from "@xterm/xterm"; |
| 2 | |
| 3 | import { getResolvedTheme, type ResolvedTheme } from "./theme"; |
| 4 | |
| 5 | export type TerminalThemePreference = "auto" | "dark" | "light"; |
| 6 | |
| 7 | const AUTO_THEME_MEDIA_QUERY = "(prefers-color-scheme: light)"; |
| 8 | let currentTerminalTheme: TerminalThemePreference = "auto"; |
| 9 | const listeners = new Set<() => void>(); |
| 10 | |
| 11 | const darkANSI: ITheme = { |
| 12 | black: "#24272b", |
| 13 | red: "#ff6b6b", |
| 14 | green: "#89d185", |
| 15 | yellow: "#f2cc60", |
| 16 | blue: "#6ca4f8", |
| 17 | magenta: "#c58af9", |
| 18 | cyan: "#56d4dd", |
| 19 | white: "#d8d8d8", |
| 20 | brightBlack: "#70777f", |
| 21 | brightRed: "#ff8787", |
| 22 | brightGreen: "#a6e3a1", |
| 23 | brightYellow: "#ffe08a", |
| 24 | brightBlue: "#8ab4f8", |
| 25 | brightMagenta: "#d7a7ff", |
| 26 | brightCyan: "#79e2e8", |
| 27 | brightWhite: "#ffffff", |
| 28 | }; |
| 29 | |
| 30 | const lightANSI: ITheme = { |
| 31 | black: "#25272a", |
| 32 | red: "#b4232d", |
| 33 | green: "#3f6f16", |
| 34 | yellow: "#8a5700", |
| 35 | blue: "#1d5fbf", |
| 36 | magenta: "#7c3aaa", |
| 37 | cyan: "#08746f", |
| 38 | white: "#555b61", |
| 39 | brightBlack: "#6b7077", |
| 40 | brightRed: "#c3343f", |
| 41 | brightGreen: "#4b7a1f", |
| 42 | brightYellow: "#986000", |
| 43 | brightBlue: "#2c6cc5", |
| 44 | brightMagenta: "#9751c6", |
| 45 | brightCyan: "#0e7d77", |
| 46 | brightWhite: "#34383d", |
| 47 | }; |
| 48 | |
| 49 | // Wails dispatches bound Go calls on separate goroutines. Keep terminal-theme |
| 50 | // writes in click order so an older save can never finish after newer intent. |
| 51 | export function createTerminalThemeSaveQueue( |
| 52 | persist: (theme: TerminalThemePreference) => Promise<void>, |
| 53 | ): (theme: TerminalThemePreference) => Promise<void> { |
| 54 | let tail = Promise.resolve(); |
| 55 | return (theme) => { |
| 56 | const save = tail.then(() => persist(theme)); |
| 57 | tail = save.catch(() => undefined); |
| 58 | return save; |
| 59 | }; |
| 60 | } |
| 61 | |
| 62 | export function normalizeTerminalThemePreference(value: unknown): TerminalThemePreference { |
| 63 | return value === "dark" || value === "light" ? value : "auto"; |
| 64 | } |
| 65 | |
| 66 | export function getTerminalThemePreference(): TerminalThemePreference { |
| 67 | return currentTerminalTheme; |
| 68 | } |
| 69 | |
| 70 | export function getResolvedTerminalTheme( |
| 71 | preference: TerminalThemePreference = getTerminalThemePreference(), |
| 72 | ): ResolvedTheme { |
| 73 | return preference === "auto" ? getResolvedTheme() : preference; |
| 74 | } |
| 75 | |
| 76 | export function applyTerminalThemePreference(value: unknown): TerminalThemePreference { |
| 77 | const next = normalizeTerminalThemePreference(value); |
| 78 | currentTerminalTheme = next; |
| 79 | if (typeof document !== "undefined") { |
| 80 | if (next === "auto") document.documentElement.removeAttribute("data-terminal-theme"); |
| 81 | else document.documentElement.setAttribute("data-terminal-theme", next); |
| 82 | } |
| 83 | for (const listener of listeners) listener(); |
| 84 | return next; |
| 85 | } |
| 86 | |
| 87 | export function onTerminalThemePreferenceChange(listener: () => void): () => void { |
| 88 | listeners.add(listener); |
| 89 | return () => listeners.delete(listener); |
| 90 | } |
| 91 | |
| 92 | function cssToken(style: CSSStyleDeclaration, name: string, fallback: string): string { |
| 93 | return style.getPropertyValue(name).trim() || fallback; |
| 94 | } |
| 95 | |
| 96 | export function terminalThemeForElement(element: Element): ITheme { |
| 97 | const resolved = getResolvedTerminalTheme(); |
| 98 | const palette = resolved === "light" ? lightANSI : darkANSI; |
| 99 | const style = getComputedStyle(element); |
| 100 | return { |
| 101 | ...palette, |
| 102 | background: cssToken(style, "--terminal-bg", resolved === "light" ? "#f7f8fa" : "#111315"), |
| 103 | foreground: cssToken(style, "--terminal-fg", resolved === "light" ? "#25272a" : "#e8e5df"), |
| 104 | cursor: cssToken(style, "--terminal-cursor", resolved === "light" ? "#9a4f00" : "#e6a15c"), |
| 105 | cursorAccent: cssToken(style, "--terminal-bg", resolved === "light" ? "#f7f8fa" : "#111315"), |
| 106 | selectionBackground: cssToken(style, "--terminal-selection", resolved === "light" ? "#b9d7ff" : "#344c68"), |
| 107 | }; |
| 108 | } |
| 109 | |
| 110 | // Watch both explicit app-theme mutations and OS theme changes. Theme packs can |
| 111 | // replace root CSS variables without changing the terminal preference itself, |
| 112 | // so the root style attribute is observed as well. |
| 113 | export function observeTerminalTheme(element: Element, listener: () => void): () => void { |
| 114 | const unsubscribe = onTerminalThemePreferenceChange(listener); |
| 115 | const root = typeof document !== "undefined" ? document.documentElement : null; |
| 116 | const observer = root && typeof MutationObserver !== "undefined" |
| 117 | ? new MutationObserver(listener) |
| 118 | : null; |
| 119 | observer?.observe(root!, { |
| 120 | attributes: true, |
| 121 | attributeFilter: ["data-theme", "data-theme-style", "data-terminal-theme", "style"], |
| 122 | }); |
| 123 | |
| 124 | const media = typeof window !== "undefined" && window.matchMedia |
| 125 | ? window.matchMedia(AUTO_THEME_MEDIA_QUERY) |
| 126 | : null; |
| 127 | media?.addEventListener?.("change", listener); |
| 128 | void element; |
| 129 | |
| 130 | return () => { |
| 131 | unsubscribe(); |
| 132 | observer?.disconnect(); |
| 133 | media?.removeEventListener?.("change", listener); |
| 134 | }; |
| 135 | } |
| 136 |