diff --git a/packages/tui/src/component/session-tabs.tsx b/packages/tui/src/component/session-tabs.tsx index 722850b9e14..548b0a26aed 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 { marqueeCycleWidth, marqueeText } from "../util/marquee" +import { marqueeCycleWidth, marqueeOverflows, marqueeText } from "../util/marquee" // A long title fades out over its last cells instead of cutting hard. const FADE_WIDTH = 4 @@ -85,7 +85,7 @@ function createMarquee(animations: () => boolean) { returning = false return scroll() } - if (stringWidth(title) <= width) return + if (!marqueeOverflows(title, width)) return cycleWidth = marqueeCycleWidth(title) setActive(sessionID) setOffset(0) @@ -258,7 +258,8 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat return value ? data.project.get(value.projectID) : undefined }) const numberWidth = () => 2 - const titleWidth = () => Math.max(1, width() - numberWidth() - 2 - (hovered() === tab.sessionID ? 1 : 0)) + const restingTitleWidth = () => Math.max(1, width() - numberWidth() - 2) + const titleWidth = () => Math.max(1, restingTitleWidth() - (hovered() === tab.sessionID ? 1 : 0)) const title = () => tab.title ?? "Untitled session" const scrolling = () => marquee.active() === tab.sessionID && marquee.offset() > 0 const visibleTitle = createMemo(() => @@ -267,7 +268,9 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat : Locale.takeWidth(title(), titleWidth()), ) const visibleTitleParts = createMemo(() => Locale.graphemes(visibleTitle())) - const titleFades = createMemo(() => stringWidth(title()) >= titleWidth() && titleWidth() > FADE_WIDTH) + const titleFades = createMemo( + () => marqueeOverflows(title(), restingTitleWidth()) && titleWidth() > FADE_WIDTH, + ) const detail = createMemo(() => { const value = session() return Locale.takeWidth(projectName(project(), value?.location.directory) ?? "", titleWidth()) @@ -349,10 +352,10 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat position="relative" flexDirection="column" backgroundColor={background()} - onMouseOver={() => marquee.enter(tab.sessionID, title(), titleWidth())} + onMouseOver={() => marquee.enter(tab.sessionID, title(), restingTitleWidth())} onMouseOut={() => marquee.leave(tab.sessionID)} onMouseDown={() => { - marquee.enter(tab.sessionID, title(), titleWidth()) + marquee.enter(tab.sessionID, title(), restingTitleWidth()) setDragging(tab.sessionID) }} onMouseUp={release} @@ -759,8 +762,8 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim // Shortcut labels stay one cell wide: 1-9, 0 for ten, then a neutral dot. const numberWidth = () => 2 // 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 restingTitleWidth = () => Math.max(1, width() - 1 - numberWidth()) + const availableTitleWidth = () => Math.max(1, restingTitleWidth() - (hovered() === tab.sessionID ? 2 : 0)) const scrolling = () => marquee.active() === tab.sessionID && marquee.offset() > 0 const visibleTitle = createMemo(() => scrolling() @@ -769,7 +772,7 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim ) const visibleTitleParts = createMemo(() => Locale.graphemes(visibleTitle())) const titleFades = createMemo( - () => stringWidth(title()) >= availableTitleWidth() && availableTitleWidth() > FADE_WIDTH, + () => marqueeOverflows(title(), restingTitleWidth()) && availableTitleWidth() > FADE_WIDTH, ) const foreground = () => { if (hovered() === tab.sessionID) return theme.text.default @@ -820,10 +823,10 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim position="relative" flexDirection="row" backgroundColor={background()} - onMouseOver={() => marquee.enter(tab.sessionID, title(), availableTitleWidth())} + onMouseOver={() => marquee.enter(tab.sessionID, title(), restingTitleWidth())} onMouseOut={() => marquee.leave(tab.sessionID)} onMouseDown={() => { - marquee.enter(tab.sessionID, title(), availableTitleWidth()) + marquee.enter(tab.sessionID, title(), restingTitleWidth()) setDragging(tab.sessionID) }} onMouseUp={release} diff --git a/packages/tui/src/util/marquee.ts b/packages/tui/src/util/marquee.ts index f1af2213495..91ecef79fd3 100644 --- a/packages/tui/src/util/marquee.ts +++ b/packages/tui/src/util/marquee.ts @@ -7,6 +7,10 @@ export function marqueeCycleWidth(value: string) { return stringWidth(value + GAP) } +export function marqueeOverflows(value: string, width: number) { + return stringWidth(value) > width +} + export function marqueeText(value: string, width: number, offset: number) { if (width <= 0) return "" if (stringWidth(value) <= width || offset <= 0) return Locale.takeWidth(value, width) diff --git a/packages/tui/test/util/marquee.test.ts b/packages/tui/test/util/marquee.test.ts index f0b871ad6a9..3ecdf9fdd78 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 { marqueeCycleWidth, marqueeText } from "../../src/util/marquee" +import { marqueeCycleWidth, marqueeOverflows, marqueeText } from "../../src/util/marquee" import { stringWidth } from "../../src/util/string-width" describe("marquee text", () => { @@ -7,6 +7,11 @@ describe("marquee text", () => { expect(marqueeText("Short", 10, 8)).toBe("Short") }) + test("does not classify an exact fit as overflow", () => { + expect(marqueeOverflows("Exact fit", 9)).toBe(false) + expect(marqueeOverflows("Exact fit", 8)).toBe(true) + }) + 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")