@trendr/core 0.1.0 → 0.2.1

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/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)
61
97
 
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)
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)
66
101
 
67
- for (let col = x + 1; col < x + width - 1; col++) {
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)
105
+
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)
109
+
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,33 +309,66 @@ 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
  }
172
338
 
173
- function extractText(node) {
339
+ function extractText(node, parentCtx) {
174
340
  if (node == null || node === true || node === false) return ''
175
341
  if (typeof node === 'string') return node
176
342
  if (typeof node === 'number') return String(node)
343
+
177
344
  const children = node.props?.children
178
345
  if (children == null || children === true || children === false) return ''
179
- if (typeof children === 'string') return children
180
- if (typeof children === 'number') return String(children)
181
- if (Array.isArray(children)) return children.map(c => extractText(c)).join('')
182
- return ''
346
+ if (typeof children === 'string' && !node.props?.style) return children
347
+ if (typeof children === 'number' && !node.props?.style) return String(children)
348
+
349
+ const style = node.props?.style
350
+ const ownAttrs = style ? resolveAttrs(style) : 0
351
+ const hasOwnStyle = style && (style.color != null || style.bg != null || ownAttrs)
352
+
353
+ const myCtx = hasOwnStyle ? {
354
+ fg: style.color ?? parentCtx?.fg ?? null,
355
+ bg: style.bg ?? parentCtx?.bg ?? null,
356
+ attrs: ownAttrs || parentCtx?.attrs || 0,
357
+ } : (parentCtx || null)
358
+
359
+ let inner
360
+ if (typeof children === 'string') inner = children
361
+ else if (typeof children === 'number') inner = String(children)
362
+ else if (Array.isArray(children)) inner = children.map(c => extractText(c, myCtx)).join('')
363
+ else inner = ''
364
+
365
+ if (parentCtx !== undefined && hasOwnStyle) {
366
+ const prefix = ansi.sgr(myCtx.fg, myCtx.bg, myCtx.attrs)
367
+ const suffix = parentCtx ? ansi.sgr(parentCtx.fg, parentCtx.bg, parentCtx.attrs) : ansi.sgrReset
368
+ return prefix + inner + suffix
369
+ }
370
+
371
+ return inner
183
372
  }
184
373
 
185
374
  function flattenChildren(children) {
@@ -269,7 +458,36 @@ export function registerHook(setupFn) {
269
458
  // instances are keyed by component function + occurrence index,
270
459
  // so multiple instances of the same component each get their own state.
271
460
 
272
- function resolveForFrame(element, parent, instances, counters, visited) {
461
+ function shallowPropsEqual(a, b) {
462
+ if (a === b) return true
463
+ if (!a || !b) return false
464
+ const keysA = Object.keys(a)
465
+ const keysB = Object.keys(b)
466
+ if (keysA.length !== keysB.length) return false
467
+ for (const k of keysA) {
468
+ if (k === 'children') continue
469
+ if (a[k] !== b[k]) return false
470
+ }
471
+ return true
472
+ }
473
+
474
+ function isInstanceClean(instance, newProps) {
475
+ if (!instance._trackedSignals) return false
476
+ if (!shallowPropsEqual(instance._lastProps, newProps)) return false
477
+ const sigs = instance._trackedSignals
478
+ const vals = instance._signalValues
479
+ for (let i = 0; i < sigs.length; i++) {
480
+ if (sigs[i]() !== vals[i]) return false
481
+ }
482
+ return true
483
+ }
484
+
485
+ function snapshotSignals(instance, signals) {
486
+ instance._trackedSignals = signals
487
+ instance._signalValues = signals.map(g => g())
488
+ }
489
+
490
+ function resolveForFrame(element, parent, instances, counters, visited, scope) {
273
491
  if (element == null || typeof element === 'boolean') return null
274
492
 
275
493
  if (typeof element === 'string' || typeof element === 'number') {
@@ -296,49 +514,63 @@ function resolveForFrame(element, parent, instances, counters, visited) {
296
514
 
297
515
  if (typeof element.type === 'function') {
298
516
  const fn = element.type
299
- const count = counters.get(fn) ?? 0
300
- counters.set(fn, count + 1)
517
+ const counterKey = `${scope}/${fn.name}`
518
+ const count = counters.get(counterKey) ?? 0
519
+ counters.set(counterKey, count + 1)
301
520
 
302
- const instanceKey = element.key != null ? `${fn.name}:key:${element.key}` : `${fn.name}:${count}`
521
+ const instanceKey = element.key != null ? `${scope}/${fn.name}:key:${element.key}` : `${scope}/${fn.name}:${count}`
303
522
  if (visited) visited.add(instanceKey)
304
523
  let instance = instances.get(instanceKey)
305
524
 
306
525
  if (!instance) {
307
526
  let result
308
- instance = { scope: null, fn, hooks: [], node: null, layout: null }
527
+ instance = { scope: null, fn, hooks: [], node: null, layout: null, _dirty: true }
309
528
  instances.set(instanceKey, instance)
310
529
  instance.scope = createScope(() => {
311
530
  startHookTracking(instance)
531
+ startRenderTracking()
312
532
  result = fn(element.props ?? {})
533
+ const signals = stopRenderTracking()
313
534
  endHookTracking()
535
+ snapshotSignals(instance, signals)
536
+ instance._lastProps = element.props
314
537
  })
315
- node._resolved = resolveForFrame(result, node, instances, counters, visited)
538
+ node._resolved = resolveForFrame(result, node, instances, counters, visited, instanceKey)
316
539
  } else {
540
+ const clean = isInstanceClean(instance, element.props)
541
+ instance._dirty = !clean
542
+
317
543
  startHookTracking(instance)
544
+ startRenderTracking()
318
545
  const result = fn(element.props ?? {})
546
+ const signals = stopRenderTracking()
319
547
  endHookTracking()
320
- node._resolved = resolveForFrame(result, node, instances, counters, visited)
548
+ snapshotSignals(instance, signals)
549
+ instance._lastProps = element.props
550
+
551
+ node._resolved = resolveForFrame(result, node, instances, counters, visited, instanceKey)
321
552
  }
322
553
 
554
+ node._instance = instance
323
555
  instance.node = node
324
556
  return node
325
557
  }
326
558
 
327
559
  if (element.type === Fragment) {
328
560
  const children = flattenChildren(element.props?.children)
329
- node._resolvedChildren = children.map(c => resolveForFrame(c, node, instances, counters, visited)).filter(Boolean)
561
+ node._resolvedChildren = children.map(c => resolveForFrame(c, node, instances, counters, visited, scope)).filter(Boolean)
330
562
  return node
331
563
  }
332
564
 
333
565
  const children = flattenChildren(element.props?.children)
334
566
  if (children.length > 0) {
335
- node._resolvedChildren = children.map(c => resolveForFrame(c, node, instances, counters, visited)).filter(Boolean)
567
+ node._resolvedChildren = children.map(c => resolveForFrame(c, node, instances, counters, visited, scope)).filter(Boolean)
336
568
  }
337
569
 
338
570
  return node
339
571
  }
340
572
 
341
- export function mount(rootComponent, { stream, stdin, title, theme } = {}) {
573
+ export function mount(rootComponent, { stream, stdin, title, theme, onExit: onExitCb } = {}) {
342
574
  const out = stream ?? process.stdout
343
575
  const inp = stdin ?? process.stdin
344
576
 
@@ -355,6 +587,8 @@ export function mount(rootComponent, { stream, stdin, title, theme } = {}) {
355
587
  // component instance cache persists across frames
356
588
  // maps instanceKey -> { scope, fn, hooks }
357
589
  const instances = new Map()
590
+ let forceFullPaint = false
591
+ let prevHadOverlays = false
358
592
 
359
593
  function frame() {
360
594
  const prevCtx = activeContext
@@ -367,38 +601,77 @@ export function mount(rootComponent, { stream, stdin, title, theme } = {}) {
367
601
  const counters = new Map()
368
602
  const visited = new Set()
369
603
  const element = { type: rootComponent, props: {}, key: null }
370
- const tree = resolveForFrame(element, null, instances, counters, visited)
604
+ const tree = resolveForFrame(element, null, instances, counters, visited, '')
371
605
 
372
606
  computeLayout(tree, { x: 0, y: 0, width, height })
373
607
 
608
+ let layoutChanged = false
374
609
  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
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
+ const prev = inst.layout
615
+ if (prev.width !== rect.width || prev.height !== rect.height || prev.contentHeight !== ch) {
616
+ layoutChanged = true
617
+ }
618
+ prev.x = rect.x
619
+ prev.y = rect.y
620
+ prev.width = rect.width
621
+ prev.height = rect.height
622
+ prev.contentHeight = ch
377
623
  }
378
624
 
379
- paintTree(tree, curr)
625
+ // layout values changed - re-resolve so components see updated useLayout()
626
+ if (layoutChanged) {
627
+ overlays = []
628
+ counters.clear()
629
+ visited.clear()
630
+ const tree2 = resolveForFrame(element, null, instances, counters, visited, '')
631
+ computeLayout(tree2, { x: 0, y: 0, width, height })
632
+ for (const inst of instances.values()) {
633
+ const rect = inst.node?._availableRect ?? inst.node?._layout
634
+ if (!rect) continue
635
+ const ch = findScrollContentHeight(inst.node)
636
+ if (!inst.layout) inst.layout = { x: 0, y: 0, width: 0, height: 0 }
637
+ inst.layout.x = rect.x
638
+ inst.layout.y = rect.y
639
+ inst.layout.width = rect.width
640
+ inst.layout.height = rect.height
641
+ inst.layout.contentHeight = ch
642
+ inst._dirty = true
643
+ }
644
+ propagateDirty(tree2)
645
+ paintTree(tree2, curr, null, null, null)
646
+ } else {
647
+ propagateDirty(tree)
648
+ paintTree(tree, curr, null, null, (forceFullPaint || prevHadOverlays) ? null : prev)
649
+ forceFullPaint = false
650
+ }
651
+
652
+ const hasOverlays = overlays.length > 0
380
653
 
381
654
  for (const { element: overlayEl, owner, backdrop, fullscreen } of overlays) {
382
655
  if (backdrop) dimBuffer(curr)
383
656
 
384
- const overlayTree = resolveForFrame(overlayEl, null, instances, counters, visited)
657
+ const overlayRect = (backdrop || fullscreen)
658
+ ? { x: 0, y: 0, width, height }
659
+ : (() => {
660
+ const anchor = owner.node?._layout ?? owner.layout ?? { x: 0, y: 0, width: 0, height: 0 }
661
+ return { x: anchor.x, y: anchor.y + 1, width: width - anchor.x, height: height - anchor.y - 1 }
662
+ })()
663
+
664
+ const overlayTree = resolveForFrame(overlayEl, null, instances, counters, visited, '')
385
665
  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
- }
666
+ computeLayout(overlayTree, overlayRect)
667
+ updateOverlayLayouts(overlayTree)
397
668
  clearOverlayRect(overlayTree, curr)
398
669
  paintTree(overlayTree, curr)
399
670
  }
400
671
  }
401
672
 
673
+ prevHadOverlays = hasOverlays
674
+
402
675
  for (const [key, inst] of instances) {
403
676
  if (!visited.has(key)) {
404
677
  disposeScope(inst.scope)
@@ -408,8 +681,20 @@ export function mount(rootComponent, { stream, stdin, title, theme } = {}) {
408
681
 
409
682
  activeContext = prevCtx
410
683
 
411
- const output = diff(prev, curr)
412
- if (output) out.write(ansi.hideCursor + output)
684
+ const { output, changed } = diff(prev, curr)
685
+ if (changed > 0) {
686
+ out.write(ansi.hideCursor)
687
+ out.write(output)
688
+ }
689
+
690
+ const now = performance.now()
691
+ if (lastFrameTimestamp > 0) {
692
+ frameTimeWindow.push(now - lastFrameTimestamp)
693
+ if (frameTimeWindow.length > 30) frameTimeWindow.shift()
694
+ }
695
+ lastFrameTimestamp = now
696
+ const avgMs = frameTimeWindow.length > 0 ? frameTimeWindow.reduce((a, b) => a + b, 0) / frameTimeWindow.length : 16.67
697
+ lastFrameStats = { changed, total: width * height, bytes: output ? Buffer.byteLength(output) : 0, fps: Math.round(1000 / avgMs) }
413
698
 
414
699
  const tmp = prev
415
700
  prev = curr
@@ -423,7 +708,7 @@ export function mount(rootComponent, { stream, stdin, title, theme } = {}) {
423
708
 
424
709
  setSchedulerHook(scheduler.requestFrame)
425
710
 
426
- out.write(ansi.altScreen + ansi.hideCursor + ansi.clearScreen + (title ? ansi.setTitle(title) : ''))
711
+ out.write(ansi.altScreen + ansi.hideCursor + ansi.clearScreen + ansi.enableMouse + (title ? ansi.setTitle(title) : ''))
427
712
  if (inp.isTTY && inp.setRawMode) inp.setRawMode(true)
428
713
 
429
714
  frame()
@@ -442,7 +727,8 @@ export function mount(rootComponent, { stream, stdin, title, theme } = {}) {
442
727
  input.onKey((event) => {
443
728
  if (event.key === 'c' && event.ctrl) {
444
729
  unmount()
445
- process.exit(0)
730
+ if (onExitCb) onExitCb()
731
+ else process.exit(0)
446
732
  }
447
733
  })
448
734
 
@@ -462,7 +748,7 @@ export function mount(rootComponent, { stream, stdin, title, theme } = {}) {
462
748
  }
463
749
  instances.clear()
464
750
 
465
- out.write(ansi.sgrReset + ansi.showCursor + ansi.exitAltScreen)
751
+ out.write(ansi.sgrReset + ansi.disableMouse + ansi.showCursor + ansi.exitAltScreen)
466
752
  if (inp.isTTY && inp.setRawMode) inp.setRawMode(false)
467
753
  activeContext = null
468
754
  setSchedulerHook(null)
@@ -474,5 +760,15 @@ export function mount(rootComponent, { stream, stdin, title, theme } = {}) {
474
760
 
475
761
  process.on('exit', onExit)
476
762
 
477
- return { unmount }
763
+ function repaint() {
764
+ prev = createBuffer(width, height)
765
+ curr = createBuffer(width, height)
766
+ forceFullPaint = true
767
+ out.write(ansi.clearScreen)
768
+ scheduler.forceFrame()
769
+ }
770
+
771
+ ctx.repaint = repaint
772
+
773
+ return { unmount, repaint, getBuffer: () => prev }
478
774
  }