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:
@@ -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),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user