@trendr/core 0.2.11 → 0.4.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/README.md +284 -24
- package/index.js +6 -2
- package/package.json +29 -34
- package/src/animation.js +25 -12
- package/src/ansi.js +6 -0
- package/src/buffer.js +63 -50
- package/src/button.js +2 -2
- package/src/checkbox.js +2 -2
- package/src/diff-engine.js +313 -0
- package/src/diff-view.js +238 -0
- package/src/diff.js +2 -2
- package/src/dropdown.js +147 -0
- package/src/focus.js +89 -27
- package/src/hooks.js +43 -25
- package/src/hotkey.js +51 -16
- package/src/input.js +211 -72
- package/src/layout.js +34 -13
- package/src/list.js +29 -16
- package/src/markdown.js +177 -0
- package/src/menu.js +13 -6
- package/src/menubar.js +46 -115
- package/src/miller-nav.js +36 -11
- package/src/modal.js +3 -1
- package/src/pick-list.js +16 -17
- package/src/progress.js +3 -3
- package/src/radio.js +16 -5
- package/src/renderer.js +291 -50
- package/src/scheduler.js +28 -10
- package/src/scroll-box.js +12 -11
- package/src/scrollable-text.js +3 -2
- package/src/select.js +65 -173
- package/src/selection.js +92 -0
- package/src/shimmer.js +2 -2
- package/src/signal.js +20 -3
- package/src/table.js +8 -27
- package/src/tabs.js +10 -7
- package/src/task.js +3 -3
- package/src/text-area.js +96 -41
- package/src/text-input.js +70 -28
- package/src/wrap.js +205 -84
package/src/wrap.js
CHANGED
|
@@ -1,51 +1,90 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
import { parseSgr, sgr } from './ansi.js'
|
|
2
|
+
|
|
3
|
+
// matches any CSI sequence (any final byte) or OSC sequence (BEL or ST terminated)
|
|
4
|
+
const ANSI_RE = /\x1b(?:\[[0-9:;<=>?]*[ -\/]*[@-~]|\][^\x07\x1b]*(?:\x07|\x1b\\)?)/g
|
|
3
5
|
|
|
4
6
|
export function stripAnsi(text) {
|
|
5
7
|
return text.indexOf('\x1b') === -1 ? text : text.replace(ANSI_RE, '')
|
|
6
8
|
}
|
|
7
9
|
|
|
10
|
+
// returns the end index (exclusive) of the escape sequence starting at i,
|
|
11
|
+
// or -1 when str[i] does not begin a recognized CSI/OSC sequence
|
|
12
|
+
export function ansiSeqEnd(str, i) {
|
|
13
|
+
if (str[i] !== '\x1b') return -1
|
|
14
|
+
const kind = str[i + 1]
|
|
15
|
+
if (kind === '[') {
|
|
16
|
+
for (let j = i + 2; j < str.length; j++) {
|
|
17
|
+
const c = str.charCodeAt(j)
|
|
18
|
+
if (c >= 0x40 && c <= 0x7e) return j + 1
|
|
19
|
+
}
|
|
20
|
+
return str.length
|
|
21
|
+
}
|
|
22
|
+
if (kind === ']') {
|
|
23
|
+
for (let j = i + 2; j < str.length; j++) {
|
|
24
|
+
const c = str.charCodeAt(j)
|
|
25
|
+
if (c === 0x07) return j + 1
|
|
26
|
+
if (c === 0x1b) return str[j + 1] === '\\' ? j + 2 : j
|
|
27
|
+
}
|
|
28
|
+
return str.length
|
|
29
|
+
}
|
|
30
|
+
return -1
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const TAB_STOP = 8
|
|
34
|
+
|
|
35
|
+
const tabWidth = (col) => TAB_STOP - (col % TAB_STOP)
|
|
36
|
+
|
|
8
37
|
export function measureText(text) {
|
|
9
38
|
const clean = stripAnsi(text)
|
|
10
39
|
let width = 0
|
|
11
40
|
for (let i = 0; i < clean.length; i++) {
|
|
12
41
|
const code = clean.codePointAt(i)
|
|
13
42
|
if (code > 0xffff) i++
|
|
14
|
-
width +=
|
|
43
|
+
width += code === 9 ? tabWidth(width) : charWidth(code)
|
|
15
44
|
}
|
|
16
45
|
return width
|
|
17
46
|
}
|
|
18
47
|
|
|
19
|
-
// iterates visible characters of a string, bundling any preceding
|
|
20
|
-
//
|
|
48
|
+
// iterates visible characters of a string, bundling any preceding ANSI
|
|
49
|
+
// sequences with the character they precede. a leftover run of trailing
|
|
50
|
+
// ANSI is flushed as a final zero-width chunk (code -1)
|
|
21
51
|
function* visibleChars(str) {
|
|
22
52
|
let i = 0
|
|
23
53
|
let pending = ''
|
|
24
54
|
while (i < str.length) {
|
|
25
|
-
if (str[i] === '\x1b'
|
|
26
|
-
const end = str
|
|
55
|
+
if (str[i] === '\x1b') {
|
|
56
|
+
const end = ansiSeqEnd(str, i)
|
|
27
57
|
if (end !== -1) {
|
|
28
|
-
pending += str.slice(i, end
|
|
29
|
-
i = end
|
|
58
|
+
pending += str.slice(i, end)
|
|
59
|
+
i = end
|
|
30
60
|
continue
|
|
31
61
|
}
|
|
32
62
|
}
|
|
33
63
|
const code = str.codePointAt(i)
|
|
34
64
|
const len = code > 0xffff ? 2 : 1
|
|
35
|
-
yield { chunk: pending + str.slice(i, i + len), width: charWidth(code) }
|
|
65
|
+
yield { chunk: pending + str.slice(i, i + len), code, width: charWidth(code) }
|
|
36
66
|
pending = ''
|
|
37
67
|
i += len
|
|
38
68
|
}
|
|
69
|
+
if (pending) yield { chunk: pending, code: -1, width: 0 }
|
|
39
70
|
}
|
|
40
71
|
|
|
72
|
+
const COMBINING_RE = /[\p{Mn}\p{Me}]/u
|
|
73
|
+
|
|
41
74
|
export function charWidth(code) {
|
|
42
|
-
|
|
75
|
+
if (code < 0x300) return 1
|
|
76
|
+
if ((code >= 0x200b && code <= 0x200d) || code === 0xfeff) return 0
|
|
77
|
+
if (code >= 0xfe00 && code <= 0xfe0f) return 0
|
|
78
|
+
if (COMBINING_RE.test(String.fromCodePoint(code))) return 0
|
|
79
|
+
if (code >= 0x1100 && isWide(code)) return 2
|
|
80
|
+
return 1
|
|
43
81
|
}
|
|
44
82
|
|
|
45
83
|
export function sliceVisible(text, maxWidth) {
|
|
46
84
|
let result = ''
|
|
47
85
|
let width = 0
|
|
48
|
-
for (const { chunk, width:
|
|
86
|
+
for (const { chunk, code, width: cw } of visibleChars(text)) {
|
|
87
|
+
const w = code === 9 ? tabWidth(width) : cw
|
|
49
88
|
if (width + w > maxWidth) break
|
|
50
89
|
result += chunk
|
|
51
90
|
width += w
|
|
@@ -53,93 +92,175 @@ export function sliceVisible(text, maxWidth) {
|
|
|
53
92
|
return result
|
|
54
93
|
}
|
|
55
94
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
95
|
+
const sgrIsDefault = (s) => s.fg == null && s.bg == null && s.attrs === 0
|
|
96
|
+
|
|
97
|
+
// minimal prefix that reproduces an SGR state on a fresh line
|
|
98
|
+
const sgrCarry = (s) => sgrIsDefault(s) ? '' : sgr(s.fg, s.bg, s.attrs)
|
|
99
|
+
|
|
100
|
+
// folds every SGR sequence found in str into state; other escapes are ignored
|
|
101
|
+
function updateSgrState(str, state) {
|
|
102
|
+
let i = str.indexOf('\x1b')
|
|
103
|
+
while (i !== -1) {
|
|
104
|
+
const end = ansiSeqEnd(str, i)
|
|
105
|
+
if (end === -1) {
|
|
106
|
+
i = str.indexOf('\x1b', i + 1)
|
|
107
|
+
continue
|
|
108
|
+
}
|
|
109
|
+
if (str[i + 1] === '[' && str[end - 1] === 'm') parseSgr(str.slice(i + 2, end - 1), state)
|
|
110
|
+
i = str.indexOf('\x1b', end)
|
|
63
111
|
}
|
|
64
|
-
return
|
|
112
|
+
return state
|
|
65
113
|
}
|
|
66
114
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
let
|
|
115
|
+
// extracts the visible codepoint range [start, end) from an ANSI string while
|
|
116
|
+
// carrying the SGR state active at the cut, so syntax colors survive a slice.
|
|
117
|
+
// start/end count codepoints, matching the diff engine's intra ranges
|
|
118
|
+
export function sliceVisibleRange(text, start, end) {
|
|
119
|
+
let idx = 0
|
|
120
|
+
let out = ''
|
|
121
|
+
let opened = false
|
|
122
|
+
let i = 0
|
|
123
|
+
const state = { fg: null, bg: null, attrs: 0 }
|
|
73
124
|
|
|
74
|
-
|
|
75
|
-
if (
|
|
76
|
-
|
|
77
|
-
|
|
125
|
+
while (i < text.length && idx < end) {
|
|
126
|
+
if (text[i] === '\x1b') {
|
|
127
|
+
const e = ansiSeqEnd(text, i)
|
|
128
|
+
if (e !== -1) {
|
|
129
|
+
if (text[i + 1] === '[' && text[e - 1] === 'm') {
|
|
130
|
+
parseSgr(text.slice(i + 2, e - 1), state)
|
|
131
|
+
if (opened) out += text.slice(i, e)
|
|
132
|
+
}
|
|
133
|
+
i = e
|
|
134
|
+
continue
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
const code = text.codePointAt(i)
|
|
138
|
+
const len = code > 0xffff ? 2 : 1
|
|
139
|
+
if (idx >= start) {
|
|
140
|
+
if (!opened) {
|
|
141
|
+
out += sgrCarry(state)
|
|
142
|
+
opened = true
|
|
143
|
+
}
|
|
144
|
+
out += text.slice(i, i + len)
|
|
78
145
|
}
|
|
146
|
+
idx++
|
|
147
|
+
i += len
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (opened && !sgrIsDefault(state)) out += '\x1b[0m'
|
|
151
|
+
return out
|
|
152
|
+
}
|
|
79
153
|
|
|
80
|
-
|
|
154
|
+
// wraps a single logical line wider than maxWidth. whitespace runs are
|
|
155
|
+
// preserved verbatim when they fit; a run at a wrap point is consumed (its
|
|
156
|
+
// SGR sequences still fold into state so colors survive the break)
|
|
157
|
+
function wrapLine(text, maxWidth, lines, state) {
|
|
158
|
+
let line = sgrCarry(state)
|
|
159
|
+
let col = 0
|
|
160
|
+
let ws = ''
|
|
161
|
+
let wsW = 0
|
|
162
|
+
let word = ''
|
|
163
|
+
let wordWidth = 0
|
|
81
164
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
165
|
+
const newline = () => {
|
|
166
|
+
lines.push(line)
|
|
167
|
+
line = sgrCarry(state)
|
|
168
|
+
col = 0
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const emit = (chunk) => {
|
|
172
|
+
updateSgrState(chunk, state)
|
|
173
|
+
line += chunk
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const breakWord = () => {
|
|
177
|
+
for (const { chunk, width } of visibleChars(word)) {
|
|
178
|
+
if (width > 0 && col + width > maxWidth) newline()
|
|
179
|
+
emit(chunk)
|
|
180
|
+
col += width
|
|
86
181
|
}
|
|
182
|
+
}
|
|
87
183
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
lineWidth += 1 + ww
|
|
113
|
-
} else if (ww > maxWidth) {
|
|
114
|
-
if (line) {
|
|
115
|
-
carry = extractTrailingAnsi(line)
|
|
116
|
-
lines.push(line)
|
|
117
|
-
line = carry
|
|
118
|
-
lineWidth = 0
|
|
119
|
-
}
|
|
120
|
-
for (const { chunk, width } of visibleChars(word)) {
|
|
121
|
-
if (lineWidth + width > maxWidth) {
|
|
122
|
-
carry = extractTrailingAnsi(line)
|
|
123
|
-
lines.push(line)
|
|
124
|
-
line = carry
|
|
125
|
-
lineWidth = 0
|
|
126
|
-
}
|
|
127
|
-
line += chunk
|
|
128
|
-
lineWidth += width
|
|
129
|
-
}
|
|
184
|
+
const placeWord = () => {
|
|
185
|
+
if (!word) {
|
|
186
|
+
if (ws) {
|
|
187
|
+
emit(ws)
|
|
188
|
+
col += wsW
|
|
189
|
+
ws = ''
|
|
190
|
+
wsW = 0
|
|
191
|
+
}
|
|
192
|
+
return
|
|
193
|
+
}
|
|
194
|
+
if (col + wsW + wordWidth <= maxWidth) {
|
|
195
|
+
emit(ws)
|
|
196
|
+
emit(word)
|
|
197
|
+
col += wsW + wordWidth
|
|
198
|
+
} else if (col === 0) {
|
|
199
|
+
emit(ws)
|
|
200
|
+
col += wsW
|
|
201
|
+
breakWord()
|
|
202
|
+
} else {
|
|
203
|
+
updateSgrState(ws, state)
|
|
204
|
+
newline()
|
|
205
|
+
if (wordWidth <= maxWidth) {
|
|
206
|
+
emit(word)
|
|
207
|
+
col += wordWidth
|
|
130
208
|
} else {
|
|
131
|
-
|
|
132
|
-
lines.push(line)
|
|
133
|
-
line = carry + word
|
|
134
|
-
lineWidth = ww
|
|
209
|
+
breakWord()
|
|
135
210
|
}
|
|
136
211
|
}
|
|
212
|
+
ws = ''
|
|
213
|
+
wsW = 0
|
|
214
|
+
word = ''
|
|
215
|
+
wordWidth = 0
|
|
216
|
+
}
|
|
137
217
|
|
|
138
|
-
|
|
139
|
-
|
|
218
|
+
for (const { chunk, code, width } of visibleChars(text)) {
|
|
219
|
+
if (code === 32 || code === 9 || code === 13) {
|
|
220
|
+
if (word) placeWord()
|
|
221
|
+
ws += chunk
|
|
222
|
+
wsW += code === 9 ? tabWidth(col + wsW) : width
|
|
223
|
+
} else {
|
|
224
|
+
word += chunk
|
|
225
|
+
wordWidth += width
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
placeWord()
|
|
229
|
+
lines.push(line)
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export function wordWrap(text, maxWidth) {
|
|
233
|
+
return wordWrapMarked(text, maxWidth).lines
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// like wordWrap, but also reports which output lines exist only because the
|
|
237
|
+
// text was too wide: soft[i] is true when line i continues line i-1 rather
|
|
238
|
+
// than starting after a hard newline. selection copy uses this to rejoin
|
|
239
|
+
// wrapped prose while keeping real line breaks
|
|
240
|
+
export function wordWrapMarked(text, maxWidth) {
|
|
241
|
+
if (maxWidth <= 0) return { lines: [], soft: [] }
|
|
242
|
+
if (!text) return { lines: [''], soft: [false] }
|
|
243
|
+
|
|
244
|
+
const lines = []
|
|
245
|
+
const soft = []
|
|
246
|
+
const state = { fg: null, bg: null, attrs: 0 }
|
|
247
|
+
|
|
248
|
+
for (const rawLine of text.split('\n')) {
|
|
249
|
+
const startIdx = lines.length
|
|
250
|
+
|
|
251
|
+
if (rawLine.length === 0) {
|
|
252
|
+
lines.push(sgrCarry(state))
|
|
253
|
+
} else if (measureText(rawLine) <= maxWidth) {
|
|
254
|
+
lines.push(sgrCarry(state) + rawLine)
|
|
255
|
+
updateSgrState(rawLine, state)
|
|
256
|
+
} else {
|
|
257
|
+
wrapLine(rawLine, maxWidth, lines, state)
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
for (let i = startIdx; i < lines.length; i++) soft.push(i > startIdx)
|
|
140
261
|
}
|
|
141
262
|
|
|
142
|
-
return lines
|
|
263
|
+
return { lines, soft }
|
|
143
264
|
}
|
|
144
265
|
|
|
145
266
|
// generated from Unicode 15.1 EastAsianWidth=W/F + Emoji_Presentation=Yes
|