From bef795b2fe11d0d4b116f213191e9fd6a07be25e Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 12 Aug 2026 11:49:17 -0400 Subject: [PATCH] fix(tui): smooth session tab marquees (#42055) --- packages/tui/src/component/session-tabs.tsx | 125 ++++++++++++++++---- packages/tui/src/util/marquee.ts | 8 +- packages/tui/test/util/marquee.test.ts | 12 +- 3 files changed, 117 insertions(+), 28 deletions(-) diff --git a/packages/tui/src/component/session-tabs.tsx b/packages/tui/src/component/session-tabs.tsx index b111af5a89..722850b9e1 100644 --- a/packages/tui/src/component/session-tabs.tsx +++ b/packages/tui/src/component/session-tabs.tsx @@ -23,7 +23,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 { marqueeText } from "../util/marquee" +import { marqueeCycleWidth, marqueeText } from "../util/marquee" // A long title fades out over its last cells instead of cutting hard. const FADE_WIDTH = 4 @@ -60,27 +60,98 @@ function fadeTitleColor(color: RGBA, background: RGBA, index: number, length: nu return opacity === 0 ? color : tint(color, background, opacity) } -function createMarquee(hovered: () => string | undefined, animations: () => boolean) { +function createMarquee(animations: () => boolean) { const [offset, setOffset] = createSignal(0) + const [active, setActive] = createSignal() const leading = createAnimatable({ opacity: 0 }, { enabled: animations, transition: tween({ duration: 0.25 }) }) + let delay: ReturnType | undefined + let interval: ReturnType | undefined + let cycleWidth = 0 + let returning = false - createEffect(() => { + const clear = () => { + if (delay) clearTimeout(delay) + if (interval) clearInterval(interval) + delay = undefined + interval = undefined + } + const scroll = () => { + interval = setInterval(() => setOffset((value) => (value + 1) % cycleWidth), MARQUEE_INTERVAL) + } + const enter = (sessionID: string, title: string, width: number) => { + if (active() === sessionID && !returning) return + clear() + if (active() === sessionID) { + returning = false + return scroll() + } + if (stringWidth(title) <= width) return + cycleWidth = marqueeCycleWidth(title) + setActive(sessionID) setOffset(0) + returning = false leading.jump({ opacity: 0 }) - if (!hovered()) return - let interval: ReturnType | undefined - const delay = setTimeout(() => { + delay = setTimeout(() => { setOffset(1) leading.animate({ opacity: 1 }) - interval = setInterval(() => setOffset((value) => value + 1), MARQUEE_INTERVAL) + scroll() }, MARQUEE_DELAY) - onCleanup(() => { - clearTimeout(delay) - if (interval) clearInterval(interval) + } + const leave = (sessionID: string) => { + if (active() !== sessionID) return + clear() + if (offset() === 0) { + setActive(undefined) + return + } + returning = true + interval = setInterval(() => { + setOffset((value) => { + const next = (value + 1) % cycleWidth + if (next !== 0) return next + clear() + returning = false + setActive(undefined) + leading.animate({ opacity: 0 }) + return 0 + }) + }, MARQUEE_INTERVAL) + } + const reset = () => { + clear() + returning = false + setActive(undefined) + setOffset(0) + leading.jump({ opacity: 0 }) + } + onCleanup(clear) + + return { offset, active, enter, leave, reset, leading: () => leading.value().opacity } +} + +function createTabMarquee(animations: () => boolean) { + const [hovered, setHovered] = createSignal() + const marquee = createMarquee(animations) + let hoverClear: ReturnType | undefined + + const enter = (sessionID: string, title: string, width: number) => { + if (hoverClear) clearTimeout(hoverClear) + setHovered(sessionID) + marquee.enter(sessionID, title, width) + } + const leave = (sessionID: string) => { + if (hoverClear) clearTimeout(hoverClear) + hoverClear = setTimeout(() => { + if (hovered() !== sessionID) return + setHovered(undefined) + marquee.leave(sessionID) }) + } + onCleanup(() => { + if (hoverClear) clearTimeout(hoverClear) }) - return { offset, leading: () => leading.value().opacity } + return { ...marquee, hovered, enter, leave } } export function SessionTabs( @@ -105,9 +176,9 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat const idleNumber = () => tint(theme.text.subdued, theme.background.default, 0.35) const separatorUpperPulseColor = createMemo(() => tint(theme.background.default, theme.text.default, 0.04)) const separatorLowerPulseColor = createMemo(() => tint(theme.background.default, theme.text.default, 0.05)) - const [hovered, setHovered] = createSignal() const [addHovered, setAddHovered] = createSignal(false) - const marquee = createMarquee(hovered, animations) + const marquee = createTabMarquee(animations) + const hovered = marquee.hovered const [dragging, setDragging] = createSignal() const [preview, setPreview] = createSignal<{ sessionID: string; index: number }>() const newTab = () => tabs.newTab?.() ?? false @@ -118,6 +189,10 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat return moveSessionTab(tabs.tabs(), pending.sessionID, pending.index) }) const items = ordered + createEffect(() => { + const active = marquee.active() + if (active && !items().some((tab) => tab.sessionID === active)) marquee.reset() + }) const statuses = createMemo( () => new Map( @@ -185,7 +260,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat const numberWidth = () => 2 const titleWidth = () => Math.max(1, width() - numberWidth() - 2 - (hovered() === tab.sessionID ? 1 : 0)) const title = () => tab.title ?? "Untitled session" - const scrolling = () => hovered() === tab.sessionID && marquee.offset() > 0 + const scrolling = () => marquee.active() === tab.sessionID && marquee.offset() > 0 const visibleTitle = createMemo(() => scrolling() ? marqueeText(title(), titleWidth(), marquee.offset()) @@ -274,10 +349,10 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat position="relative" flexDirection="column" backgroundColor={background()} - onMouseOver={() => setHovered(tab.sessionID)} - onMouseOut={() => setHovered(undefined)} + onMouseOver={() => marquee.enter(tab.sessionID, title(), titleWidth())} + onMouseOut={() => marquee.leave(tab.sessionID)} onMouseDown={() => { - setHovered(tab.sessionID) + marquee.enter(tab.sessionID, title(), titleWidth()) setDragging(tab.sessionID) }} onMouseUp={release} @@ -492,9 +567,9 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim const { mode } = useThemes() const config = useConfig().data const animations = () => props.animations ?? config.animations ?? true - const [hovered, setHovered] = createSignal() const [addHovered, setAddHovered] = createSignal(false) - const marquee = createMarquee(hovered, animations) + const marquee = createTabMarquee(animations) + const hovered = marquee.hovered const [dragging, setDragging] = createSignal() // A drag reorders a local preview and persists one move on release instead of writing // per slot crossing; the preview holds after release until the store reflects the move, @@ -530,6 +605,10 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim previous?.start, ), ) + createEffect(() => { + const active = marquee.active() + if (active && !layout().tabs.some((tab) => tab.sessionID === active)) marquee.reset() + }) const statuses = createMemo( () => new Map( @@ -682,7 +761,7 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim // Hovering reveals the close mark, so the title's right bound shifts left of it. const availableTitleWidth = () => Math.max(1, width() - 1 - numberWidth() - (hovered() === tab.sessionID ? 2 : 0)) - const scrolling = () => hovered() === tab.sessionID && marquee.offset() > 0 + const scrolling = () => marquee.active() === tab.sessionID && marquee.offset() > 0 const visibleTitle = createMemo(() => scrolling() ? marqueeText(title(), availableTitleWidth(), marquee.offset()) @@ -741,10 +820,10 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim position="relative" flexDirection="row" backgroundColor={background()} - onMouseOver={() => setHovered(tab.sessionID)} - onMouseOut={() => setHovered(undefined)} + onMouseOver={() => marquee.enter(tab.sessionID, title(), availableTitleWidth())} + onMouseOut={() => marquee.leave(tab.sessionID)} onMouseDown={() => { - setHovered(tab.sessionID) + marquee.enter(tab.sessionID, title(), availableTitleWidth()) setDragging(tab.sessionID) }} onMouseUp={release} diff --git a/packages/tui/src/util/marquee.ts b/packages/tui/src/util/marquee.ts index b0c740a032..f1af221349 100644 --- a/packages/tui/src/util/marquee.ts +++ b/packages/tui/src/util/marquee.ts @@ -1,14 +1,18 @@ import { Locale } from "./locale" import { stringWidth } from "./string-width" -const GAP = " " +const GAP = " · " + +export function marqueeCycleWidth(value: string) { + return stringWidth(value + GAP) +} export function marqueeText(value: string, width: number, offset: number) { if (width <= 0) return "" if (stringWidth(value) <= width || offset <= 0) return Locale.takeWidth(value, width) const loop = value + GAP - const cursor = offset % stringWidth(loop) + const cursor = offset % marqueeCycleWidth(value) const segments = Locale.graphemes(loop + loop) const start = segments.reduce( (state, segment, index) => diff --git a/packages/tui/test/util/marquee.test.ts b/packages/tui/test/util/marquee.test.ts index 60740d0c2a..f0b871ad6a 100644 --- a/packages/tui/test/util/marquee.test.ts +++ b/packages/tui/test/util/marquee.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import { marqueeText } from "../../src/util/marquee" +import { marqueeCycleWidth, marqueeText } from "../../src/util/marquee" import { stringWidth } from "../../src/util/string-width" describe("marquee text", () => { @@ -10,8 +10,14 @@ describe("marquee text", () => { test("starts clipped and scrolls through a long title", () => { expect(marqueeText("A long session title", 8, 0)).toBe("A long s") expect(marqueeText("A long session title", 8, 2)).toBe("long ses") - expect(marqueeText("A long session title", 8, 15)).toBe("title ") - expect(marqueeText("A long session title", 8, 20)).toBe(" A lo") + expect(marqueeText("A long session title", 8, 15)).toBe("title · ") + expect(marqueeText("A long session title", 8, 20)).toBe(" · A lon") + }) + + test("loops after one spaced dot separator", () => { + const title = "A long session title" + expect(marqueeText(title, 8, marqueeCycleWidth(title) - 3)).toBe(" · A lon") + expect(marqueeText(title, 8, marqueeCycleWidth(title))).toBe("A long s") }) test("clips wide graphemes to terminal cells", () => {