tui: add preview margin to menu scrolling

The footer menu now starts scrolling before the selection reaches
the viewport edge, keeping surrounding items visible for context.
Extract offset logic into pure helpers and add tests.

Simplify theme primary color selection to use fixed ansi colors
instead of a contrast/chroma heuristic, and suppress diff
backgrounds in scrollback mode for cleaner rendering.
This commit is contained in:
Simon Klee
2026-05-06 11:24:48 +02:00
parent 2c260c4b46
commit ca1f041b02
5 changed files with 188 additions and 115 deletions
+110 -50
View File
@@ -16,6 +16,41 @@ type RunFooterMenuRow =
| { type: "item"; item: RunFooterMenuItem; index: number }
| { type: "spacer" }
function maxOffset(count: number, limit: number) {
return Math.max(0, count - limit)
}
function previewMargin(limit: number) {
return Math.max(0, Math.min(2, Math.floor((limit - 1) / 2)))
}
function revealOffset(value: number, input: { count: number; limit: number; selected: number }) {
const max = maxOffset(input.count, input.limit)
if (input.selected < value) {
return Math.min(max, input.selected)
}
if (input.selected >= value + input.limit) {
return Math.min(max, input.selected - input.limit + 1)
}
return Math.min(max, value)
}
function moveOffset(value: number, input: { count: number; limit: number; selected: number; dir: -1 | 1 }) {
const max = maxOffset(input.count, input.limit)
const margin = previewMargin(input.limit)
if (input.dir < 0 && input.selected < value + margin) {
return Math.max(0, Math.min(max, input.selected - margin))
}
if (input.dir > 0 && input.selected > value + input.limit - margin - 1) {
return Math.min(max, input.selected - input.limit + margin + 1)
}
return Math.min(max, value)
}
export function createFooterMenuState(input: { count: Accessor<number>; limit?: number }) {
const [selected, setSelected] = createSignal(0)
const [offset, setOffset] = createSignal(0)
@@ -32,18 +67,7 @@ export function createFooterMenuState(input: { count: Accessor<number>; limit?:
const next = Math.max(0, Math.min(count - 1, index))
setSelected(next)
setOffset((value) => {
const max = Math.max(0, count - limit())
if (next < value) {
return Math.min(max, next)
}
if (next >= value + limit()) {
return Math.min(max, next - limit() + 1)
}
return Math.min(max, value)
})
setOffset((value) => revealOffset(value, { count, limit: limit(), selected: next }))
}
const reset = () => {
@@ -62,27 +86,28 @@ export function createFooterMenuState(input: { count: Accessor<number>; limit?:
setSelected(count - 1)
}
setOffset((value) => {
const max = Math.max(0, count - limit())
if (selected() < value) {
return Math.min(max, selected())
}
if (selected() >= value + limit()) {
return Math.min(max, selected() - limit() + 1)
}
return Math.min(max, value)
})
setOffset((value) => revealOffset(value, { count, limit: limit(), selected: selected() }))
})
const move = (dir: -1 | 1) => {
const count = input.count()
if (count === 0) {
reset()
return
}
const next = Math.max(0, Math.min(count - 1, selected() + dir))
setSelected(next)
setOffset((value) => moveOffset(value, { count, limit: limit(), selected: next, dir }))
}
return {
selected,
offset,
rows,
reveal,
reset,
move: (dir: -1 | 1) => reveal(selected() + dir),
move,
}
}
@@ -102,15 +127,9 @@ export function RunFooterMenu(props: {
}) {
const limit = () => props.limit ?? FOOTER_MENU_ROWS
const border = () => props.border ?? true
const rows = createMemo<RunFooterMenuRow[]>(() => {
if (!props.grouped) {
return props.items().slice(props.offset(), props.offset() + limit()).map((item, index) => ({
type: "item",
item,
index: index + props.offset(),
}))
}
const [groupOffset, setGroupOffset] = createSignal(0)
let previous = -1
const groupedRows = createMemo<RunFooterMenuRow[]>(() => {
const all: RunFooterMenuRow[] = []
let category = ""
props.items().forEach((item, index) => {
@@ -125,13 +144,45 @@ export function RunFooterMenu(props: {
all.push({ type: "item", item, index })
})
return all
})
const selected = all.findIndex((item) => item.type === "item" && item.index === props.selected())
if (selected === -1) {
return all.slice(0, limit())
createEffect(() => {
if (!props.grouped) {
return
}
const start = Math.max(0, Math.min(selected - limit() + 1, all.length - limit()))
const all = groupedRows()
const selected = all.findIndex((item) => item.type === "item" && item.index === props.selected())
if (all.length === 0 || selected === -1) {
setGroupOffset(0)
previous = props.selected()
return
}
const dir =
props.selected() === previous + 1 ? 1
: props.selected() === previous - 1 ? -1
: undefined
setGroupOffset((value) =>
dir
? moveOffset(value, { count: all.length, limit: limit(), selected, dir })
: revealOffset(value, { count: all.length, limit: limit(), selected }),
)
previous = props.selected()
})
const rows = createMemo<RunFooterMenuRow[]>(() => {
if (!props.grouped) {
return props.items().slice(props.offset(), props.offset() + limit()).map((item, index) => ({
type: "item",
item,
index: index + props.offset(),
}))
}
const all = groupedRows()
const start = Math.max(0, Math.min(groupOffset(), all.length - limit()))
return all.slice(start, start + limit())
})
const descriptionColumn = createMemo(() => {
@@ -189,6 +240,7 @@ export function RunFooterMenu(props: {
}
const active = () => row.index === props.selected()
const inset = () => (active() ? 1 : 0)
return (
<box paddingRight={0} flexDirection="row" backgroundColor={transparent}>
{border() ? (
@@ -199,19 +251,27 @@ export function RunFooterMenu(props: {
<box
flexGrow={1}
flexShrink={1}
paddingLeft={props.paddingLeft ?? 1}
paddingRight={props.paddingRight ?? 0}
backgroundColor={active() ? props.theme().highlight : props.theme().surface}
paddingLeft={inset()}
paddingRight={inset()}
backgroundColor={props.theme().surface}
>
<text fg={active() ? props.theme().surface : props.theme().text} wrapMode="none" truncate>
{row.item.display}
{row.item.description ? (
<span style={{ fg: active() ? props.theme().surface : props.theme().muted }}>
{descriptionPad(row.item)}
{row.item.description}
</span>
) : undefined}
</text>
<box
flexGrow={1}
flexShrink={1}
paddingLeft={Math.max(0, (props.paddingLeft ?? 1) - inset())}
paddingRight={Math.max(0, (props.paddingRight ?? 0) - inset())}
backgroundColor={active() ? props.theme().highlight : props.theme().surface}
>
<text fg={active() ? props.theme().surface : props.theme().text} wrapMode="none" truncate>
{row.item.display}
{row.item.description ? (
<span style={{ fg: active() ? props.theme().surface : props.theme().muted }}>
{descriptionPad(row.item)}
{row.item.description}
</span>
) : undefined}
</text>
</box>
</box>
</box>
)
@@ -1,12 +1,12 @@
/** @jsxImportSource @opentui/solid */
import { createScrollbackWriter } from "@opentui/solid"
import { TextRenderable, type ScrollbackWriter } from "@opentui/core"
import { TextRenderable, type ColorInput, type ScrollbackWriter } from "@opentui/core"
import { Match, Switch, createMemo } from "solid-js"
import { entryBody, entryFlags } from "./entry.body"
import { entryColor, entryLook, entrySyntax } from "./scrollback.shared"
import { toolDiffView, toolFiletype, toolStructuredFinal } from "./tool"
import { RUN_THEME_FALLBACK, type RunTheme } from "./theme"
import { RUN_THEME_FALLBACK, transparent, type RunTheme } from "./theme"
import type { EntryLayout, RunEntryBody, ScrollbackOptions, StreamCommit } from "./types"
function todoText(item: { status: string; content: string }): string {
@@ -94,6 +94,10 @@ export function RunEntryContent(props: {
const style = createMemo(() => entryLook(props.commit, theme().entry))
const syntax = createMemo(() => entrySyntax(props.commit, theme()))
const color = createMemo(() => entryColor(props.commit, theme()))
const suppressBackgrounds = createMemo(() => props.opts?.suppressBackgrounds === true)
const diffBg = (color: ColorInput) => (suppressBackgrounds() ? transparent : color)
const diffSign = (normal: ColorInput, highlight: ColorInput) => (suppressBackgrounds() ? normal : highlight)
const diffTitle = createMemo(() => (suppressBackgrounds() ? theme().block.text : theme().block.muted))
const streaming = createMemo(() => props.commit.phase === "progress")
const width = createMemo(() => Math.max(1, Math.trunc(props.width ?? 80)))
const view = createMemo(() => toolDiffView(width(), props.opts?.diffStyle))
@@ -184,7 +188,7 @@ export function RunEntryContent(props: {
<box width="100%" flexDirection="column" gap={1}>
{snap().items.map((item) => (
<box width="100%" flexDirection="column" gap={1}>
<text width="100%" wrapMode="word" fg={theme().block.muted}>
<text width="100%" wrapMode="word" fg={diffTitle()}>
{item.title}
</text>
{item.diff.trim() ? (
@@ -198,15 +202,15 @@ export function RunEntryContent(props: {
width="100%"
wrapMode="word"
fg={theme().block.text}
addedBg={theme().block.diffAddedBg}
removedBg={theme().block.diffRemovedBg}
contextBg={theme().block.diffContextBg}
addedSignColor={theme().block.diffHighlightAdded}
removedSignColor={theme().block.diffHighlightRemoved}
addedBg={diffBg(theme().block.diffAddedBg)}
removedBg={diffBg(theme().block.diffRemovedBg)}
contextBg={diffBg(theme().block.diffContextBg)}
addedSignColor={diffSign(theme().block.diffAdded, theme().block.diffHighlightAdded)}
removedSignColor={diffSign(theme().block.diffRemoved, theme().block.diffHighlightRemoved)}
lineNumberFg={theme().block.diffLineNumber}
lineNumberBg={theme().block.diffContextBg}
addedLineNumberBg={theme().block.diffAddedLineNumberBg}
removedLineNumberBg={theme().block.diffRemovedLineNumberBg}
lineNumberBg={diffBg(theme().block.diffContextBg)}
addedLineNumberBg={diffBg(theme().block.diffAddedLineNumberBg)}
removedLineNumberBg={diffBg(theme().block.diffRemovedLineNumberBg)}
/>
</box>
) : (
@@ -309,7 +313,14 @@ export function entryWriter(input: {
opts?: ScrollbackOptions
}): ScrollbackWriter {
return createScrollbackWriter(
(ctx) => <RunEntryContent commit={input.commit} theme={input.theme} opts={input.opts} width={ctx.width} />,
(ctx) => (
<RunEntryContent
commit={input.commit}
theme={input.theme}
opts={{ ...input.opts, suppressBackgrounds: true }}
width={ctx.width}
/>
),
entryFlags(input.commit),
)
}
+11 -53
View File
@@ -176,10 +176,6 @@ function blend(color: RGBA, bg: RGBA): RGBA {
)
}
function luminance(color: RGBA) {
return 0.299 * color.r + 0.587 * color.g + 0.114 * color.b
}
function chroma(color: RGBA) {
return Math.max(color.r, color.g, color.b) - Math.min(color.r, color.g, color.b)
}
@@ -289,24 +285,6 @@ export function resolveTheme(theme: ThemeJson, pick: "dark" | "light"): TuiTheme
}
}
function pickPrimaryColor(
bg: RGBA,
candidates: Array<{
key: string
color: RGBA | undefined
}>,
) {
return candidates
.flatMap((item) => {
if (!item.color) return []
const contrast = Math.abs(luminance(item.color) - luminance(bg))
const vivid = chroma(item.color)
if (contrast < 0.16 || vivid < 0.12) return []
return [{ key: item.key, color: item.color, score: vivid * 1.5 + contrast }]
})
.sort((a, b) => b.score - a.score)[0]
}
function generateGrayScale(bg: RGBA, isDark: boolean, map: (rgba: RGBA) => RGBA): Record<number, RGBA> {
const r = bg.r * 255
const g = bg.g * 255
@@ -389,34 +367,14 @@ export function generateSystem(colors: TerminalColors, pick: "dark" | "light"):
const diff_alpha = isDark ? 0.22 : 0.14
const diff_context_bg = grays[2]
const primary =
pickPrimaryColor(bg_snapshot, [
{
key: "cursor",
color: colors.cursorColor ? nearest(RGBA.fromHex(colors.cursorColor)) : undefined,
},
{
key: "selection",
color: colors.highlightBackground ? nearest(RGBA.fromHex(colors.highlightBackground)) : undefined,
},
{
key: "blue",
color: ansi.blue,
},
{
key: "magenta",
color: ansi.magenta,
},
]) ?? {
key: "blue",
color: ansi.blue,
}
const primary = ansi.cyan
const secondary = ansi.magenta
return {
theme: {
primary: primary.color,
secondary: primary.key === "magenta" ? ansi.blue : ansi.magenta,
accent: primary.color,
primary,
secondary,
accent: primary,
error: ansi.red,
warning: ansi.yellow,
success: ansi.green,
@@ -550,13 +508,13 @@ function map(
}
const seed = {
highlight: rgba("#38bdf8"),
muted: rgba("#64748b"),
text: rgba("#f8fafc"),
highlight: RGBA.fromIndex(6, rgba("#38bdf8")),
muted: RGBA.fromIndex(8, rgba("#64748b")),
text: RGBA.defaultForeground(rgba("#f8fafc")),
panel: rgba("#0f172a"),
success: rgba("#22c55e"),
warning: rgba("#f59e0b"),
error: rgba("#ef4444"),
success: RGBA.fromIndex(2, rgba("#22c55e")),
warning: RGBA.fromIndex(3, rgba("#f59e0b")),
error: RGBA.fromIndex(1, rgba("#ef4444")),
}
function tone(body: ColorInput, start?: ColorInput): Tone {
@@ -89,6 +89,7 @@ export type RunDiffStyle = "auto" | "stacked"
export type ScrollbackOptions = {
diffStyle?: RunDiffStyle
suppressBackgrounds?: boolean
}
export type ToolCodeSnapshot = {
@@ -0,0 +1,43 @@
import { expect, test } from "bun:test"
import { createRoot } from "solid-js"
import { FOOTER_MENU_ROWS, createFooterMenuState } from "@/cli/cmd/run/footer.menu"
function mount(count: number, limit = FOOTER_MENU_ROWS) {
let dispose!: () => void
let menu!: ReturnType<typeof createFooterMenuState>
createRoot((nextDispose) => {
dispose = nextDispose
menu = createFooterMenuState({ count: () => count, limit })
return null
})
return { menu, dispose }
}
test("footer menu scrolls before the selected row hits the bottom edge", () => {
const state = mount(20)
try {
Array.from({ length: 6 }).forEach(() => state.menu.move(1))
expect(state.menu.selected()).toBe(6)
expect(state.menu.offset()).toBe(1)
} finally {
state.dispose()
}
})
test("footer menu scrolls before the selected row hits the top edge", () => {
const state = mount(20)
try {
Array.from({ length: 13 }).forEach(() => state.menu.move(1))
Array.from({ length: 4 }).forEach(() => state.menu.move(-1))
expect(state.menu.selected()).toBe(9)
expect(state.menu.offset()).toBe(7)
} finally {
state.dispose()
}
})