@trendr/core 0.2.9 → 0.2.11

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/index.js CHANGED
@@ -21,6 +21,7 @@ export { ScrollBox } from './src/scroll-box.js'
21
21
  export { SplitPane } from './src/split-pane.js'
22
22
  export { MillerNav } from './src/miller-nav.js'
23
23
  export { MenuBar } from './src/menubar.js'
24
+ export { Menu } from './src/menu.js'
24
25
  export { Task } from './src/task.js'
25
26
  export { Shimmer } from './src/shimmer.js'
26
27
  export { useFocus, useFocusTrap } from './src/focus.js'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trendr/core",
3
- "version": "0.2.9",
3
+ "version": "0.2.11",
4
4
  "description": "direct-mode TUI renderer with JSX, signals, and per-cell diffing",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/ansi.js CHANGED
@@ -11,6 +11,7 @@ export const showCursor = `${ESC}?25h`
11
11
  export const clearScreen = `${ESC}2J`
12
12
  export const clearLine = `${ESC}2K`
13
13
  export const clearDown = `${ESC}0J`
14
+ export const clearScrollback = `${ESC}3J`
14
15
  export const altScreen = `${ESC}?1049h`
15
16
  export const exitAltScreen = `${ESC}?1049l`
16
17
  export const sgrReset = `${ESC}0m`
