Просмотр исходного кода

feature: optional selectedListItemText element in themes and luminance-based fallback to solve 4369 (#4572)

Co-authored-by: knanao <[email protected]>
Co-authored-by: knanao <[email protected]>
Ariane Emory 3 месяцев назад
Родитель
Сommit
7d11986a0a

+ 4 - 4
packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx

@@ -5,7 +5,7 @@ import { createMemo, createResource, createEffect, onMount, For, Show } from "so
 import { createStore } from "solid-js/store"
 import { useSDK } from "@tui/context/sdk"
 import { useSync } from "@tui/context/sync"
-import { useTheme } from "@tui/context/theme"
+import { useTheme, selectedForeground } from "@tui/context/theme"
 import { SplitBorder } from "@tui/component/border"
 import { useCommandDialog } from "@tui/component/dialog-command"
 import { Locale } from "@/util/locale"
@@ -455,7 +455,7 @@ export function Autocomplete(props: {
       {...SplitBorder}
       borderColor={theme.border}
     >
-      <box backgroundColor={theme.backgroundElement} height={height()}>
+      <box backgroundColor={theme.backgroundMenu} height={height()}>
         <For
           each={options()}
           fallback={
@@ -471,11 +471,11 @@ export function Autocomplete(props: {
               backgroundColor={index() === store.selected ? theme.primary : undefined}
               flexDirection="row"
             >
-              <text fg={index() === store.selected ? theme.background : theme.text} flexShrink={0}>
+              <text fg={index() === store.selected ? selectedForeground(theme) : theme.text} flexShrink={0}>
                 {option.display}
               </text>
               <Show when={option.description}>
-                <text fg={index() === store.selected ? theme.background : theme.textMuted} wrapMode="none">
+                <text fg={index() === store.selected ? selectedForeground(theme) : theme.textMuted} wrapMode="none">
                   {option.description}
                 </text>
               </Show>

+ 11 - 4
packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx

@@ -791,10 +791,17 @@ export function Prompt(props: PromptProps) {
             height={1}
             border={["bottom"]}
             borderColor={theme.backgroundElement}
-            customBorderChars={{
-              ...EmptyBorder,
-              horizontal: "▀",
-            }}
+            customBorderChars={
+              theme.background.a != 0
+                ? {
+                    ...EmptyBorder,
+                    horizontal: "▀",
+                  }
+                : {
+                    ...EmptyBorder,
+                    horizontal: " ",
+                  }
+            }
           />
         </box>
         <box flexDirection="row" justifyContent="space-between">

+ 62 - 9
packages/opencode/src/cli/cmd/tui/context/theme.tsx

@@ -33,7 +33,7 @@ import { createStore, produce } from "solid-js/store"
 import { Global } from "@/global"
 import { Filesystem } from "@/util/filesystem"
 
-type Theme = {
+type ThemeColors = {
   primary: RGBA
   secondary: RGBA
   accent: RGBA
@@ -43,9 +43,11 @@ type Theme = {
   info: RGBA
   text: RGBA
   textMuted: RGBA
+  selectedListItemText: RGBA
   background: RGBA
   backgroundPanel: RGBA
   backgroundElement: RGBA
+  backgroundMenu: RGBA
   border: RGBA
   borderActive: RGBA
   borderSubtle: RGBA
@@ -86,6 +88,27 @@ type Theme = {
   syntaxPunctuation: RGBA
 }
 
+type Theme = ThemeColors & {
+  _hasSelectedListItemText: boolean
+}
+
+export function selectedForeground(theme: Theme): RGBA {
+  // If theme explicitly defines selectedListItemText, use it
+  if (theme._hasSelectedListItemText) {
+    return theme.selectedListItemText
+  }
+
+  // For transparent backgrounds, calculate contrast based on primary color
+  if (theme.background.a === 0) {
+    const { r, g, b } = theme.primary
+    const luminance = 0.299 * r + 0.587 * g + 0.114 * b
+    return luminance > 0.5 ? RGBA.fromInts(0, 0, 0) : RGBA.fromInts(255, 255, 255)
+  }
+
+  // Fall back to background color
+  return theme.background
+}
+
 type HexColor = `#${string}`
 type RefName = string
 type Variant = {
@@ -96,7 +119,10 @@ type ColorValue = HexColor | RefName | Variant | RGBA
 type ThemeJson = {
   $schema?: string
   defs?: Record<string, HexColor | RefName>
-  theme: Record<keyof Theme, ColorValue>
+  theme: Omit<Record<keyof ThemeColors, ColorValue>, "selectedListItemText" | "backgroundMenu"> & {
+    selectedListItemText?: ColorValue
+    backgroundMenu?: ColorValue
+  }
 }
 
 export const DEFAULT_THEMES: Record<string, ThemeJson> = {
@@ -137,19 +163,44 @@ function resolveTheme(theme: ThemeJson, mode: "dark" | "light") {
 
       if (defs[c]) {
         return resolveColor(defs[c])
-      } else if (theme.theme[c as keyof Theme]) {
-        return resolveColor(theme.theme[c as keyof Theme])
+      } else if (theme.theme[c as keyof ThemeColors] !== undefined) {
+        return resolveColor(theme.theme[c as keyof ThemeColors]!)
       } else {
         throw new Error(`Color reference "${c}" not found in defs or theme`)
       }
     }
     return resolveColor(c[mode])
   }
-  return Object.fromEntries(
-    Object.entries(theme.theme).map(([key, value]) => {
-      return [key, resolveColor(value)]
-    }),
-  ) as Theme
+
+  const resolved = Object.fromEntries(
+    Object.entries(theme.theme)
+      .filter(([key]) => key !== "selectedListItemText" && key !== "backgroundMenu")
+      .map(([key, value]) => {
+        return [key, resolveColor(value)]
+      }),
+  ) as Partial<ThemeColors>
+
+  // Handle selectedListItemText separately since it's optional
+  const hasSelectedListItemText = theme.theme.selectedListItemText !== undefined
+  if (hasSelectedListItemText) {
+    resolved.selectedListItemText = resolveColor(theme.theme.selectedListItemText!)
+  } else {
+    // Backward compatibility: if selectedListItemText is not defined, use background color
+    // This preserves the current behavior for all existing themes
+    resolved.selectedListItemText = resolved.background
+  }
+
+  // Handle backgroundMenu - optional with fallback to backgroundElement
+  if (theme.theme.backgroundMenu !== undefined) {
+    resolved.backgroundMenu = resolveColor(theme.theme.backgroundMenu)
+  } else {
+    resolved.backgroundMenu = resolved.backgroundElement
+  }
+
+  return {
+    ...resolved,
+    _hasSelectedListItemText: hasSelectedListItemText,
+  } as Theme
 }
 
 export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
@@ -288,11 +339,13 @@ function generateSystem(colors: TerminalColors, mode: "dark" | "light"): ThemeJs
       // Text colors
       text: fg,
       textMuted,
+      selectedListItemText: bg,
 
       // Background colors
       background: bg,
       backgroundPanel: grays[2],
       backgroundElement: grays[3],
+      backgroundMenu: grays[3],
 
       // Border colors
       borderSubtle: grays[6],

+ 1 - 1
packages/opencode/src/cli/cmd/tui/ui/dialog-alert.tsx

@@ -38,7 +38,7 @@ export function DialogAlert(props: DialogAlertProps) {
             dialog.clear()
           }}
         >
-          <text fg={theme.background}>ok</text>
+          <text fg={theme.selectedListItemText}>ok</text>
         </box>
       </box>
     </box>

+ 3 - 1
packages/opencode/src/cli/cmd/tui/ui/dialog-confirm.tsx

@@ -53,7 +53,9 @@ export function DialogConfirm(props: DialogConfirmProps) {
                 dialog.clear()
               }}
             >
-              <text fg={key === store.active ? theme.background : theme.textMuted}>{Locale.titlecase(key)}</text>
+              <text fg={key === store.active ? theme.selectedListItemText : theme.textMuted}>
+                {Locale.titlecase(key)}
+              </text>
             </box>
           )}
         </For>

+ 1 - 1
packages/opencode/src/cli/cmd/tui/ui/dialog-help.tsx

@@ -28,7 +28,7 @@ export function DialogHelp() {
       </box>
       <box flexDirection="row" justifyContent="flex-end" paddingBottom={1}>
         <box paddingLeft={3} paddingRight={3} backgroundColor={theme.primary} onMouseUp={() => dialog.clear()}>
-          <text fg={theme.background}>ok</text>
+          <text fg={theme.selectedListItemText}>ok</text>
         </box>
       </box>
     </box>

+ 7 - 10
packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx

@@ -1,5 +1,5 @@
 import { InputRenderable, RGBA, ScrollBoxRenderable, TextAttributes } from "@opentui/core"
-import { useTheme } from "@tui/context/theme"
+import { useTheme, selectedForeground } from "@tui/context/theme"
 import { entries, filter, flatMap, groupBy, pipe, take } from "remeda"
 import { batch, createEffect, createMemo, For, Show, type JSX } from "solid-js"
 import { createStore } from "solid-js/store"
@@ -262,32 +262,29 @@ function Option(props: {
   onMouseOver?: () => void
 }) {
   const { theme } = useTheme()
+  const fg = selectedForeground(theme)
 
   return (
     <>
       <Show when={props.current}>
-        <text
-          flexShrink={0}
-          fg={props.active ? theme.background : props.current ? theme.primary : theme.text}
-          marginRight={0.5}
-        >
-          ◆
+        <text flexShrink={0} fg={props.active ? fg : props.current ? theme.primary : theme.text} marginRight={0.5}>
+          ●
         </text>
       </Show>
       <text
         flexGrow={1}
-        fg={props.active ? theme.background : props.current ? theme.primary : theme.text}
+        fg={props.active ? fg : props.current ? theme.primary : theme.text}
         attributes={props.active ? TextAttributes.BOLD : undefined}
         overflow="hidden"
         wrapMode="none"
         paddingLeft={3}
       >
         {Locale.truncate(props.title, 62)}
-        <span style={{ fg: props.active ? theme.background : theme.textMuted }}> {props.description}</span>
+        <span style={{ fg: props.active ? fg : theme.textMuted }}> {props.description}</span>
       </text>
       <Show when={props.footer}>
         <box flexShrink={0}>
-          <text fg={props.active ? theme.background : theme.textMuted}>{props.footer}</text>
+          <text fg={props.active ? fg : theme.textMuted}>{props.footer}</text>
         </box>
       </Show>
     </>

+ 1 - 0
packages/web/public/theme.json

@@ -46,6 +46,7 @@
         "info": { "$ref": "#/definitions/colorValue" },
         "text": { "$ref": "#/definitions/colorValue" },
         "textMuted": { "$ref": "#/definitions/colorValue" },
+        "selectedListItemText": { "$ref": "#/definitions/colorValue" },
         "background": { "$ref": "#/definitions/colorValue" },
         "backgroundPanel": { "$ref": "#/definitions/colorValue" },
         "backgroundElement": { "$ref": "#/definitions/colorValue" },