mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-19 00:33:24 +00:00
fix(tui): restore tab marquee hover (#42093)
This commit is contained in:
parent
dff6eb631b
commit
0dec446ee6
2 changed files with 112 additions and 43 deletions
|
|
@ -72,14 +72,13 @@ function fadeTitleColor(color: RGBA, background: RGBA, index: number, length: nu
|
|||
return opacity === 0 ? color : tint(color, background, opacity)
|
||||
}
|
||||
|
||||
function createMarquee(animations: () => boolean) {
|
||||
export function createMarquee(animations: () => boolean) {
|
||||
const [offset, setOffset] = createSignal(0)
|
||||
const [active, setActive] = createSignal<string>()
|
||||
const leading = createAnimatable({ opacity: 0 }, { enabled: animations, transition: tween({ duration: 0.25 }) })
|
||||
let delay: ReturnType<typeof setTimeout> | undefined
|
||||
let interval: ReturnType<typeof setInterval> | undefined
|
||||
let cycleWidth = 0
|
||||
let returning = false
|
||||
|
||||
const clear = () => {
|
||||
if (delay) clearTimeout(delay)
|
||||
|
|
@ -91,17 +90,15 @@ function createMarquee(animations: () => boolean) {
|
|||
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 (!marqueeOverflows(title, width)) {
|
||||
reset()
|
||||
return
|
||||
}
|
||||
if (!marqueeOverflows(title, width)) return
|
||||
if (active() === sessionID) return
|
||||
clear()
|
||||
cycleWidth = marqueeCycleWidth(title)
|
||||
setActive(sessionID)
|
||||
setOffset(0)
|
||||
returning = false
|
||||
leading.jump({ opacity: 0 })
|
||||
delay = setTimeout(() => {
|
||||
setOffset(1)
|
||||
|
|
@ -111,27 +108,10 @@ function createMarquee(animations: () => boolean) {
|
|||
}
|
||||
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)
|
||||
reset()
|
||||
}
|
||||
const reset = () => {
|
||||
clear()
|
||||
returning = false
|
||||
setActive(undefined)
|
||||
setOffset(0)
|
||||
leading.jump({ opacity: 0 })
|
||||
|
|
@ -141,7 +121,7 @@ function createMarquee(animations: () => boolean) {
|
|||
return { offset, active, enter, leave, reset, leading: () => leading.value().opacity }
|
||||
}
|
||||
|
||||
function createTabMarquee(animations: () => boolean) {
|
||||
export function createTabMarquee(animations: () => boolean) {
|
||||
const [hovered, setHovered] = createSignal<string>()
|
||||
const marquee = createMarquee(animations)
|
||||
let hoverClear: ReturnType<typeof setTimeout> | undefined
|
||||
|
|
@ -159,11 +139,21 @@ function createTabMarquee(animations: () => boolean) {
|
|||
marquee.leave(sessionID)
|
||||
})
|
||||
}
|
||||
const leaveHovered = () => {
|
||||
const sessionID = hovered()
|
||||
if (sessionID) leave(sessionID)
|
||||
}
|
||||
const reset = () => {
|
||||
if (hoverClear) clearTimeout(hoverClear)
|
||||
hoverClear = undefined
|
||||
setHovered(undefined)
|
||||
marquee.reset()
|
||||
}
|
||||
onCleanup(() => {
|
||||
if (hoverClear) clearTimeout(hoverClear)
|
||||
})
|
||||
|
||||
return { ...marquee, hovered, enter, leave }
|
||||
return { ...marquee, hovered, enter, leave, leaveHovered, reset }
|
||||
}
|
||||
|
||||
function TabContextMenu(props: { state: TabContextMenuState; tabs: SessionTabsController; onClose: () => void }) {
|
||||
|
|
@ -348,6 +338,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
|||
position="relative"
|
||||
paddingTop={1}
|
||||
backgroundColor={theme.background.default}
|
||||
onMouseOut={marquee.leaveHovered}
|
||||
>
|
||||
<scrollbox ref={(element) => (scroll = element)} flexGrow={1} scrollbarOptions={{ visible: false }}>
|
||||
<box flexShrink={0} flexDirection="column" gap={1}>
|
||||
|
|
@ -356,6 +347,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
|||
const selected = () => activeID() === tab.sessionID
|
||||
const status = createMemo(() => itemStatus(tab))
|
||||
const [sweepLevel, setSweepLevel] = createSignal(0)
|
||||
const [closeHovered, setCloseHovered] = createSignal(false)
|
||||
const session = createMemo(() => data.session.get(tab.sessionID))
|
||||
const project = createMemo(() => {
|
||||
const value = session()
|
||||
|
|
@ -363,9 +355,11 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
|||
})
|
||||
const numberWidth = () => 2
|
||||
const restingTitleWidth = () => Math.max(1, width() - numberWidth() - 2)
|
||||
const titleWidth = () => Math.max(1, restingTitleWidth() - (hovered() === tab.sessionID ? 1 : 0))
|
||||
const hoveredTitleWidth = () => Math.max(1, restingTitleWidth() - 1)
|
||||
const titleWidth = () =>
|
||||
hovered() === tab.sessionID ? hoveredTitleWidth() : restingTitleWidth()
|
||||
const title = () => tab.title ?? "Untitled session"
|
||||
const scrolling = () => marquee.active() === tab.sessionID && marquee.offset() > 0
|
||||
const scrolling = () => marquee.active() === tab.sessionID
|
||||
const visibleTitle = createMemo(() =>
|
||||
scrolling()
|
||||
? marqueeText(title(), titleWidth(), marquee.offset())
|
||||
|
|
@ -373,7 +367,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
|||
)
|
||||
const visibleTitleParts = createMemo(() => Locale.graphemes(visibleTitle()))
|
||||
const titleFades = createMemo(
|
||||
() => marqueeOverflows(title(), restingTitleWidth()) && titleWidth() > FADE_WIDTH,
|
||||
() => marqueeOverflows(title(), titleWidth()) && titleWidth() > FADE_WIDTH,
|
||||
)
|
||||
const detail = createMemo(() => {
|
||||
const value = session()
|
||||
|
|
@ -456,7 +450,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
|||
position="relative"
|
||||
flexDirection="column"
|
||||
backgroundColor={background()}
|
||||
onMouseOver={() => marquee.enter(tab.sessionID, title(), restingTitleWidth())}
|
||||
onMouseOver={() => marquee.enter(tab.sessionID, title(), hoveredTitleWidth())}
|
||||
onMouseOut={() => marquee.leave(tab.sessionID)}
|
||||
onMouseDown={(event) => {
|
||||
if (event.button === RIGHT_MOUSE_BUTTON) {
|
||||
|
|
@ -472,7 +466,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
|||
event.stopPropagation()
|
||||
return
|
||||
}
|
||||
marquee.enter(tab.sessionID, title(), restingTitleWidth())
|
||||
marquee.enter(tab.sessionID, title(), hoveredTitleWidth())
|
||||
setDragging(tab.sessionID)
|
||||
}}
|
||||
onMouseUp={(event) => {
|
||||
|
|
@ -584,8 +578,10 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
|||
right={1}
|
||||
zIndex={2}
|
||||
width={1}
|
||||
fg={theme.text.subdued}
|
||||
fg={closeHovered() ? theme.text.default : theme.text.subdued}
|
||||
selectable={false}
|
||||
onMouseOver={() => setCloseHovered(true)}
|
||||
onMouseOut={() => setCloseHovered(false)}
|
||||
onMouseUp={(event) => {
|
||||
if (event.button === RIGHT_MOUSE_BUTTON) return
|
||||
if (hovered() !== tab.sessionID) return
|
||||
|
|
@ -593,7 +589,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
|||
tabs.close(tab.sessionID)
|
||||
}}
|
||||
>
|
||||
{hovered() === tab.sessionID ? "×" : ""}
|
||||
{hovered() === tab.sessionID ? "✕" : ""}
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
|
|
@ -842,6 +838,7 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
|
|||
position="relative"
|
||||
flexDirection="row"
|
||||
zIndex={1}
|
||||
onMouseOut={marquee.leaveHovered}
|
||||
renderAfter={function (buffer) {
|
||||
const x = Math.max(0, this.screenX)
|
||||
const y = this.screenY + this.height
|
||||
|
|
@ -897,8 +894,10 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
|
|||
const numberWidth = () => 2
|
||||
// Hovering reveals the close mark, so the title's right bound shifts left of it.
|
||||
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 hoveredTitleWidth = () => Math.max(1, restingTitleWidth() - 2)
|
||||
const availableTitleWidth = () =>
|
||||
hovered() === tab.sessionID ? hoveredTitleWidth() : restingTitleWidth()
|
||||
const scrolling = () => marquee.active() === tab.sessionID
|
||||
const visibleTitle = createMemo(() =>
|
||||
scrolling()
|
||||
? marqueeText(title(), availableTitleWidth(), marquee.offset())
|
||||
|
|
@ -906,7 +905,7 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
|
|||
)
|
||||
const visibleTitleParts = createMemo(() => Locale.graphemes(visibleTitle()))
|
||||
const titleFades = createMemo(
|
||||
() => marqueeOverflows(title(), restingTitleWidth()) && availableTitleWidth() > FADE_WIDTH,
|
||||
() => marqueeOverflows(title(), availableTitleWidth()) && availableTitleWidth() > FADE_WIDTH,
|
||||
)
|
||||
const foreground = () => {
|
||||
if (hovered() === tab.sessionID) return theme.text.default
|
||||
|
|
@ -929,6 +928,7 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
|
|||
}
|
||||
// The running sweep's level under the number cell, reported by the pulse renderable.
|
||||
const [sweepLevel, setSweepLevel] = createSignal(0)
|
||||
const [closeHovered, setCloseHovered] = createSignal(false)
|
||||
const numberColor = () => {
|
||||
const feedback = feedbackColor()
|
||||
if (feedback) return feedback
|
||||
|
|
@ -957,7 +957,7 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
|
|||
position="relative"
|
||||
flexDirection="row"
|
||||
backgroundColor={background()}
|
||||
onMouseOver={() => marquee.enter(tab.sessionID, title(), restingTitleWidth())}
|
||||
onMouseOver={() => marquee.enter(tab.sessionID, title(), hoveredTitleWidth())}
|
||||
onMouseOut={() => marquee.leave(tab.sessionID)}
|
||||
onMouseDown={(event) => {
|
||||
if (event.button === RIGHT_MOUSE_BUTTON) {
|
||||
|
|
@ -972,7 +972,7 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
|
|||
event.stopPropagation()
|
||||
return
|
||||
}
|
||||
marquee.enter(tab.sessionID, title(), restingTitleWidth())
|
||||
marquee.enter(tab.sessionID, title(), hoveredTitleWidth())
|
||||
setDragging(tab.sessionID)
|
||||
}}
|
||||
onMouseUp={(event) => {
|
||||
|
|
@ -1026,8 +1026,10 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
|
|||
right={1}
|
||||
zIndex={2}
|
||||
width={1}
|
||||
fg={closeColor()}
|
||||
fg={closeHovered() ? theme.text.default : closeColor()}
|
||||
selectable={false}
|
||||
onMouseOver={() => setCloseHovered(true)}
|
||||
onMouseOut={() => setCloseHovered(false)}
|
||||
onMouseUp={(event) => {
|
||||
if (event.button === RIGHT_MOUSE_BUTTON) return
|
||||
// The close mark only renders while hovered; without motion events a click can
|
||||
|
|
@ -1037,7 +1039,7 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
|
|||
tabs.close(tab === NEW_SESSION_TAB ? undefined : tab.sessionID)
|
||||
}}
|
||||
>
|
||||
{hovered() === tab.sessionID ? "×" : ""}
|
||||
{hovered() === tab.sessionID ? "✕" : ""}
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
|
|
|
|||
67
packages/tui/test/component/session-tabs-marquee.test.ts
Normal file
67
packages/tui/test/component/session-tabs-marquee.test.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import { afterEach, describe, expect, jest, test } from "bun:test"
|
||||
import { createRoot } from "solid-js"
|
||||
import { createMarquee, createTabMarquee } from "../../src/component/session-tabs"
|
||||
|
||||
afterEach(() => jest.useRealTimers())
|
||||
|
||||
describe("session tab marquee", () => {
|
||||
test("starts for the hovered width and resets when the next tab fits", () => {
|
||||
jest.useFakeTimers()
|
||||
const scope = createRoot((dispose) => ({ marquee: createMarquee(() => false), dispose }))
|
||||
|
||||
scope.marquee.enter("first", "opencode", 6)
|
||||
expect(scope.marquee.active()).toBe("first")
|
||||
expect(scope.marquee.offset()).toBe(0)
|
||||
|
||||
jest.advanceTimersByTime(600)
|
||||
expect(scope.marquee.offset()).toBe(1)
|
||||
|
||||
scope.marquee.enter("second", "short", 6)
|
||||
expect(scope.marquee.active()).toBeUndefined()
|
||||
expect(scope.marquee.offset()).toBe(0)
|
||||
expect(scope.marquee.leading()).toBe(0)
|
||||
scope.dispose()
|
||||
})
|
||||
|
||||
test("keeps the leading fade through a natural loop boundary", () => {
|
||||
jest.useFakeTimers()
|
||||
const scope = createRoot((dispose) => ({ marquee: createMarquee(() => false), dispose }))
|
||||
|
||||
scope.marquee.enter("first", "opencode", 6)
|
||||
jest.advanceTimersByTime(1_600)
|
||||
|
||||
expect(scope.marquee.active()).toBe("first")
|
||||
expect(scope.marquee.offset()).toBe(0)
|
||||
expect(scope.marquee.leading()).toBe(1)
|
||||
scope.dispose()
|
||||
})
|
||||
|
||||
test("resets immediately after leaving", () => {
|
||||
jest.useFakeTimers()
|
||||
const scope = createRoot((dispose) => ({ marquee: createMarquee(() => false), dispose }))
|
||||
|
||||
scope.marquee.enter("first", "opencode", 6)
|
||||
jest.advanceTimersByTime(700)
|
||||
scope.marquee.leave("first")
|
||||
|
||||
expect(scope.marquee.active()).toBeUndefined()
|
||||
expect(scope.marquee.offset()).toBe(0)
|
||||
expect(scope.marquee.leading()).toBe(0)
|
||||
scope.dispose()
|
||||
})
|
||||
|
||||
test("resets when the pointer leaves the tab rail", () => {
|
||||
jest.useFakeTimers()
|
||||
const scope = createRoot((dispose) => ({ marquee: createTabMarquee(() => false), dispose }))
|
||||
|
||||
scope.marquee.enter("first", "opencode", 6)
|
||||
jest.advanceTimersByTime(700)
|
||||
scope.marquee.leaveHovered()
|
||||
jest.advanceTimersByTime(0)
|
||||
|
||||
expect(scope.marquee.hovered()).toBeUndefined()
|
||||
expect(scope.marquee.active()).toBeUndefined()
|
||||
expect(scope.marquee.offset()).toBe(0)
|
||||
scope.dispose()
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue