refactor(tui): extract reusable pane resize logic (#45939)

This commit is contained in:
James Long 2026-08-28 11:20:23 -04:00 committed by GitHub
parent 201536f265
commit e142a783f7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 516 additions and 80 deletions

View file

@ -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()}>

View 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>
)
}

View 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,
}
}

View 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()
}
})
}

View 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])
})