refactor(tui): simplify scrollbox sizing

This commit is contained in:
Dax Raad
2026-09-18 11:17:22 -04:00
parent 278db3023f
commit 9e1fa80fb7
2 changed files with 54 additions and 99 deletions
@@ -1,6 +1,6 @@
import { CliRenderEvents, TextAttributes, type ScrollBoxRenderable } from "@opentui/core"
import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
import { createEffect, createMemo, createSignal, onCleanup, Show } from "solid-js"
import { TextAttributes, type ScrollBoxRenderable } from "@opentui/core"
import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
import { createSignal, Show } from "solid-js"
import { useConfig } from "../config"
import { useClipboard } from "../context/clipboard"
import { Keymap } from "../context/keymap"
@@ -28,34 +28,10 @@ export function DialogErrorDetails(props: {
const route = useRoute()
const toast = useToast()
const theme = useTheme("elevated")
const renderer = useRenderer()
const dimensions = useTerminalDimensions()
const config = useConfig().data
const [copied, setCopied] = createSignal(false)
const [scrollable, setScrollable] = createSignal(false)
const [height, setHeight] = createSignal(1)
const maxHeight = createMemo(() => Math.max(3, Math.floor(dimensions().height / 2) - 5))
let scroll: ScrollBoxRenderable | undefined
let measure: (() => void) | undefined
createEffect(() => {
dimensions()
props.error
if (measure) renderer.off(CliRenderEvents.FRAME, measure)
measure = () => {
measure = undefined
if (!scroll) return
const next = Math.max(1, Math.min(maxHeight(), scroll.scrollHeight))
setHeight(next)
setScrollable(scroll.scrollHeight > next)
}
renderer.once(CliRenderEvents.FRAME, measure)
renderer.requestRender()
})
onCleanup(() => {
if (measure) renderer.off(CliRenderEvents.FRAME, measure)
})
const copy = () => {
void clipboard
@@ -86,11 +62,10 @@ export function DialogErrorDetails(props: {
}))
useKeyboard((event) => {
if (!scrollable()) return
if (event.name === "up") return scroll?.scrollBy(-1)
if (event.name === "down") return scroll?.scrollBy(1)
if (event.name === "pageup") return scroll?.scrollBy(-maxHeight())
if (event.name === "pagedown") return scroll?.scrollBy(maxHeight())
if (event.name === "pageup") return scroll?.scrollBy(-20)
if (event.name === "pagedown") return scroll?.scrollBy(20)
if (event.name === "home") return scroll?.scrollTo(0)
if (event.name === "end" && scroll) return scroll.scrollTo(scroll.scrollHeight)
})
@@ -126,7 +101,8 @@ export function DialogErrorDetails(props: {
<box>
<scrollbox
ref={(element: ScrollBoxRenderable) => (scroll = element)}
height={height()}
maxHeight={20}
contentOptions={{ minHeight: 0 }}
scrollbarOptions={{ visible: false }}
scrollAcceleration={getScrollAcceleration(config)}
>
@@ -151,9 +127,7 @@ export function DialogErrorDetails(props: {
</span>
<span style={{ fg: theme.text.subdued }}>{copied() ? "" : " copy details"}</span>
</text>
<Show when={scrollable()}>
<text fg={theme.text.subdued}>↑/↓ scroll</text>
</Show>
<text fg={theme.text.subdued}>↑/↓ scroll</text>
</box>
</box>
)
+46 -65
View File
@@ -2,10 +2,10 @@ import { createStore, unwrap } from "solid-js/store"
import { createEffect, createMemo, createSignal, For, onCleanup, onMount, Show } from "solid-js"
import { usePaste, useRenderer, useTerminalDimensions } from "@opentui/solid"
import {
CliRenderEvents,
decodePasteBytes,
stripAnsiSequences,
TextAttributes,
type BoxRenderable,
type ScrollBoxRenderable,
type TextareaRenderable,
} from "@opentui/core"
@@ -75,8 +75,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
drafts.delete(props.form.id)
const [tabHover, setTabHover] = createSignal<number | "confirm" | null>(null)
const [reviewHeight, setReviewHeight] = createSignal(1)
const [reviewScrollable, setReviewScrollable] = createSignal(false)
const [reviewContentHeight, setReviewContentHeight] = createSignal(0)
const [store, setStore] = createStore<FormDraft>(
draft ?? {
tab: 0,
@@ -92,7 +91,6 @@ export function FormPrompt(props: { form: FormWithLocation }) {
let textarea: TextareaRenderable | undefined
const [inputTarget, setInputTarget] = createSignal<TextareaRenderable>()
let review: ScrollBoxRenderable | undefined
let measureReview: (() => void) | undefined
const message = createMemo(() => {
const value = props.form.metadata?.["message"]
@@ -141,6 +139,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
return current?.type === "external" ? current : undefined
})
const confirm = createMemo(() => !single() && store.tab >= fields().length)
const reviewMaxHeight = createMemo(() => Math.max(3, dimensions().height - 14))
const configuredRows = createMemo(() => {
const current = answerField()
return current ? formRows(current) : []
@@ -211,32 +210,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
return "confirm"
})
createEffect(() => {
if (measureReview) renderer.off(CliRenderEvents.FRAME, measureReview)
if (!confirm()) {
measureReview = undefined
review = undefined
setReviewScrollable(false)
return
}
const limit = Math.max(3, dimensions().height - 14)
const initial = Math.min(Math.max(1, fields().length), limit)
Object.values(store.answers)
setReviewHeight(initial)
setReviewScrollable(false)
measureReview = () => {
measureReview = undefined
const content = review?.scrollHeight ?? initial
const height = Math.min(Math.max(1, content), limit)
setReviewHeight(height)
setReviewScrollable(content > height)
}
renderer.once(CliRenderEvents.FRAME, measureReview)
renderer.requestRender()
})
onCleanup(() => {
if (measureReview) renderer.off(CliRenderEvents.FRAME, measureReview)
// A reply or cancel removes the form from data before this unmount runs, so a
// form still listed here is only hidden by navigation and worth restoring.
const pending = data.session.form
@@ -1102,56 +1076,63 @@ export function FormPrompt(props: { form: FormWithLocation }) {
<Show when={confirm()}>
<scrollbox
height={reviewHeight()}
maxHeight={reviewMaxHeight()}
contentOptions={{ minHeight: 0 }}
scrollbarOptions={{ visible: false }}
ref={(r: ScrollBoxRenderable) => (review = r)}
>
<For each={fields()}>
{(item) => {
if (item.type === "external") {
const acknowledged = () => store.answers[item.key] === true
<box
onSizeChange={function (this: BoxRenderable) {
setReviewContentHeight(this.height)
}}
>
<For each={fields()}>
{(item) => {
if (item.type === "external") {
const acknowledged = () => store.answers[item.key] === true
return (
<box paddingLeft={1}>
<text>
<span style={{ fg: theme.text.subdued }}>{truncate(formLabel(item), 40)}:</span>{" "}
<span
style={{
fg: acknowledged()
? theme.text.feedback.success.default
: theme.text.feedback.error.default,
}}
>
{acknowledged() ? "Acknowledged" : "(acknowledgement required)"}
</span>
</text>
</box>
)
}
const value = () => formDisplayValue(item, store.answers[item.key], "(none)")
const answered = () => store.answers[item.key] !== undefined
const missing = () => !answered() && item.required === true
const invalid = () => formValidateValue(item, store.answers[item.key])
return (
<box paddingLeft={1}>
<text>
<span style={{ fg: theme.text.subdued }}>{truncate(formLabel(item), 40)}:</span>{" "}
<span
style={{
fg: acknowledged()
? theme.text.feedback.success.default
: theme.text.feedback.error.default,
fg:
invalid() || missing()
? theme.text.feedback.error.default
: answered()
? theme.text.default
: theme.text.subdued,
}}
>
{acknowledged() ? "Acknowledged" : "(acknowledgement required)"}
{invalid() ?? (answered() ? value() : missing() ? "(required)" : "(not answered)")}
</span>
</text>
</box>
)
}
const value = () => formDisplayValue(item, store.answers[item.key], "(none)")
const answered = () => store.answers[item.key] !== undefined
const missing = () => !answered() && item.required === true
const invalid = () => formValidateValue(item, store.answers[item.key])
return (
<box paddingLeft={1}>
<text>
<span style={{ fg: theme.text.subdued }}>{truncate(formLabel(item), 40)}:</span>{" "}
<span
style={{
fg:
invalid() || missing()
? theme.text.feedback.error.default
: answered()
? theme.text.default
: theme.text.subdued,
}}
>
{invalid() ?? (answered() ? value() : missing() ? "(required)" : "(not answered)")}
</span>
</text>
</box>
)
}}
</For>
}}
</For>
</box>
</scrollbox>
</Show>
</box>
@@ -1175,7 +1156,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
{"↑↓"} <span style={{ fg: theme.text.subdued }}>select</span>
</text>
</Show>
<Show when={confirm() && reviewScrollable()}>
<Show when={confirm() && reviewContentHeight() > reviewMaxHeight()}>
<text fg={theme.text.default}>
{"↑↓"} <span style={{ fg: theme.text.subdued }}>scroll</span>
</text>