diff --git a/packages/tui/src/component/session-tabs.tsx b/packages/tui/src/component/session-tabs.tsx index 84e41f68126..43bc9cfb4b2 100644 --- a/packages/tui/src/component/session-tabs.tsx +++ b/packages/tui/src/component/session-tabs.tsx @@ -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} > - 0 || titleFades()} fallback={visibleTitle()}> - - {(character, index) => {character}} - + 0 || titleFades()} + fallback={visibleTitle()} + > + + {(part, index) => ( + {part().value} + )} + 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()} > - - - {(character, index) => {character}} - + + + {(part, index) => ( + {part().value} + )} + 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) } diff --git a/packages/tui/test/component/session-tabs-marquee.test.ts b/packages/tui/test/component/session-tabs-marquee.test.ts index 7bdf3e21383..504c798795f 100644 --- a/packages/tui/test/component/session-tabs-marquee.test.ts +++ b/packages/tui/test/component/session-tabs-marquee.test.ts @@ -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) diff --git a/packages/tui/test/util/marquee.test.ts b/packages/tui/test/util/marquee.test.ts index 3ecdf9fdd78..7b391be5f84 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, 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")