@trendr/core 0.1.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/LICENSE +21 -0
- package/README.md +526 -0
- package/index.js +22 -0
- package/jsx-runtime.js +9 -0
- package/package.json +35 -0
- package/src/ansi.js +149 -0
- package/src/buffer.js +101 -0
- package/src/button.js +26 -0
- package/src/checkbox.js +28 -0
- package/src/components.js +13 -0
- package/src/diff.js +51 -0
- package/src/element.js +81 -0
- package/src/focus.js +135 -0
- package/src/hooks.js +63 -0
- package/src/hotkey.js +35 -0
- package/src/input.js +143 -0
- package/src/layout.js +312 -0
- package/src/list.js +46 -0
- package/src/modal.js +44 -0
- package/src/progress.js +21 -0
- package/src/radio.js +52 -0
- package/src/renderer.js +478 -0
- package/src/scheduler.js +55 -0
- package/src/scrollable-text.js +75 -0
- package/src/select.js +151 -0
- package/src/signal.js +159 -0
- package/src/spinner.js +21 -0
- package/src/table.js +51 -0
- package/src/tabs.js +30 -0
- package/src/text-area.js +286 -0
- package/src/text-input.js +131 -0
- package/src/toast.js +61 -0
- package/src/wrap.js +133 -0
package/src/select.js
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { jsx } from '../jsx-runtime.js'
|
|
2
|
+
import { createSignal } from './signal.js'
|
|
3
|
+
import { useInput, useTheme } from './hooks.js'
|
|
4
|
+
import { registerOverlay, getInstanceLayout, getContext } from './renderer.js'
|
|
5
|
+
|
|
6
|
+
export function Select({ items, selected, onSelect, focused = false, overlay = false, maxVisible = 10, placeholder = 'select...', renderItem, style: userStyle }) {
|
|
7
|
+
const { accent = 'cyan' } = useTheme()
|
|
8
|
+
const defaults = {
|
|
9
|
+
border: 'single',
|
|
10
|
+
borderColor: accent,
|
|
11
|
+
bg: null,
|
|
12
|
+
cursorBg: accent,
|
|
13
|
+
cursorTextColor: 'black',
|
|
14
|
+
color: null,
|
|
15
|
+
focusedBg: accent,
|
|
16
|
+
focusedColor: 'black',
|
|
17
|
+
}
|
|
18
|
+
const s = { ...defaults, ...userStyle }
|
|
19
|
+
|
|
20
|
+
const [open, setOpen] = createSignal(false)
|
|
21
|
+
const [cursor, setCursor] = createSignal(0)
|
|
22
|
+
const [scroll, setScroll] = createSignal(0)
|
|
23
|
+
|
|
24
|
+
useInput((event) => {
|
|
25
|
+
if (!focused) return
|
|
26
|
+
const { key } = event
|
|
27
|
+
|
|
28
|
+
if (!open()) {
|
|
29
|
+
if (key === 'return' || key === 'space') {
|
|
30
|
+
const idx = items.indexOf(selected)
|
|
31
|
+
setCursor(idx >= 0 ? idx : 0)
|
|
32
|
+
setOpen(true)
|
|
33
|
+
event.stopPropagation()
|
|
34
|
+
}
|
|
35
|
+
return
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const len = items.length
|
|
39
|
+
if (key === 'up' || key === 'k') { setCursor(c => Math.max(0, c - 1)); event.stopPropagation() }
|
|
40
|
+
else if (key === 'down' || key === 'j') { setCursor(c => Math.min(len - 1, c + 1)); event.stopPropagation() }
|
|
41
|
+
else if (key === 'return' || key === 'space') { onSelect?.(items[cursor()]); setOpen(false); event.stopPropagation() }
|
|
42
|
+
else if (key === 'escape') { setOpen(false); event.stopPropagation() }
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
const display = selected ?? placeholder
|
|
46
|
+
const collapsed = jsx('text', {
|
|
47
|
+
style: {
|
|
48
|
+
bg: focused ? s.focusedBg : null,
|
|
49
|
+
color: focused ? s.focusedColor : (selected ? s.color : 'gray'),
|
|
50
|
+
bold: focused,
|
|
51
|
+
},
|
|
52
|
+
children: `\u25be ${display}`,
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
if (!open()) return collapsed
|
|
56
|
+
|
|
57
|
+
const maxLen = items.reduce((m, v) => Math.max(m, v.length), 0)
|
|
58
|
+
|
|
59
|
+
let maxRows = Math.min(items.length, maxVisible)
|
|
60
|
+
|
|
61
|
+
if (overlay) {
|
|
62
|
+
const layout = getInstanceLayout()
|
|
63
|
+
const ctx = getContext()
|
|
64
|
+
const termH = ctx?.stream?.rows ?? 24
|
|
65
|
+
const anchorY = layout.y + 1
|
|
66
|
+
const available = termH - anchorY - 2
|
|
67
|
+
if (available > 0) maxRows = Math.min(maxRows, available)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const scrollable = items.length > maxRows
|
|
71
|
+
const visibleCount = maxRows
|
|
72
|
+
|
|
73
|
+
const cur = cursor()
|
|
74
|
+
const sc = scroll()
|
|
75
|
+
let newScroll = sc
|
|
76
|
+
if (cur < sc) newScroll = cur
|
|
77
|
+
else if (cur >= sc + visibleCount) newScroll = cur - visibleCount + 1
|
|
78
|
+
newScroll = Math.max(0, Math.min(newScroll, items.length - visibleCount))
|
|
79
|
+
if (newScroll !== sc) setScroll(newScroll)
|
|
80
|
+
|
|
81
|
+
const visible = items.slice(newScroll, newScroll + visibleCount)
|
|
82
|
+
|
|
83
|
+
const thumbH = scrollable ? Math.max(1, Math.round((visibleCount / items.length) * visibleCount)) : 0
|
|
84
|
+
const maxSc = items.length - visibleCount
|
|
85
|
+
const thumbStart = scrollable && maxSc > 0 ? Math.round((newScroll / maxSc) * (visibleCount - thumbH)) : 0
|
|
86
|
+
|
|
87
|
+
const dropdownChildren = visible.map((item, vi) => {
|
|
88
|
+
const i = vi + newScroll
|
|
89
|
+
const isCursor = i === cur
|
|
90
|
+
|
|
91
|
+
const barChar = scrollable
|
|
92
|
+
? (vi >= thumbStart && vi < thumbStart + thumbH ? '\u2588' : '\u2502')
|
|
93
|
+
: null
|
|
94
|
+
|
|
95
|
+
const row = (content) => {
|
|
96
|
+
if (!scrollable) return content
|
|
97
|
+
const barIsThumb = vi >= thumbStart && vi < thumbStart + thumbH
|
|
98
|
+
return jsx('box', {
|
|
99
|
+
key: i,
|
|
100
|
+
style: { flexDirection: 'row' },
|
|
101
|
+
children: [
|
|
102
|
+
content,
|
|
103
|
+
jsx('text', {
|
|
104
|
+
style: { color: barIsThumb ? accent : 'gray', dim: !barIsThumb },
|
|
105
|
+
children: ' ' + barChar,
|
|
106
|
+
}),
|
|
107
|
+
],
|
|
108
|
+
})
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (renderItem) {
|
|
112
|
+
const content = jsx('box', {
|
|
113
|
+
key: scrollable ? undefined : i,
|
|
114
|
+
style: { bg: isCursor ? s.cursorBg : s.bg, flexGrow: 1 },
|
|
115
|
+
children: renderItem(item, { selected: isCursor, index: i }),
|
|
116
|
+
})
|
|
117
|
+
return row(content)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const content = jsx('box', {
|
|
121
|
+
key: scrollable ? undefined : i,
|
|
122
|
+
style: { bg: isCursor ? s.cursorBg : s.bg, paddingX: 1, flexGrow: 1 },
|
|
123
|
+
children: jsx('text', {
|
|
124
|
+
style: { color: isCursor ? s.cursorTextColor : s.color },
|
|
125
|
+
children: item,
|
|
126
|
+
}),
|
|
127
|
+
})
|
|
128
|
+
return row(content)
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
const dropdownH = visibleCount + 2
|
|
132
|
+
|
|
133
|
+
const dropdown = jsx('box', {
|
|
134
|
+
style: {
|
|
135
|
+
flexDirection: 'column',
|
|
136
|
+
border: s.border,
|
|
137
|
+
borderColor: s.borderColor,
|
|
138
|
+
height: dropdownH,
|
|
139
|
+
width: maxLen + 4 + (scrollable ? 2 : 0),
|
|
140
|
+
bg: s.bg,
|
|
141
|
+
},
|
|
142
|
+
children: dropdownChildren,
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
if (overlay) {
|
|
146
|
+
registerOverlay(dropdown)
|
|
147
|
+
return collapsed
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return jsx('box', { style: { flexDirection: 'column' }, children: [collapsed, dropdown] })
|
|
151
|
+
}
|
package/src/signal.js
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
let currentEffect = null
|
|
2
|
+
let currentScope = null
|
|
3
|
+
let pendingEffects = null
|
|
4
|
+
let batchDepth = 0
|
|
5
|
+
let schedulerHook = null
|
|
6
|
+
let hookRegistrar = null
|
|
7
|
+
|
|
8
|
+
export function setSchedulerHook(fn) {
|
|
9
|
+
schedulerHook = fn
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function setHookRegistrar(fn) {
|
|
13
|
+
hookRegistrar = fn
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function createSignalRaw(value) {
|
|
17
|
+
const subs = new Set()
|
|
18
|
+
|
|
19
|
+
function get() {
|
|
20
|
+
if (currentEffect) subs.add(currentEffect)
|
|
21
|
+
return value
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function set(next) {
|
|
25
|
+
const v = typeof next === 'function' ? next(value) : next
|
|
26
|
+
if (v === value) return
|
|
27
|
+
value = v
|
|
28
|
+
if (batchDepth > 0) {
|
|
29
|
+
for (const s of subs) pendingEffects.add(s)
|
|
30
|
+
} else {
|
|
31
|
+
const snapshot = [...subs]
|
|
32
|
+
for (const s of snapshot) s.run()
|
|
33
|
+
}
|
|
34
|
+
if (schedulerHook && batchDepth === 0) schedulerHook()
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return [get, set]
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function createSignal(value) {
|
|
41
|
+
if (hookRegistrar) {
|
|
42
|
+
return hookRegistrar(() => createSignalRaw(value))
|
|
43
|
+
}
|
|
44
|
+
return createSignalRaw(value)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function createEffect(fn) {
|
|
48
|
+
const effect = {
|
|
49
|
+
fn,
|
|
50
|
+
cleanup: null,
|
|
51
|
+
run() {
|
|
52
|
+
if (effect.cleanup) effect.cleanup()
|
|
53
|
+
const prev = currentEffect
|
|
54
|
+
currentEffect = effect
|
|
55
|
+
try {
|
|
56
|
+
const result = fn()
|
|
57
|
+
effect.cleanup = typeof result === 'function' ? result : null
|
|
58
|
+
} finally {
|
|
59
|
+
currentEffect = prev
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
effect.run()
|
|
65
|
+
|
|
66
|
+
if (currentScope) currentScope.effects.push(effect)
|
|
67
|
+
|
|
68
|
+
return effect
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function createMemo(fn) {
|
|
72
|
+
const [get, set] = createSignal(undefined)
|
|
73
|
+
createEffect(() => set(fn()))
|
|
74
|
+
return get
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function batch(fn) {
|
|
78
|
+
if (batchDepth === 0) pendingEffects = new Set()
|
|
79
|
+
batchDepth++
|
|
80
|
+
try {
|
|
81
|
+
fn()
|
|
82
|
+
} finally {
|
|
83
|
+
batchDepth--
|
|
84
|
+
if (batchDepth === 0) {
|
|
85
|
+
const effects = [...pendingEffects]
|
|
86
|
+
pendingEffects = null
|
|
87
|
+
for (const e of effects) e.run()
|
|
88
|
+
if (schedulerHook) schedulerHook()
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function untrack(fn) {
|
|
94
|
+
const prev = currentEffect
|
|
95
|
+
currentEffect = null
|
|
96
|
+
try {
|
|
97
|
+
return fn()
|
|
98
|
+
} finally {
|
|
99
|
+
currentEffect = prev
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function onCleanup(fn) {
|
|
104
|
+
if (currentScope) currentScope.cleanups.push(fn)
|
|
105
|
+
else if (currentEffect) {
|
|
106
|
+
const prev = currentEffect.cleanup
|
|
107
|
+
currentEffect.cleanup = prev
|
|
108
|
+
? () => { prev(); fn() }
|
|
109
|
+
: fn
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function createScope(fn) {
|
|
114
|
+
const scope = {
|
|
115
|
+
effects: [],
|
|
116
|
+
children: [],
|
|
117
|
+
cleanups: [],
|
|
118
|
+
parent: currentScope,
|
|
119
|
+
}
|
|
120
|
+
if (currentScope) currentScope.children.push(scope)
|
|
121
|
+
|
|
122
|
+
const prev = currentScope
|
|
123
|
+
currentScope = scope
|
|
124
|
+
try {
|
|
125
|
+
fn()
|
|
126
|
+
} finally {
|
|
127
|
+
currentScope = prev
|
|
128
|
+
}
|
|
129
|
+
return scope
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function disposeScope(scope) {
|
|
133
|
+
for (const child of scope.children) disposeScope(child)
|
|
134
|
+
for (const effect of scope.effects) {
|
|
135
|
+
if (effect.cleanup) effect.cleanup()
|
|
136
|
+
}
|
|
137
|
+
for (const fn of scope.cleanups) fn()
|
|
138
|
+
scope.effects.length = 0
|
|
139
|
+
scope.children.length = 0
|
|
140
|
+
scope.cleanups.length = 0
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function getCurrentScope() {
|
|
144
|
+
return currentScope
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function runInScope(scope, fn) {
|
|
148
|
+
const prev = currentScope
|
|
149
|
+
currentScope = scope
|
|
150
|
+
try {
|
|
151
|
+
return fn()
|
|
152
|
+
} finally {
|
|
153
|
+
currentScope = prev
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function notifyScheduler() {
|
|
158
|
+
if (schedulerHook) schedulerHook()
|
|
159
|
+
}
|
package/src/spinner.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { jsx, jsxs } from '../jsx-runtime.js'
|
|
2
|
+
import { createSignal } from './signal.js'
|
|
3
|
+
import { useInterval, useTheme } from './hooks.js'
|
|
4
|
+
import { registerHook } from './renderer.js'
|
|
5
|
+
|
|
6
|
+
const FRAMES = ['\u280B', '\u2819', '\u2839', '\u2838', '\u283C', '\u2834', '\u2826', '\u2827', '\u2807', '\u280F']
|
|
7
|
+
|
|
8
|
+
export function Spinner({ label, color, interval = 80 }) {
|
|
9
|
+
const { accent = 'cyan' } = useTheme()
|
|
10
|
+
const c = color ?? accent
|
|
11
|
+
const [frame, setFrame] = createSignal(0)
|
|
12
|
+
useInterval(() => setFrame(f => (f + 1) % FRAMES.length), interval)
|
|
13
|
+
|
|
14
|
+
const children = [jsx('text', { style: { color: c }, children: FRAMES[frame()] })]
|
|
15
|
+
|
|
16
|
+
if (label != null) {
|
|
17
|
+
children.push(jsx('text', { children: ` ${label}` }))
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return jsxs('box', { style: { flexDirection: 'row' }, children })
|
|
21
|
+
}
|
package/src/table.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { jsx, jsxs } from '../jsx-runtime.js'
|
|
2
|
+
import { useTheme } from './hooks.js'
|
|
3
|
+
import { List } from './list.js'
|
|
4
|
+
|
|
5
|
+
export function Table({ columns, data, selected, onSelect, focused = true }) {
|
|
6
|
+
const { accent = 'cyan' } = useTheme()
|
|
7
|
+
|
|
8
|
+
const header = jsxs('box', {
|
|
9
|
+
style: { flexDirection: 'row' },
|
|
10
|
+
children: columns.map((col, i) =>
|
|
11
|
+
jsx('text', {
|
|
12
|
+
key: i,
|
|
13
|
+
style: {
|
|
14
|
+
width: col.width,
|
|
15
|
+
flexGrow: col.flexGrow,
|
|
16
|
+
color: 'gray',
|
|
17
|
+
dim: true,
|
|
18
|
+
bold: true,
|
|
19
|
+
overflow: 'truncate',
|
|
20
|
+
paddingX: col.paddingX ?? 1,
|
|
21
|
+
},
|
|
22
|
+
children: col.header,
|
|
23
|
+
})
|
|
24
|
+
),
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
return jsx(List, {
|
|
28
|
+
items: data,
|
|
29
|
+
selected,
|
|
30
|
+
onSelect,
|
|
31
|
+
focused,
|
|
32
|
+
header,
|
|
33
|
+
renderItem: (row, { selected: isSel }) =>
|
|
34
|
+
jsxs('box', {
|
|
35
|
+
style: { flexDirection: 'row', bg: isSel ? (focused ? accent : 'gray') : null },
|
|
36
|
+
children: columns.map((col, i) =>
|
|
37
|
+
jsx('text', {
|
|
38
|
+
key: i,
|
|
39
|
+
style: {
|
|
40
|
+
width: col.width,
|
|
41
|
+
flexGrow: col.flexGrow,
|
|
42
|
+
overflow: 'truncate',
|
|
43
|
+
paddingX: col.paddingX ?? 1,
|
|
44
|
+
color: isSel && focused ? 'black' : (col.color ?? null),
|
|
45
|
+
},
|
|
46
|
+
children: col.render ? col.render(row, isSel) : String(row[col.key] ?? ''),
|
|
47
|
+
})
|
|
48
|
+
),
|
|
49
|
+
}),
|
|
50
|
+
})
|
|
51
|
+
}
|
package/src/tabs.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { jsx, jsxs } from '../jsx-runtime.js'
|
|
2
|
+
import { useInput } from './hooks.js'
|
|
3
|
+
|
|
4
|
+
export function Tabs({ items, selected, onSelect, focused = true }) {
|
|
5
|
+
useInput(({ key }) => {
|
|
6
|
+
if (!focused) return
|
|
7
|
+
|
|
8
|
+
const idx = items.indexOf(selected)
|
|
9
|
+
if (idx === -1) return
|
|
10
|
+
|
|
11
|
+
if (key === 'left' || key === 'shift-tab') {
|
|
12
|
+
onSelect(items[(idx - 1 + items.length) % items.length])
|
|
13
|
+
} else if (key === 'right' || key === 'tab') {
|
|
14
|
+
onSelect(items[(idx + 1) % items.length])
|
|
15
|
+
}
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
const children = items.map(item => {
|
|
19
|
+
const isSelected = item === selected
|
|
20
|
+
return jsx('text', {
|
|
21
|
+
style: isSelected ? { inverse: true, bold: true } : { color: 'gray' },
|
|
22
|
+
children: ` ${item} `,
|
|
23
|
+
})
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
return jsxs('box', {
|
|
27
|
+
style: { flexDirection: 'row', gap: 1 },
|
|
28
|
+
children,
|
|
29
|
+
})
|
|
30
|
+
}
|
package/src/text-area.js
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
import { jsx, jsxs } from '../jsx-runtime.js'
|
|
2
|
+
import { createSignal } from './signal.js'
|
|
3
|
+
import { useInput, useLayout } from './hooks.js'
|
|
4
|
+
import { registerHook } from './renderer.js'
|
|
5
|
+
|
|
6
|
+
export function wrapForEditor(text, width) {
|
|
7
|
+
if (width <= 0) return [{ start: 0, end: 0, hard: true }]
|
|
8
|
+
if (text.length === 0) return [{ start: 0, end: 0, hard: true }]
|
|
9
|
+
|
|
10
|
+
const lines = []
|
|
11
|
+
let pos = 0
|
|
12
|
+
|
|
13
|
+
while (pos < text.length) {
|
|
14
|
+
const nlIdx = text.indexOf('\n', pos)
|
|
15
|
+
const logicalEnd = nlIdx === -1 ? text.length : nlIdx
|
|
16
|
+
const segment = text.slice(pos, logicalEnd)
|
|
17
|
+
|
|
18
|
+
if (segment.length === 0) {
|
|
19
|
+
lines.push({ start: pos, end: pos, hard: true })
|
|
20
|
+
pos = logicalEnd + 1
|
|
21
|
+
continue
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
let segStart = 0
|
|
25
|
+
while (segStart < segment.length) {
|
|
26
|
+
const remaining = segment.slice(segStart)
|
|
27
|
+
|
|
28
|
+
if (remaining.length <= width) {
|
|
29
|
+
lines.push({ start: pos + segStart, end: pos + segStart + remaining.length, hard: true })
|
|
30
|
+
segStart += remaining.length
|
|
31
|
+
break
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const chunk = remaining.slice(0, width)
|
|
35
|
+
const lastSpace = chunk.lastIndexOf(' ')
|
|
36
|
+
|
|
37
|
+
if (lastSpace > 0) {
|
|
38
|
+
lines.push({ start: pos + segStart, end: pos + segStart + lastSpace, hard: false })
|
|
39
|
+
segStart += lastSpace + 1
|
|
40
|
+
} else {
|
|
41
|
+
lines.push({ start: pos + segStart, end: pos + segStart + width, hard: false })
|
|
42
|
+
segStart += width
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
pos = logicalEnd + (nlIdx !== -1 ? 1 : 0)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (text.length > 0 && text[text.length - 1] === '\n') {
|
|
50
|
+
lines.push({ start: text.length, end: text.length, hard: true })
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (lines.length === 0) {
|
|
54
|
+
lines.push({ start: 0, end: 0, hard: true })
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return lines
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function cursorToDisplay(cursor, lineMap) {
|
|
61
|
+
for (let row = 0; row < lineMap.length; row++) {
|
|
62
|
+
const line = lineMap[row]
|
|
63
|
+
if (cursor >= line.start && cursor <= line.end) {
|
|
64
|
+
if (cursor === line.end && !line.hard && row + 1 < lineMap.length) {
|
|
65
|
+
return { row: row + 1, col: 0 }
|
|
66
|
+
}
|
|
67
|
+
return { row, col: cursor - line.start }
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
const last = lineMap[lineMap.length - 1]
|
|
71
|
+
return { row: lineMap.length - 1, col: last.end - last.start }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function displayToCursor(row, col, lineMap) {
|
|
75
|
+
const r = Math.max(0, Math.min(row, lineMap.length - 1))
|
|
76
|
+
const line = lineMap[r]
|
|
77
|
+
const maxCol = line.end - line.start
|
|
78
|
+
const c = Math.max(0, Math.min(col, maxCol))
|
|
79
|
+
return line.start + c
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function ensureVisible(cursorRow, scroll, height, totalLines) {
|
|
83
|
+
const maxScroll = Math.max(0, totalLines - height)
|
|
84
|
+
if (scroll > maxScroll) scroll = maxScroll
|
|
85
|
+
if (cursorRow < scroll) return cursorRow
|
|
86
|
+
if (cursorRow >= scroll + height) return cursorRow - height + 1
|
|
87
|
+
return scroll
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function TextArea({ onSubmit, onCancel, onChange, placeholder, focused = true, maxHeight = 10 }) {
|
|
91
|
+
const [value, setValue] = createSignal('')
|
|
92
|
+
const [cursor, setCursor] = createSignal(0)
|
|
93
|
+
const ref = registerHook(() => ({ scroll: 0, goalCol: null }))
|
|
94
|
+
const layout = useLayout()
|
|
95
|
+
|
|
96
|
+
function update(v, c) {
|
|
97
|
+
setValue(v)
|
|
98
|
+
setCursor(c)
|
|
99
|
+
ref.goalCol = null
|
|
100
|
+
if (onChange) onChange(v)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
useInput((event) => {
|
|
104
|
+
if (!focused) return
|
|
105
|
+
|
|
106
|
+
const { key, raw, ctrl, meta } = event
|
|
107
|
+
const v = value()
|
|
108
|
+
const c = cursor()
|
|
109
|
+
|
|
110
|
+
if (meta && key === '\r') {
|
|
111
|
+
if (onSubmit) onSubmit(v)
|
|
112
|
+
update('', 0)
|
|
113
|
+
ref.scroll = 0
|
|
114
|
+
event.stopPropagation()
|
|
115
|
+
return
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (key === 'return') {
|
|
119
|
+
update(v.slice(0, c) + '\n' + v.slice(c), c + 1)
|
|
120
|
+
event.stopPropagation()
|
|
121
|
+
return
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (key === 'escape') {
|
|
125
|
+
if (onCancel) {
|
|
126
|
+
onCancel()
|
|
127
|
+
event.stopPropagation()
|
|
128
|
+
}
|
|
129
|
+
return
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (key === 'backspace') {
|
|
133
|
+
if (c > 0) update(v.slice(0, c - 1) + v.slice(c), c - 1)
|
|
134
|
+
event.stopPropagation()
|
|
135
|
+
return
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (key === 'delete') {
|
|
139
|
+
if (c < v.length) update(v.slice(0, c) + v.slice(c + 1), c)
|
|
140
|
+
event.stopPropagation()
|
|
141
|
+
return
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (key === 'left') {
|
|
145
|
+
setCursor(Math.max(0, c - 1))
|
|
146
|
+
ref.goalCol = null
|
|
147
|
+
event.stopPropagation()
|
|
148
|
+
return
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (key === 'right') {
|
|
152
|
+
setCursor(Math.min(v.length, c + 1))
|
|
153
|
+
ref.goalCol = null
|
|
154
|
+
event.stopPropagation()
|
|
155
|
+
return
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (key === 'up' || key === 'down') {
|
|
159
|
+
const w = layout.width || 80
|
|
160
|
+
const lineMap = wrapForEditor(v, w)
|
|
161
|
+
const pos = cursorToDisplay(c, lineMap)
|
|
162
|
+
const goal = ref.goalCol !== null ? ref.goalCol : pos.col
|
|
163
|
+
ref.goalCol = goal
|
|
164
|
+
|
|
165
|
+
const newRow = key === 'up' ? pos.row - 1 : pos.row + 1
|
|
166
|
+
if (newRow >= 0 && newRow < lineMap.length) {
|
|
167
|
+
setCursor(displayToCursor(newRow, goal, lineMap))
|
|
168
|
+
}
|
|
169
|
+
event.stopPropagation()
|
|
170
|
+
return
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (key === 'home' || (ctrl && raw === '\x01')) {
|
|
174
|
+
const w = layout.width || 80
|
|
175
|
+
const lineMap = wrapForEditor(v, w)
|
|
176
|
+
const pos = cursorToDisplay(c, lineMap)
|
|
177
|
+
setCursor(lineMap[pos.row].start)
|
|
178
|
+
ref.goalCol = null
|
|
179
|
+
event.stopPropagation()
|
|
180
|
+
return
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (key === 'end' || (ctrl && raw === '\x05')) {
|
|
184
|
+
const w = layout.width || 80
|
|
185
|
+
const lineMap = wrapForEditor(v, w)
|
|
186
|
+
const pos = cursorToDisplay(c, lineMap)
|
|
187
|
+
setCursor(lineMap[pos.row].end)
|
|
188
|
+
ref.goalCol = null
|
|
189
|
+
event.stopPropagation()
|
|
190
|
+
return
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (ctrl && raw === '\x15') {
|
|
194
|
+
const before = v.slice(0, c)
|
|
195
|
+
const nlIdx = before.lastIndexOf('\n')
|
|
196
|
+
const lineStart = nlIdx + 1
|
|
197
|
+
update(v.slice(0, lineStart) + v.slice(c), lineStart)
|
|
198
|
+
event.stopPropagation()
|
|
199
|
+
return
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (ctrl && raw === '\x0b') {
|
|
203
|
+
const after = v.slice(c)
|
|
204
|
+
const nlIdx = after.indexOf('\n')
|
|
205
|
+
const deleteEnd = nlIdx === -1 ? v.length : c + nlIdx
|
|
206
|
+
update(v.slice(0, c) + v.slice(deleteEnd), c)
|
|
207
|
+
event.stopPropagation()
|
|
208
|
+
return
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (ctrl && raw === '\x17') {
|
|
212
|
+
const before = v.slice(0, c)
|
|
213
|
+
const after = v.slice(c)
|
|
214
|
+
const trimmed = before.replace(/\S+\s*$/, '')
|
|
215
|
+
update(trimmed + after, trimmed.length)
|
|
216
|
+
event.stopPropagation()
|
|
217
|
+
return
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (!ctrl && !meta && raw.length === 1 && raw >= ' ') {
|
|
221
|
+
update(v.slice(0, c) + raw + v.slice(c), c + raw.length)
|
|
222
|
+
event.stopPropagation()
|
|
223
|
+
}
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
const v = value()
|
|
227
|
+
const c = cursor()
|
|
228
|
+
const w = layout.width || 0
|
|
229
|
+
|
|
230
|
+
if (!v && placeholder && !focused) {
|
|
231
|
+
return jsx('text', { style: { color: 'gray', flexGrow: 1 }, children: placeholder })
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const effectiveWidth = w || 80
|
|
235
|
+
const lineMap = wrapForEditor(v, effectiveWidth)
|
|
236
|
+
const displayPos = cursorToDisplay(c, lineMap)
|
|
237
|
+
|
|
238
|
+
const displayHeight = Math.max(1, Math.min(lineMap.length, maxHeight))
|
|
239
|
+
ref.scroll = ensureVisible(displayPos.row, ref.scroll, displayHeight, lineMap.length)
|
|
240
|
+
const scroll = ref.scroll
|
|
241
|
+
|
|
242
|
+
const visibleLines = lineMap.slice(scroll, scroll + displayHeight)
|
|
243
|
+
|
|
244
|
+
if (!v && placeholder && focused) {
|
|
245
|
+
return jsx('box', {
|
|
246
|
+
style: { flexDirection: 'column', height: 1, minHeight: 1, flexGrow: 1 },
|
|
247
|
+
children: jsxs('box', {
|
|
248
|
+
style: { flexDirection: 'row', height: 1 },
|
|
249
|
+
children: [
|
|
250
|
+
jsx('text', { style: { inverse: true, color: 'gray' }, children: placeholder[0] }),
|
|
251
|
+
placeholder.length > 1 && jsx('text', { style: { color: 'gray' }, children: placeholder.slice(1) }),
|
|
252
|
+
],
|
|
253
|
+
}),
|
|
254
|
+
})
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const rows = visibleLines.map((line, i) => {
|
|
258
|
+
const row = scroll + i
|
|
259
|
+
const content = v.slice(line.start, line.end)
|
|
260
|
+
const hasCursor = focused && row === displayPos.row
|
|
261
|
+
|
|
262
|
+
if (!hasCursor) {
|
|
263
|
+
return jsx('text', { key: row, children: content || ' ' })
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const cursorCol = displayPos.col
|
|
267
|
+
const before = content.slice(0, cursorCol)
|
|
268
|
+
const cursorChar = content[cursorCol] || ' '
|
|
269
|
+
const after = content.slice(cursorCol + 1)
|
|
270
|
+
|
|
271
|
+
return jsxs('box', {
|
|
272
|
+
key: row,
|
|
273
|
+
style: { flexDirection: 'row', height: 1 },
|
|
274
|
+
children: [
|
|
275
|
+
before && jsx('text', { children: before }),
|
|
276
|
+
jsx('text', { style: { inverse: true }, children: cursorChar }),
|
|
277
|
+
after && jsx('text', { children: after }),
|
|
278
|
+
],
|
|
279
|
+
})
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
return jsx('box', {
|
|
283
|
+
style: { flexDirection: 'column', height: displayHeight, minHeight: 1, flexGrow: 1 },
|
|
284
|
+
children: rows,
|
|
285
|
+
})
|
|
286
|
+
}
|