@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 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`, so it can be combined with `useLayout()` to derive hover state. Built-in components support click, scroll wheel, and scrollbar dragging.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trendr/core",
3
- "version": "0.4.11",
3
+ "version": "0.4.13",
4
4
  "description": "direct-mode TUI renderer with JSX, signals, and per-cell diffing",
5
5
  "license": "MIT",
6
6
  "author": "Jonathan Pyers",
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
- const { x, y } = event
147
- if (x < layout.x || x >= layout.x + layout.width || y < layout.y || y >= layout.y + layout.height) return
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
- const children = node._resolvedChildren
318
- if (!children || children.length === 0) {
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
- out.write(ansi.hideCursor)
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.repaint()
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.repaint()
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.repaint()
84
+ ctx.requestFrame()
85
85
  if (text) {
86
86
  if (copy) ctx.stream.write(osc52Copy(text))
87
87
  if (onCopy) onCopy(text)