feat(tui): add floating side panel for theme selector

Position theme dialog on right edge without backdrop overlay,
allowing full visibility of theme changes in real-time.
This commit is contained in:
anntnzrb 2025-12-20 18:44:23 -05:00
parent c81506b28d
commit 60b730f7ca
3 changed files with 48 additions and 17 deletions

View file

@ -16,6 +16,10 @@ export function DialogThemeList() {
let ref: DialogSelectRef<string>
const initial = theme.selected
onMount(() => {
dialog.setPosition("floating-right")
})
onCleanup(() => {
if (!confirmed) theme.set(initial)
})
@ -25,6 +29,7 @@ export function DialogThemeList() {
title="Themes"
options={options}
current={initial}
compact
onMove={(opt) => {
theme.set(opt.value)
}}

View file

@ -26,6 +26,7 @@ export interface DialogSelectProps<T> {
onTrigger: (option: DialogSelectOption<T>) => void
}[]
current?: T
compact?: boolean
}
export interface DialogSelectOption<T = any> {
@ -97,9 +98,13 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
})
const dimensions = useTerminalDimensions()
const height = createMemo(() =>
Math.min(flat().length + grouped().length * 2 - 1, Math.floor(dimensions().height / 2) - 6),
)
const height = createMemo(() => {
const itemCount = flat().length + grouped().length * 2 - 1
const maxHeight = props.compact
? Math.floor(dimensions().height * 0.8) - 6
: Math.floor(dimensions().height / 2) - 6
return Math.min(itemCount, maxHeight)
})
const selected = createMemo(() => flat()[store.selected])
@ -185,7 +190,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
return (
<box gap={1} paddingBottom={1}>
<box paddingLeft={4} paddingRight={4}>
<box paddingLeft={props.compact ? 2 : 4} paddingRight={props.compact ? 2 : 4}>
<box flexDirection="row" justifyContent="space-between">
<text fg={theme.text} attributes={TextAttributes.BOLD}>
{props.title}
@ -257,6 +262,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
active={active()}
current={current()}
gutter={option.gutter}
compact={props.compact}
/>
</box>
)
@ -292,6 +298,7 @@ function Option(props: {
footer?: JSX.Element | string
gutter?: JSX.Element
onMouseOver?: () => void
compact?: boolean
}) {
const { theme } = useTheme()
const fg = selectedForeground(theme)
@ -315,7 +322,7 @@ function Option(props: {
overflow="hidden"
paddingLeft={3}
>
{Locale.truncate(props.title, 61)}
{Locale.truncate(props.title, props.compact ? 24 : 61)}
<Show when={props.description}>
<span style={{ fg: props.active ? fg : theme.textMuted }}> {props.description}</span>
</Show>

View file

@ -6,15 +6,19 @@ import { createStore } from "solid-js/store"
import { Clipboard } from "@tui/util/clipboard"
import { useToast } from "./toast"
export type DialogPosition = "center" | "floating-right"
export function Dialog(
props: ParentProps<{
size?: "medium" | "large"
position?: DialogPosition
onClose: () => void
}>,
) {
const dimensions = useTerminalDimensions()
const { theme } = useTheme()
const renderer = useRenderer()
const isFloating = () => props.position === "floating-right"
return (
<box
@ -24,20 +28,24 @@ export function Dialog(
}}
width={dimensions().width}
height={dimensions().height}
alignItems="center"
alignItems={isFloating() ? "flex-end" : "center"}
justifyContent={isFloating() ? "flex-end" : undefined}
position="absolute"
paddingTop={dimensions().height / 4}
paddingTop={isFloating() ? 0 : dimensions().height / 4}
paddingRight={isFloating() ? 1 : 0}
paddingBottom={isFloating() ? 1 : 0}
left={0}
top={0}
backgroundColor={RGBA.fromInts(0, 0, 0, 150)}
backgroundColor={isFloating() ? RGBA.fromInts(0, 0, 0, 0) : RGBA.fromInts(0, 0, 0, 150)}
>
<box
onMouseUp={async (e) => {
if (renderer.getSelection()) return
e.stopPropagation()
}}
width={props.size === "large" ? 80 : 60}
width={isFloating() ? 30 : props.size === "large" ? 80 : 60}
maxWidth={dimensions().width - 2}
maxHeight={isFloating() ? Math.floor(dimensions().height * 0.8) : undefined}
backgroundColor={theme.backgroundPanel}
paddingTop={1}
>
@ -54,6 +62,7 @@ function init() {
onClose?: () => void
}[],
size: "medium" as "medium" | "large",
position: "center" as DialogPosition,
})
useKeyboard((evt) => {
@ -92,6 +101,7 @@ function init() {
}
batch(() => {
setStore("size", "medium")
setStore("position", "center")
setStore("stack", [])
})
refocus()
@ -103,13 +113,16 @@ function init() {
for (const item of store.stack) {
if (item.onClose) item.onClose()
}
setStore("size", "medium")
setStore("stack", [
{
element: input,
onClose,
},
])
batch(() => {
setStore("size", "medium")
setStore("position", "center")
setStore("stack", [
{
element: input,
onClose,
},
])
})
},
get stack() {
return store.stack
@ -117,9 +130,15 @@ function init() {
get size() {
return store.size
},
get position() {
return store.position
},
setSize(size: "medium" | "large") {
setStore("size", size)
},
setPosition(position: DialogPosition) {
setStore("position", position)
},
}
}
@ -152,7 +171,7 @@ export function DialogProvider(props: ParentProps) {
}}
>
<Show when={value.stack.length}>
<Dialog onClose={() => value.clear()} size={value.size}>
<Dialog onClose={() => value.clear()} size={value.size} position={value.position}>
{value.stack.at(-1)!.element}
</Dialog>
</Show>