@trendr/core 0.4.11 → 0.4.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +33 -1
- package/index.js +1 -1
- package/package.json +1 -1
- package/src/ansi.js +3 -0
- package/src/buffer.js +3 -0
- package/src/diff-view.js +5 -3
- package/src/hooks.js +12 -0
- package/src/layout.js +4 -2
- package/src/renderer.js +28 -1
- package/src/selection.js +3 -3
package/README.md
CHANGED
|
@@ -313,7 +313,39 @@ useMouse((event) => {
|
|
|
313
313
|
})
|
|
314
314
|
```
|
|
315
315
|
|
|
316
|
-
Mouse is enabled automatically. Passive pointer movement is reported as `move
|
|
316
|
+
Mouse is enabled automatically. Passive pointer movement is reported as `move`; combine it with `useHitTest()` to derive hover state. Built-in components support click, scroll wheel, and scrollbar dragging.
|
|
317
|
+
|
|
318
|
+
### useHitTest
|
|
319
|
+
|
|
320
|
+
Mouse events carry terminal coordinates, but `useLayout()` returns logical content-space coordinates - the two disagree inside any scrolled or clipped container. `useHitTest()` returns a function that tests terminal coordinates against the component's final painted, clipped rectangle, with ancestor scroll offsets applied.
|
|
321
|
+
|
|
322
|
+
```jsx
|
|
323
|
+
function Hoverable({ children, action, onAction }) {
|
|
324
|
+
const hitTest = useHitTest()
|
|
325
|
+
const [hovered, setHovered] = createSignal(false)
|
|
326
|
+
|
|
327
|
+
useMouse((event) => {
|
|
328
|
+
if (event.action === 'move') setHovered(hitTest(event.x, event.y))
|
|
329
|
+
if (event.action === 'press' && event.button === 'left' && hovered() && hitTest(event.x, event.y)) {
|
|
330
|
+
onAction()
|
|
331
|
+
event.stopPropagation()
|
|
332
|
+
}
|
|
333
|
+
})
|
|
334
|
+
|
|
335
|
+
return (
|
|
336
|
+
<box>
|
|
337
|
+
{children}
|
|
338
|
+
{hovered() && (
|
|
339
|
+
<box style={{ position: 'absolute', top: 0, right: 1 }}>
|
|
340
|
+
<text style={{ inverse: true }}>{` ${action} `}</text>
|
|
341
|
+
</box>
|
|
342
|
+
)}
|
|
343
|
+
</box>
|
|
344
|
+
)
|
|
345
|
+
}
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
The returned function stays accurate across scrolling, resizing, and relayout, and returns `false` once the component unmounts. It answers geometric containment only - focus and z-order stay with the input system. Never bounds-check mouse events against `useLayout()`; inside a scrolled viewport that either misses real hits or, worse, captures events belonging to other components.
|
|
317
349
|
|
|
318
350
|
### useStdout
|
|
319
351
|
|
package/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export { mount, registerOverlay } from './src/renderer.js'
|
|
2
2
|
export { createSignal } from './src/signal.js'
|
|
3
3
|
export { createEffect, createMemo, batch, untrack, onCleanup } from './src/signal.js'
|
|
4
|
-
export { useInput, useMouse, useResize, useInterval, useTimeout, useLayout, useStdout, useRepaint, useTitle, useTheme, useCursor, useFrameStats, useAsync } from './src/hooks.js'
|
|
4
|
+
export { useInput, useMouse, useResize, useInterval, useTimeout, useLayout, useHitTest, useStdout, useRepaint, useTitle, useTheme, useCursor, useFrameStats, useAsync } from './src/hooks.js'
|
|
5
5
|
export { Box, Text, Spacer } from './src/components.js'
|
|
6
6
|
export { TextInput } from './src/text-input.js'
|
|
7
7
|
export { TextArea } from './src/text-area.js'
|
package/package.json
CHANGED
package/src/ansi.js
CHANGED
|
@@ -19,6 +19,9 @@ export const sgrReset = `${ESC}0m`
|
|
|
19
19
|
export const setTitle = (title) => `\x1b]2;${title}\x07`
|
|
20
20
|
export const osc52Copy = (text) => `\x1b]52;c;${Buffer.from(text, 'utf8').toString('base64')}\x07`
|
|
21
21
|
|
|
22
|
+
export const beginSync = `${ESC}?2026h`
|
|
23
|
+
export const endSync = `${ESC}?2026l`
|
|
24
|
+
|
|
22
25
|
export const enableMouse = `${ESC}?1003h${ESC}?1006h`
|
|
23
26
|
export const disableMouse = `${ESC}?1003l${ESC}?1006l`
|
|
24
27
|
|
package/src/buffer.js
CHANGED
|
@@ -159,5 +159,8 @@ export function blitRect(src, dst, x, y, w, h) {
|
|
|
159
159
|
for (let col = x1; col < x2; col++) {
|
|
160
160
|
dst.cells[base + col] = src.cells[base + col]
|
|
161
161
|
}
|
|
162
|
+
// softWrap is per-row metadata set at text paint time; a blitted clean
|
|
163
|
+
// subtree must carry its wrap flags or selection re-joins lines wrongly
|
|
164
|
+
if (src.softWrap[row]) dst.softWrap[row] = 1
|
|
162
165
|
}
|
|
163
166
|
}
|
package/src/diff-view.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { jsx, jsxs } from '../jsx-runtime.js'
|
|
2
2
|
import { createSignal } from './signal.js'
|
|
3
|
-
import { useInput, useMouse, useLayout, useScrollDrag } from './hooks.js'
|
|
3
|
+
import { useInput, useMouse, useLayout, useHitTest, useScrollDrag } from './hooks.js'
|
|
4
4
|
import { sliceVisibleRange } from './wrap.js'
|
|
5
5
|
import { computeDiff } from './diff-engine.js'
|
|
6
6
|
|
|
@@ -141,10 +141,12 @@ export function Diff({
|
|
|
141
141
|
else if (key === 'end' || key === 'G') setOffset(maxOffset)
|
|
142
142
|
})
|
|
143
143
|
|
|
144
|
+
const hitTest = useHitTest()
|
|
144
145
|
useMouse((event) => {
|
|
145
146
|
if (event.action !== 'scroll' || rows.length <= h) return
|
|
146
|
-
|
|
147
|
-
|
|
147
|
+
// painted-geometry bounds: logical layout coords are wrong for a diff
|
|
148
|
+
// inside a scrolled container and would swallow wheel events meant for it
|
|
149
|
+
if (!hitTest(event.x, event.y)) return
|
|
148
150
|
if (event.direction !== 'up' && event.direction !== 'down') return
|
|
149
151
|
setOffset(clamp(clamped + (event.direction === 'up' ? -3 : 3)))
|
|
150
152
|
event.stopPropagation()
|
package/src/hooks.js
CHANGED
|
@@ -60,6 +60,18 @@ export function useLayout() {
|
|
|
60
60
|
return getInstanceLayout()
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
// terminal-space hit testing: tests mouse coordinates against the component's
|
|
64
|
+
// final painted, clipped rectangle - unlike useLayout, which is logical
|
|
65
|
+
// content space and knows nothing of ancestor scroll offsets or clipping
|
|
66
|
+
export function useHitTest() {
|
|
67
|
+
const state = registerHook(() => ({ owner: getCurrentHookOwner() }))
|
|
68
|
+
return (x, y) => {
|
|
69
|
+
const rect = state.owner?._paintedRect
|
|
70
|
+
if (!rect) return false
|
|
71
|
+
return x >= rect.x && x < rect.x + rect.width && y >= rect.y && y < rect.y + rect.height
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
63
75
|
export function useTheme() {
|
|
64
76
|
return getTheme()
|
|
65
77
|
}
|
package/src/layout.js
CHANGED
|
@@ -314,8 +314,10 @@ function measureIntrinsic(node, availW, availH) {
|
|
|
314
314
|
const innerW = availW - chrome.x
|
|
315
315
|
const innerH = availH - chrome.y
|
|
316
316
|
|
|
317
|
-
|
|
318
|
-
|
|
317
|
+
// absolute children are excluded from flow layout, so they must not
|
|
318
|
+
// contribute to intrinsic size either, or a hover overlay grows its parent
|
|
319
|
+
const children = (node._resolvedChildren || []).filter((c) => childStyle(c).position !== 'absolute')
|
|
320
|
+
if (children.length === 0) {
|
|
319
321
|
return { width: chrome.x, height: chrome.y }
|
|
320
322
|
}
|
|
321
323
|
|
package/src/renderer.js
CHANGED
|
@@ -319,6 +319,23 @@ function paintTree(node, buf, clip, offset, prevBuf) {
|
|
|
319
319
|
|
|
320
320
|
if (node._resolved) {
|
|
321
321
|
const inst = node._instance
|
|
322
|
+
// painted terminal-space geometry for useHitTest: logical layout plus
|
|
323
|
+
// accumulated ancestor paint offset, intersected with the active clip.
|
|
324
|
+
// recorded before the blit fast-path so clean subtrees stay hit-testable
|
|
325
|
+
if (inst) {
|
|
326
|
+
const lay = node._resolved?._layout ?? node._layout
|
|
327
|
+
if (lay && lay.width > 0 && lay.height > 0) {
|
|
328
|
+
const painted = offset
|
|
329
|
+
? { x: lay.x + offset.x, y: lay.y + offset.y, width: lay.width, height: lay.height }
|
|
330
|
+
: lay
|
|
331
|
+
const vis = clip ? clipRect(painted, clip) : painted
|
|
332
|
+
inst._paintedRect = vis.width > 0 && vis.height > 0
|
|
333
|
+
? { x: vis.x, y: vis.y, width: vis.width, height: vis.height }
|
|
334
|
+
: null
|
|
335
|
+
} else {
|
|
336
|
+
inst._paintedRect = null
|
|
337
|
+
}
|
|
338
|
+
}
|
|
322
339
|
if (prevBuf && inst && !inst._subtreeDirty) {
|
|
323
340
|
const layout = node._resolved?._layout ?? node._layout
|
|
324
341
|
if (layout && layoutEqual(layout, inst._lastLayout)) {
|
|
@@ -913,6 +930,7 @@ export function mount(rootComponent, { stream, stdin, title, theme, onExit: onEx
|
|
|
913
930
|
|
|
914
931
|
for (const [key, inst] of instances) {
|
|
915
932
|
if (!visited.has(key)) {
|
|
933
|
+
inst._paintedRect = null
|
|
916
934
|
disposeScope(inst.scope)
|
|
917
935
|
instances.delete(key)
|
|
918
936
|
}
|
|
@@ -1060,6 +1078,7 @@ export function mount(rootComponent, { stream, stdin, title, theme, onExit: onEx
|
|
|
1060
1078
|
|
|
1061
1079
|
for (const [key, inst] of instances) {
|
|
1062
1080
|
if (!visited.has(key)) {
|
|
1081
|
+
inst._paintedRect = null
|
|
1063
1082
|
disposeScope(inst.scope)
|
|
1064
1083
|
instances.delete(key)
|
|
1065
1084
|
}
|
|
@@ -1069,10 +1088,13 @@ export function mount(rootComponent, { stream, stdin, title, theme, onExit: onEx
|
|
|
1069
1088
|
|
|
1070
1089
|
const { output, changed } = diff(prev, curr)
|
|
1071
1090
|
if (changed > 0) {
|
|
1072
|
-
|
|
1091
|
+
// synchronized output (dec 2026): supporting terminals apply the whole
|
|
1092
|
+
// frame atomically instead of tearing mid-write; others ignore it
|
|
1093
|
+
out.write(ansi.beginSync + ansi.hideCursor)
|
|
1073
1094
|
// diff() returns a view into a shared double buffer that gets reused two
|
|
1074
1095
|
// frames later; write a copy so backpressured streams never see it mutate
|
|
1075
1096
|
out.write(Buffer.from(output))
|
|
1097
|
+
out.write(ansi.endSync)
|
|
1076
1098
|
}
|
|
1077
1099
|
|
|
1078
1100
|
const now = performance.now()
|
|
@@ -1170,6 +1192,7 @@ export function mount(rootComponent, { stream, stdin, title, theme, onExit: onEx
|
|
|
1170
1192
|
process.off('SIGHUP', onSighup)
|
|
1171
1193
|
|
|
1172
1194
|
for (const inst of instances.values()) {
|
|
1195
|
+
inst._paintedRect = null
|
|
1173
1196
|
disposeScope(inst.scope)
|
|
1174
1197
|
}
|
|
1175
1198
|
instances.clear()
|
|
@@ -1227,6 +1250,10 @@ export function mount(rootComponent, { stream, stdin, title, theme, onExit: onEx
|
|
|
1227
1250
|
}
|
|
1228
1251
|
|
|
1229
1252
|
ctx.repaint = repaint
|
|
1253
|
+
// gentle sibling of repaint: schedules a normal diffed frame with no
|
|
1254
|
+
// clear-screen; right for overlay state like selection that frame()
|
|
1255
|
+
// already applies each pass
|
|
1256
|
+
ctx.requestFrame = () => scheduler.forceFrame()
|
|
1230
1257
|
ctx.getPaintBuffer = () => (inline ? lastInlineBuf : prev)
|
|
1231
1258
|
|
|
1232
1259
|
return { unmount, repaint, setTheme, getBuffer: ctx.getPaintBuffer }
|
package/src/selection.js
CHANGED
|
@@ -65,7 +65,7 @@ export function useSelection({ onCopy, copy = true } = {}) {
|
|
|
65
65
|
state.dragging = false
|
|
66
66
|
if (ctx.selection) {
|
|
67
67
|
ctx.selection = null
|
|
68
|
-
ctx.
|
|
68
|
+
ctx.requestFrame()
|
|
69
69
|
}
|
|
70
70
|
return
|
|
71
71
|
}
|
|
@@ -73,7 +73,7 @@ export function useSelection({ onCopy, copy = true } = {}) {
|
|
|
73
73
|
if (event.action === 'drag' && state.anchor) {
|
|
74
74
|
state.dragging = true
|
|
75
75
|
ctx.selection = normalize(state.anchor, { x: event.x, y: event.y })
|
|
76
|
-
ctx.
|
|
76
|
+
ctx.requestFrame()
|
|
77
77
|
return
|
|
78
78
|
}
|
|
79
79
|
|
|
@@ -81,7 +81,7 @@ export function useSelection({ onCopy, copy = true } = {}) {
|
|
|
81
81
|
if (state.dragging && ctx.selection) {
|
|
82
82
|
const text = extractSelectionText(ctx.getPaintBuffer(), ctx.selection)
|
|
83
83
|
ctx.selection = null
|
|
84
|
-
ctx.
|
|
84
|
+
ctx.requestFrame()
|
|
85
85
|
if (text) {
|
|
86
86
|
if (copy) ctx.stream.write(osc52Copy(text))
|
|
87
87
|
if (onCopy) onCopy(text)
|