feat(tui): support single-mode themes (#37930)
This commit is contained in:
parent
3f4fb3f9db
commit
7be95bcd6a
13 changed files with 283 additions and 77 deletions
|
|
@ -420,7 +420,7 @@ function App(props: { pair?: DialogPairCredentials; started: number }) {
|
|||
const client = useClient()
|
||||
const toast = useToast()
|
||||
const themeState = useTheme()
|
||||
const { themeV2, mode, setMode, locked, lock, unlock } = themeState
|
||||
const { themeV2, mode, supports, setMode, locked, lock, unlock } = themeState
|
||||
const data = useData()
|
||||
const location = useLocation()
|
||||
const exit = useExit()
|
||||
|
|
@ -818,6 +818,7 @@ function App(props: { pair?: DialogPairCredentials; started: number }) {
|
|||
name: "theme.switch_mode",
|
||||
title: mode() === "dark" ? "Switch to light mode" : "Switch to dark mode",
|
||||
palette: undefined,
|
||||
enabled: () => supports(mode() === "dark" ? "light" : "dark"),
|
||||
run: () => {
|
||||
setMode(mode() === "dark" ? "light" : "dark")
|
||||
dialog.clear()
|
||||
|
|
|
|||
|
|
@ -4,8 +4,10 @@ import { useTheme } from "../context/theme"
|
|||
import { DevTools } from "../devtools"
|
||||
|
||||
export function DevToolsSidebar() {
|
||||
const { themeV2, mode, setMode } = useTheme().contextual("elevated")
|
||||
const { themeV2, mode, supports, setMode } = useTheme().contextual("elevated")
|
||||
const [modeHovered, setModeHovered] = createSignal(false)
|
||||
const nextMode = () => (mode() === "dark" ? "light" : "dark")
|
||||
const canSwitchMode = () => supports(nextMode())
|
||||
|
||||
return (
|
||||
<box
|
||||
|
|
@ -29,12 +31,12 @@ export function DevToolsSidebar() {
|
|||
<box
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={modeHovered() ? themeV2.background.action("hovered") : undefined}
|
||||
onMouseOver={() => setModeHovered(true)}
|
||||
backgroundColor={modeHovered() && canSwitchMode() ? themeV2.background.action("hovered") : undefined}
|
||||
onMouseOver={() => setModeHovered(canSwitchMode())}
|
||||
onMouseOut={() => setModeHovered(false)}
|
||||
onMouseUp={() => setMode(mode() === "dark" ? "light" : "dark")}
|
||||
onMouseUp={canSwitchMode() ? () => setMode(nextMode()) : undefined}
|
||||
>
|
||||
<text fg={themeV2.text()}>{mode()}</text>
|
||||
<text fg={canSwitchMode() ? themeV2.text() : themeV2.text.subdued()}>{mode()}</text>
|
||||
</box>
|
||||
</box>
|
||||
</box>
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import { discoverThemes, themeDirectories } from "../theme/discovery"
|
|||
import { createComponentTheme, type ComponentTheme } from "../theme/v2/component"
|
||||
import { resolveThemeFile } from "../theme/v2/resolve"
|
||||
import { migrateV1 } from "../theme/v2/v1-migrate"
|
||||
import { themeModes } from "../theme/v2/select"
|
||||
import { createEffect, createMemo, onCleanup, onMount, type Accessor } from "solid-js"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { createSimpleContext } from "./helper"
|
||||
|
|
@ -78,10 +79,12 @@ type ThemeService = {
|
|||
has: typeof hasTheme
|
||||
syntax: Accessor<SyntaxStyle>
|
||||
mode: Accessor<"dark" | "light">
|
||||
modes: Accessor<readonly ("dark" | "light")[]>
|
||||
supports(mode: "dark" | "light"): boolean
|
||||
locked: Accessor<boolean>
|
||||
lock(): void
|
||||
unlock(): void
|
||||
setMode(mode?: "dark" | "light", persist?: boolean): void
|
||||
setMode(mode?: "dark" | "light", persist?: boolean): boolean
|
||||
set(theme: string): boolean
|
||||
readonly ready: boolean
|
||||
}
|
||||
|
|
@ -271,17 +274,25 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
|
|||
|
||||
const source = createMemo(() => store.themes[store.active] ?? store.themes.opencode)
|
||||
const sourceName = createMemo(() => (store.themes[store.active] ? store.active : "opencode"))
|
||||
const values = createMemo(() => resolveTheme(source(), store.mode))
|
||||
const valuesV2 = createMemo(() => {
|
||||
const file = createMemo(() => {
|
||||
const started = performance.now()
|
||||
const file = migrateV1(source())
|
||||
const result = migrateV1(source())
|
||||
themePerformance.set("Convert V1 to V2", duration(performance.now() - started))
|
||||
return result
|
||||
})
|
||||
const modes = createMemo(() => themeModes(file()))
|
||||
const mode = () => {
|
||||
const supported = modes()
|
||||
if (supported.includes(store.mode)) return store.mode
|
||||
return supported[0] ?? store.mode
|
||||
}
|
||||
const values = createMemo(() => resolveTheme(source(), mode()))
|
||||
const valuesV2 = createMemo(() => {
|
||||
const resolveStarted = performance.now()
|
||||
const result = resolveThemeFile(file, store.mode, sourceName())
|
||||
const result = resolveThemeFile(file(), mode(), sourceName())
|
||||
themePerformance.set("Resolve final theme", duration(performance.now() - resolveStarted))
|
||||
return result
|
||||
})
|
||||
const mode = () => store.mode
|
||||
const themeV2 = createComponentTheme(valuesV2, mode)
|
||||
const contextsV2 = {
|
||||
elevated: createComponentTheme(() => {
|
||||
|
|
@ -319,11 +330,17 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
|
|||
all: allThemes,
|
||||
has: hasTheme,
|
||||
syntax,
|
||||
mode: () => store.mode,
|
||||
mode,
|
||||
modes,
|
||||
supports: (requested) => modes().includes(requested),
|
||||
locked: () => store.lock !== undefined,
|
||||
lock: () => pin(store.mode),
|
||||
lock: () => pin(mode()),
|
||||
unlock: free,
|
||||
setMode: pin,
|
||||
setMode(requested = mode(), persist = true) {
|
||||
if (!modes().includes(requested)) return false
|
||||
pin(requested, persist)
|
||||
return true
|
||||
},
|
||||
set(theme: string) {
|
||||
if (!hasTheme(theme)) return false
|
||||
setStore("active", theme)
|
||||
|
|
|
|||
|
|
@ -41,3 +41,4 @@ export type {
|
|||
StatefulColor,
|
||||
} from "./types"
|
||||
export { migrateV1 } from "./v1-migrate"
|
||||
export { selectTheme, selectThemeMode, supportsThemeMode, themeModes } from "./select"
|
||||
|
|
|
|||
|
|
@ -137,15 +137,35 @@ const BackgroundDefinition = Schema.Struct({
|
|||
export type BackgroundDefinition = Schema.Schema.Type<typeof BackgroundDefinition>
|
||||
|
||||
export const SyntaxToken = Schema.Literals([
|
||||
"comment", "keyword", "function", "variable", "string", "number", "type", "operator", "punctuation",
|
||||
"comment",
|
||||
"keyword",
|
||||
"function",
|
||||
"variable",
|
||||
"string",
|
||||
"number",
|
||||
"type",
|
||||
"operator",
|
||||
"punctuation",
|
||||
])
|
||||
export type SyntaxToken = Schema.Schema.Type<typeof SyntaxToken>
|
||||
export const SyntaxDefinition = Schema.Record(SyntaxToken, Schema.optionalKey(HueColorValue))
|
||||
export type SyntaxDefinition = Schema.Schema.Type<typeof SyntaxDefinition>
|
||||
|
||||
export const MarkdownToken = Schema.Literals([
|
||||
"text", "heading", "link", "linkText", "code", "blockQuote", "emphasis", "strong", "horizontalRule", "listItem",
|
||||
"listEnumeration", "image", "imageText", "codeBlock",
|
||||
"text",
|
||||
"heading",
|
||||
"link",
|
||||
"linkText",
|
||||
"code",
|
||||
"blockQuote",
|
||||
"emphasis",
|
||||
"strong",
|
||||
"horizontalRule",
|
||||
"listItem",
|
||||
"listEnumeration",
|
||||
"image",
|
||||
"imageText",
|
||||
"codeBlock",
|
||||
])
|
||||
export type MarkdownToken = Schema.Schema.Type<typeof MarkdownToken>
|
||||
export const MarkdownDefinition = Schema.Record(MarkdownToken, Schema.optionalKey(HueColorValue))
|
||||
|
|
@ -225,5 +245,8 @@ const FileMetadata = {
|
|||
version: Schema.Literal(2),
|
||||
standalone: Schema.optional(Schema.Boolean),
|
||||
}
|
||||
export const ThemeFile = Schema.Struct({ ...FileMetadata, light: ModeDefinition, dark: ModeDefinition })
|
||||
export const ThemeFile = Schema.Union([
|
||||
Schema.Struct({ ...FileMetadata, light: ModeDefinition, dark: Schema.optional(ModeDefinition) }),
|
||||
Schema.Struct({ ...FileMetadata, light: Schema.optional(ModeDefinition), dark: ModeDefinition }),
|
||||
])
|
||||
export type ThemeFile = Schema.Schema.Type<typeof ThemeFile>
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import type {
|
|||
} from "./index"
|
||||
|
||||
export function selectTheme(
|
||||
file: Omit<ThemeFile, "light" | "dark"> & { light: ThemeDefinition; dark: ThemeDefinition },
|
||||
file: ThemeFile & { light: ThemeDefinition; dark: ThemeDefinition },
|
||||
mode?: Mode,
|
||||
): ThemeDefinition
|
||||
export function selectTheme(file: ThemeFile, mode?: Mode): FileThemeDefinition
|
||||
|
|
@ -21,17 +21,31 @@ export function selectThemeMode(
|
|||
file: ThemeFile,
|
||||
mode: Mode = "light",
|
||||
): { theme: FileThemeDefinition; mode: Mode; expanded: boolean } {
|
||||
const modes = themeModes(file)
|
||||
const selectedMode = modes.includes(mode) ? mode : modes[0]
|
||||
const selected = file[selectedMode]
|
||||
if (!selected) throw new Error("Theme must provide at least one mode")
|
||||
if (merges(file.light) && merges(file.dark)) throw new Error("Light and dark themes cannot both merge modes")
|
||||
const selected = file[mode]
|
||||
if (!merges(selected)) return { theme: selected, mode, expanded: false }
|
||||
if (!merges(selected)) return { theme: selected, mode: selectedMode, expanded: false }
|
||||
|
||||
const otherMode = mode === "light" ? "dark" : "light"
|
||||
const otherMode = selectedMode === "light" ? "dark" : "light"
|
||||
const other = file[otherMode]
|
||||
if (!other) throw new Error(`The ${selectedMode} theme cannot merge without a ${otherMode} theme`)
|
||||
const merged = mergeTheme(expandTheme(other), expandTheme(selected))
|
||||
if (!merged["hue"]) throw new Error(`The ${otherMode} theme must provide hues when ${mode} merges modes`)
|
||||
return { theme: merged as FileThemeDefinition, mode, expanded: true }
|
||||
if (!merged["hue"]) throw new Error(`The ${otherMode} theme must provide hues when ${selectedMode} merges modes`)
|
||||
return { theme: merged as FileThemeDefinition, mode: selectedMode, expanded: true }
|
||||
}
|
||||
|
||||
function merges(definition: ModeDefinition): definition is MergeModeDefinition {
|
||||
return "mergeMode" in definition && definition.mergeMode === true
|
||||
export function themeModes(file: ThemeFile): readonly Mode[] {
|
||||
if (merges(file.light) && !file.dark) throw new Error("The light theme cannot merge without a dark theme")
|
||||
if (merges(file.dark) && !file.light) throw new Error("The dark theme cannot merge without a light theme")
|
||||
return (["light", "dark"] as const).filter((mode) => file[mode] !== undefined)
|
||||
}
|
||||
|
||||
export function supportsThemeMode(file: ThemeFile, mode: Mode) {
|
||||
return themeModes(file).includes(mode)
|
||||
}
|
||||
|
||||
function merges(definition: ModeDefinition | undefined): definition is MergeModeDefinition {
|
||||
return definition !== undefined && "mergeMode" in definition && definition.mergeMode === true
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { RGBA } from "@opentui/core"
|
|||
import { oklchToHex, rgbToOklch } from "@opencode-ai/ui/theme/color"
|
||||
import type { Theme, ThemeJson } from "../index"
|
||||
import { DEFAULT_THEME } from "./defaults"
|
||||
import type { ThemeFile } from "./index"
|
||||
import type { FileThemeDefinition, Mode, ThemeFile } from "./index"
|
||||
import { HueStep } from "./schema"
|
||||
|
||||
type ThemeColor = Exclude<keyof Theme, "thinkingOpacity" | "_hasSelectedListItemText">
|
||||
|
|
@ -13,15 +13,33 @@ const minimumChroma = 0.03
|
|||
const lightThreshold = 0.6
|
||||
|
||||
export function migrateV1(theme: ThemeJson): ThemeFile {
|
||||
const light = resolveV1(theme, "light")
|
||||
const dark = resolveV1(theme, "dark")
|
||||
if (light.background.a > 0 && dark.background.a > 0 && light.background.equals(dark.background)) {
|
||||
const lightMode = detectMode(light)
|
||||
const darkMode = detectMode(dark)
|
||||
if (lightMode === darkMode) {
|
||||
if (lightMode === "light") return { version: 2, standalone: true, light: migrateMode(light, "light") }
|
||||
return { version: 2, standalone: true, dark: migrateMode(dark, "dark") }
|
||||
}
|
||||
}
|
||||
return {
|
||||
version: 2,
|
||||
standalone: true,
|
||||
light: migrateMode(resolveV1(theme, "light"), "light"),
|
||||
dark: migrateMode(resolveV1(theme, "dark"), "dark"),
|
||||
light: migrateMode(light, "light"),
|
||||
dark: migrateMode(dark, "dark"),
|
||||
}
|
||||
}
|
||||
|
||||
function migrateMode(theme: Theme, mode: "light" | "dark"): ThemeFile["light"] {
|
||||
function detectMode(theme: Theme): Mode {
|
||||
return luminance(theme.text) > luminance(theme.background) ? "dark" : "light"
|
||||
}
|
||||
|
||||
function luminance(color: RGBA) {
|
||||
return 0.299 * color.r + 0.587 * color.g + 0.114 * color.b
|
||||
}
|
||||
|
||||
function migrateMode(theme: Theme, mode: Mode): FileThemeDefinition {
|
||||
const color = (key: ThemeColor) => hex(theme[key])
|
||||
const selected = hex(selectedForeground(theme, theme.primary))
|
||||
const destructive = hex(selectedForeground(theme, theme.error))
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue