@trendr/core 0.1.0 → 0.2.0

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/src/radio.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, useTheme } from './hooks.js'
3
+ import { useInput, useMouse, useLayout, useTheme } from './hooks.js'
4
4
 
5
5
  export function Radio({ options, selected, onSelect, focused = false }) {
6
6
  const { accent = 'cyan' } = useTheme()
@@ -29,12 +29,26 @@ export function Radio({ options, selected, onSelect, focused = false }) {
29
29
  }
30
30
  })
31
31
 
32
+ const layout = useLayout()
33
+
34
+ useMouse((event) => {
35
+ if (event.action !== 'press' || event.button !== 'left') return
36
+ const { x, y } = event
37
+ if (x < layout.x || x >= layout.x + layout.width || y < layout.y || y >= layout.y + layout.height) return
38
+ const idx = y - layout.y
39
+ if (idx >= 0 && idx < options.length) {
40
+ setCursor(idx)
41
+ onSelect?.(options[idx])
42
+ event.stopPropagation()
43
+ }
44
+ })
45
+
32
46
  const c = cursor()
33
47
 
34
48
  const children = options.map((option, i) => {
35
49
  const isSelected = option === selected
36
50
  const isCursor = focused && i === c
37
- const icon = isSelected ? '\u25c9' : '\u25cb'
51
+ const icon = isSelected ? '\u25cf' : '\u25cb'
38
52
  const bg = isCursor ? accent : null
39
53
  const color = isCursor ? 'black' : null
40
54
 
package/src/renderer.js CHANGED
@@ -1,29 +1,45 @@
1
- import { createBuffer, clearBuffer, fillRect, writeText, dimBuffer } from './buffer.js'
1
+ import { createBuffer, clearBuffer, fillRect, writeText, dimBuffer, blitRect } from './buffer.js'
2
2
  import { diff } from './diff.js'
3
- import { computeLayout } from './layout.js'
3
+ import { computeLayout, resolveBorderEdges } from './layout.js'
4
4
  import { Fragment } from './element.js'
5
5
  import { createScheduler } from './scheduler.js'
6
6
  import { createInputHandler } from './input.js'
7
- import { setSchedulerHook, setHookRegistrar, createScope, disposeScope, onCleanup } from './signal.js'
7
+ import { setSchedulerHook, setHookRegistrar, createScope, disposeScope, onCleanup, startRenderTracking, stopRenderTracking } from './signal.js'
8
8
  import { wordWrap, measureText, sliceVisible } from './wrap.js'
9
9
  import * as ansi from './ansi.js'
10
10
 
11
11
  let activeContext = null
12
12
  let overlays = []
13
+ let lastFrameStats = { changed: 0, total: 0, bytes: 0, fps: 0 }
14
+ let frameTimeWindow = []
15
+ let lastFrameTimestamp = 0
13
16
 
14
17
  export function getContext() {
15
18
  return activeContext
16
19
  }
17
20
 
21
+ const DEFAULT_CURSOR = { blink: false, rate: 530, style: 'block' }
18
22
  const DEFAULT_THEME = { accent: 'cyan' }
19
23
 
20
24
  export function getTheme() {
21
25
  return activeContext?.theme ?? DEFAULT_THEME
22
26
  }
23
27
 
28
+ export function getCursor(propCursor) {
29
+ const themeCursor = activeContext?.theme?.cursor
30
+ if (!propCursor && !themeCursor) return DEFAULT_CURSOR
31
+ if (propCursor === true) return { ...DEFAULT_CURSOR, blink: true, ...themeCursor }
32
+ return { ...DEFAULT_CURSOR, ...themeCursor, ...propCursor }
33
+ }
34
+
35
+ export function getFrameStats() {
36
+ return lastFrameStats
37
+ }
38
+
24
39
  export function getInstanceLayout() {
25
40
  if (!currentHookOwner) return { x: 0, y: 0, width: 0, height: 0 }
26
- return currentHookOwner.layout ?? { x: 0, y: 0, width: 0, height: 0 }
41
+ if (!currentHookOwner.layout) currentHookOwner.layout = { x: 0, y: 0, width: 0, height: 0 }
42
+ return currentHookOwner.layout
27
43
  }
28
44
 
29
45
  export function registerOverlay(element, { backdrop, fullscreen } = {}) {
@@ -32,10 +48,25 @@ export function registerOverlay(element, { backdrop, fullscreen } = {}) {
32
48
  }
33
49
 
34
50
  const BORDER_CHARS = {
35
- single: { tl: '\u250c', tr: '\u2510', bl: '\u2514', br: '\u2518', h: '\u2500', v: '\u2502' },
36
- double: { tl: '\u2554', tr: '\u2557', bl: '\u255a', br: '\u255d', h: '\u2550', v: '\u2551' },
37
- round: { tl: '\u256d', tr: '\u256e', bl: '\u2570', br: '\u256f', h: '\u2500', v: '\u2502' },
38
- bold: { tl: '\u250f', tr: '\u2513', bl: '\u2517', br: '\u251b', h: '\u2501', v: '\u2503' },
51
+ single: { tl: '\u250c', tr: '\u2510', bl: '\u2514', br: '\u2518', h: '\u2500', v: '\u2502', tDown: '\u252c', tUp: '\u2534', tRight: '\u251c', tLeft: '\u2524' },
52
+ double: { tl: '\u2554', tr: '\u2557', bl: '\u255a', br: '\u255d', h: '\u2550', v: '\u2551', tDown: '\u2566', tUp: '\u2569', tRight: '\u2560', tLeft: '\u2563' },
53
+ round: { tl: '\u256d', tr: '\u256e', bl: '\u2570', br: '\u256f', h: '\u2500', v: '\u2502', tDown: '\u252c', tUp: '\u2534', tRight: '\u251c', tLeft: '\u2524' },
54
+ bold: { tl: '\u250f', tr: '\u2513', bl: '\u2517', br: '\u251b', h: '\u2501', v: '\u2503', tDown: '\u2533', tUp: '\u253b', tRight: '\u2523', tLeft: '\u252b' },
55
+ }
56
+
57
+ const TEXTURE_PRESETS = {
58
+ 'shade-light': '░',
59
+ 'shade-medium': '▒',
60
+ 'shade-heavy': '▓',
61
+ 'dots': '·',
62
+ 'cross': '╳',
63
+ 'grid': '┼',
64
+ 'dash': '╌',
65
+ }
66
+
67
+ function resolveTexture(texture) {
68
+ if (!texture) return null
69
+ return TEXTURE_PRESETS[texture] ?? texture
39
70
  }
40
71
 
41
72
  function resolveAttrs(style) {
@@ -49,7 +80,7 @@ function resolveAttrs(style) {
49
80
  return attrs
50
81
  }
51
82
 
52
- function paintBorder(buf, rect, borderStyle, fg) {
83
+ function paintBorder(buf, rect, borderStyle, fg, edges) {
53
84
  const chars = typeof borderStyle === 'string'
54
85
  ? (BORDER_CHARS[borderStyle] ?? BORDER_CHARS.single)
55
86
  : BORDER_CHARS.single
@@ -58,20 +89,62 @@ function paintBorder(buf, rect, borderStyle, fg) {
58
89
  if (width < 2 || height < 2) return
59
90
 
60
91
  const cell = (ch) => ({ ch, fg: fg ?? null, bg: null, attrs: 0 })
92
+ const { top, right, bottom, left } = edges
93
+
94
+ if (top && left) buf.cells[y * buf.width + x] = cell(chars.tl)
95
+ else if (top) buf.cells[y * buf.width + x] = cell(chars.h)
96
+ else if (left) buf.cells[y * buf.width + x] = cell(chars.v)
97
+
98
+ if (top && right) buf.cells[y * buf.width + x + width - 1] = cell(chars.tr)
99
+ else if (top) buf.cells[y * buf.width + x + width - 1] = cell(chars.h)
100
+ else if (right) buf.cells[y * buf.width + x + width - 1] = cell(chars.v)
101
+
102
+ if (bottom && left) buf.cells[(y + height - 1) * buf.width + x] = cell(chars.bl)
103
+ else if (bottom) buf.cells[(y + height - 1) * buf.width + x] = cell(chars.h)
104
+ else if (left) buf.cells[(y + height - 1) * buf.width + x] = cell(chars.v)
61
105
 
62
- buf.cells[y * buf.width + x] = cell(chars.tl)
63
- buf.cells[y * buf.width + x + width - 1] = cell(chars.tr)
64
- buf.cells[(y + height - 1) * buf.width + x] = cell(chars.bl)
65
- buf.cells[(y + height - 1) * buf.width + x + width - 1] = cell(chars.br)
106
+ if (bottom && right) buf.cells[(y + height - 1) * buf.width + x + width - 1] = cell(chars.br)
107
+ else if (bottom) buf.cells[(y + height - 1) * buf.width + x + width - 1] = cell(chars.h)
108
+ else if (right) buf.cells[(y + height - 1) * buf.width + x + width - 1] = cell(chars.v)
66
109
 
67
- for (let col = x + 1; col < x + width - 1; col++) {
110
+ if (top) for (let col = x + 1; col < x + width - 1; col++)
68
111
  buf.cells[y * buf.width + col] = cell(chars.h)
112
+
113
+ if (bottom) for (let col = x + 1; col < x + width - 1; col++)
69
114
  buf.cells[(y + height - 1) * buf.width + col] = cell(chars.h)
70
- }
71
115
 
72
- for (let row = y + 1; row < y + height - 1; row++) {
116
+ if (left) for (let row = y + 1; row < y + height - 1; row++)
73
117
  buf.cells[row * buf.width + x] = cell(chars.v)
118
+
119
+ if (right) for (let row = y + 1; row < y + height - 1; row++)
74
120
  buf.cells[row * buf.width + x + width - 1] = cell(chars.v)
121
+ }
122
+
123
+ function paintJunctions(buf, rect, borderStyle, fg, children, edges) {
124
+ if (!children) return
125
+ const chars = typeof borderStyle === 'string'
126
+ ? (BORDER_CHARS[borderStyle] ?? BORDER_CHARS.single)
127
+ : BORDER_CHARS.single
128
+ const cell = (ch) => ({ ch, fg: fg ?? null, bg: null, attrs: 0 })
129
+
130
+ for (const child of children) {
131
+ const leaf = child._resolved ? child._resolved : child
132
+ const divider = leaf?.props?.style?._divider
133
+ if (!divider) continue
134
+ const cl = leaf._layout
135
+ if (!cl) continue
136
+
137
+ if (divider === 'vertical') {
138
+ if (cl.x >= rect.x && cl.x < rect.x + rect.width) {
139
+ if (edges.top) buf.cells[rect.y * buf.width + cl.x] = cell(chars.tDown)
140
+ if (edges.bottom) buf.cells[(rect.y + rect.height - 1) * buf.width + cl.x] = cell(chars.tUp)
141
+ }
142
+ } else if (divider === 'horizontal') {
143
+ if (cl.y >= rect.y && cl.y < rect.y + rect.height) {
144
+ if (edges.left) buf.cells[cl.y * buf.width + rect.x] = cell(chars.tRight)
145
+ if (edges.right) buf.cells[cl.y * buf.width + rect.x + rect.width - 1] = cell(chars.tLeft)
146
+ }
147
+ }
75
148
  }
76
149
  }
77
150
 
@@ -79,14 +152,25 @@ function findContentRect(node) {
79
152
  if (!node?._layout) return null
80
153
  const style = node.props?.style ?? {}
81
154
  if (style.border) return node._layout
155
+ if (node.type === 'text' || style.bg || style.inverse) return node._layout
156
+
157
+ let bounds = null
158
+ const merge = (rect) => {
159
+ if (!rect) return
160
+ if (!bounds) { bounds = { ...rect }; return }
161
+ const r = Math.max(bounds.x + bounds.width, rect.x + rect.width)
162
+ const b = Math.max(bounds.y + bounds.height, rect.y + rect.height)
163
+ bounds.x = Math.min(bounds.x, rect.x)
164
+ bounds.y = Math.min(bounds.y, rect.y)
165
+ bounds.width = r - bounds.x
166
+ bounds.height = b - bounds.y
167
+ }
168
+
82
169
  if (node._resolvedChildren) {
83
- for (const child of node._resolvedChildren) {
84
- const found = findContentRect(child)
85
- if (found) return found
86
- }
170
+ for (const child of node._resolvedChildren) merge(findContentRect(child))
87
171
  }
88
- if (node._resolved) return findContentRect(node._resolved)
89
- return null
172
+ if (node._resolved) merge(findContentRect(node._resolved))
173
+ return bounds
90
174
  }
91
175
 
92
176
  function clearOverlayRect(overlayTree, buf) {
@@ -95,6 +179,40 @@ function clearOverlayRect(overlayTree, buf) {
95
179
  fillRect(buf, rect.x, rect.y, rect.width, rect.height, ' ', null, null, 0)
96
180
  }
97
181
 
182
+ function findScrollContentHeight(node) {
183
+ if (!node) return null
184
+ if (node._contentHeight != null) return node._contentHeight
185
+ if (node._resolved) return findScrollContentHeight(node._resolved)
186
+ if (node._resolvedChildren) {
187
+ for (const child of node._resolvedChildren) {
188
+ const h = findScrollContentHeight(child)
189
+ if (h != null) return h
190
+ }
191
+ }
192
+ return null
193
+ }
194
+
195
+ function updateOverlayLayouts(node) {
196
+ if (!node) return
197
+ if (node._instance) {
198
+ const rect = node._availableRect ?? node._layout
199
+ if (rect) {
200
+ const ch = findScrollContentHeight(node)
201
+ if (!node._instance.layout) node._instance.layout = { x: 0, y: 0, width: 0, height: 0 }
202
+ const target = node._instance.layout
203
+ target.x = rect.x
204
+ target.y = rect.y
205
+ target.width = rect.width
206
+ target.height = rect.height
207
+ target.contentHeight = ch
208
+ }
209
+ }
210
+ if (node._resolved) updateOverlayLayouts(node._resolved)
211
+ if (node._resolvedChildren) {
212
+ for (const child of node._resolvedChildren) updateOverlayLayouts(child)
213
+ }
214
+ }
215
+
98
216
  function clipRect(a, b) {
99
217
  const x = Math.max(a.x, b.x)
100
218
  const y = Math.max(a.y, b.y)
@@ -103,23 +221,61 @@ function clipRect(a, b) {
103
221
  return { x, y, width: Math.max(0, r - x), height: Math.max(0, bot - y) }
104
222
  }
105
223
 
106
- function paintTree(node, buf, clip) {
224
+ function propagateDirty(node) {
225
+ if (!node) return false
226
+ if (node._resolved) {
227
+ const childDirty = propagateDirty(node._resolved)
228
+ const inst = node._instance
229
+ if (inst) {
230
+ inst._subtreeDirty = inst._dirty || childDirty
231
+ return inst._subtreeDirty
232
+ }
233
+ return childDirty
234
+ }
235
+ if (node._resolvedChildren) {
236
+ let anyDirty = false
237
+ for (const child of node._resolvedChildren) {
238
+ if (propagateDirty(child)) anyDirty = true
239
+ }
240
+ return anyDirty
241
+ }
242
+ return false
243
+ }
244
+
245
+ function layoutEqual(a, b) {
246
+ return a && b && a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height
247
+ }
248
+
249
+ function paintTree(node, buf, clip, offset, prevBuf) {
107
250
  if (!node) return
108
251
 
109
252
  if (node._resolved) {
110
- paintTree(node._resolved, buf, clip)
253
+ const inst = node._instance
254
+ if (prevBuf && inst && !inst._subtreeDirty) {
255
+ const layout = node._resolved?._layout ?? node._layout
256
+ if (layout && layoutEqual(layout, inst._lastLayout)) {
257
+ blitRect(prevBuf, buf, layout.x, layout.y, layout.width, layout.height)
258
+ return
259
+ }
260
+ }
261
+ if (inst) inst._lastLayout = node._resolved?._layout ?? node._layout
262
+ paintTree(node._resolved, buf, clip, offset, prevBuf)
111
263
  return
112
264
  }
113
265
 
114
266
  if (node.type === Fragment) {
115
267
  if (node._resolvedChildren) {
116
- for (const child of node._resolvedChildren) paintTree(child, buf, clip)
268
+ for (const child of node._resolvedChildren) paintTree(child, buf, clip, offset, prevBuf)
117
269
  }
118
270
  return
119
271
  }
120
272
 
121
- const layout = node._layout
122
- if (!layout || layout.width <= 0 || layout.height <= 0) return
273
+ const rawLayout = node._layout
274
+ if (!rawLayout || rawLayout.width <= 0 || rawLayout.height <= 0) return
275
+
276
+ const layout = offset
277
+ ? { x: rawLayout.x + offset.x, y: rawLayout.y + offset.y, width: rawLayout.width, height: rawLayout.height }
278
+ : rawLayout
123
279
 
124
280
  const clipped = clip ? clipRect(layout, clip) : layout
125
281
  if (clipped.width <= 0 || clipped.height <= 0) return
@@ -153,19 +309,29 @@ function paintTree(node, buf, clip) {
153
309
  return
154
310
  }
155
311
 
156
- if (style.bg) {
157
- fillRect(buf, clipped.x, clipped.y, clipped.width, clipped.height, ' ', null, style.bg, 0)
312
+ if (style.bg || style.texture) {
313
+ const ch = resolveTexture(style.texture) ?? ' '
314
+ const fg = style.textureColor ?? null
315
+ fillRect(buf, clipped.x, clipped.y, clipped.width, clipped.height, ch, fg, style.bg, 0)
158
316
  }
159
317
 
160
318
  if (style.border) {
161
- paintBorder(buf, layout, style.border, style.borderColor)
319
+ const edges = resolveBorderEdges(style)
320
+ paintBorder(buf, layout, style.border, style.borderColor, edges)
321
+ paintJunctions(buf, layout, style.border, style.borderColor, node._resolvedChildren, edges)
162
322
  }
163
323
 
164
324
  const childClip = clip ? clipRect(layout, clip) : layout
165
325
 
326
+ let childOffset = offset
327
+ if (style.overflow === 'scroll') {
328
+ const scrollY = style.scrollOffset ?? 0
329
+ childOffset = { x: offset?.x ?? 0, y: (offset?.y ?? 0) - scrollY }
330
+ }
331
+
166
332
  if (node._resolvedChildren) {
167
333
  for (const child of node._resolvedChildren) {
168
- paintTree(child, buf, childClip)
334
+ paintTree(child, buf, childClip, childOffset, prevBuf)
169
335
  }
170
336
  }
171
337
  }
@@ -269,7 +435,36 @@ export function registerHook(setupFn) {
269
435
  // instances are keyed by component function + occurrence index,
270
436
  // so multiple instances of the same component each get their own state.
271
437
 
272
- function resolveForFrame(element, parent, instances, counters, visited) {
438
+ function shallowPropsEqual(a, b) {
439
+ if (a === b) return true
440
+ if (!a || !b) return false
441
+ const keysA = Object.keys(a)
442
+ const keysB = Object.keys(b)
443
+ if (keysA.length !== keysB.length) return false
444
+ for (const k of keysA) {
445
+ if (k === 'children') continue
446
+ if (a[k] !== b[k]) return false
447
+ }
448
+ return true
449
+ }
450
+
451
+ function isInstanceClean(instance, newProps) {
452
+ if (!instance._trackedSignals) return false
453
+ if (!shallowPropsEqual(instance._lastProps, newProps)) return false
454
+ const sigs = instance._trackedSignals
455
+ const vals = instance._signalValues
456
+ for (let i = 0; i < sigs.length; i++) {
457
+ if (sigs[i]() !== vals[i]) return false
458
+ }
459
+ return true
460
+ }
461
+
462
+ function snapshotSignals(instance, signals) {
463
+ instance._trackedSignals = signals
464
+ instance._signalValues = signals.map(g => g())
465
+ }
466
+
467
+ function resolveForFrame(element, parent, instances, counters, visited, scope) {
273
468
  if (element == null || typeof element === 'boolean') return null
274
469
 
275
470
  if (typeof element === 'string' || typeof element === 'number') {
@@ -296,49 +491,63 @@ function resolveForFrame(element, parent, instances, counters, visited) {
296
491
 
297
492
  if (typeof element.type === 'function') {
298
493
  const fn = element.type
299
- const count = counters.get(fn) ?? 0
300
- counters.set(fn, count + 1)
494
+ const counterKey = `${scope}/${fn.name}`
495
+ const count = counters.get(counterKey) ?? 0
496
+ counters.set(counterKey, count + 1)
301
497
 
302
- const instanceKey = element.key != null ? `${fn.name}:key:${element.key}` : `${fn.name}:${count}`
498
+ const instanceKey = element.key != null ? `${scope}/${fn.name}:key:${element.key}` : `${scope}/${fn.name}:${count}`
303
499
  if (visited) visited.add(instanceKey)
304
500
  let instance = instances.get(instanceKey)
305
501
 
306
502
  if (!instance) {
307
503
  let result
308
- instance = { scope: null, fn, hooks: [], node: null, layout: null }
504
+ instance = { scope: null, fn, hooks: [], node: null, layout: null, _dirty: true }
309
505
  instances.set(instanceKey, instance)
310
506
  instance.scope = createScope(() => {
311
507
  startHookTracking(instance)
508
+ startRenderTracking()
312
509
  result = fn(element.props ?? {})
510
+ const signals = stopRenderTracking()
313
511
  endHookTracking()
512
+ snapshotSignals(instance, signals)
513
+ instance._lastProps = element.props
314
514
  })
315
- node._resolved = resolveForFrame(result, node, instances, counters, visited)
515
+ node._resolved = resolveForFrame(result, node, instances, counters, visited, instanceKey)
316
516
  } else {
517
+ const clean = isInstanceClean(instance, element.props)
518
+ instance._dirty = !clean
519
+
317
520
  startHookTracking(instance)
521
+ startRenderTracking()
318
522
  const result = fn(element.props ?? {})
523
+ const signals = stopRenderTracking()
319
524
  endHookTracking()
320
- node._resolved = resolveForFrame(result, node, instances, counters, visited)
525
+ snapshotSignals(instance, signals)
526
+ instance._lastProps = element.props
527
+
528
+ node._resolved = resolveForFrame(result, node, instances, counters, visited, instanceKey)
321
529
  }
322
530
 
531
+ node._instance = instance
323
532
  instance.node = node
324
533
  return node
325
534
  }
326
535
 
327
536
  if (element.type === Fragment) {
328
537
  const children = flattenChildren(element.props?.children)
329
- node._resolvedChildren = children.map(c => resolveForFrame(c, node, instances, counters, visited)).filter(Boolean)
538
+ node._resolvedChildren = children.map(c => resolveForFrame(c, node, instances, counters, visited, scope)).filter(Boolean)
330
539
  return node
331
540
  }
332
541
 
333
542
  const children = flattenChildren(element.props?.children)
334
543
  if (children.length > 0) {
335
- node._resolvedChildren = children.map(c => resolveForFrame(c, node, instances, counters, visited)).filter(Boolean)
544
+ node._resolvedChildren = children.map(c => resolveForFrame(c, node, instances, counters, visited, scope)).filter(Boolean)
336
545
  }
337
546
 
338
547
  return node
339
548
  }
340
549
 
341
- export function mount(rootComponent, { stream, stdin, title, theme } = {}) {
550
+ export function mount(rootComponent, { stream, stdin, title, theme, onExit: onExitCb } = {}) {
342
551
  const out = stream ?? process.stdout
343
552
  const inp = stdin ?? process.stdin
344
553
 
@@ -355,6 +564,8 @@ export function mount(rootComponent, { stream, stdin, title, theme } = {}) {
355
564
  // component instance cache persists across frames
356
565
  // maps instanceKey -> { scope, fn, hooks }
357
566
  const instances = new Map()
567
+ let forceFullPaint = false
568
+ let prevHadOverlays = false
358
569
 
359
570
  function frame() {
360
571
  const prevCtx = activeContext
@@ -367,38 +578,77 @@ export function mount(rootComponent, { stream, stdin, title, theme } = {}) {
367
578
  const counters = new Map()
368
579
  const visited = new Set()
369
580
  const element = { type: rootComponent, props: {}, key: null }
370
- const tree = resolveForFrame(element, null, instances, counters, visited)
581
+ const tree = resolveForFrame(element, null, instances, counters, visited, '')
371
582
 
372
583
  computeLayout(tree, { x: 0, y: 0, width, height })
373
584
 
585
+ let layoutChanged = false
374
586
  for (const inst of instances.values()) {
375
- if (inst.node?._availableRect) inst.layout = inst.node._availableRect
376
- else if (inst.node?._layout) inst.layout = inst.node._layout
587
+ const rect = inst.node?._availableRect ?? inst.node?._layout
588
+ if (!rect) continue
589
+ const ch = findScrollContentHeight(inst.node)
590
+ if (!inst.layout) inst.layout = { x: 0, y: 0, width: 0, height: 0 }
591
+ const prev = inst.layout
592
+ if (prev.width !== rect.width || prev.height !== rect.height || prev.contentHeight !== ch) {
593
+ layoutChanged = true
594
+ }
595
+ prev.x = rect.x
596
+ prev.y = rect.y
597
+ prev.width = rect.width
598
+ prev.height = rect.height
599
+ prev.contentHeight = ch
377
600
  }
378
601
 
379
- paintTree(tree, curr)
602
+ // layout values changed - re-resolve so components see updated useLayout()
603
+ if (layoutChanged) {
604
+ overlays = []
605
+ counters.clear()
606
+ visited.clear()
607
+ const tree2 = resolveForFrame(element, null, instances, counters, visited, '')
608
+ computeLayout(tree2, { x: 0, y: 0, width, height })
609
+ for (const inst of instances.values()) {
610
+ const rect = inst.node?._availableRect ?? inst.node?._layout
611
+ if (!rect) continue
612
+ const ch = findScrollContentHeight(inst.node)
613
+ if (!inst.layout) inst.layout = { x: 0, y: 0, width: 0, height: 0 }
614
+ inst.layout.x = rect.x
615
+ inst.layout.y = rect.y
616
+ inst.layout.width = rect.width
617
+ inst.layout.height = rect.height
618
+ inst.layout.contentHeight = ch
619
+ inst._dirty = true
620
+ }
621
+ propagateDirty(tree2)
622
+ paintTree(tree2, curr, null, null, null)
623
+ } else {
624
+ propagateDirty(tree)
625
+ paintTree(tree, curr, null, null, (forceFullPaint || prevHadOverlays) ? null : prev)
626
+ forceFullPaint = false
627
+ }
628
+
629
+ const hasOverlays = overlays.length > 0
380
630
 
381
631
  for (const { element: overlayEl, owner, backdrop, fullscreen } of overlays) {
382
632
  if (backdrop) dimBuffer(curr)
383
633
 
384
- const overlayTree = resolveForFrame(overlayEl, null, instances, counters, visited)
634
+ const overlayRect = (backdrop || fullscreen)
635
+ ? { x: 0, y: 0, width, height }
636
+ : (() => {
637
+ const anchor = owner.node?._layout ?? owner.layout ?? { x: 0, y: 0, width: 0, height: 0 }
638
+ return { x: anchor.x, y: anchor.y + 1, width: width - anchor.x, height: height - anchor.y - 1 }
639
+ })()
640
+
641
+ const overlayTree = resolveForFrame(overlayEl, null, instances, counters, visited, '')
385
642
  if (overlayTree) {
386
- if (backdrop || fullscreen) {
387
- computeLayout(overlayTree, { x: 0, y: 0, width, height })
388
- } else {
389
- const anchor = owner.node?._layout ?? owner.layout ?? { x: 0, y: 0, width: 0, height: 0 }
390
- computeLayout(overlayTree, {
391
- x: anchor.x,
392
- y: anchor.y + 1,
393
- width: width - anchor.x,
394
- height: height - anchor.y - 1,
395
- })
396
- }
643
+ computeLayout(overlayTree, overlayRect)
644
+ updateOverlayLayouts(overlayTree)
397
645
  clearOverlayRect(overlayTree, curr)
398
646
  paintTree(overlayTree, curr)
399
647
  }
400
648
  }
401
649
 
650
+ prevHadOverlays = hasOverlays
651
+
402
652
  for (const [key, inst] of instances) {
403
653
  if (!visited.has(key)) {
404
654
  disposeScope(inst.scope)
@@ -408,8 +658,20 @@ export function mount(rootComponent, { stream, stdin, title, theme } = {}) {
408
658
 
409
659
  activeContext = prevCtx
410
660
 
411
- const output = diff(prev, curr)
412
- if (output) out.write(ansi.hideCursor + output)
661
+ const { output, changed } = diff(prev, curr)
662
+ if (changed > 0) {
663
+ out.write(ansi.hideCursor)
664
+ out.write(output)
665
+ }
666
+
667
+ const now = performance.now()
668
+ if (lastFrameTimestamp > 0) {
669
+ frameTimeWindow.push(now - lastFrameTimestamp)
670
+ if (frameTimeWindow.length > 30) frameTimeWindow.shift()
671
+ }
672
+ lastFrameTimestamp = now
673
+ const avgMs = frameTimeWindow.length > 0 ? frameTimeWindow.reduce((a, b) => a + b, 0) / frameTimeWindow.length : 16.67
674
+ lastFrameStats = { changed, total: width * height, bytes: output ? Buffer.byteLength(output) : 0, fps: Math.round(1000 / avgMs) }
413
675
 
414
676
  const tmp = prev
415
677
  prev = curr
@@ -423,7 +685,7 @@ export function mount(rootComponent, { stream, stdin, title, theme } = {}) {
423
685
 
424
686
  setSchedulerHook(scheduler.requestFrame)
425
687
 
426
- out.write(ansi.altScreen + ansi.hideCursor + ansi.clearScreen + (title ? ansi.setTitle(title) : ''))
688
+ out.write(ansi.altScreen + ansi.hideCursor + ansi.clearScreen + ansi.enableMouse + (title ? ansi.setTitle(title) : ''))
427
689
  if (inp.isTTY && inp.setRawMode) inp.setRawMode(true)
428
690
 
429
691
  frame()
@@ -442,7 +704,8 @@ export function mount(rootComponent, { stream, stdin, title, theme } = {}) {
442
704
  input.onKey((event) => {
443
705
  if (event.key === 'c' && event.ctrl) {
444
706
  unmount()
445
- process.exit(0)
707
+ if (onExitCb) onExitCb()
708
+ else process.exit(0)
446
709
  }
447
710
  })
448
711
 
@@ -462,7 +725,7 @@ export function mount(rootComponent, { stream, stdin, title, theme } = {}) {
462
725
  }
463
726
  instances.clear()
464
727
 
465
- out.write(ansi.sgrReset + ansi.showCursor + ansi.exitAltScreen)
728
+ out.write(ansi.sgrReset + ansi.disableMouse + ansi.showCursor + ansi.exitAltScreen)
466
729
  if (inp.isTTY && inp.setRawMode) inp.setRawMode(false)
467
730
  activeContext = null
468
731
  setSchedulerHook(null)
@@ -474,5 +737,15 @@ export function mount(rootComponent, { stream, stdin, title, theme } = {}) {
474
737
 
475
738
  process.on('exit', onExit)
476
739
 
477
- return { unmount }
740
+ function repaint() {
741
+ prev = createBuffer(width, height)
742
+ curr = createBuffer(width, height)
743
+ forceFullPaint = true
744
+ out.write(ansi.clearScreen)
745
+ scheduler.forceFrame()
746
+ }
747
+
748
+ ctx.repaint = repaint
749
+
750
+ return { unmount, repaint }
478
751
  }