package/src/menu.js ADDED
@@ -0,0 +1,87 @@
1
+ import { jsx } from '../jsx-runtime.js'
2
+ import { createSignal } from './signal.js'
3
+ import { useInput, useTheme } from './hooks.js'
4
+ import { List } from './list.js'
5
+
6
+ // a windowed single-select menu. shows at most maxVisible rows and scrolls
7
+ // with scrolloff as the active item moves. it carries no text input of its
8
+ // own, so it pairs with an external composer: render it after that input and,
9
+ // while focused, it intercepts up/down/enter before the input sees them
10
+ export function Menu({
11
+ items,
12
+ selected: selectedProp,
13
+ onSelect,
14
+ onSubmit,
15
+ onCancel,
16
+ focused = true,
17
+ maxVisible = 5,
18
+ scrolloff = 2,
19
+ itemHeight = 1,
20
+ gap = 0,
21
+ renderItem,
22
+ arrow = '›',
23
+ }) {
24
+ const { accent = 'cyan' } = useTheme()
25
+ const [internal, setInternal] = createSignal(0)
26
+
27
+ const len = items.length
28
+ const raw = selectedProp ?? internal()
29
+ const selected = Math.min(Math.max(0, raw), Math.max(0, len - 1))
30
+ const setSelected = onSelect ?? setInternal
31
+
32
+ if (selected !== raw && len > 0) setSelected(selected)
33
+
34
+ useInput((event) => {
35
+ if (!focused || len === 0) return
36
+ const { key, ctrl, shift, meta } = event
37
+
38
+ if (key === 'up' || (ctrl && key === 'p')) {
39
+ setSelected(Math.max(0, selected - 1))
40
+ event.stopPropagation()
41
+ } else if (key === 'down' || (ctrl && key === 'n')) {
42
+ setSelected(Math.min(len - 1, selected + 1))
43
+ event.stopPropagation()
44
+ } else if (key === 'return' && !shift && !meta) {
45
+ if (onSubmit) onSubmit(items[selected], selected)
46
+ event.stopPropagation()
47
+ } else if (key === 'escape') {
48
+ if (onCancel) {
49
+ onCancel()
50
+ event.stopPropagation()
51
+ }
52
+ }
53
+ })
54
+
55
+ if (len === 0) return jsx('box', { style: { height: 0 } })
56
+
57
+ const visible = Math.min(maxVisible, len)
58
+ const height = visible * itemHeight + Math.max(0, visible - 1) * gap
59
+
60
+ const defaultRender = (item, { active }) => {
61
+ const label = typeof item === 'string' ? item : (item.label ?? item.name ?? String(item))
62
+ return jsx('box', {
63
+ style: { flexDirection: 'row' },
64
+ children: [
65
+ jsx('text', { style: { color: active ? accent : null }, children: active ? `${arrow} ` : ' ' }),
66
+ jsx('text', { style: { color: active ? accent : 'gray' }, children: label }),
67
+ ],
68
+ })
69
+ }
70
+
71
+ const render = renderItem ?? defaultRender
72
+
73
+ return jsx('box', {
74
+ style: { height, minHeight: height },
75
+ children: jsx(List, {
76
+ items,
77
+ selected,
78
+ onSelect: setSelected,
79
+ focused: false,
80
+ interactive: false,
81
+ itemHeight,
82
+ gap,
83
+ scrolloff,
84
+ renderItem: (item, ctx) => render(item, { active: ctx.selected, index: ctx.index }),
85
+ }),
86
+ })
87
+ }
package/src/renderer.js CHANGED
@@ -660,12 +660,37 @@ export function mount(rootComponent, { stream, stdin, title, theme, onExit: onEx
660
660
  let forceFullPaint = false
661
661
  let prevHadOverlays = false
662
662
 
663
- // inline mode state: how many scrollback items have been committed, how many
664
- // lines the live region occupied last emit, and that emit's text for skipping
663
+ // inline mode state: how many scrollback items have been committed, the
664
+ // visible width of each line the live region last emitted (so a later resize
665
+ // can compute how many physical rows they reflowed into), and that emit's
666
+ // text for skipping unchanged frames
665
667
  let flushedCount = 0
666
- let prevLiveLines = 0
668
+ let prevLineLens = []
667
669
  let prevLiveText = null
668
670
 
671
+ // mirror per-instance layout (incl. scroll contentHeight/childHeights) back
672
+ // onto each instance so useLayout() consumers like List/Menu can window.
673
+ // returns whether anything changed, so the caller can re-resolve once and let
674
+ // those components observe the freshly measured values
675
+ function syncInstanceLayouts() {
676
+ let changed = false
677
+ for (const inst of instances.values()) {
678
+ const rect = inst.node?._availableRect ?? inst.node?._layout
679
+ if (!rect) continue
680
+ const ch = findScrollContentHeight(inst.node)
681
+ if (!inst.layout) inst.layout = { x: 0, y: 0, width: 0, height: 0 }
682
+ const p = inst.layout
683
+ if (p.width !== rect.width || p.height !== rect.height || p.contentHeight !== ch) changed = true
684
+ p.x = rect.x
685
+ p.y = rect.y
686
+ p.width = rect.width
687
+ p.height = rect.height
688
+ p.contentHeight = ch
689
+ p.childHeights = findScrollChildHeights(inst.node)
690
+ }
691
+ return changed
692
+ }
693
+
669
694
  function inlineFrame() {
670
695
  const prevCtx = activeContext
671
696
  activeContext = ctx
@@ -674,7 +699,7 @@ export function mount(rootComponent, { stream, stdin, title, theme, onExit: onEx
674
699
  const counters = new Map()
675
700
  const visited = new Set()
676
701
  const element = { type: rootComponent, props: {}, key: null }
677
- const tree = resolveForFrame(element, null, instances, counters, visited, '')
702
+ let tree = resolveForFrame(element, null, instances, counters, visited, '')
678
703
 
679
704
  const sb = findScrollback(tree)
680
705
  const items = sb?.props?.items ?? []
@@ -691,9 +716,18 @@ export function mount(rootComponent, { stream, stdin, title, theme, onExit: onEx
691
716
  // which we can't un-print, so just resync the counter
692
717
  flushedCount = items.length
693
718
 
694
- const liveHeight = Math.min(height, Math.max(1, intrinsicHeight(tree, width, height)))
719
+ let liveHeight = Math.min(height, Math.max(1, intrinsicHeight(tree, width, height)))
695
720
  computeLayout(tree, { x: 0, y: 0, width, height: liveHeight })
696
721
 
722
+ if (syncInstanceLayouts()) {
723
+ counters.clear()
724
+ visited.clear()
725
+ tree = resolveForFrame(element, null, instances, counters, visited, '')
726
+ liveHeight = Math.min(height, Math.max(1, intrinsicHeight(tree, width, height)))
727
+ computeLayout(tree, { x: 0, y: 0, width, height: liveHeight })
728
+ syncInstanceLayouts()
729
+ }
730
+
697
731
  for (const [key, inst] of instances) {
698
732
  if (!visited.has(key)) {
699
733
  disposeScope(inst.scope)
@@ -711,16 +745,25 @@ export function mount(rootComponent, { stream, stdin, title, theme, onExit: onEx
711
745
  const liveText = liveLines.join('\r\n')
712
746
  if (!committed && liveText === prevLiveText) return
713
747
 
748
+ // the cursor rests at the end of the last live line between frames. erase
749
+ // the previous live region before repainting: since it was drawn, a resize
750
+ // may have reflowed each of its lines into ceil(visibleWidth / width) rows,
751
+ // so step back over that real physical height rather than the logical line
752
+ // count - otherwise a shrink leaves the extra wrapped rows stranded as
753
+ // ghost bars. history above is left to the terminal's own reflow
754
+ let phys = 0
755
+ for (const len of prevLineLens) phys += Math.max(1, Math.ceil(len / width))
756
+
714
757
  let out_ = ''
715
- if (prevLiveLines > 0) {
758
+ if (phys > 0) {
716
759
  out_ += '\r'
717
- if (prevLiveLines > 1) out_ += ansi.moveUp(prevLiveLines - 1)
760
+ if (phys > 1) out_ += ansi.moveUp(phys - 1)
718
761
  out_ += ansi.clearDown
719
762
  }
720
763
  out_ += committed + liveText
721
764
 
722
765
  out.write(ansi.hideCursor + out_)
723
- prevLiveLines = liveLines.length
766
+ prevLineLens = liveLines.map(l => measureText(l))
724
767
  prevLiveText = liveText
725
768
  }
726
769
 
@@ -864,11 +907,16 @@ export function mount(rootComponent, { stream, stdin, title, theme, onExit: onEx
864
907
  width = out.columns ?? 80
865
908
  height = out.rows ?? 24
866
909
  if (inline) {
867
- // the terminal reflows committed history on its own; drop our live-region
868
- // geometry and repaint it fresh below wherever the cursor landed
869
- prevLiveLines = 0
910
+ // a resize reflows committed scrollback unpredictably, and a relative
911
+ // erase of the live region cannot reliably track it across terminals.
912
+ // rebuild instead: wipe the screen and scrollback, reset the commit
913
+ // cursor, and re-commit the whole transcript cleanly at the new width
914
+ width = out.columns ?? 80
915
+ height = out.rows ?? 24
916
+ out.write(ansi.clearScrollback + ansi.clearScreen + ansi.moveTo(1, 1))
917
+ flushedCount = 0
918
+ prevLineLens = []
870
919
  prevLiveText = null
871
- out.write('\r\n')
872
920
  scheduler.forceFrame()
873
921
  return
874
922
  }