@trendr/core 0.2.10 → 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/src/wrap.js CHANGED
@@ -1,51 +1,90 @@
1
- // matches any ANSI escape sequence (CSI + OSC)
2
- const ANSI_RE = /\x1b\[[0-9;]*m/g
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 += (code >= 0x1100 && isWide(code)) ? 2 : 1
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
- // ANSI sequences with the character they precede
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' && str[i + 1] === '[') {
26
- const end = str.indexOf('m', i + 2)
55
+ if (str[i] === '\x1b') {
56
+ const end = ansiSeqEnd(str, i)
27
57
  if (end !== -1) {
28
- pending += str.slice(i, end + 1)
29
- i = end + 1
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
- return (code >= 0x1100 && isWide(code)) ? 2 : 1
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: w } of visibleChars(text)) {
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
- function extractTrailingAnsi(str) {
57
- let active = ''
58
- const re = /\x1b\[[0-9;]*m/g
59
- let m
60
- while ((m = re.exec(str)) !== null) {
61
- if (m[0] === '\x1b[0m') active = ''
62
- else active += m[0]
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 active
112
+ return state
65
113
  }
66
114
 
67
- export function wordWrap(text, maxWidth) {
68
- if (maxWidth <= 0) return []
69
- if (!text) return ['']
70
-
71
- const lines = []
72
- let carry = ''
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
- for (const rawLine of text.split('\n')) {
75
- if (rawLine.length === 0) {
76
- lines.push(carry || '')
77
- continue
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
- const prefixed = carry ? carry + rawLine : rawLine
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
- if (measureText(prefixed) <= maxWidth) {
83
- lines.push(prefixed)
84
- carry = extractTrailingAnsi(prefixed)
85
- continue
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
- const words = prefixed.split(/\s+/)
89
- let line = ''
90
- let lineWidth = 0
91
-
92
- for (const word of words) {
93
- if (!word) continue
94
- const ww = measureText(word)
95
-
96
- if (lineWidth === 0 && ww <= maxWidth) {
97
- line = word
98
- lineWidth = ww
99
- } else if (lineWidth === 0 && ww > maxWidth) {
100
- for (const { chunk, width } of visibleChars(word)) {
101
- if (lineWidth + width > maxWidth) {
102
- carry = extractTrailingAnsi(line)
103
- lines.push(line)
104
- line = carry
105
- lineWidth = 0
106
- }
107
- line += chunk
108
- lineWidth += width
109
- }
110
- } else if (lineWidth + 1 + ww <= maxWidth) {
111
- line += ' ' + word
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
- carry = extractTrailingAnsi(line)
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
- lines.push(line)
139
- carry = extractTrailingAnsi(line)
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