Просмотр исходного кода

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

Kit Langton 1 день назад
Родитель
Сommit
a609acbb67

+ 46 - 22
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}
                       >
-                        <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
packages/tui/src/util/marquee.ts

@@ -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("")
+}
+
+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 = value + GAP
+  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)
 }

+ 1 - 1
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)

+ 13 - 1
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")