diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index d6207054c4a..aa4eb989caf 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -15,7 +15,6 @@ import { MouseButton, type CliRenderer, type CliRendererConfig, - type MouseEvent, type ThemeMode, } from "@opentui/core" import { RouteProvider, useRoute } from "./context/route" @@ -72,6 +71,8 @@ import { DialogSessionList } from "./component/dialog-session-list" import { DialogOpen, DialogOpenKey, loadDialogOpen } from "./component/dialog-open" import { SessionTabs } from "./component/session-tabs" import { clampSessionTabsWidth, sessionTabsFitVertically, SESSION_SIDEBAR_WIDTH } from "./ui/layout" +import { createPaneResize } from "./ui/pane-resize" +import { PaneResizeHandle } from "./ui/pane-resize-handle" import { ThemeErrorToast } from "./component/theme-error-toast" import { createThemeSource, ThemeProvider, useTheme, useThemes } from "./context/theme" import { Home } from "./routes/home" @@ -472,7 +473,6 @@ function App(props: { pair?: DialogPairCredentials }) { const client = useClient() const toast = useToast() const theme = useTheme() - const tabsTheme = useTheme("elevated") const { mode, supports, setMode, locked, lock, unlock } = useThemes() const data = useData() const location = useLocation() @@ -495,38 +495,18 @@ function App(props: { pair?: DialogPairCredentials }) { const [layout, updateLayout] = useStorage().store<{ verticalTabsWidth?: number }>("layout", { initial: { verticalTabsWidth: SESSION_SIDEBAR_WIDTH }, }) - const [preferredTabsWidth, setPreferredTabsWidth] = createSignal(layout.verticalTabsWidth ?? SESSION_SIDEBAR_WIDTH) - const [tabsResizeHovered, setTabsResizeHovered] = createSignal(false) - const [tabsResizing, setTabsResizing] = createSignal(false) - let requestedTabsWidth = layout.verticalTabsWidth ?? SESSION_SIDEBAR_WIDTH - createEffect(() => { - if (tabsResizing()) return - requestedTabsWidth = layout.verticalTabsWidth ?? SESSION_SIDEBAR_WIDTH - setPreferredTabsWidth(requestedTabsWidth) + const tabsResize = createPaneResize({ + value: () => layout.verticalTabsWidth ?? SESSION_SIDEBAR_WIDTH, + defaultValue: () => SESSION_SIDEBAR_WIDTH, + clamp: (width) => clampSessionTabsWidth(width, dimensions().width), + fromMouse: (event) => event.x + 1, + contains: (event, width) => event.x >= width - 1 && event.x <= width, + onCommit: (width) => { + void updateLayout((draft) => { + draft.verticalTabsWidth = width + }).catch((error) => console.error("Failed to persist TUI layout", error)) + }, }) - const verticalTabsWidth = () => clampSessionTabsWidth(preferredTabsWidth(), dimensions().width) - const resizeVerticalTabs = (width: number) => setPreferredTabsWidth(clampSessionTabsWidth(width, dimensions().width)) - const commitVerticalTabsWidth = (width: number) => { - const next = clampSessionTabsWidth(width, dimensions().width) - setPreferredTabsWidth(next) - if (requestedTabsWidth === next) return - requestedTabsWidth = next - void updateLayout((draft) => { - draft.verticalTabsWidth = next - }).catch((error) => console.error("Failed to persist TUI layout", error)) - } - let tabsResizeMoved = false - let lastTabsBoundaryClick = 0 - const finishTabsResize = (event: MouseEvent) => { - if (!tabsResizing()) return - const next = tabsResizeMoved ? event.x + 1 : verticalTabsWidth() - setTabsResizing(false) - lastTabsBoundaryClick = tabsResizeMoved ? 0 : Date.now() - commitVerticalTabsWidth(next) - const width = clampSessionTabsWidth(next, dimensions().width) - setTabsResizeHovered(event.x >= width - 1 && event.x <= width) - event.stopPropagation() - } let openingOpen: Promise | undefined // Toast once when an MCP server enters a failed or needs-auth state so the user knows to act, // without having to open the status panel. Tracking the last alerted status avoids re-toasting @@ -587,7 +567,7 @@ function App(props: { pair?: DialogPairCredentials }) { const terminalTitleEnabled = () => config.data.terminal?.title ?? true const pasteSummaryEnabled = () => config.data.prompt?.paste !== "full" const tabsVertical = () => - config.data.tabs.layout === "vertical" && sessionTabsFitVertically(dimensions().width, preferredTabsWidth()) + config.data.tabs.layout === "vertical" && sessionTabsFitVertically(dimensions().width, tabsResize.preferredSize()) const tabsVisible = () => sessionTabs.enabled() && sessionTabs.tabs().length > 0 && route.data.type !== "plugin" const verticalTabsVisible = () => tabsVisible() && tabsVertical() @@ -1293,18 +1273,12 @@ function App(props: { pair?: DialogPairCredentials }) { minHeight={0} flexDirection="row" position="relative" - onMouseDrag={(event) => { - if (!tabsResizing()) return - tabsResizeMoved = true - lastTabsBoundaryClick = 0 - resizeVerticalTabs(event.x + 1) - event.stopPropagation() - }} - onMouseDragEnd={finishTabsResize} - onMouseUp={finishTabsResize} + onMouseDrag={tabsResize.onMouseDrag} + onMouseDragEnd={tabsResize.onMouseDragEnd} + onMouseUp={tabsResize.onMouseUp} > - + @@ -1321,7 +1295,7 @@ function App(props: { pair?: DialogPairCredentials }) { {(sessionID) => ( )} @@ -1339,41 +1313,7 @@ function App(props: { pair?: DialogPairCredentials }) { - setTabsResizeHovered(true)} - onMouseOut={() => setTabsResizeHovered(false)} - onMouseDown={(event) => { - if (event.button !== MouseButton.LEFT) return - const now = Date.now() - if (now - lastTabsBoundaryClick < 300) { - lastTabsBoundaryClick = 0 - setTabsResizing(false) - setTabsResizeHovered(false) - commitVerticalTabsWidth(SESSION_SIDEBAR_WIDTH) - event.preventDefault() - event.stopPropagation() - return - } - tabsResizeMoved = false - setTabsResizing(true) - event.preventDefault() - event.stopPropagation() - }} - > - - + diff --git a/packages/tui/src/ui/pane-resize-handle.tsx b/packages/tui/src/ui/pane-resize-handle.tsx new file mode 100644 index 00000000000..180ac92f226 --- /dev/null +++ b/packages/tui/src/ui/pane-resize-handle.tsx @@ -0,0 +1,28 @@ +import { useTheme } from "../context/theme" +import type { createPaneResize } from "./pane-resize" + +export function PaneResizeHandle(props: { resize: ReturnType; left: number }) { + const theme = useTheme("elevated") + + return ( + + + + ) +} diff --git a/packages/tui/src/ui/pane-resize.ts b/packages/tui/src/ui/pane-resize.ts new file mode 100644 index 00000000000..eb62fed78c1 --- /dev/null +++ b/packages/tui/src/ui/pane-resize.ts @@ -0,0 +1,76 @@ +import { MouseButton, type MouseEvent } from "@opentui/core" +import { createEffect, createSignal } from "solid-js" + +export function createPaneResize(options: { + value: () => number + defaultValue: () => number + clamp: (size: number) => number + fromMouse: (event: MouseEvent) => number + contains: (event: MouseEvent, size: number) => boolean + onCommit: (size: number) => void +}) { + const [preferredSize, setPreferredSize] = createSignal(options.value()) + const [hovered, setHovered] = createSignal(false) + const [resizing, setResizing] = createSignal(false) + let requestedSize = options.value() + createEffect(() => { + if (resizing()) return + requestedSize = options.value() + setPreferredSize(requestedSize) + }) + const size = () => options.clamp(preferredSize()) + const commit = (value: number) => { + const next = options.clamp(value) + setPreferredSize(next) + if (requestedSize === next) return + requestedSize = next + options.onCommit(next) + } + let moved = false + let lastBoundaryClick = 0 + const finish = (event: MouseEvent) => { + if (!resizing()) return + const next = moved ? options.fromMouse(event) : size() + setResizing(false) + lastBoundaryClick = moved ? 0 : Date.now() + commit(next) + setHovered(options.contains(event, options.clamp(next))) + event.stopPropagation() + } + + // Bind drag/release on the parent so resizing continues outside the handle. + return { + preferredSize, + size, + hovered, + resizing, + onMouseOver: () => setHovered(true), + onMouseOut: () => setHovered(false), + onMouseDown: (event: MouseEvent) => { + if (event.button !== MouseButton.LEFT) return + const now = Date.now() + if (now - lastBoundaryClick < 300) { + lastBoundaryClick = 0 + setResizing(false) + setHovered(false) + commit(options.defaultValue()) + event.preventDefault() + event.stopPropagation() + return + } + moved = false + setResizing(true) + event.preventDefault() + event.stopPropagation() + }, + onMouseDrag: (event: MouseEvent) => { + if (!resizing()) return + moved = true + lastBoundaryClick = 0 + setPreferredSize(options.clamp(options.fromMouse(event))) + event.stopPropagation() + }, + onMouseDragEnd: finish, + onMouseUp: finish, + } +} diff --git a/packages/tui/test/ui/pane-resize-handle.test.tsx b/packages/tui/test/ui/pane-resize-handle.test.tsx new file mode 100644 index 00000000000..75ad40ff2ef --- /dev/null +++ b/packages/tui/test/ui/pane-resize-handle.test.tsx @@ -0,0 +1,134 @@ +/** @jsxImportSource @opentui/solid */ +import { BoxRenderable } from "@opentui/core" +import { testRender } from "@opentui/solid" +import { expect, test } from "bun:test" +import { createSignal } from "solid-js" +import { ConfigProvider } from "../../src/config" +import { ThemeProvider, useTheme } from "../../src/context/theme" +import { createPaneResize } from "../../src/ui/pane-resize" +import { PaneResizeHandle } from "../../src/ui/pane-resize-handle" +import { emptyThemeSource } from "../fixture/fixture" +import { TestTuiContexts } from "../fixture/tui-environment" +import { createTuiResolvedConfig } from "../fixture/tui-runtime" + +for (const mode of ["dark", "light"] as const) { + test(`${mode} pane resize handle renders themed hover and keeps parent-owned dragging and reset`, async () => { + const [value, setValue] = createSignal(20) + const commits: number[] = [] + let resize!: ReturnType + let theme!: ReturnType + let parent!: BoxRenderable + function Pane() { + theme = useTheme("elevated") + resize = createPaneResize({ + value, + defaultValue: () => 16, + clamp: (size) => Math.max(10, Math.min(40, size)), + fromMouse: (event) => event.x + 1, + contains: (event, size) => event.x >= size - 1 && event.x <= size, + onCommit: (size) => { + commits.push(size) + setValue(size) + }, + }) + return ( + (parent = element)} + width="100%" + height="100%" + onMouseDrag={resize.onMouseDrag} + onMouseDragEnd={resize.onMouseDragEnd} + onMouseUp={resize.onMouseUp} + > + + + ) + } + const app = await testRender( + () => ( + + + + + + + + ), + { width: 60, height: 5 }, + ) + + try { + app.renderer.start() + await app.waitForFrame(() => parent?.height === 5) + const handle = parent.getChildren()[0] as BoxRenderable + const line = handle.getChildren()[0] as BoxRenderable + expect(handle).toMatchObject({ x: 19, y: 0, width: 2, height: 5, zIndex: 10 }) + expect(line).toMatchObject({ x: 19, y: 0, width: 1, height: 5 }) + expect(line.backgroundColor.a).toBe(0) + expect(handle.backgroundColor.a).toBe(0) + + setValue(24) + await app.renderOnce() + expect(handle.x).toBe(23) + expect(line.x).toBe(23) + expect(commits).toEqual([]) + + // The transparent second column is still part of the hitbox. + await app.mockMouse.moveTo(24, 2) + await app.renderOnce() + expect(resize.hovered()).toBe(true) + expect(resize.resizing()).toBe(false) + expect(line.backgroundColor.toInts()).toEqual(theme.background.action.primary.hovered.toInts()) + expect(handle.backgroundColor.a).toBe(0) + for (const row of app.captureSpans().lines) { + const colors = row.spans.flatMap((span) => Array.from({ length: span.width }, () => span.bg.toInts())) + expect(colors[23]).toEqual(theme.background.action.primary.hovered.toInts()) + expect(colors[24]).not.toEqual(colors[23]) + } + + await app.mockMouse.moveTo(5, 2) + await app.renderOnce() + expect(resize.hovered()).toBe(false) + expect(line.backgroundColor.a).toBe(0) + + await app.mockMouse.pressDown(24, 2) + expect(resize.resizing()).toBe(true) + await app.mockMouse.moveTo(30, 2) + await app.renderOnce() + expect(resize.size()).toBe(31) + expect(handle.x).toBe(30) + expect(line.x).toBe(30) + expect(commits).toEqual([]) + + // Clamping leaves the pointer outside the handle while the parent keeps dragging. + await app.mockMouse.moveTo(50, 2) + await app.renderOnce() + expect(resize.size()).toBe(40) + expect(handle.x).toBe(39) + expect(resize.hovered()).toBe(false) + expect(resize.resizing()).toBe(true) + expect(line.backgroundColor.toInts()).toEqual(theme.background.action.primary.hovered.toInts()) + expect(commits).toEqual([]) + + await app.mockMouse.release(55, 2) + await app.renderOnce() + expect(resize.resizing()).toBe(false) + expect(resize.hovered()).toBe(false) + expect(line.backgroundColor.a).toBe(0) + expect(value()).toBe(40) + expect(commits).toEqual([40]) + + await app.mockMouse.doubleClick(40, 2) + await app.renderOnce() + expect(value()).toBe(16) + expect(handle.x).toBe(15) + expect(line.x).toBe(15) + expect(resize.resizing()).toBe(false) + expect(resize.hovered()).toBe(false) + expect(line.backgroundColor.a).toBe(0) + expect(commits).toEqual([40, 16]) + } finally { + app.renderer.destroy() + } + }) +} diff --git a/packages/tui/test/ui/pane-resize.test.ts b/packages/tui/test/ui/pane-resize.test.ts new file mode 100644 index 00000000000..013e06b7008 --- /dev/null +++ b/packages/tui/test/ui/pane-resize.test.ts @@ -0,0 +1,258 @@ +import { afterEach, beforeEach, expect, setSystemTime, test } from "bun:test" +import { MouseButton, MouseEvent } from "@opentui/core" +import { createRoot, createSignal } from "solid-js" +import { createPaneResize } from "../../src/ui/pane-resize" + +const disposals: Array<() => void> = [] + +beforeEach(() => setSystemTime(new Date(1_000))) +afterEach(() => { + disposals.splice(0).forEach((dispose) => dispose()) + setSystemTime() +}) + +function setup() { + return createRoot((dispose) => { + disposals.push(dispose) + const [value, setValue] = createSignal(40) + const [maximum, setMaximum] = createSignal(80) + const [defaultValue, setDefaultValue] = createSignal(30) + const commits: number[] = [] + const resize = createPaneResize({ + value, + defaultValue, + clamp: (size) => Math.max(10, Math.min(maximum(), size)), + fromMouse: (event) => event.x + 1, + contains: (event, size) => event.x >= size - 1 && event.x <= size, + onCommit: (size) => { + commits.push(size) + setValue(size) + }, + }) + return { resize, commits, setValue, setMaximum, setDefaultValue } + }) +} + +function mouse(type: MouseEvent["type"], x = 39, button = MouseButton.LEFT, y = 0) { + return new MouseEvent(null, { type, x, y, button, modifiers: { shift: false, alt: false, ctrl: false } }) +} + +test("syncs external preferences and clamps responsively without persisting or shrinking the preference", () => { + const scope = setup() + expect(scope.resize.preferredSize()).toBe(40) + expect(scope.resize.size()).toBe(40) + expect(scope.resize.resizing()).toBe(false) + expect(scope.resize.hovered()).toBe(false) + + scope.setValue(60) + expect(scope.resize.preferredSize()).toBe(60) + expect(scope.resize.size()).toBe(60) + scope.setMaximum(25) + expect(scope.resize.preferredSize()).toBe(60) + expect(scope.resize.size()).toBe(25) + scope.setMaximum(80) + expect(scope.resize.size()).toBe(60) + + scope.setValue(100) + expect(scope.resize.preferredSize()).toBe(100) + expect(scope.resize.size()).toBe(80) + scope.setMaximum(110) + expect(scope.resize.size()).toBe(100) + expect(scope.commits).toEqual([]) +}) + +test("handles only left-button starts, commits the final drag coordinate, and ignores duplicate releases", () => { + const scope = setup() + const idleDrag = mouse("drag", 70) + scope.resize.onMouseDrag(idleDrag) + const idleRelease = mouse("up", 70) + scope.resize.onMouseUp(idleRelease) + expect(idleDrag.propagationStopped).toBe(false) + expect(idleRelease.propagationStopped).toBe(false) + ;[MouseButton.MIDDLE, MouseButton.RIGHT].forEach((button) => { + const event = mouse("down", 39, button) + scope.resize.onMouseDown(event) + expect(scope.resize.resizing()).toBe(false) + expect(event.defaultPrevented).toBe(false) + expect(event.propagationStopped).toBe(false) + }) + + const down = mouse("down") + scope.resize.onMouseDown(down) + expect(scope.resize.resizing()).toBe(true) + expect(down.defaultPrevented).toBe(true) + expect(down.propagationStopped).toBe(true) + + const drag = mouse("drag", 47) + scope.resize.onMouseDrag(drag) + expect(scope.resize.preferredSize()).toBe(48) + expect(scope.commits).toEqual([]) + expect(drag.propagationStopped).toBe(true) + expect(drag.defaultPrevented).toBe(false) + + const release = mouse("drag-end", 54) + scope.resize.onMouseDragEnd(release) + expect(scope.resize.resizing()).toBe(false) + expect(scope.resize.size()).toBe(55) + expect(scope.commits).toEqual([55]) + expect(release.propagationStopped).toBe(true) + expect(release.defaultPrevented).toBe(false) + + const duplicate = mouse("up", 70) + scope.resize.onMouseUp(duplicate) + expect(duplicate.propagationStopped).toBe(false) + expect(scope.commits).toEqual([55]) + + scope.resize.onMouseDown(mouse("down", 54)) + scope.resize.onMouseUp(mouse("up", 70)) + expect(scope.resize.size()).toBe(55) + expect(scope.commits).toEqual([55]) + + scope.setMaximum(35) + setSystemTime(new Date(1_300)) + scope.resize.onMouseDown(mouse("down", 34)) + scope.resize.onMouseUp(mouse("up", 70)) + expect(scope.resize.preferredSize()).toBe(35) + expect(scope.commits).toEqual([55, 35]) +}) + +test("resets only within 300ms of a clean release and reads the current clamped default", () => { + const scope = setup() + scope.resize.onMouseDown(mouse("down")) + setSystemTime(new Date(2_000)) + scope.resize.onMouseUp(mouse("up")) + + setSystemTime(new Date(2_300)) + scope.resize.onMouseDown(mouse("down")) + expect(scope.resize.resizing()).toBe(true) + expect(scope.resize.size()).toBe(40) + setSystemTime(new Date(3_000)) + scope.resize.onMouseUp(mouse("up")) + + scope.setDefaultValue(70) + scope.setMaximum(60) + scope.resize.onMouseOver() + setSystemTime(new Date(3_299)) + const reset = mouse("down") + scope.resize.onMouseDown(reset) + expect(scope.resize.resizing()).toBe(false) + expect(scope.resize.hovered()).toBe(false) + expect(scope.resize.preferredSize()).toBe(60) + expect(scope.commits).toEqual([60]) + expect(reset.defaultPrevented).toBe(true) + expect(reset.propagationStopped).toBe(true) + + const release = mouse("up", 59) + scope.resize.onMouseUp(release) + expect(release.propagationStopped).toBe(false) + expect(scope.commits).toEqual([60]) + setSystemTime(new Date(3_300)) + scope.resize.onMouseDown(mouse("down", 59)) + expect(scope.resize.resizing()).toBe(true) +}) + +test("even a drag with no size change clears the clean-click timer", () => { + const scope = setup() + scope.resize.onMouseDown(mouse("down")) + scope.resize.onMouseUp(mouse("up")) + + setSystemTime(new Date(1_300)) + scope.resize.onMouseDown(mouse("down")) + setSystemTime(new Date(1_400)) + scope.resize.onMouseDrag(mouse("drag")) + setSystemTime(new Date(1_450)) + scope.resize.onMouseUp(mouse("up")) + expect(scope.commits).toEqual([]) + + setSystemTime(new Date(1_500)) + scope.resize.onMouseDown(mouse("down")) + expect(scope.resize.resizing()).toBe(true) + expect(scope.resize.size()).toBe(40) + expect(scope.commits).toEqual([]) +}) + +test("updates hover without consuming events and checks release against the clamped boundary", () => { + const scope = setup() + const onMouseOver: (event: MouseEvent) => void = scope.resize.onMouseOver + const onMouseOut: (event: MouseEvent) => void = scope.resize.onMouseOut + const over = mouse("over") + onMouseOver(over) + expect(scope.resize.hovered()).toBe(true) + expect(over.propagationStopped).toBe(false) + expect(over.defaultPrevented).toBe(false) + const out = mouse("out") + onMouseOut(out) + expect(scope.resize.hovered()).toBe(false) + expect(out.propagationStopped).toBe(false) + expect(out.defaultPrevented).toBe(false) + + scope.setMaximum(50) + scope.resize.onMouseDown(mouse("down")) + scope.resize.onMouseDrag(mouse("drag", 100)) + scope.resize.onMouseUp(mouse("up", 100)) + expect(scope.resize.size()).toBe(50) + expect(scope.resize.hovered()).toBe(false) + + scope.resize.onMouseDown(mouse("down", 49)) + scope.resize.onMouseUp(mouse("up", 50)) + expect(scope.resize.hovered()).toBe(true) + setSystemTime(new Date(1_300)) + scope.resize.onMouseDown(mouse("down", 49)) + scope.resize.onMouseUp(mouse("up", 49)) + expect(scope.resize.hovered()).toBe(true) + setSystemTime(new Date(1_600)) + scope.resize.onMouseDown(mouse("down", 49)) + scope.resize.onMouseUp(mouse("up", 48)) + expect(scope.resize.hovered()).toBe(false) + expect(scope.commits).toEqual([50]) +}) + +test("ignores storage changes during a drag and resumes external synchronization after release", () => { + const scope = setup() + scope.resize.onMouseDown(mouse("down")) + scope.resize.onMouseDrag(mouse("drag", 49)) + scope.setValue(70) + expect(scope.resize.resizing()).toBe(true) + expect(scope.resize.preferredSize()).toBe(50) + expect(scope.resize.size()).toBe(50) + expect(scope.commits).toEqual([]) + + scope.resize.onMouseUp(mouse("up", 59)) + expect(scope.resize.preferredSize()).toBe(60) + expect(scope.commits).toEqual([60]) + scope.setValue(35) + expect(scope.resize.preferredSize()).toBe(35) + expect(scope.commits).toEqual([60]) +}) + +test("supports right-anchored coordinates and live constraints through caller callbacks", () => { + const scope = createRoot((dispose) => { + disposals.push(dispose) + const [value, setValue] = createSignal(30) + const [width, setWidth] = createSignal(100) + const commits: number[] = [] + const resize = createPaneResize({ + value, + defaultValue: () => 20, + clamp: (size) => Math.max(10, Math.min(width() - 20, size)), + fromMouse: (event) => width() - event.x, + contains: (event, size) => event.x === width() - size && event.y >= 2, + onCommit: (size) => { + commits.push(size) + setValue(size) + }, + }) + return { resize, setWidth, commits } + }) + + scope.resize.onMouseDown(mouse("down", 70)) + scope.resize.onMouseDrag(mouse("drag", 50)) + expect(scope.resize.size()).toBe(50) + scope.setWidth(60) + expect(scope.resize.preferredSize()).toBe(50) + expect(scope.resize.size()).toBe(40) + scope.resize.onMouseUp(mouse("up", 25, MouseButton.LEFT, 2)) + expect(scope.resize.size()).toBe(35) + expect(scope.resize.hovered()).toBe(true) + expect(scope.commits).toEqual([35]) +})