fix(tui): refine tab marquee motion (#42617)

This commit is contained in:
Kit Langton
2026-08-14 13:30:21 -04:00
committed by GitHub
parent f4620939aa
commit a609acbb67
4 changed files with 92 additions and 32 deletions
+46 -22
View File
@@ -1,6 +1,7 @@
import { RGBA, ScrollBoxRenderable, TextAttributes, type MouseEvent } from "@opentui/core"
import {
For,
Index,
Match,
Show,
Switch,
@@ -34,7 +35,7 @@ import { TabPulse, unreadGlowIntensity } from "./tab-pulse"
import { tint } from "../theme/color"
import { SESSION_SIDEBAR_WIDTH } from "../ui/layout"
import { projectName } from "../util/project"
import { marqueeCycleWidth, marqueeOverflows, marqueeText } from "../util/marquee"
import { marqueeCycleWidth, marqueeOverflows, marqueeTextParts } from "../util/marquee"
import { useDialog } from "../ui/dialog"
import { DialogSessionRename } from "./dialog-session-rename"
@@ -43,7 +44,7 @@ const FADE_WIDTH = 4
// The add button renders as " + " at the end of the strip, so the tab layout leaves it room.
const ADD_TAB_WIDTH = 3
const MARQUEE_DELAY = 600
const MARQUEE_INTERVAL = 100
const MARQUEE_INTERVAL = 80
const CONTEXT_MENU_WIDTH = 16
const RIGHT_MOUSE_BUTTON = 2
@@ -394,12 +395,19 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
const titleWidth = () => (hovered() === tab.sessionID ? hoveredTitleWidth() : restingTitleWidth())
const title = () => tab.title ?? "Untitled session"
const scrolling = () => marquee.active() === tab.sessionID
const visibleTitle = createMemo(() =>
const visibleTitleParts = createMemo(() =>
scrolling()
? marqueeText(title(), titleWidth(), marquee.offset())
: Locale.takeWidth(title(), titleWidth()),
? marqueeTextParts(title(), titleWidth(), marquee.offset())
: Locale.graphemes(Locale.takeWidth(title(), titleWidth())).map((value) => ({
value,
separator: false,
})),
)
const visibleTitle = createMemo(() =>
visibleTitleParts()
.map((part) => part.value)
.join(""),
)
const visibleTitleParts = createMemo(() => Locale.graphemes(visibleTitle()))
const titleFades = createMemo(() => marqueeOverflows(title(), titleWidth()) && titleWidth() > FADE_WIDTH)
const detail = createMemo(() => {
const fixture = tabs.detail?.(tab.sessionID)
@@ -497,13 +505,13 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
}
const separatorUpperColor = createMemo(() => tint(theme.background.default, previousGlowHue(), 0.1))
const separatorLowerColor = createMemo(() => tint(theme.background.default, glowHue(), 0.12))
const titleColor = (index: number) => {
const titleColor = (index: number, separator: boolean) => {
const level = titleGlow.value().level
const color =
level === 0
? foreground()
: glowTextColor(foreground(), glowColor(), 1 + numberWidth() + index, width(), level)
return titleFades()
const faded = titleFades()
? fadeTitleColor(
color,
pulseBackground(),
@@ -512,6 +520,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
scrolling() ? marquee.leading() : 0,
)
: color
return separator ? tint(faded, pulseBackground(), 0.55) : faded
}
const release = () => {
setDragging(undefined)
@@ -648,10 +657,15 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
selectable={false}
attributes={selected() ? TextAttributes.BOLD : undefined}
>
<Show when={titleGlow.value().level > 0 || titleFades()} fallback={visibleTitle()}>
<For each={visibleTitleParts()}>
{(character, index) => <span style={{ fg: titleColor(index()) }}>{character}</span>}
</For>
<Show
when={scrolling() || titleGlow.value().level > 0 || titleFades()}
fallback={visibleTitle()}
>
<Index each={visibleTitleParts()}>
{(part, index) => (
<span style={{ fg: titleColor(index, part().separator) }}>{part().value}</span>
)}
</Index>
</Show>
</text>
<text
@@ -984,12 +998,19 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
const hoveredTitleWidth = () => Math.max(1, restingTitleWidth() - 2)
const availableTitleWidth = () => (hovered() === tab.sessionID ? hoveredTitleWidth() : restingTitleWidth())
const scrolling = () => marquee.active() === tab.sessionID
const visibleTitle = createMemo(() =>
const visibleTitleParts = createMemo(() =>
scrolling()
? marqueeText(title(), availableTitleWidth(), marquee.offset())
: Locale.takeWidth(title(), availableTitleWidth()),
? marqueeTextParts(title(), availableTitleWidth(), marquee.offset())
: Locale.graphemes(Locale.takeWidth(title(), availableTitleWidth())).map((value) => ({
value,
separator: false,
})),
)
const visibleTitle = createMemo(() =>
visibleTitleParts()
.map((part) => part.value)
.join(""),
)
const visibleTitleParts = createMemo(() => Locale.graphemes(visibleTitle()))
const titleFades = createMemo(
() => marqueeOverflows(title(), availableTitleWidth()) && availableTitleWidth() > FADE_WIDTH,
)
@@ -1001,10 +1022,10 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
}
// Title characters sitting over the glow tinge toward its color, following the same
// spatial falloff as the glow itself; characters beyond the tail stay neutral.
const characterColor = (index: number) => {
const characterColor = (index: number, separator: boolean) => {
const base = foreground()
const color = glows() ? glowTextColor(base, glowColor(), 1 + numberWidth() + index, width()) : base
return titleFades()
const faded = titleFades()
? fadeTitleColor(
color,
background(),
@@ -1013,6 +1034,7 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
scrolling() ? marquee.leading() : 0,
)
: color
return separator ? tint(faded, background(), 0.55) : faded
}
// The running sweep's level under the number cell, reported by the pulse renderable.
const [sweepLevel, setSweepLevel] = createSignal(0)
@@ -1101,10 +1123,12 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
selectable={false}
attributes={bold()}
>
<Show when={glows() || titleFades()} fallback={visibleTitle()}>
<For each={visibleTitleParts()}>
{(character, index) => <span style={{ fg: characterColor(index()) }}>{character}</span>}
</For>
<Show when={scrolling() || glows() || titleFades()} fallback={visibleTitle()}>
<Index each={visibleTitleParts()}>
{(part, index) => (
<span style={{ fg: characterColor(index, part().separator) }}>{part().value}</span>
)}
</Index>
</Show>
</text>
<text
+32 -8
View File
@@ -3,6 +3,11 @@ import { stringWidth } from "./string-width"
const GAP = " · "
export type MarqueeTextPart = {
value: string
separator: boolean
}
export function marqueeCycleWidth(value: string) {
return stringWidth(value + GAP)
}
@@ -12,16 +17,35 @@ export function marqueeOverflows(value: string, width: number) {
}
export function marqueeText(value: string, width: number, offset: number) {
if (width <= 0) return ""
if (stringWidth(value) <= width || offset <= 0) return Locale.takeWidth(value, width)
return marqueeTextParts(value, width, offset)
.map((part) => part.value)
.join("")
}
const loop = value + GAP
export function marqueeTextParts(value: string, width: number, offset: number): MarqueeTextPart[] {
if (width <= 0) return []
if (stringWidth(value) <= width || offset <= 0)
return Locale.graphemes(Locale.takeWidth(value, width)).map((value) => ({ value, separator: false }))
const loop = [
...Locale.graphemes(value).map((value) => ({ value, separator: false })),
...Locale.graphemes(GAP).map((value) => ({ value, separator: value === "·" })),
]
const cursor = offset % marqueeCycleWidth(value)
const segments = Locale.graphemes(loop + loop)
const start = segments.reduce(
(state, segment, index) =>
state.width >= cursor ? state : { index: index + 1, width: state.width + stringWidth(segment) },
const parts = [...loop, ...loop]
const start = parts.reduce(
(state, part, index) =>
state.width >= cursor ? state : { index: index + 1, width: state.width + stringWidth(part.value) },
{ index: 0, width: 0 },
).index
return Locale.takeWidth(segments.slice(start).join(""), width)
const visible = parts.slice(start).reduce(
(state, part) => {
if (state.done) return state
const next = stringWidth(part.value)
if (state.width + next > width) return { ...state, done: true }
return { count: state.count + 1, width: state.width + next, done: false }
},
{ count: 0, width: 0, done: false },
).count
return parts.slice(start, start + visible)
}
@@ -28,7 +28,7 @@ describe("session tab marquee", () => {
const scope = createRoot((dispose) => ({ marquee: createMarquee(() => false), dispose }))
scope.marquee.enter("first", "opencode", 6)
jest.advanceTimersByTime(1_600)
jest.advanceTimersByTime(1_400)
expect(scope.marquee.active()).toBe("first")
expect(scope.marquee.offset()).toBe(0)
+13 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { marqueeCycleWidth, marqueeOverflows, marqueeText } from "../../src/util/marquee"
import { marqueeCycleWidth, marqueeOverflows, marqueeText, marqueeTextParts } from "../../src/util/marquee"
import { stringWidth } from "../../src/util/string-width"
describe("marquee text", () => {
@@ -25,6 +25,18 @@ describe("marquee text", () => {
expect(marqueeText(title, 8, marqueeCycleWidth(title))).toBe("A long s")
})
test("identifies only the generated separator dot", () => {
expect(marqueeTextParts("A · title", 6, 7)).toEqual([
{ value: "l", separator: false },
{ value: "e", separator: false },
{ value: " ", separator: false },
{ value: "·", separator: true },
{ value: " ", separator: false },
{ value: "A", separator: false },
])
expect(marqueeTextParts("A · title", 6, 2)[0]).toEqual({ value: "·", separator: false })
})
test("clips wide graphemes to terminal cells", () => {
const frame = marqueeText("Plan 🧭 the release", 8, 5)
expect(frame).toBe("🧭 the r")