mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-30 01:22:07 +00:00
refactor(tui): extract reusable pane resize logic (#45939)
This commit is contained in:
parent
201536f265
commit
e142a783f7
5 changed files with 516 additions and 80 deletions
|
|
@ -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<SessionInfo[]> | 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}
|
||||
>
|
||||
<Show when={verticalTabsVisible()}>
|
||||
<SessionTabs orientation="vertical" width={verticalTabsWidth()} />
|
||||
<SessionTabs orientation="vertical" width={tabsResize.size()} />
|
||||
</Show>
|
||||
<box flexGrow={1} minWidth={0} flexDirection="column">
|
||||
<Show when={plugins.ready()}>
|
||||
|
|
@ -1321,7 +1295,7 @@ function App(props: { pair?: DialogPairCredentials }) {
|
|||
{(sessionID) => (
|
||||
<SessionFrame
|
||||
sessionID={sessionID}
|
||||
verticalTabsWidth={verticalTabsVisible() ? verticalTabsWidth() : 0}
|
||||
verticalTabsWidth={verticalTabsVisible() ? tabsResize.size() : 0}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
|
|
@ -1339,41 +1313,7 @@ function App(props: { pair?: DialogPairCredentials }) {
|
|||
</Show>
|
||||
</box>
|
||||
<Show when={verticalTabsVisible()}>
|
||||
<box
|
||||
position="absolute"
|
||||
left={verticalTabsWidth() - 1}
|
||||
top={0}
|
||||
zIndex={10}
|
||||
width={2}
|
||||
height="100%"
|
||||
onMouseOver={() => 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()
|
||||
}}
|
||||
>
|
||||
<box
|
||||
width={1}
|
||||
height="100%"
|
||||
backgroundColor={
|
||||
tabsResizeHovered() || tabsResizing() ? tabsTheme.background.action.primary.hovered : undefined
|
||||
}
|
||||
/>
|
||||
</box>
|
||||
<PaneResizeHandle resize={tabsResize} left={tabsResize.size() - 1} />
|
||||
</Show>
|
||||
</box>
|
||||
<Show when={devtools()}>
|
||||
|
|
|
|||
28
packages/tui/src/ui/pane-resize-handle.tsx
Normal file
28
packages/tui/src/ui/pane-resize-handle.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { useTheme } from "../context/theme"
|
||||
import type { createPaneResize } from "./pane-resize"
|
||||
|
||||
export function PaneResizeHandle(props: { resize: ReturnType<typeof createPaneResize>; left: number }) {
|
||||
const theme = useTheme("elevated")
|
||||
|
||||
return (
|
||||
<box
|
||||
position="absolute"
|
||||
left={props.left}
|
||||
top={0}
|
||||
zIndex={10}
|
||||
width={2}
|
||||
height="100%"
|
||||
onMouseOver={props.resize.onMouseOver}
|
||||
onMouseOut={props.resize.onMouseOut}
|
||||
onMouseDown={props.resize.onMouseDown}
|
||||
>
|
||||
<box
|
||||
width={1}
|
||||
height="100%"
|
||||
backgroundColor={
|
||||
props.resize.hovered() || props.resize.resizing() ? theme.background.action.primary.hovered : undefined
|
||||
}
|
||||
/>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
76
packages/tui/src/ui/pane-resize.ts
Normal file
76
packages/tui/src/ui/pane-resize.ts
Normal file
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
134
packages/tui/test/ui/pane-resize-handle.test.tsx
Normal file
134
packages/tui/test/ui/pane-resize-handle.test.tsx
Normal file
|
|
@ -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<typeof createPaneResize>
|
||||
let theme!: ReturnType<typeof useTheme>
|
||||
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 (
|
||||
<box
|
||||
ref={(element) => (parent = element)}
|
||||
width="100%"
|
||||
height="100%"
|
||||
onMouseDrag={resize.onMouseDrag}
|
||||
onMouseDragEnd={resize.onMouseDragEnd}
|
||||
onMouseUp={resize.onMouseUp}
|
||||
>
|
||||
<PaneResizeHandle resize={resize} left={resize.size() - 1} />
|
||||
</box>
|
||||
)
|
||||
}
|
||||
const app = await testRender(
|
||||
() => (
|
||||
<TestTuiContexts>
|
||||
<ConfigProvider config={createTuiResolvedConfig({ theme: { name: "opencode", mode } })}>
|
||||
<ThemeProvider mode={mode} source={emptyThemeSource}>
|
||||
<Pane />
|
||||
</ThemeProvider>
|
||||
</ConfigProvider>
|
||||
</TestTuiContexts>
|
||||
),
|
||||
{ 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()
|
||||
}
|
||||
})
|
||||
}
|
||||
258
packages/tui/test/ui/pane-resize.test.ts
Normal file
258
packages/tui/test/ui/pane-resize.test.ts
Normal file
|
|
@ -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])
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue