feat: add scrollbar and fix selection on scroll

This commit is contained in:
Brooklyn Nicholson 2026-04-14 14:34:33 -05:00
parent 9804aa7443
commit 52c11d172a
10 changed files with 397 additions and 126 deletions

View file

@ -5,6 +5,7 @@ import { logForDebugging } from '../../utils/debug.js'
import { stopCapturingEarlyInput } from '../../utils/earlyInput.js'
import { isMouseClicksDisabled } from '../../utils/fullscreen.js'
import { logError } from '../../utils/log.js'
import type { DOMElement } from '../dom.js'
import { EventEmitter } from '../events/emitter.js'
import { InputEvent } from '../events/input-event.js'
import { TerminalFocusEvent } from '../events/terminal-focus-event.js'
@ -67,6 +68,9 @@ type Props = {
// No-op (returns false) outside fullscreen mode (Ink.dispatchClick
// gates on altScreenActive).
readonly onClickAt: (col: number, row: number) => boolean
readonly onMouseDownAt: (col: number, row: number, button: number) => DOMElement | undefined
readonly onMouseUpAt: (target: DOMElement, col: number, row: number, button: number) => void
readonly onMouseDragAt: (target: DOMElement, col: number, row: number, button: number) => void
// Dispatch hover (onMouseEnter/onMouseLeave) as the pointer moves over
// DOM elements. Called for mode-1003 motion events with no button held.
// No-op outside fullscreen (Ink.dispatchHover gates on altScreenActive).
@ -155,6 +159,7 @@ export default class App extends PureComponent<Props, State> {
// repeat events (drag-then-release at same cell, etc.).
lastHoverCol = -1
lastHoverRow = -1
mouseCaptureTarget: DOMElement | undefined
// Timestamp of last stdin chunk. Used to detect long gaps (tmux attach,
// ssh reconnect, laptop wake) and trigger terminal mode re-assert.
@ -578,6 +583,11 @@ export function handleMouseEvent(app: App, m: ParsedMouse): void {
if (m.action === 'press') {
if ((m.button & 0x20) !== 0 && baseButton === 3) {
if (app.mouseCaptureTarget) {
app.props.onMouseUpAt(app.mouseCaptureTarget, col, row, baseButton)
app.mouseCaptureTarget = undefined
}
// Mode-1003 motion with no button held. Dispatch hover; skip the
// rest of this handler (no selection, no click-count side effects).
// Lost-release recovery: no-button motion while isDragging=true means
@ -611,6 +621,12 @@ export function handleMouseEvent(app: App, m: ParsedMouse): void {
}
if ((m.button & 0x20) !== 0) {
if (app.mouseCaptureTarget) {
app.props.onMouseDragAt(app.mouseCaptureTarget, col, row, baseButton)
return
}
// Drag motion: mode-aware extension (char/word/line). onSelectionDrag
// calls notifySelectionChange internally — no extra onSelectionChange.
app.props.onSelectionDrag(col, row)
@ -628,6 +644,15 @@ export function handleMouseEvent(app: App, m: ParsedMouse): void {
app.props.onSelectionChange()
}
const capture = app.props.onMouseDownAt(col, row, baseButton)
if (capture) {
app.mouseCaptureTarget = capture
app.clickCount = 0
return
}
// Fresh left press. Detect multi-click HERE (not on release) so the
// word/line highlight appears immediately and a subsequent drag can
// extend by word/line like native macOS. Previously detected on
@ -677,6 +702,13 @@ export function handleMouseEvent(app: App, m: ParsedMouse): void {
// isDragging=true and leave drag-to-scroll's timer running until the
// scroll boundary. Only act on non-left releases when we ARE dragging
// (so an unrelated middle/right click-release doesn't touch selection).
if (app.mouseCaptureTarget) {
app.props.onMouseUpAt(app.mouseCaptureTarget, col, row, baseButton)
app.mouseCaptureTarget = undefined
return
}
if (baseButton !== 0) {
if (!sel.isDragging) {
return

View file

@ -8,6 +8,7 @@ import type { DOMElement } from '../dom.js'
import type { ClickEvent } from '../events/click-event.js'
import type { FocusEvent } from '../events/focus-event.js'
import type { KeyboardEvent } from '../events/keyboard-event.js'
import type { MouseEvent } from '../events/mouse-event.js'
import type { Styles } from '../styles.js'
import * as warn from '../warn.js'
export type Props = Except<Styles, 'textWrap'> & {
@ -31,6 +32,9 @@ export type Props = Except<Styles, 'textWrap'> & {
* ancestors; call `event.stopImmediatePropagation()` to stop bubbling.
*/
onClick?: (event: ClickEvent) => void
onMouseDown?: (event: MouseEvent) => void
onMouseUp?: (event: MouseEvent) => void
onMouseDrag?: (event: MouseEvent) => void
onFocus?: (event: FocusEvent) => void
onFocusCapture?: (event: FocusEvent) => void
onBlur?: (event: FocusEvent) => void
@ -52,7 +56,7 @@ export type Props = Except<Styles, 'textWrap'> & {
* `<Box>` is an essential Ink component to build your layout. It's like `<div style="display: flex">` in the browser.
*/
function Box(t0: Props) {
const $ = _c(42)
const $ = _c(48)
let autoFocus
let children
let flexDirection
@ -66,8 +70,11 @@ function Box(t0: Props) {
let onFocusCapture
let onKeyDown
let onKeyDownCapture
let onMouseDown
let onMouseDrag
let onMouseEnter
let onMouseLeave
let onMouseUp
let ref
let style
let tabIndex
@ -87,11 +94,14 @@ function Box(t0: Props) {
onFocusCapture: t11,
onBlur: t12,
onBlurCapture: t13,
onMouseEnter: t14,
onMouseLeave: t15,
onKeyDown: t16,
onKeyDownCapture: t17,
...t18
onMouseDown: t14,
onMouseUp: t15,
onMouseDrag: t16,
onMouseEnter: t17,
onMouseLeave: t18,
onKeyDown: t19,
onKeyDownCapture: t20,
...t21
} = t0
children = t1
@ -103,11 +113,14 @@ function Box(t0: Props) {
onFocusCapture = t11
onBlur = t12
onBlurCapture = t13
onMouseEnter = t14
onMouseLeave = t15
onKeyDown = t16
onKeyDownCapture = t17
style = t18
onMouseDown = t14
onMouseUp = t15
onMouseDrag = t16
onMouseEnter = t17
onMouseLeave = t18
onKeyDown = t19
onKeyDownCapture = t20
style = t21
flexWrap = t2 === undefined ? 'nowrap' : t2
flexDirection = t3 === undefined ? 'row' : t3
flexGrow = t4 === undefined ? 0 : t4
@ -143,11 +156,14 @@ function Box(t0: Props) {
$[11] = onFocusCapture
$[12] = onKeyDown
$[13] = onKeyDownCapture
$[14] = onMouseEnter
$[15] = onMouseLeave
$[16] = ref
$[17] = style
$[18] = tabIndex
$[14] = onMouseDown
$[15] = onMouseUp
$[16] = onMouseDrag
$[17] = onMouseEnter
$[18] = onMouseLeave
$[19] = ref
$[20] = style
$[21] = tabIndex
} else {
autoFocus = $[1]
children = $[2]
@ -162,11 +178,14 @@ function Box(t0: Props) {
onFocusCapture = $[11]
onKeyDown = $[12]
onKeyDownCapture = $[13]
onMouseEnter = $[14]
onMouseLeave = $[15]
ref = $[16]
style = $[17]
tabIndex = $[18]
onMouseDown = $[14]
onMouseUp = $[15]
onMouseDrag = $[16]
onMouseEnter = $[17]
onMouseLeave = $[18]
ref = $[19]
style = $[20]
tabIndex = $[21]
}
const t1 = style.overflowX ?? style.overflow ?? 'visible'
@ -174,13 +193,13 @@ function Box(t0: Props) {
let t3
if (
$[19] !== flexDirection ||
$[20] !== flexGrow ||
$[21] !== flexShrink ||
$[22] !== flexWrap ||
$[23] !== style ||
$[24] !== t1 ||
$[25] !== t2
$[22] !== flexDirection ||
$[23] !== flexGrow ||
$[24] !== flexShrink ||
$[25] !== flexWrap ||
$[26] !== style ||
$[27] !== t1 ||
$[28] !== t2
) {
t3 = {
flexWrap,
@ -191,35 +210,38 @@ function Box(t0: Props) {
overflowX: t1,
overflowY: t2
}
$[19] = flexDirection
$[20] = flexGrow
$[21] = flexShrink
$[22] = flexWrap
$[23] = style
$[24] = t1
$[25] = t2
$[26] = t3
$[22] = flexDirection
$[23] = flexGrow
$[24] = flexShrink
$[25] = flexWrap
$[26] = style
$[27] = t1
$[28] = t2
$[29] = t3
} else {
t3 = $[26]
t3 = $[29]
}
let t4
if (
$[27] !== autoFocus ||
$[28] !== children ||
$[29] !== onBlur ||
$[30] !== onBlurCapture ||
$[31] !== onClick ||
$[32] !== onFocus ||
$[33] !== onFocusCapture ||
$[34] !== onKeyDown ||
$[35] !== onKeyDownCapture ||
$[36] !== onMouseEnter ||
$[37] !== onMouseLeave ||
$[38] !== ref ||
$[39] !== t3 ||
$[40] !== tabIndex
$[30] !== autoFocus ||
$[31] !== children ||
$[32] !== onBlur ||
$[33] !== onBlurCapture ||
$[34] !== onClick ||
$[35] !== onFocus ||
$[36] !== onFocusCapture ||
$[37] !== onKeyDown ||
$[38] !== onKeyDownCapture ||
$[39] !== onMouseDown ||
$[40] !== onMouseUp ||
$[41] !== onMouseDrag ||
$[42] !== onMouseEnter ||
$[43] !== onMouseLeave ||
$[44] !== ref ||
$[45] !== t3 ||
$[46] !== tabIndex
) {
t4 = (
<ink-box
@ -231,8 +253,11 @@ function Box(t0: Props) {
onFocusCapture={onFocusCapture}
onKeyDown={onKeyDown}
onKeyDownCapture={onKeyDownCapture}
onMouseDown={onMouseDown}
onMouseDrag={onMouseDrag}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
onMouseUp={onMouseUp}
ref={ref}
style={t3}
tabIndex={tabIndex}
@ -240,23 +265,26 @@ function Box(t0: Props) {
{children}
</ink-box>
)
$[27] = autoFocus
$[28] = children
$[29] = onBlur
$[30] = onBlurCapture
$[31] = onClick
$[32] = onFocus
$[33] = onFocusCapture
$[34] = onKeyDown
$[35] = onKeyDownCapture
$[36] = onMouseEnter
$[37] = onMouseLeave
$[38] = ref
$[39] = t3
$[40] = tabIndex
$[41] = t4
$[30] = autoFocus
$[31] = children
$[32] = onBlur
$[33] = onBlurCapture
$[34] = onClick
$[35] = onFocus
$[36] = onFocusCapture
$[37] = onKeyDown
$[38] = onKeyDownCapture
$[39] = onMouseDown
$[40] = onMouseUp
$[41] = onMouseDrag
$[42] = onMouseEnter
$[43] = onMouseLeave
$[44] = ref
$[45] = t3
$[46] = tabIndex
$[47] = t4
} else {
t4 = $[41]
t4 = $[47]
}
return t4

View file

@ -1,6 +1,7 @@
import type { ClickEvent } from './click-event.js'
import type { FocusEvent } from './focus-event.js'
import type { KeyboardEvent } from './keyboard-event.js'
import type { MouseEvent } from './mouse-event.js'
import type { PasteEvent } from './paste-event.js'
import type { ResizeEvent } from './resize-event.js'
@ -9,6 +10,7 @@ type FocusEventHandler = (event: FocusEvent) => void
type PasteEventHandler = (event: PasteEvent) => void
type ResizeEventHandler = (event: ResizeEvent) => void
type ClickEventHandler = (event: ClickEvent) => void
type MouseEventHandler = (event: MouseEvent) => void
type HoverEventHandler = () => void
/**
@ -33,6 +35,9 @@ export type EventHandlerProps = {
onResize?: ResizeEventHandler
onClick?: ClickEventHandler
onMouseDown?: MouseEventHandler
onMouseUp?: MouseEventHandler
onMouseDrag?: MouseEventHandler
onMouseEnter?: HoverEventHandler
onMouseLeave?: HoverEventHandler
}
@ -50,7 +55,10 @@ export const HANDLER_FOR_EVENT: Record<
blur: { bubble: 'onBlur', capture: 'onBlurCapture' },
paste: { bubble: 'onPaste', capture: 'onPasteCapture' },
resize: { bubble: 'onResize' },
click: { bubble: 'onClick' }
click: { bubble: 'onClick' },
mousedown: { bubble: 'onMouseDown' },
mouseup: { bubble: 'onMouseUp' },
mousedrag: { bubble: 'onMouseDrag' }
}
/**
@ -68,6 +76,9 @@ export const EVENT_HANDLER_PROPS = new Set<string>([
'onPasteCapture',
'onResize',
'onClick',
'onMouseDown',
'onMouseUp',
'onMouseDrag',
'onMouseEnter',
'onMouseLeave'
])

View file

@ -0,0 +1,18 @@
import { Event } from './event.js'
export class MouseEvent extends Event {
readonly col: number
readonly row: number
localCol = 0
localRow = 0
readonly cellIsBlank: boolean
readonly button: number
constructor(col: number, row: number, cellIsBlank: boolean, button: number) {
super()
this.col = col
this.row = row
this.cellIsBlank = cellIsBlank
this.button = button
}
}

View file

@ -1,6 +1,7 @@
import type { DOMElement } from './dom.js'
import { ClickEvent } from './events/click-event.js'
import type { EventHandlerProps } from './events/event-handlers.js'
import { MouseEvent } from './events/mouse-event.js'
import { nodeCache } from './node-cache.js'
/**
@ -101,6 +102,51 @@ export function dispatchClick(root: DOMElement, col: number, row: number, cellIs
return handled
}
type MouseHandler = 'onMouseDown' | 'onMouseUp' | 'onMouseDrag'
export function dispatchMouse(
root: DOMElement,
col: number,
row: number,
handlerName: MouseHandler,
button: number,
cellIsBlank = false,
target?: DOMElement
): DOMElement | undefined {
let node: DOMElement | undefined = target ?? hitTest(root, col, row) ?? undefined
if (!node) {
return undefined
}
const event = new MouseEvent(col, row, cellIsBlank, button)
let handled: DOMElement | undefined
while (node) {
const handler = node._eventHandlers?.[handlerName] as ((event: MouseEvent) => void) | undefined
if (handler) {
handled ??= node
const rect = nodeCache.get(node)
if (rect) {
event.localCol = col - rect.x
event.localRow = row - rect.y
}
handler(event)
if (event.didStopImmediatePropagation()) {
return handled
}
}
node = node.parentNode
}
return handled
}
/**
* Fire onMouseEnter/onMouseLeave as the pointer moves. Like DOM
* mouseenter/mouseleave: does NOT bubble moving between children does

View file

@ -22,7 +22,7 @@ import * as dom from './dom.js'
import { KeyboardEvent } from './events/keyboard-event.js'
import { FocusManager } from './focus.js'
import { emptyFrame, type Frame, type FrameEvent } from './frame.js'
import { dispatchClick, dispatchHover } from './hit-test.js'
import { dispatchClick, dispatchHover, dispatchMouse } from './hit-test.js'
import instances from './instances.js'
import { LogUpdate } from './log-update.js'
import { nodeCache } from './node-cache.js'
@ -1538,6 +1538,42 @@ export default class Ink {
return dispatchClick(this.rootNode, col, row, blank)
}
dispatchMouseDown(col: number, row: number, button: number): dom.DOMElement | undefined {
if (!this.altScreenActive) {
return undefined
}
return dispatchMouse(
this.rootNode,
col,
row,
'onMouseDown',
button,
isEmptyCellAt(this.frontFrame.screen, col, row)
)
}
dispatchMouseUp(target: dom.DOMElement, col: number, row: number, button: number): void {
if (!this.altScreenActive) {
return
}
dispatchMouse(this.rootNode, col, row, 'onMouseUp', button, isEmptyCellAt(this.frontFrame.screen, col, row), target)
}
dispatchMouseDrag(target: dom.DOMElement, col: number, row: number, button: number): void {
if (!this.altScreenActive) {
return
}
dispatchMouse(
this.rootNode,
col,
row,
'onMouseDrag',
button,
isEmptyCellAt(this.frontFrame.screen, col, row),
target
)
}
dispatchHover(col: number, row: number): void {
if (!this.altScreenActive) {
return
@ -1764,6 +1800,9 @@ export default class Ink {
onCursorDeclaration={this.setCursorDeclaration}
onExit={this.unmount}
onHoverAt={this.dispatchHover}
onMouseDownAt={this.dispatchMouseDown}
onMouseDragAt={this.dispatchMouseDrag}
onMouseUpAt={this.dispatchMouseUp}
onMultiClick={this.handleMultiClick}
onOpenHyperlink={this.openHyperlink}
onSelectionChange={this.notifySelectionChange}