mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-10-16 10:30:01 +08:00
fix(tui): refine tab marquee motion (#42617)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user