@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 +1 -0
- package/package.json +1 -1
- package/src/ansi.js +1 -0
- package/src/menu.js +87 -0
- package/src/renderer.js +60 -12
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
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,
|
|
664
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
758
|
+
if (phys > 0) {
|
|
716
759
|
out_ += '\r'
|
|
717
|
-
if (
|
|
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
|
-
|
|
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
|
-
//
|
|
868
|
-
//
|
|
869
|
-
|
|
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
|
}
|