@silvery/term 0.3.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/package.json +54 -0
- package/src/adapters/canvas-adapter.ts +356 -0
- package/src/adapters/dom-adapter.ts +452 -0
- package/src/adapters/flexily-zero-adapter.ts +368 -0
- package/src/adapters/terminal-adapter.ts +305 -0
- package/src/adapters/yoga-adapter.ts +370 -0
- package/src/ansi/ansi.ts +251 -0
- package/src/ansi/constants.ts +76 -0
- package/src/ansi/detection.ts +441 -0
- package/src/ansi/hyperlink.ts +38 -0
- package/src/ansi/index.ts +201 -0
- package/src/ansi/patch-console.ts +159 -0
- package/src/ansi/sgr-codes.ts +34 -0
- package/src/ansi/storybook.ts +209 -0
- package/src/ansi/term.ts +724 -0
- package/src/ansi/types.ts +202 -0
- package/src/ansi/underline.ts +156 -0
- package/src/ansi/utils.ts +65 -0
- package/src/ansi-sanitize.ts +509 -0
- package/src/app.ts +571 -0
- package/src/bound-term.ts +94 -0
- package/src/bracketed-paste.ts +75 -0
- package/src/browser-renderer.ts +174 -0
- package/src/buffer.ts +1984 -0
- package/src/clipboard.ts +74 -0
- package/src/cursor-query.ts +85 -0
- package/src/device-attrs.ts +228 -0
- package/src/devtools.ts +123 -0
- package/src/dom/index.ts +194 -0
- package/src/errors.ts +39 -0
- package/src/focus-reporting.ts +48 -0
- package/src/hit-registry-core.ts +228 -0
- package/src/hit-registry.ts +176 -0
- package/src/index.ts +458 -0
- package/src/input.ts +119 -0
- package/src/inspector.ts +155 -0
- package/src/kitty-detect.ts +95 -0
- package/src/kitty-manager.ts +160 -0
- package/src/layout-engine.ts +296 -0
- package/src/layout.ts +26 -0
- package/src/measurer.ts +74 -0
- package/src/mode-query.ts +106 -0
- package/src/mouse-events.ts +419 -0
- package/src/mouse.ts +83 -0
- package/src/non-tty.ts +223 -0
- package/src/osc-markers.ts +32 -0
- package/src/osc-palette.ts +169 -0
- package/src/output.ts +406 -0
- package/src/pane-manager.ts +248 -0
- package/src/pipeline/CLAUDE.md +587 -0
- package/src/pipeline/content-phase-adapter.ts +976 -0
- package/src/pipeline/content-phase.ts +1765 -0
- package/src/pipeline/helpers.ts +42 -0
- package/src/pipeline/index.ts +416 -0
- package/src/pipeline/layout-phase.ts +686 -0
- package/src/pipeline/measure-phase.ts +198 -0
- package/src/pipeline/measure-stats.ts +21 -0
- package/src/pipeline/output-phase.ts +2593 -0
- package/src/pipeline/render-box.ts +343 -0
- package/src/pipeline/render-helpers.ts +243 -0
- package/src/pipeline/render-text.ts +1255 -0
- package/src/pipeline/types.ts +161 -0
- package/src/pipeline.ts +29 -0
- package/src/pixel-size.ts +119 -0
- package/src/render-adapter.ts +179 -0
- package/src/renderer.ts +1330 -0
- package/src/runtime/create-app.tsx +1845 -0
- package/src/runtime/create-buffer.ts +18 -0
- package/src/runtime/create-runtime.ts +325 -0
- package/src/runtime/diff.ts +56 -0
- package/src/runtime/event-handlers.ts +254 -0
- package/src/runtime/index.ts +119 -0
- package/src/runtime/keys.ts +8 -0
- package/src/runtime/layout.ts +164 -0
- package/src/runtime/run.tsx +318 -0
- package/src/runtime/term-provider.ts +399 -0
- package/src/runtime/terminal-lifecycle.ts +246 -0
- package/src/runtime/tick.ts +219 -0
- package/src/runtime/types.ts +210 -0
- package/src/scheduler.ts +723 -0
- package/src/screenshot.ts +57 -0
- package/src/scroll-region.ts +69 -0
- package/src/scroll-utils.ts +97 -0
- package/src/term-def.ts +267 -0
- package/src/terminal-caps.ts +5 -0
- package/src/terminal-colors.ts +216 -0
- package/src/termtest.ts +224 -0
- package/src/text-sizing.ts +109 -0
- package/src/toolbelt/index.ts +72 -0
- package/src/unicode.ts +1763 -0
- package/src/xterm/index.ts +491 -0
- package/src/xterm/xterm-provider.ts +204 -0
package/src/unicode.ts
ADDED
|
@@ -0,0 +1,1763 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unicode handling for Silvery.
|
|
3
|
+
*
|
|
4
|
+
* Uses Intl.Segmenter for proper grapheme cluster segmentation and
|
|
5
|
+
* string-width for accurate terminal width calculation.
|
|
6
|
+
*
|
|
7
|
+
* Key concepts:
|
|
8
|
+
* - Grapheme: A user-perceived character (may be multiple code points)
|
|
9
|
+
* - Display width: How many terminal columns a character occupies (0, 1, or 2)
|
|
10
|
+
* - Wide characters: CJK ideographs, emoji, etc. that take 2 columns
|
|
11
|
+
* - Combining characters: Diacritics, emoji modifiers that take 0 columns
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { BG_OVERRIDE_CODE } from "./ansi/index"
|
|
15
|
+
import sliceAnsi from "slice-ansi"
|
|
16
|
+
import stringWidth from "string-width"
|
|
17
|
+
import { type Cell, type Style, type TerminalBuffer, type UnderlineStyle, createMutableCell } from "./buffer"
|
|
18
|
+
import { isPrivateUseArea } from "./text-sizing"
|
|
19
|
+
|
|
20
|
+
// Re-export for consumers of silvery
|
|
21
|
+
export { BG_OVERRIDE_CODE }
|
|
22
|
+
|
|
23
|
+
// ============================================================================
|
|
24
|
+
// Grapheme Segmentation
|
|
25
|
+
// ============================================================================
|
|
26
|
+
|
|
27
|
+
// Singleton Intl.Segmenter instance (stateless, reusable)
|
|
28
|
+
const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" })
|
|
29
|
+
|
|
30
|
+
// ============================================================================
|
|
31
|
+
// Performance: LRU Cache for displayWidth
|
|
32
|
+
// ============================================================================
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Simple LRU cache for displayWidth results.
|
|
36
|
+
* String width calculation is expensive (~8us for ASCII text),
|
|
37
|
+
* but the same strings are often measured repeatedly.
|
|
38
|
+
*/
|
|
39
|
+
class DisplayWidthCache {
|
|
40
|
+
private cache = new Map<string, number>()
|
|
41
|
+
private maxSize: number
|
|
42
|
+
|
|
43
|
+
constructor(maxSize = 1000) {
|
|
44
|
+
this.maxSize = maxSize
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
get(text: string): number | undefined {
|
|
48
|
+
const cached = this.cache.get(text)
|
|
49
|
+
if (cached !== undefined) {
|
|
50
|
+
// Move to end (most recently used)
|
|
51
|
+
this.cache.delete(text)
|
|
52
|
+
this.cache.set(text, cached)
|
|
53
|
+
}
|
|
54
|
+
return cached
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
set(text: string, width: number): void {
|
|
58
|
+
// Evict oldest if at capacity
|
|
59
|
+
if (this.cache.size >= this.maxSize) {
|
|
60
|
+
const firstKey = this.cache.keys().next().value
|
|
61
|
+
if (firstKey !== undefined) {
|
|
62
|
+
this.cache.delete(firstKey)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
this.cache.set(text, width)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
clear(): void {
|
|
69
|
+
this.cache.clear()
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Cache size: 10K entries should be enough for most TUI apps
|
|
74
|
+
// Each entry is a string key + number value, ~100 bytes, so 10K = ~1MB
|
|
75
|
+
const displayWidthCache = new DisplayWidthCache(10000)
|
|
76
|
+
|
|
77
|
+
// ============================================================================
|
|
78
|
+
// Text Sizing Protocol (OSC 66) State
|
|
79
|
+
// ============================================================================
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Default text-presentation emoji width: true (modern terminal assumption).
|
|
83
|
+
* Modern terminals (Ghostty, iTerm, Kitty) render text-presentation emoji
|
|
84
|
+
* as 2-wide. Terminal.app renders them as 1-wide.
|
|
85
|
+
*/
|
|
86
|
+
const DEFAULT_TEXT_EMOJI_WIDE = true
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Default text sizing mode: false.
|
|
90
|
+
* When enabled via a Measurer, PUA characters are treated as 2-wide.
|
|
91
|
+
*/
|
|
92
|
+
const DEFAULT_TEXT_SIZING_ENABLED = false
|
|
93
|
+
|
|
94
|
+
// ============================================================================
|
|
95
|
+
// Scoped Measurer (pipeline execution context)
|
|
96
|
+
// ============================================================================
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Active measurer for the current pipeline execution.
|
|
100
|
+
* Set by runWithMeasurer() during executeRender, restored after.
|
|
101
|
+
* When null, module-level functions use the lazy default measurer.
|
|
102
|
+
*/
|
|
103
|
+
let _scopedMeasurer: WidthMeasurer | null = null
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Run a function with a specific measurer as the active scope.
|
|
107
|
+
* Module-level convenience functions (graphemeWidth, displayWidth, etc.)
|
|
108
|
+
* will use this measurer instead of the lazy default for the duration.
|
|
109
|
+
*/
|
|
110
|
+
export function runWithMeasurer<T>(measurer: WidthMeasurer, fn: () => T): T {
|
|
111
|
+
const prev = _scopedMeasurer
|
|
112
|
+
_scopedMeasurer = measurer
|
|
113
|
+
try {
|
|
114
|
+
return fn()
|
|
115
|
+
} finally {
|
|
116
|
+
_scopedMeasurer = prev
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* @deprecated Use createWidthMeasurer() with { textEmojiWide } instead.
|
|
122
|
+
* Kept for backward compatibility but is a no-op.
|
|
123
|
+
*/
|
|
124
|
+
export function setTextEmojiWide(_wide: boolean): void {
|
|
125
|
+
// No-op: use createWidthMeasurer() with { textEmojiWide } instead
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* @deprecated Use createWidthMeasurer() with { textSizingEnabled } instead.
|
|
130
|
+
* Kept for backward compatibility but is a no-op.
|
|
131
|
+
*/
|
|
132
|
+
export function setTextSizingEnabled(_enabled: boolean): void {
|
|
133
|
+
// No-op: use createWidthMeasurer() with { textSizingEnabled } instead
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Check if text sizing mode is currently enabled.
|
|
138
|
+
* Returns the default (false) since globals have been removed.
|
|
139
|
+
* Use measurer.textSizingEnabled for scoped queries.
|
|
140
|
+
*/
|
|
141
|
+
export function isTextSizingEnabled(): boolean {
|
|
142
|
+
if (_scopedMeasurer) return _scopedMeasurer.textSizingEnabled
|
|
143
|
+
return DEFAULT_TEXT_SIZING_ENABLED
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ============================================================================
|
|
147
|
+
// Width Measurer (per-term instance, no globals)
|
|
148
|
+
// ============================================================================
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Width measurement functions scoped to specific terminal capabilities.
|
|
152
|
+
* Created by createWidthMeasurer() from TerminalCaps.
|
|
153
|
+
*/
|
|
154
|
+
export interface Measurer {
|
|
155
|
+
readonly textEmojiWide: boolean
|
|
156
|
+
readonly textSizingEnabled: boolean
|
|
157
|
+
displayWidth(text: string): number
|
|
158
|
+
displayWidthAnsi(text: string): number
|
|
159
|
+
graphemeWidth(grapheme: string): number
|
|
160
|
+
wrapText(text: string, width: number, trim?: boolean, hard?: boolean): string[]
|
|
161
|
+
sliceByWidth(text: string, maxWidth: number): string
|
|
162
|
+
sliceByWidthFromEnd(text: string, maxWidth: number): string
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Backward-compatible alias for Measurer. */
|
|
166
|
+
export type WidthMeasurer = Measurer
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Strip OSC 8 hyperlink sequences before passing to slice-ansi.
|
|
170
|
+
* slice-ansi doesn't understand OSC sequences and corrupts them.
|
|
171
|
+
*/
|
|
172
|
+
const OSC8_RE = /\x1b\]8;;[^\x07\x1b]*(?:\x07|\x1b\\)/g
|
|
173
|
+
function stripOsc8ForSlice(text: string): string {
|
|
174
|
+
return text.replace(OSC8_RE, "")
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Create a width measurer scoped to terminal capabilities.
|
|
179
|
+
* Each measurer has its own caches (no shared global state).
|
|
180
|
+
*/
|
|
181
|
+
export function createWidthMeasurer(caps: { textEmojiWide?: boolean; textSizingEnabled?: boolean } = {}): Measurer {
|
|
182
|
+
const textEmojiWide = caps.textEmojiWide ?? true
|
|
183
|
+
const textSizingEnabled = caps.textSizingEnabled ?? false
|
|
184
|
+
const cache = new DisplayWidthCache(10000)
|
|
185
|
+
|
|
186
|
+
function measuredGraphemeWidth(grapheme: string): number {
|
|
187
|
+
const width = stringWidth(grapheme)
|
|
188
|
+
if (width !== 1) return width
|
|
189
|
+
if (textEmojiWide && isTextPresentationEmoji(grapheme)) return 2
|
|
190
|
+
if (textSizingEnabled) {
|
|
191
|
+
const cp = grapheme.codePointAt(0)
|
|
192
|
+
if (cp !== undefined && isPrivateUseArea(cp)) return 2
|
|
193
|
+
}
|
|
194
|
+
return width
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function measuredDisplayWidth(text: string): number {
|
|
198
|
+
const cached = cache.get(text)
|
|
199
|
+
if (cached !== undefined) return cached
|
|
200
|
+
|
|
201
|
+
let width: number
|
|
202
|
+
const needsSlowPath = MAY_CONTAIN_TEXT_EMOJI.test(text) || (textSizingEnabled && MAY_CONTAIN_PUA.test(text))
|
|
203
|
+
if (!needsSlowPath) {
|
|
204
|
+
width = stringWidth(text)
|
|
205
|
+
} else {
|
|
206
|
+
const stripped = stripAnsi(text)
|
|
207
|
+
width = 0
|
|
208
|
+
for (const grapheme of splitGraphemes(stripped)) {
|
|
209
|
+
width += measuredGraphemeWidth(grapheme)
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
cache.set(text, width)
|
|
213
|
+
return width
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function measuredDisplayWidthAnsi(text: string): number {
|
|
217
|
+
return measuredDisplayWidth(stripAnsi(text))
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function measuredSliceByWidth(text: string, maxWidth: number): string {
|
|
221
|
+
if (hasAnsi(text)) {
|
|
222
|
+
return sliceAnsi(stripOsc8ForSlice(text), 0, maxWidth)
|
|
223
|
+
}
|
|
224
|
+
let width = 0
|
|
225
|
+
let result = ""
|
|
226
|
+
const graphemes = splitGraphemes(text)
|
|
227
|
+
for (const grapheme of graphemes) {
|
|
228
|
+
const gWidth = measuredGraphemeWidth(grapheme)
|
|
229
|
+
if (width + gWidth > maxWidth) break
|
|
230
|
+
result += grapheme
|
|
231
|
+
width += gWidth
|
|
232
|
+
}
|
|
233
|
+
return result
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function measuredSliceByWidthFromEnd(text: string, maxWidth: number): string {
|
|
237
|
+
const totalWidth = measuredDisplayWidthAnsi(text)
|
|
238
|
+
if (totalWidth <= maxWidth) return text
|
|
239
|
+
if (hasAnsi(text)) {
|
|
240
|
+
const cleaned = stripOsc8ForSlice(text)
|
|
241
|
+
const cleanedWidth = measuredDisplayWidthAnsi(cleaned)
|
|
242
|
+
const startIndex = cleanedWidth - maxWidth
|
|
243
|
+
return sliceAnsi(cleaned, startIndex)
|
|
244
|
+
}
|
|
245
|
+
const graphemes = splitGraphemes(text)
|
|
246
|
+
let width = 0
|
|
247
|
+
let startIdx = graphemes.length
|
|
248
|
+
for (let i = graphemes.length - 1; i >= 0; i--) {
|
|
249
|
+
const gWidth = measuredGraphemeWidth(graphemes[i]!)
|
|
250
|
+
if (width + gWidth > maxWidth) break
|
|
251
|
+
width += gWidth
|
|
252
|
+
startIdx = i
|
|
253
|
+
}
|
|
254
|
+
return graphemes.slice(startIdx).join("")
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function measuredWrapText(text: string, width: number, trim?: boolean, hard?: boolean): string[] {
|
|
258
|
+
return wrapTextWithMeasurer(text, width, measurer, trim ?? false, hard ?? false)
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const measurer: Measurer = {
|
|
262
|
+
textEmojiWide,
|
|
263
|
+
textSizingEnabled,
|
|
264
|
+
displayWidth: measuredDisplayWidth,
|
|
265
|
+
displayWidthAnsi: measuredDisplayWidthAnsi,
|
|
266
|
+
graphemeWidth: measuredGraphemeWidth,
|
|
267
|
+
wrapText: measuredWrapText,
|
|
268
|
+
sliceByWidth: measuredSliceByWidth,
|
|
269
|
+
sliceByWidthFromEnd: measuredSliceByWidthFromEnd,
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
return measurer
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Alias for createWidthMeasurer. */
|
|
276
|
+
export const createMeasurer = createWidthMeasurer
|
|
277
|
+
|
|
278
|
+
// ============================================================================
|
|
279
|
+
// Default Measurer (lazy singleton for module-level convenience functions)
|
|
280
|
+
// ============================================================================
|
|
281
|
+
|
|
282
|
+
let _defaultMeasurer: Measurer | undefined
|
|
283
|
+
|
|
284
|
+
/** Get the default measurer (lazy init, uses default caps). */
|
|
285
|
+
function getDefaultMeasurer(): Measurer {
|
|
286
|
+
if (!_defaultMeasurer) {
|
|
287
|
+
_defaultMeasurer = createWidthMeasurer()
|
|
288
|
+
}
|
|
289
|
+
return _defaultMeasurer
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* @deprecated Use createWidthMeasurer() and pass the measurer explicitly.
|
|
294
|
+
* Kept as a no-op for backward compatibility.
|
|
295
|
+
*/
|
|
296
|
+
export function withMeasurer<T>(_measurer: WidthMeasurer, fn: () => T): T {
|
|
297
|
+
return fn()
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Split a string into grapheme clusters.
|
|
302
|
+
* Each grapheme is a user-perceived character that may consist of
|
|
303
|
+
* multiple Unicode code points.
|
|
304
|
+
*
|
|
305
|
+
* Examples:
|
|
306
|
+
* - "cafe\u0301" (café with combining accent) -> ["c", "a", "f", "e\u0301"]
|
|
307
|
+
* - "👨👩👧" (family emoji) -> ["👨👩👧"]
|
|
308
|
+
* - "한국어" -> ["한", "국", "어"]
|
|
309
|
+
*/
|
|
310
|
+
export function splitGraphemes(text: string): string[] {
|
|
311
|
+
return [...segmenter.segment(text)].map((s) => s.segment)
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Count the number of graphemes in a string.
|
|
316
|
+
*/
|
|
317
|
+
export function graphemeCount(text: string): number {
|
|
318
|
+
let count = 0
|
|
319
|
+
for (const _ of segmenter.segment(text)) count++
|
|
320
|
+
return count
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// ============================================================================
|
|
324
|
+
// Emoji Width Correction
|
|
325
|
+
// ============================================================================
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Regex for Extended_Pictographic characters that have default text presentation.
|
|
329
|
+
* These characters are reported as width 1 by string-width (per Unicode EAW),
|
|
330
|
+
* but most modern terminals render them as 2 columns wide using emoji glyphs.
|
|
331
|
+
*
|
|
332
|
+
* Specifically: Extended_Pictographic AND NOT Emoji_Presentation.
|
|
333
|
+
* Examples: ⚠ (U+26A0), ☑ (U+2611), ✈ (U+2708), ❤ (U+2764)
|
|
334
|
+
* Counter-examples: 📁 (U+1F4C1) has Emoji_Presentation so string-width is correct.
|
|
335
|
+
*
|
|
336
|
+
* Uses the RGI_Emoji regex with VS16 to detect characters that support
|
|
337
|
+
* emoji presentation -- if char+VS16 is RGI emoji, the terminal likely
|
|
338
|
+
* renders the bare char as 2-wide.
|
|
339
|
+
*/
|
|
340
|
+
const TEXT_PRESENTATION_EMOJI_REGEX = /^\p{Extended_Pictographic}$/u
|
|
341
|
+
const EMOJI_PRESENTATION_REGEX = /^\p{Emoji_Presentation}$/u
|
|
342
|
+
// @ts-expect-error -- RGI_Emoji v flag needs es2024 target but works at runtime
|
|
343
|
+
const RGI_EMOJI_REGEX = /^\p{RGI_Emoji}$/v
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Cache for isTextPresentationEmoji results.
|
|
347
|
+
* Maps first code point to boolean.
|
|
348
|
+
*/
|
|
349
|
+
const textPresentationEmojiCache = new Map<number, boolean>()
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Check if a grapheme is a text-presentation emoji that terminals render wide.
|
|
353
|
+
*
|
|
354
|
+
* Returns true for characters that are Extended_Pictographic, do NOT have
|
|
355
|
+
* the Emoji_Presentation property, but become RGI emoji when followed by
|
|
356
|
+
* VS16 (U+FE0F). These characters are rendered as 2 columns in most
|
|
357
|
+
* modern terminals despite string-width reporting width 1.
|
|
358
|
+
*/
|
|
359
|
+
export function isTextPresentationEmoji(grapheme: string): boolean {
|
|
360
|
+
const cp = grapheme.codePointAt(0)
|
|
361
|
+
if (cp === undefined) return false
|
|
362
|
+
|
|
363
|
+
// Check cache
|
|
364
|
+
const cached = textPresentationEmojiCache.get(cp)
|
|
365
|
+
if (cached !== undefined) return cached
|
|
366
|
+
|
|
367
|
+
// Multi-codepoint graphemes (with VS16, ZWJ, etc.) are already handled
|
|
368
|
+
// correctly by string-width. Only check single-codepoint graphemes.
|
|
369
|
+
const singleChar = String.fromCodePoint(cp)
|
|
370
|
+
if (singleChar.length !== grapheme.length) {
|
|
371
|
+
textPresentationEmojiCache.set(cp, false)
|
|
372
|
+
return false
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// Must be Extended_Pictographic but NOT Emoji_Presentation
|
|
376
|
+
const isExtPict = TEXT_PRESENTATION_EMOJI_REGEX.test(grapheme)
|
|
377
|
+
const isEmojiPres = EMOJI_PRESENTATION_REGEX.test(grapheme)
|
|
378
|
+
if (!isExtPict || isEmojiPres) {
|
|
379
|
+
textPresentationEmojiCache.set(cp, false)
|
|
380
|
+
return false
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// Check if adding VS16 makes it an RGI emoji sequence
|
|
384
|
+
const withVs16 = grapheme + "\uFE0F"
|
|
385
|
+
const result = RGI_EMOJI_REGEX.test(withVs16)
|
|
386
|
+
textPresentationEmojiCache.set(cp, result)
|
|
387
|
+
return result
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// ============================================================================
|
|
391
|
+
// Private Use Area (PUA) — Nerdfont / Powerline Icons
|
|
392
|
+
// ============================================================================
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Append VS16 (U+FE0F) to emoji characters that have default text presentation.
|
|
396
|
+
*
|
|
397
|
+
* Use this to normalize icon characters for consistent terminal rendering.
|
|
398
|
+
* Characters that already have emoji presentation or VS16 are returned unchanged.
|
|
399
|
+
*
|
|
400
|
+
* @example
|
|
401
|
+
* ```ts
|
|
402
|
+
* ensureEmojiPresentation('⚠') // '⚠\uFE0F' (⚠️)
|
|
403
|
+
* ensureEmojiPresentation('☑') // '☑\uFE0F' (☑️)
|
|
404
|
+
* ensureEmojiPresentation('☐') // '☐' (unchanged, not an emoji)
|
|
405
|
+
* ensureEmojiPresentation('📁') // '📁' (unchanged, already emoji presentation)
|
|
406
|
+
* ```
|
|
407
|
+
*/
|
|
408
|
+
export function ensureEmojiPresentation(char: string): string {
|
|
409
|
+
if (char.includes("\uFE0F")) return char // Already has VS16
|
|
410
|
+
if (isTextPresentationEmoji(char)) return char + "\uFE0F"
|
|
411
|
+
return char
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// ============================================================================
|
|
415
|
+
// Display Width Calculation
|
|
416
|
+
// ============================================================================
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Regex to detect strings that MAY contain text-presentation emoji.
|
|
420
|
+
* Used as a fast pre-check before the more expensive grapheme-based calculation.
|
|
421
|
+
* Covers the Unicode blocks where Extended_Pictographic characters live:
|
|
422
|
+
* - Miscellaneous Technical (U+2300-U+23FF)
|
|
423
|
+
* - Miscellaneous Symbols (U+2600-U+26FF)
|
|
424
|
+
* - Dingbats (U+2700-U+27BF)
|
|
425
|
+
* - Miscellaneous Symbols and Arrows (U+2B00-U+2BFF)
|
|
426
|
+
* - Other scattered ranges
|
|
427
|
+
*/
|
|
428
|
+
const MAY_CONTAIN_TEXT_EMOJI =
|
|
429
|
+
/[\u203C\u2049\u2122\u2139\u2194-\u2199\u21A9\u21AA\u2328\u23CF\u23ED-\u23EF\u23F1\u23F2\u23F8-\u23FA\u25AA\u25AB\u25B6\u25C0\u25FB-\u25FE\u2600-\u2604\u260E\u2611\u2614\u2615\u2618\u261D\u2620\u2622\u2623\u2626\u262A\u262E\u262F\u2638-\u263A\u2640\u2642\u2648-\u2653\u265F\u2660\u2663\u2665\u2666\u2668\u267B\u267E\u267F\u2692-\u2697\u2699\u269B\u269C\u26A0\u26A1\u26A7\u26AA\u26AB\u26B0\u26B1\u26BD\u26BE\u26C4\u26C5\u26C8\u26CE\u26CF\u26D1\u26D3\u26D4\u26E9\u26EA\u26F0-\u26F5\u26F7-\u26FA\u26FD\u2702\u2705\u2708-\u270D\u270F\u2712\u2714\u2716\u271D\u2721\u2728\u2733\u2734\u2744\u2747\u274C\u274E\u2753-\u2755\u2757\u2763\u2764\u2795-\u2797\u27A1\u27B0\u27BF\u2934\u2935\u2B05-\u2B07\u2B1B\u2B1C\u2B50\u2B55\u3030\u303D\u3297\u3299]/
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Fast pre-check regex for BMP Private Use Area characters (U+E000-U+F8FF).
|
|
433
|
+
* Used to gate the slow grapheme-by-grapheme path when text sizing is enabled.
|
|
434
|
+
*/
|
|
435
|
+
const MAY_CONTAIN_PUA = /[\uE000-\uF8FF]/
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* Get the display width of a string (number of terminal columns).
|
|
439
|
+
* Uses string-width which handles:
|
|
440
|
+
* - Wide characters (CJK) -> 2 columns
|
|
441
|
+
* - Regular ASCII -> 1 column
|
|
442
|
+
* - Zero-width characters (combining, ZWJ) -> 0 columns
|
|
443
|
+
* - Emoji -> varies (1 or 2)
|
|
444
|
+
* - ANSI escape sequences -> 0 columns (stripped)
|
|
445
|
+
*
|
|
446
|
+
* Corrects string-width for text-presentation emoji characters
|
|
447
|
+
* (e.g., ⚠ U+26A0) that terminals render as 2 columns wide.
|
|
448
|
+
*
|
|
449
|
+
* Results are cached for performance.
|
|
450
|
+
*/
|
|
451
|
+
export function displayWidth(text: string): number {
|
|
452
|
+
if (_scopedMeasurer) return _scopedMeasurer.displayWidth(text)
|
|
453
|
+
// Check cache first
|
|
454
|
+
const cached = displayWidthCache.get(text)
|
|
455
|
+
if (cached !== undefined) {
|
|
456
|
+
return cached
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
let width: number
|
|
460
|
+
// Fast path: if text cannot contain text-presentation emoji, use string-width directly.
|
|
461
|
+
// Default measurer does not enable text sizing, so PUA check uses the constant default.
|
|
462
|
+
const needsSlowPath = MAY_CONTAIN_TEXT_EMOJI.test(text) || (DEFAULT_TEXT_SIZING_ENABLED && MAY_CONTAIN_PUA.test(text))
|
|
463
|
+
if (!needsSlowPath) {
|
|
464
|
+
width = stringWidth(text)
|
|
465
|
+
} else {
|
|
466
|
+
// Slow path: strip ANSI codes first (they'd inflate the grapheme count),
|
|
467
|
+
// then split into graphemes and sum corrected widths
|
|
468
|
+
const stripped = stripAnsi(text)
|
|
469
|
+
width = 0
|
|
470
|
+
for (const grapheme of splitGraphemes(stripped)) {
|
|
471
|
+
width += graphemeWidth(grapheme)
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
displayWidthCache.set(text, width)
|
|
476
|
+
return width
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* Get the display width of a single grapheme.
|
|
481
|
+
*
|
|
482
|
+
* Overrides string-width for characters that are Extended_Pictographic with
|
|
483
|
+
* default text presentation. These characters (e.g., ⚠ U+26A0, ☑ U+2611)
|
|
484
|
+
* are reported as width 1 by string-width (per Unicode EAW tables), but most
|
|
485
|
+
* modern terminals render them as 2 columns wide using emoji glyphs.
|
|
486
|
+
*
|
|
487
|
+
* The mismatch causes text after these characters to be placed at the wrong
|
|
488
|
+
* column, leading to truncation or overlap.
|
|
489
|
+
*/
|
|
490
|
+
export function graphemeWidth(grapheme: string): number {
|
|
491
|
+
if (_scopedMeasurer) return _scopedMeasurer.graphemeWidth(grapheme)
|
|
492
|
+
const width = stringWidth(grapheme)
|
|
493
|
+
// If string-width already says 2 (or 0), trust it
|
|
494
|
+
if (width !== 1) return width
|
|
495
|
+
// Check if this is a text-presentation emoji that terminals render wide.
|
|
496
|
+
// Uses DEFAULT_TEXT_EMOJI_WIDE (true) — assumes modern terminal.
|
|
497
|
+
if (DEFAULT_TEXT_EMOJI_WIDE && isTextPresentationEmoji(grapheme)) return 2
|
|
498
|
+
// Default module-level function does not enable text sizing.
|
|
499
|
+
// Scoped measurers handle PUA via their own graphemeWidth.
|
|
500
|
+
if (DEFAULT_TEXT_SIZING_ENABLED) {
|
|
501
|
+
const cp = grapheme.codePointAt(0)
|
|
502
|
+
if (cp !== undefined && isPrivateUseArea(cp)) return 2
|
|
503
|
+
}
|
|
504
|
+
return width
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* Check if a grapheme is a wide character (takes 2 columns).
|
|
509
|
+
*/
|
|
510
|
+
export function isWideGrapheme(grapheme: string): boolean {
|
|
511
|
+
return graphemeWidth(grapheme) === 2
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* Check if a grapheme is zero-width (combining character, ZWJ, etc.).
|
|
516
|
+
*/
|
|
517
|
+
export function isZeroWidthGrapheme(grapheme: string): boolean {
|
|
518
|
+
return stringWidth(grapheme) === 0
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// ============================================================================
|
|
522
|
+
// Text Manipulation
|
|
523
|
+
// ============================================================================
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* Truncate a string to fit within a given display width.
|
|
527
|
+
* Handles wide characters and ANSI escape sequences (including OSC 8 hyperlinks) correctly.
|
|
528
|
+
*
|
|
529
|
+
* @param text - The text to truncate (may contain ANSI escape sequences)
|
|
530
|
+
* @param maxWidth - Maximum display width
|
|
531
|
+
* @param ellipsis - Ellipsis to append if truncated (default: "...")
|
|
532
|
+
* @returns Truncated string
|
|
533
|
+
*/
|
|
534
|
+
export function truncateText(
|
|
535
|
+
text: string,
|
|
536
|
+
maxWidth: number,
|
|
537
|
+
ellipsis = "\u2026", // Unicode ellipsis (single character)
|
|
538
|
+
): string {
|
|
539
|
+
const textWidth = displayWidth(text)
|
|
540
|
+
|
|
541
|
+
// No truncation needed
|
|
542
|
+
if (textWidth <= maxWidth) {
|
|
543
|
+
return text
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
const ellipsisWidth = displayWidth(ellipsis)
|
|
547
|
+
const targetWidth = maxWidth - ellipsisWidth
|
|
548
|
+
|
|
549
|
+
if (targetWidth <= 0) {
|
|
550
|
+
// Not enough space for even the ellipsis
|
|
551
|
+
return maxWidth > 0 ? ellipsis.slice(0, maxWidth) : ""
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// Use ANSI-aware grapheme splitting when text contains escape sequences
|
|
555
|
+
// (including OSC 8 hyperlinks) to avoid counting escape bytes as visible width.
|
|
556
|
+
const graphemes = hasAnsi(text) ? splitGraphemesAnsiAware(text) : splitGraphemes(text)
|
|
557
|
+
let result = ""
|
|
558
|
+
let currentWidth = 0
|
|
559
|
+
|
|
560
|
+
for (const grapheme of graphemes) {
|
|
561
|
+
const gWidth = graphemeWidth(grapheme)
|
|
562
|
+
if (currentWidth + gWidth > targetWidth) {
|
|
563
|
+
break
|
|
564
|
+
}
|
|
565
|
+
result += grapheme
|
|
566
|
+
currentWidth += gWidth
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
return result + ellipsis
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/**
|
|
573
|
+
* Pad a string to a given display width.
|
|
574
|
+
*
|
|
575
|
+
* @param text - The text to pad
|
|
576
|
+
* @param width - Target display width
|
|
577
|
+
* @param align - Alignment: 'left', 'right', or 'center'
|
|
578
|
+
* @param padChar - Character to use for padding (default: space)
|
|
579
|
+
* @returns Padded string
|
|
580
|
+
*/
|
|
581
|
+
export function padText(
|
|
582
|
+
text: string,
|
|
583
|
+
width: number,
|
|
584
|
+
align: "left" | "right" | "center" = "left",
|
|
585
|
+
padChar = " ",
|
|
586
|
+
): string {
|
|
587
|
+
const textWidth = displayWidth(text)
|
|
588
|
+
const padWidth = width - textWidth
|
|
589
|
+
|
|
590
|
+
if (padWidth <= 0) {
|
|
591
|
+
return text
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
const padCharWidth = displayWidth(padChar)
|
|
595
|
+
if (padCharWidth === 0) {
|
|
596
|
+
// Can't pad with zero-width characters
|
|
597
|
+
return text
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// Calculate number of pad characters needed
|
|
601
|
+
const padCount = Math.floor(padWidth / padCharWidth)
|
|
602
|
+
|
|
603
|
+
switch (align) {
|
|
604
|
+
case "left":
|
|
605
|
+
return text + padChar.repeat(padCount)
|
|
606
|
+
case "right":
|
|
607
|
+
return padChar.repeat(padCount) + text
|
|
608
|
+
case "center": {
|
|
609
|
+
const leftPad = Math.floor(padCount / 2)
|
|
610
|
+
const rightPad = padCount - leftPad
|
|
611
|
+
return padChar.repeat(leftPad) + text + padChar.repeat(rightPad)
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/**
|
|
617
|
+
* Constrain text to width and height limits.
|
|
618
|
+
* Combines wrapping and truncation to fit text in a box.
|
|
619
|
+
*
|
|
620
|
+
* @param text - Text to constrain (may contain ANSI codes)
|
|
621
|
+
* @param width - Maximum display width per line
|
|
622
|
+
* @param maxLines - Maximum number of lines
|
|
623
|
+
* @param pad - If true, pad lines to full width
|
|
624
|
+
* @param ellipsis - Custom ellipsis character (default: "…")
|
|
625
|
+
* @returns Object with lines array and truncated flag
|
|
626
|
+
*/
|
|
627
|
+
export function constrainText(
|
|
628
|
+
text: string,
|
|
629
|
+
width: number,
|
|
630
|
+
maxLines: number,
|
|
631
|
+
pad = false,
|
|
632
|
+
ellipsis = "…",
|
|
633
|
+
): { lines: string[]; truncated: boolean } {
|
|
634
|
+
const allLines = wrapText(text, width)
|
|
635
|
+
const truncated = allLines.length > maxLines
|
|
636
|
+
let lines = allLines.slice(0, maxLines)
|
|
637
|
+
|
|
638
|
+
if (truncated && lines.length > 0) {
|
|
639
|
+
const lastIdx = lines.length - 1
|
|
640
|
+
const lastLine = lines[lastIdx]
|
|
641
|
+
if (lastLine) {
|
|
642
|
+
const ellipsisLen = displayWidth(ellipsis)
|
|
643
|
+
const lastLineLen = displayWidth(lastLine)
|
|
644
|
+
if (lastLineLen + ellipsisLen <= width) {
|
|
645
|
+
lines[lastIdx] = lastLine + ellipsis
|
|
646
|
+
} else {
|
|
647
|
+
lines[lastIdx] = truncateText(lastLine, width, ellipsis)
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
if (pad) {
|
|
653
|
+
lines = lines.map((line) => padText(line, width))
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
return { lines, truncated }
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
/**
|
|
660
|
+
* Check if a grapheme is a word boundary character (space, hyphen, etc.)
|
|
661
|
+
*/
|
|
662
|
+
function isWordBoundary(grapheme: string): boolean {
|
|
663
|
+
// Common word boundary characters
|
|
664
|
+
return grapheme === " " || grapheme === "-" || grapheme === "\t"
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
/**
|
|
668
|
+
* Look ahead from a space to check if the next word is a single-character
|
|
669
|
+
* operator (like +, =, *, /, etc.) followed by another space. Breaking before
|
|
670
|
+
* such operators looks bad — e.g. "$12k\n+ $400" — so we suppress the break
|
|
671
|
+
* point to keep the operator with its left operand.
|
|
672
|
+
*
|
|
673
|
+
* Accepts an explicit graphemeWidth function so it works with both the
|
|
674
|
+
* module-level default and per-measurer instances.
|
|
675
|
+
*/
|
|
676
|
+
function isBreakBeforeOperatorWith(graphemes: string[], spaceIndex: number, gWidthFn: (g: string) => number): boolean {
|
|
677
|
+
// Look for pattern: [current space] [operator] [space]
|
|
678
|
+
// spaceIndex is the index of the current space in the graphemes array
|
|
679
|
+
let j = spaceIndex + 1
|
|
680
|
+
// Skip any zero-width characters (ANSI escapes)
|
|
681
|
+
while (j < graphemes.length && gWidthFn(graphemes[j]!) === 0) j++
|
|
682
|
+
if (j >= graphemes.length) return false
|
|
683
|
+
const nextChar = graphemes[j]!
|
|
684
|
+
// Must be a single visible character that is not alphanumeric or space
|
|
685
|
+
if (gWidthFn(nextChar) !== 1) return false
|
|
686
|
+
if (/^[a-zA-Z0-9\s]$/.test(nextChar)) return false
|
|
687
|
+
// Check that it's followed by a space (it's an infix operator, not a prefix)
|
|
688
|
+
let k = j + 1
|
|
689
|
+
while (k < graphemes.length && gWidthFn(graphemes[k]!) === 0) k++
|
|
690
|
+
if (k >= graphemes.length) return false
|
|
691
|
+
return graphemes[k] === " "
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
/**
|
|
695
|
+
* Check if a grapheme can break anywhere (CJK characters).
|
|
696
|
+
* CJK text doesn't use spaces between words, so any character boundary is valid.
|
|
697
|
+
*/
|
|
698
|
+
function canBreakAnywhere(grapheme: string): boolean {
|
|
699
|
+
return isCJK(grapheme)
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
// ANSI CSI pattern: ESC [ (params) (letter)
|
|
703
|
+
const ANSI_CSI_RE = /^\x1b\[[0-9;:?]*[A-Za-z]/
|
|
704
|
+
// ANSI OSC pattern: ESC ] ... (BEL or ST)
|
|
705
|
+
const ANSI_OSC_RE = /^\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/
|
|
706
|
+
// Single-char escape: ESC followed by one letter
|
|
707
|
+
const ANSI_SINGLE_RE = /^\x1b[DME78(B]/
|
|
708
|
+
|
|
709
|
+
/**
|
|
710
|
+
* Split text into graphemes, keeping ANSI escape sequences as single zero-width tokens.
|
|
711
|
+
* Without this, `splitGraphemes` would split `\x1b[38;5;1m` into individual characters
|
|
712
|
+
* like `[`, `3`, `8`, `;`, etc., each consuming display width.
|
|
713
|
+
*/
|
|
714
|
+
function splitGraphemesAnsiAware(text: string): string[] {
|
|
715
|
+
if (!hasAnsi(text)) {
|
|
716
|
+
return splitGraphemes(text)
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
const result: string[] = []
|
|
720
|
+
let pos = 0
|
|
721
|
+
|
|
722
|
+
while (pos < text.length) {
|
|
723
|
+
if (text[pos] === "\x1b") {
|
|
724
|
+
// Try to match an ANSI sequence starting at pos
|
|
725
|
+
const remaining = text.slice(pos)
|
|
726
|
+
const csi = remaining.match(ANSI_CSI_RE)
|
|
727
|
+
if (csi) {
|
|
728
|
+
result.push(csi[0])
|
|
729
|
+
pos += csi[0].length
|
|
730
|
+
continue
|
|
731
|
+
}
|
|
732
|
+
const osc = remaining.match(ANSI_OSC_RE)
|
|
733
|
+
if (osc) {
|
|
734
|
+
result.push(osc[0])
|
|
735
|
+
pos += osc[0].length
|
|
736
|
+
continue
|
|
737
|
+
}
|
|
738
|
+
const single = remaining.match(ANSI_SINGLE_RE)
|
|
739
|
+
if (single) {
|
|
740
|
+
result.push(single[0])
|
|
741
|
+
pos += single[0].length
|
|
742
|
+
continue
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
// Find the next ESC or end of string
|
|
747
|
+
const nextEsc = text.indexOf("\x1b", pos + 1)
|
|
748
|
+
const chunk = nextEsc === -1 ? text.slice(pos) : text.slice(pos, nextEsc)
|
|
749
|
+
|
|
750
|
+
// Split this non-ANSI chunk into graphemes
|
|
751
|
+
for (const g of splitGraphemes(chunk)) {
|
|
752
|
+
result.push(g)
|
|
753
|
+
}
|
|
754
|
+
pos += chunk.length
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
return result
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
/**
|
|
761
|
+
* Wrap text to fit within a given width.
|
|
762
|
+
*
|
|
763
|
+
* Implements word-boundary wrapping:
|
|
764
|
+
* 1. Breaks at word boundaries (spaces, hyphens) when possible
|
|
765
|
+
* 2. Falls back to character wrap only when necessary (very long words)
|
|
766
|
+
* 3. Handles CJK text properly (can break anywhere since CJK has no word spaces)
|
|
767
|
+
* 4. Preserves intentional line breaks
|
|
768
|
+
*
|
|
769
|
+
* @param text - The text to wrap (may contain ANSI escape sequences)
|
|
770
|
+
* @param width - Maximum display width per line
|
|
771
|
+
* @param preserveNewlines - Whether to preserve existing newlines
|
|
772
|
+
* @param trim - Trim trailing spaces on broken lines and skip leading spaces on continuation lines (useful for rendering)
|
|
773
|
+
* @returns Array of wrapped lines
|
|
774
|
+
*/
|
|
775
|
+
export function wrapText(text: string, width: number, preserveNewlines = true, trim = false): string[] {
|
|
776
|
+
return wrapTextWithMeasurer(text, width, _scopedMeasurer ?? undefined, trim, false, preserveNewlines)
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
/**
|
|
780
|
+
* Internal: wrap text using an explicit measurer for grapheme width calculations.
|
|
781
|
+
* When measurer is undefined, falls back to the module-level graphemeWidth.
|
|
782
|
+
*/
|
|
783
|
+
function wrapTextWithMeasurer(
|
|
784
|
+
text: string,
|
|
785
|
+
width: number,
|
|
786
|
+
measurer: Measurer | undefined,
|
|
787
|
+
trim = false,
|
|
788
|
+
_hard = false,
|
|
789
|
+
preserveNewlines = true,
|
|
790
|
+
): string[] {
|
|
791
|
+
if (width <= 0) {
|
|
792
|
+
return []
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
const gWidthFn = measurer ? measurer.graphemeWidth.bind(measurer) : graphemeWidth
|
|
796
|
+
|
|
797
|
+
const lines: string[] = []
|
|
798
|
+
|
|
799
|
+
// Split by newlines first if preserving
|
|
800
|
+
const inputLines = preserveNewlines ? text.split("\n") : [text.replace(/\n/g, " ")]
|
|
801
|
+
|
|
802
|
+
for (const line of inputLines) {
|
|
803
|
+
// Handle empty lines
|
|
804
|
+
if (line === "") {
|
|
805
|
+
lines.push("")
|
|
806
|
+
continue
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
// If the line contains ANSI escape sequences, split them out so they
|
|
810
|
+
// don't consume display width. We interleave visible graphemes with
|
|
811
|
+
// zero-width ANSI "tokens" that are appended to currentLine untouched.
|
|
812
|
+
const graphemes = splitGraphemesAnsiAware(line)
|
|
813
|
+
let currentLine = ""
|
|
814
|
+
let currentWidth = 0
|
|
815
|
+
let isFirstLineOfParagraph = true
|
|
816
|
+
|
|
817
|
+
// Track the last valid break point
|
|
818
|
+
let lastBreakIndex = -1 // Index in currentLine (character position)
|
|
819
|
+
let lastBreakWidth = 0 // Width at break point
|
|
820
|
+
let lastBreakGraphemeIndex = -1 // Index in graphemes array
|
|
821
|
+
|
|
822
|
+
for (let i = 0; i < graphemes.length; i++) {
|
|
823
|
+
const grapheme = graphemes[i]!
|
|
824
|
+
const gWidth = gWidthFn(grapheme)
|
|
825
|
+
|
|
826
|
+
// Handle zero-width characters
|
|
827
|
+
if (gWidth === 0) {
|
|
828
|
+
currentLine += grapheme
|
|
829
|
+
continue
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
// In trim mode, skip leading spaces on continuation lines
|
|
833
|
+
if (trim && !isFirstLineOfParagraph && currentWidth === 0 && isWordBoundary(grapheme) && grapheme !== "-") {
|
|
834
|
+
continue
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
// Check if this grapheme is a break point
|
|
838
|
+
// Break AFTER spaces/hyphens, or BEFORE CJK characters
|
|
839
|
+
if (isWordBoundary(grapheme)) {
|
|
840
|
+
// Include the boundary character, then mark as break point
|
|
841
|
+
if (currentWidth + gWidth <= width) {
|
|
842
|
+
currentLine += grapheme
|
|
843
|
+
currentWidth += gWidth
|
|
844
|
+
// Suppress break point if the next word is a lone operator (e.g. "+", "=")
|
|
845
|
+
// to avoid orphaning operators at the start of the next line.
|
|
846
|
+
if (grapheme !== " " || !isBreakBeforeOperatorWith(graphemes, i, gWidthFn)) {
|
|
847
|
+
lastBreakIndex = currentLine.length
|
|
848
|
+
lastBreakWidth = currentWidth
|
|
849
|
+
lastBreakGraphemeIndex = i + 1
|
|
850
|
+
}
|
|
851
|
+
continue
|
|
852
|
+
}
|
|
853
|
+
// Space/hyphen doesn't fit — break here (before the boundary char).
|
|
854
|
+
// The current line is complete; the boundary char is consumed as the break.
|
|
855
|
+
if (currentLine) {
|
|
856
|
+
let lineToAdd = currentLine
|
|
857
|
+
if (trim) lineToAdd = lineToAdd.trimEnd()
|
|
858
|
+
lines.push(lineToAdd)
|
|
859
|
+
isFirstLineOfParagraph = false
|
|
860
|
+
}
|
|
861
|
+
currentLine = ""
|
|
862
|
+
currentWidth = 0
|
|
863
|
+
lastBreakIndex = -1
|
|
864
|
+
lastBreakWidth = 0
|
|
865
|
+
lastBreakGraphemeIndex = -1
|
|
866
|
+
continue
|
|
867
|
+
} else if (canBreakAnywhere(grapheme)) {
|
|
868
|
+
// CJK: can break before this character
|
|
869
|
+
lastBreakIndex = currentLine.length
|
|
870
|
+
lastBreakWidth = currentWidth
|
|
871
|
+
lastBreakGraphemeIndex = i
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
// Would this grapheme overflow?
|
|
875
|
+
if (currentWidth + gWidth > width) {
|
|
876
|
+
if (lastBreakIndex > 0) {
|
|
877
|
+
// We have a valid break point - use it
|
|
878
|
+
let lineToAdd = currentLine.slice(0, lastBreakIndex)
|
|
879
|
+
if (trim) lineToAdd = lineToAdd.trimEnd()
|
|
880
|
+
lines.push(lineToAdd)
|
|
881
|
+
isFirstLineOfParagraph = false
|
|
882
|
+
|
|
883
|
+
// Reset and continue from break point
|
|
884
|
+
currentLine = currentLine.slice(lastBreakIndex)
|
|
885
|
+
currentWidth = currentWidth - lastBreakWidth
|
|
886
|
+
|
|
887
|
+
// Rewind to process graphemes after the break
|
|
888
|
+
i = lastBreakGraphemeIndex - 1
|
|
889
|
+
currentLine = ""
|
|
890
|
+
currentWidth = 0
|
|
891
|
+
lastBreakIndex = -1
|
|
892
|
+
lastBreakWidth = 0
|
|
893
|
+
lastBreakGraphemeIndex = -1
|
|
894
|
+
} else {
|
|
895
|
+
// No break point found - must do character wrap
|
|
896
|
+
if (currentLine) {
|
|
897
|
+
if (trim) currentLine = currentLine.trimEnd()
|
|
898
|
+
lines.push(currentLine)
|
|
899
|
+
isFirstLineOfParagraph = false
|
|
900
|
+
}
|
|
901
|
+
currentLine = grapheme
|
|
902
|
+
currentWidth = gWidth
|
|
903
|
+
lastBreakIndex = -1
|
|
904
|
+
lastBreakWidth = 0
|
|
905
|
+
lastBreakGraphemeIndex = -1
|
|
906
|
+
}
|
|
907
|
+
} else {
|
|
908
|
+
currentLine += grapheme
|
|
909
|
+
currentWidth += gWidth
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
// Push remaining content
|
|
914
|
+
if (currentLine) {
|
|
915
|
+
lines.push(currentLine)
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
return lines
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
/**
|
|
923
|
+
* Slice text by display width (from start).
|
|
924
|
+
* Returns the first `maxWidth` columns of text.
|
|
925
|
+
* Uses the default measurer for width calculations.
|
|
926
|
+
* Handles both ANSI-styled and plain text.
|
|
927
|
+
*
|
|
928
|
+
* @param text - The text to slice
|
|
929
|
+
* @param maxWidth - Maximum display width to keep from the start
|
|
930
|
+
* @returns Sliced string from the start
|
|
931
|
+
*/
|
|
932
|
+
export function sliceByWidth(text: string, maxWidth: number): string {
|
|
933
|
+
return (_scopedMeasurer ?? getDefaultMeasurer()).sliceByWidth(text, maxWidth)
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
/**
|
|
937
|
+
* Slice a string by display width range.
|
|
938
|
+
* Like string.slice() but works with display columns.
|
|
939
|
+
*
|
|
940
|
+
* @param text - The text to slice
|
|
941
|
+
* @param start - Start display column (inclusive)
|
|
942
|
+
* @param end - End display column (exclusive)
|
|
943
|
+
* @returns Sliced string
|
|
944
|
+
*/
|
|
945
|
+
export function sliceByWidthRange(text: string, start: number, end?: number): string {
|
|
946
|
+
const graphemes = splitGraphemes(text)
|
|
947
|
+
let result = ""
|
|
948
|
+
let currentCol = 0
|
|
949
|
+
const endCol = end ?? Number.POSITIVE_INFINITY
|
|
950
|
+
|
|
951
|
+
for (const grapheme of graphemes) {
|
|
952
|
+
const gWidth = graphemeWidth(grapheme)
|
|
953
|
+
|
|
954
|
+
// Haven't reached start yet
|
|
955
|
+
if (currentCol + gWidth <= start) {
|
|
956
|
+
currentCol += gWidth
|
|
957
|
+
continue
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
// Past the end
|
|
961
|
+
if (currentCol >= endCol) {
|
|
962
|
+
break
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
// This grapheme is at least partially in range
|
|
966
|
+
result += grapheme
|
|
967
|
+
currentCol += gWidth
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
return result
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
/**
|
|
974
|
+
* Slice text by display width from the end.
|
|
975
|
+
* Returns the last `maxWidth` columns of text.
|
|
976
|
+
* Uses the default measurer for width calculations.
|
|
977
|
+
*
|
|
978
|
+
* @param text - The text to slice
|
|
979
|
+
* @param maxWidth - Maximum display width to keep from the end
|
|
980
|
+
* @returns Sliced string from the end
|
|
981
|
+
*/
|
|
982
|
+
export function sliceByWidthFromEnd(text: string, maxWidth: number): string {
|
|
983
|
+
return (_scopedMeasurer ?? getDefaultMeasurer()).sliceByWidthFromEnd(text, maxWidth)
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
// ============================================================================
|
|
987
|
+
// Buffer Writing
|
|
988
|
+
// ============================================================================
|
|
989
|
+
|
|
990
|
+
/**
|
|
991
|
+
* Write styled text to a terminal buffer.
|
|
992
|
+
*
|
|
993
|
+
* Handles:
|
|
994
|
+
* - Multi-byte graphemes (emoji, combining characters)
|
|
995
|
+
* - Wide characters (CJK) that take 2 cells
|
|
996
|
+
* - Zero-width characters (appended to previous cell)
|
|
997
|
+
*
|
|
998
|
+
* @param buffer - The buffer to write to
|
|
999
|
+
* @param x - Starting column
|
|
1000
|
+
* @param y - Row
|
|
1001
|
+
* @param text - Text to write
|
|
1002
|
+
* @param style - Style to apply
|
|
1003
|
+
* @returns The ending column (x + display_width)
|
|
1004
|
+
*/
|
|
1005
|
+
export function writeTextToBuffer(
|
|
1006
|
+
buffer: TerminalBuffer,
|
|
1007
|
+
x: number,
|
|
1008
|
+
y: number,
|
|
1009
|
+
text: string,
|
|
1010
|
+
style: Style = { fg: null, bg: null, attrs: {} },
|
|
1011
|
+
): number {
|
|
1012
|
+
const graphemes = splitGraphemes(text)
|
|
1013
|
+
let col = x
|
|
1014
|
+
let combineCell: Cell | null = null
|
|
1015
|
+
|
|
1016
|
+
for (const grapheme of graphemes) {
|
|
1017
|
+
const width = graphemeWidth(grapheme)
|
|
1018
|
+
|
|
1019
|
+
if (width === 0) {
|
|
1020
|
+
// Zero-width character: combine with previous cell.
|
|
1021
|
+
// Use readCellInto to avoid allocating a fresh Cell on each combine.
|
|
1022
|
+
if (col > 0 && buffer.inBounds(col - 1, y)) {
|
|
1023
|
+
// Lazy-init reusable cell (zero-width combining is uncommon)
|
|
1024
|
+
combineCell ??= createMutableCell()
|
|
1025
|
+
buffer.readCellInto(col - 1, y, combineCell)
|
|
1026
|
+
combineCell.char = combineCell.char + grapheme
|
|
1027
|
+
buffer.setCell(col - 1, y, combineCell)
|
|
1028
|
+
}
|
|
1029
|
+
} else if (width === 1) {
|
|
1030
|
+
// Normal single-width character
|
|
1031
|
+
if (buffer.inBounds(col, y)) {
|
|
1032
|
+
buffer.setCell(col, y, {
|
|
1033
|
+
char: grapheme,
|
|
1034
|
+
fg: style.fg,
|
|
1035
|
+
bg: style.bg,
|
|
1036
|
+
attrs: style.attrs,
|
|
1037
|
+
wide: false,
|
|
1038
|
+
continuation: false,
|
|
1039
|
+
})
|
|
1040
|
+
}
|
|
1041
|
+
col++
|
|
1042
|
+
} else if (width === 2) {
|
|
1043
|
+
// Wide character: takes 2 cells
|
|
1044
|
+
// For text-presentation emoji, add VS16 so terminals render at 2 columns
|
|
1045
|
+
const outputChar = ensureEmojiPresentation(grapheme)
|
|
1046
|
+
if (buffer.inBounds(col, y)) {
|
|
1047
|
+
buffer.setCell(col, y, {
|
|
1048
|
+
char: outputChar,
|
|
1049
|
+
fg: style.fg,
|
|
1050
|
+
bg: style.bg,
|
|
1051
|
+
attrs: style.attrs,
|
|
1052
|
+
wide: true,
|
|
1053
|
+
continuation: false,
|
|
1054
|
+
})
|
|
1055
|
+
}
|
|
1056
|
+
if (buffer.inBounds(col + 1, y)) {
|
|
1057
|
+
buffer.setCell(col + 1, y, {
|
|
1058
|
+
char: "",
|
|
1059
|
+
fg: style.fg,
|
|
1060
|
+
bg: style.bg,
|
|
1061
|
+
attrs: style.attrs,
|
|
1062
|
+
wide: false,
|
|
1063
|
+
continuation: true,
|
|
1064
|
+
})
|
|
1065
|
+
}
|
|
1066
|
+
col += 2
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
// Stop if we've gone past the buffer edge
|
|
1070
|
+
if (col >= buffer.width) {
|
|
1071
|
+
break
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
return col
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
/**
|
|
1079
|
+
* Write styled text to a buffer with automatic truncation.
|
|
1080
|
+
*
|
|
1081
|
+
* @param buffer - The buffer to write to
|
|
1082
|
+
* @param x - Starting column
|
|
1083
|
+
* @param y - Row
|
|
1084
|
+
* @param text - Text to write
|
|
1085
|
+
* @param maxWidth - Maximum width (truncate if exceeded)
|
|
1086
|
+
* @param style - Style to apply
|
|
1087
|
+
* @param ellipsis - Ellipsis for truncated text
|
|
1088
|
+
*/
|
|
1089
|
+
export function writeTextTruncated(
|
|
1090
|
+
buffer: TerminalBuffer,
|
|
1091
|
+
x: number,
|
|
1092
|
+
y: number,
|
|
1093
|
+
text: string,
|
|
1094
|
+
maxWidth: number,
|
|
1095
|
+
style: Style = { fg: null, bg: null, attrs: {} },
|
|
1096
|
+
ellipsis = "\u2026",
|
|
1097
|
+
): void {
|
|
1098
|
+
const textWidth = displayWidth(text)
|
|
1099
|
+
|
|
1100
|
+
if (textWidth <= maxWidth) {
|
|
1101
|
+
writeTextToBuffer(buffer, x, y, text, style)
|
|
1102
|
+
} else {
|
|
1103
|
+
const truncated = truncateText(text, maxWidth, ellipsis)
|
|
1104
|
+
writeTextToBuffer(buffer, x, y, truncated, style)
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
/**
|
|
1109
|
+
* Write multiple lines of styled text to a buffer.
|
|
1110
|
+
*
|
|
1111
|
+
* @param buffer - The buffer to write to
|
|
1112
|
+
* @param x - Starting column
|
|
1113
|
+
* @param y - Starting row
|
|
1114
|
+
* @param lines - Lines to write
|
|
1115
|
+
* @param style - Style to apply
|
|
1116
|
+
*/
|
|
1117
|
+
export function writeLinesToBuffer(
|
|
1118
|
+
buffer: TerminalBuffer,
|
|
1119
|
+
x: number,
|
|
1120
|
+
y: number,
|
|
1121
|
+
lines: string[],
|
|
1122
|
+
style: Style = { fg: null, bg: null, attrs: {} },
|
|
1123
|
+
): void {
|
|
1124
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1125
|
+
if (y + i >= buffer.height) break
|
|
1126
|
+
writeTextToBuffer(buffer, x, y + i, lines[i]!, style)
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
// ============================================================================
|
|
1131
|
+
// ANSI-Aware Operations
|
|
1132
|
+
// ============================================================================
|
|
1133
|
+
|
|
1134
|
+
/**
|
|
1135
|
+
* Strip all ANSI escape codes from a string.
|
|
1136
|
+
*
|
|
1137
|
+
* Handles:
|
|
1138
|
+
* - CSI sequences (cursor movement, colors, SGR, etc.)
|
|
1139
|
+
* - OSC sequences (window titles, hyperlinks)
|
|
1140
|
+
* - Single-character escape sequences
|
|
1141
|
+
* - Character set selection
|
|
1142
|
+
*/
|
|
1143
|
+
export function stripAnsi(text: string): string {
|
|
1144
|
+
return text
|
|
1145
|
+
.replace(/\x1b\[[0-9;:?]*[A-Za-z]/g, "") // ESC CSI sequences (including SGR with colons)
|
|
1146
|
+
.replace(/\x9b[0-9;:?]*[A-Za-z]/g, "") // C1 CSI sequences
|
|
1147
|
+
.replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, "") // ESC OSC sequences
|
|
1148
|
+
.replace(/\x9d[^\x07\x1b\x9c]*(?:\x07|\x1b\\|\x9c)/g, "") // C1 OSC sequences
|
|
1149
|
+
.replace(/\x1b[DME78]/g, "") // Single-char sequences
|
|
1150
|
+
.replace(/\x1b\(B/g, "") // Character set selection
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
/**
|
|
1154
|
+
* Get display width of text with ANSI sequences.
|
|
1155
|
+
* ANSI sequences don't contribute to display width.
|
|
1156
|
+
*/
|
|
1157
|
+
export function displayWidthAnsi(text: string): number {
|
|
1158
|
+
return displayWidth(stripAnsi(text))
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
/**
|
|
1162
|
+
* Truncate text that may contain ANSI sequences.
|
|
1163
|
+
* Preserves ANSI codes while truncating visible characters.
|
|
1164
|
+
*
|
|
1165
|
+
* Note: This is a simplified implementation that strips ANSI before
|
|
1166
|
+
* truncation. For proper ANSI-aware truncation, consider using
|
|
1167
|
+
* slice-ansi or similar library.
|
|
1168
|
+
*/
|
|
1169
|
+
export function truncateAnsi(text: string, maxWidth: number, ellipsis = "\u2026"): string {
|
|
1170
|
+
// Simple approach: if text has ANSI, strip and truncate
|
|
1171
|
+
// A more sophisticated approach would preserve styles
|
|
1172
|
+
const stripped = stripAnsi(text)
|
|
1173
|
+
return truncateText(stripped, maxWidth, ellipsis)
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
// ============================================================================
|
|
1177
|
+
// ANSI Parsing
|
|
1178
|
+
// ============================================================================
|
|
1179
|
+
|
|
1180
|
+
// BG_OVERRIDE_CODE is imported from ansi and re-exported at top of file
|
|
1181
|
+
|
|
1182
|
+
/** Styled text segment with associated ANSI colors/attributes */
|
|
1183
|
+
export interface StyledSegment {
|
|
1184
|
+
text: string
|
|
1185
|
+
fg?: number | null // SGR color code (30-37, 90-97, or 38;5;N / 38;2;r;g;b)
|
|
1186
|
+
bg?: number | null // SGR color code (40-47, 100-107, or 48;5;N / 48;2;r;g;b)
|
|
1187
|
+
/**
|
|
1188
|
+
* Underline color (SGR 58).
|
|
1189
|
+
* Same format as fg/bg: packed RGB with 0x1000000 marker, or 256-color index.
|
|
1190
|
+
*/
|
|
1191
|
+
underlineColor?: number | null
|
|
1192
|
+
bold?: boolean
|
|
1193
|
+
dim?: boolean
|
|
1194
|
+
italic?: boolean
|
|
1195
|
+
underline?: boolean
|
|
1196
|
+
/**
|
|
1197
|
+
* Underline style variant (SGR 4:x).
|
|
1198
|
+
* Uses UnderlineStyle from buffer.ts.
|
|
1199
|
+
*/
|
|
1200
|
+
underlineStyle?: UnderlineStyle
|
|
1201
|
+
inverse?: boolean
|
|
1202
|
+
bgOverride?: boolean // Set when BG_OVERRIDE_CODE (9999) is present
|
|
1203
|
+
/**
|
|
1204
|
+
* OSC 8 hyperlink URL.
|
|
1205
|
+
* Set when the segment is inside an OSC 8 hyperlink sequence.
|
|
1206
|
+
*/
|
|
1207
|
+
hyperlink?: string
|
|
1208
|
+
/**
|
|
1209
|
+
* True when the foreground color was specified using colon-separated SGR
|
|
1210
|
+
* (e.g., `38:2::255:100:0m` instead of `38;2;255;100;0m`).
|
|
1211
|
+
* Used to preserve the original format in round-trip output.
|
|
1212
|
+
*/
|
|
1213
|
+
colonFg?: boolean
|
|
1214
|
+
/**
|
|
1215
|
+
* True when the background color was specified using colon-separated SGR.
|
|
1216
|
+
*/
|
|
1217
|
+
colonBg?: boolean
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
/**
|
|
1221
|
+
* Map SGR 4:x subparameter to underline style.
|
|
1222
|
+
* 0=none, 1=single, 2=double, 3=curly, 4=dotted, 5=dashed
|
|
1223
|
+
*/
|
|
1224
|
+
function parseUnderlineStyle(subparam: number): UnderlineStyle {
|
|
1225
|
+
switch (subparam) {
|
|
1226
|
+
case 0:
|
|
1227
|
+
return false
|
|
1228
|
+
case 1:
|
|
1229
|
+
return "single"
|
|
1230
|
+
case 2:
|
|
1231
|
+
return "double"
|
|
1232
|
+
case 3:
|
|
1233
|
+
return "curly"
|
|
1234
|
+
case 4:
|
|
1235
|
+
return "dotted"
|
|
1236
|
+
case 5:
|
|
1237
|
+
return "dashed"
|
|
1238
|
+
default:
|
|
1239
|
+
return "single" // Unknown, default to single
|
|
1240
|
+
}
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
/**
|
|
1244
|
+
* Parse text with ANSI escape sequences into styled segments.
|
|
1245
|
+
* Handles basic SGR (Select Graphic Rendition) codes including:
|
|
1246
|
+
* - Standard colors (30-37, 40-47, 90-97, 100-107)
|
|
1247
|
+
* - Extended colors (38;5;N, 48;5;N for 256-color, 38;2;r;g;b, 48;2;r;g;b for RGB)
|
|
1248
|
+
* - Underline styles (4:x where x = 0-5)
|
|
1249
|
+
* - Underline color (58;5;N for 256-color, 58;2;r;g;b for RGB)
|
|
1250
|
+
*/
|
|
1251
|
+
export function parseAnsiText(text: string): StyledSegment[] {
|
|
1252
|
+
const segments: StyledSegment[] = []
|
|
1253
|
+
|
|
1254
|
+
// Step 1: Strip non-SGR CSI sequences (cursor movement, erase, etc.) that would
|
|
1255
|
+
// otherwise leak through as literal text. SGR sequences end in 'm'; all
|
|
1256
|
+
// other CSI sequences (ending in A-L, H, J, K, S, T, etc.) are stripped.
|
|
1257
|
+
// Handles both ESC-based CSI (\x1b[) and C1 CSI (\x9b).
|
|
1258
|
+
// This must happen BEFORE OSC 8 processing so hyperlink position tracking
|
|
1259
|
+
// is based on the cleaned text (no position drift from stripped sequences).
|
|
1260
|
+
const sanitizedText = text.replace(/\x1b\[[0-9;:]*[A-LN-Za-ln-z@`]/g, "").replace(/\x9b[0-9;:]*[A-LN-Za-ln-z@`]/g, "")
|
|
1261
|
+
|
|
1262
|
+
// Step 2: Strip OSC 8 hyperlink sequences and build a position-to-URL map.
|
|
1263
|
+
// OSC 8 format: \x1b]8;;URL(\x1b\\ | \x07) for open, \x1b]8;;(\x1b\\ | \x07) for close.
|
|
1264
|
+
// Also handles C1 OSC (\x9d) form: \x9d8;;URL(\x1b\\ | \x07 | \x9c)
|
|
1265
|
+
// We strip these from the text before SGR parsing and track which character
|
|
1266
|
+
// positions map to which hyperlink URL.
|
|
1267
|
+
//
|
|
1268
|
+
// Hyperlink format metadata is encoded in the URL using control char prefixes:
|
|
1269
|
+
// \x01c1b\x02 = C1 OSC intro + BEL terminator
|
|
1270
|
+
// \x01c1s\x02 = C1 OSC intro + ST terminator
|
|
1271
|
+
// \x01e7b\x02 = ESC OSC intro + BEL terminator
|
|
1272
|
+
// (no prefix) = ESC OSC intro + ST terminator (default)
|
|
1273
|
+
// bufferToStyledText reads these prefixes to emit the original format.
|
|
1274
|
+
const oscPattern = /(?:\x1b\]|\x9d)8;;([^\x07\x1b\x9c]*)(?:\x07|\x1b\\|\x9c)/g
|
|
1275
|
+
let currentHyperlink: string | undefined
|
|
1276
|
+
// Map from character index in cleaned text to hyperlink URL
|
|
1277
|
+
const hyperlinkRanges: Array<{ start: number; end: number; url: string }> = []
|
|
1278
|
+
let rangeStart = -1
|
|
1279
|
+
let cleaned = ""
|
|
1280
|
+
let oscMatch: RegExpExecArray | null
|
|
1281
|
+
let oscLastIndex = 0
|
|
1282
|
+
|
|
1283
|
+
while ((oscMatch = oscPattern.exec(sanitizedText)) !== null) {
|
|
1284
|
+
// Append text between last OSC and this one (preserving SGR codes)
|
|
1285
|
+
cleaned += sanitizedText.slice(oscLastIndex, oscMatch.index)
|
|
1286
|
+
const url = oscMatch[1]!
|
|
1287
|
+
|
|
1288
|
+
// Detect format: C1 vs ESC intro, BEL vs ST terminator
|
|
1289
|
+
const matchStr = oscMatch[0]
|
|
1290
|
+
const isC1 = matchStr.charCodeAt(0) === 0x9d
|
|
1291
|
+
const lastChar = matchStr.charCodeAt(matchStr.length - 1)
|
|
1292
|
+
const isBel = lastChar === 0x07
|
|
1293
|
+
const isSt9c = lastChar === 0x9c
|
|
1294
|
+
|
|
1295
|
+
if (url === "") {
|
|
1296
|
+
// Close hyperlink — format prefix matches the open sequence
|
|
1297
|
+
if (currentHyperlink && rangeStart >= 0) {
|
|
1298
|
+
hyperlinkRanges.push({ start: rangeStart, end: cleaned.length, url: currentHyperlink })
|
|
1299
|
+
}
|
|
1300
|
+
currentHyperlink = undefined
|
|
1301
|
+
rangeStart = -1
|
|
1302
|
+
} else {
|
|
1303
|
+
// Open hyperlink — encode format metadata in URL prefix
|
|
1304
|
+
if (currentHyperlink && rangeStart >= 0) {
|
|
1305
|
+
// Close previous unclosed hyperlink
|
|
1306
|
+
hyperlinkRanges.push({ start: rangeStart, end: cleaned.length, url: currentHyperlink })
|
|
1307
|
+
}
|
|
1308
|
+
// Encode hyperlink format as a prefix on the URL:
|
|
1309
|
+
// \x01c1b\x02 = C1 intro + BEL terminator
|
|
1310
|
+
// \x01c1s\x02 = C1 intro + ST terminator (ESC \ or C1 ST \x9c)
|
|
1311
|
+
// \x01e7b\x02 = ESC intro + BEL terminator
|
|
1312
|
+
// no prefix = ESC intro + ST terminator (default)
|
|
1313
|
+
let encodedUrl = url
|
|
1314
|
+
if (isC1 && (isBel || isSt9c)) {
|
|
1315
|
+
encodedUrl = `\x01c1b\x02${url}`
|
|
1316
|
+
} else if (isC1) {
|
|
1317
|
+
encodedUrl = `\x01c1s\x02${url}`
|
|
1318
|
+
} else if (isBel) {
|
|
1319
|
+
encodedUrl = `\x01e7b\x02${url}`
|
|
1320
|
+
}
|
|
1321
|
+
// else: ESC + ST = default, no prefix needed
|
|
1322
|
+
currentHyperlink = encodedUrl
|
|
1323
|
+
rangeStart = cleaned.length
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
oscLastIndex = oscMatch.index + oscMatch[0].length
|
|
1327
|
+
}
|
|
1328
|
+
// Append remaining text after last OSC
|
|
1329
|
+
cleaned += sanitizedText.slice(oscLastIndex)
|
|
1330
|
+
// Close any still-open hyperlink
|
|
1331
|
+
if (currentHyperlink && rangeStart >= 0) {
|
|
1332
|
+
hyperlinkRanges.push({ start: rangeStart, end: cleaned.length, url: currentHyperlink })
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
// If no OSC 8 sequences found, use sanitized text for efficiency
|
|
1336
|
+
const processText = hyperlinkRanges.length > 0 ? cleaned : sanitizedText
|
|
1337
|
+
|
|
1338
|
+
// Extended pattern: matches SGR with semicolons AND colons (for 4:x, 58:2::r:g:b)
|
|
1339
|
+
// Handles both ESC-based CSI (\x1b[) and C1 CSI (\x9b)
|
|
1340
|
+
const ansiPattern = /(?:\x1b\[|\x9b)([0-9;:]*)m/g
|
|
1341
|
+
|
|
1342
|
+
let currentStyle: Omit<StyledSegment, "text"> = {}
|
|
1343
|
+
let lastIndex = 0
|
|
1344
|
+
let match: RegExpExecArray | null
|
|
1345
|
+
|
|
1346
|
+
// Helper to find hyperlink URL for a position in the cleaned text.
|
|
1347
|
+
// Positions in cleaned text map directly to hyperlinkRanges since OSC 8
|
|
1348
|
+
// sequences were stripped but SGR sequences remain at the same indices.
|
|
1349
|
+
function getHyperlinkAt(pos: number): string | undefined {
|
|
1350
|
+
for (const range of hyperlinkRanges) {
|
|
1351
|
+
if (pos >= range.start && pos < range.end) return range.url
|
|
1352
|
+
}
|
|
1353
|
+
return undefined
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
while ((match = ansiPattern.exec(processText)) !== null) {
|
|
1357
|
+
// Add text before this escape sequence
|
|
1358
|
+
if (match.index > lastIndex) {
|
|
1359
|
+
const content = processText.slice(lastIndex, match.index)
|
|
1360
|
+
if (content.length > 0) {
|
|
1361
|
+
if (hyperlinkRanges.length > 0) {
|
|
1362
|
+
// Split content into runs by hyperlink URL.
|
|
1363
|
+
// lastIndex is the position of content[0] in processText/cleaned.
|
|
1364
|
+
let segStart = 0
|
|
1365
|
+
for (let ci = 0; ci < content.length; ci++) {
|
|
1366
|
+
const hl = getHyperlinkAt(lastIndex + ci)
|
|
1367
|
+
const prevHl = ci > 0 ? getHyperlinkAt(lastIndex + ci - 1) : undefined
|
|
1368
|
+
if (ci > 0 && hl !== prevHl) {
|
|
1369
|
+
const sub = content.slice(segStart, ci)
|
|
1370
|
+
if (sub.length > 0) {
|
|
1371
|
+
const seg: StyledSegment = { text: sub, ...currentStyle }
|
|
1372
|
+
if (prevHl) seg.hyperlink = prevHl
|
|
1373
|
+
segments.push(seg)
|
|
1374
|
+
}
|
|
1375
|
+
segStart = ci
|
|
1376
|
+
}
|
|
1377
|
+
}
|
|
1378
|
+
// Push remaining
|
|
1379
|
+
const sub = content.slice(segStart)
|
|
1380
|
+
if (sub.length > 0) {
|
|
1381
|
+
const hl = getHyperlinkAt(lastIndex + segStart)
|
|
1382
|
+
const seg: StyledSegment = { text: sub, ...currentStyle }
|
|
1383
|
+
if (hl) seg.hyperlink = hl
|
|
1384
|
+
segments.push(seg)
|
|
1385
|
+
}
|
|
1386
|
+
} else {
|
|
1387
|
+
segments.push({ text: content, ...currentStyle })
|
|
1388
|
+
}
|
|
1389
|
+
}
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
// Parse SGR codes - split by semicolon first, then handle colon subparams
|
|
1393
|
+
const rawParams = match[1]!
|
|
1394
|
+
|
|
1395
|
+
// Handle colon-separated sequences (like 4:3 for curly underline, 58:2::r:g:b)
|
|
1396
|
+
// Split by semicolon first to get top-level params
|
|
1397
|
+
const params = rawParams.split(";")
|
|
1398
|
+
|
|
1399
|
+
for (let i = 0; i < params.length; i++) {
|
|
1400
|
+
const param = params[i]!
|
|
1401
|
+
|
|
1402
|
+
// Check if this param has colon subparameters (e.g., "4:3", "58:2::255:0:0")
|
|
1403
|
+
if (param.includes(":")) {
|
|
1404
|
+
const subparts = param.split(":").map((s) => (s === "" ? 0 : Number(s)))
|
|
1405
|
+
const mainCode = subparts[0]!
|
|
1406
|
+
|
|
1407
|
+
if (mainCode === 4) {
|
|
1408
|
+
// SGR 4:x - underline style
|
|
1409
|
+
const styleCode = subparts[1] ?? 1
|
|
1410
|
+
currentStyle.underlineStyle = parseUnderlineStyle(styleCode)
|
|
1411
|
+
currentStyle.underline = currentStyle.underlineStyle !== false
|
|
1412
|
+
} else if (mainCode === 58) {
|
|
1413
|
+
// SGR 58 - underline color
|
|
1414
|
+
// Format: 58:5:N (256-color) or 58:2::r:g:b (RGB, note double colon)
|
|
1415
|
+
if (subparts[1] === 5 && subparts[2] !== undefined) {
|
|
1416
|
+
currentStyle.underlineColor = subparts[2]
|
|
1417
|
+
} else if (subparts[1] === 2) {
|
|
1418
|
+
// RGB: 58:2::r:g:b (indices 3,4,5 after the empty slot)
|
|
1419
|
+
// or 58:2:r:g:b (indices 2,3,4)
|
|
1420
|
+
// Handle both formats by looking for valid RGB values
|
|
1421
|
+
const r = subparts[3] ?? subparts[2] ?? 0
|
|
1422
|
+
const g = subparts[4] ?? subparts[3] ?? 0
|
|
1423
|
+
const b = subparts[5] ?? subparts[4] ?? 0
|
|
1424
|
+
currentStyle.underlineColor = 0x1000000 | ((r & 0xff) << 16) | ((g & 0xff) << 8) | (b & 0xff)
|
|
1425
|
+
}
|
|
1426
|
+
} else if (mainCode === 38) {
|
|
1427
|
+
// SGR 38:2::r:g:b or 38:5:N format (colon-separated)
|
|
1428
|
+
if (subparts[1] === 5 && subparts[2] !== undefined) {
|
|
1429
|
+
currentStyle.fg = subparts[2]
|
|
1430
|
+
currentStyle.colonFg = true
|
|
1431
|
+
} else if (subparts[1] === 2) {
|
|
1432
|
+
const r = subparts[3] ?? subparts[2] ?? 0
|
|
1433
|
+
const g = subparts[4] ?? subparts[3] ?? 0
|
|
1434
|
+
const b = subparts[5] ?? subparts[4] ?? 0
|
|
1435
|
+
currentStyle.fg = 0x1000000 | ((r & 0xff) << 16) | ((g & 0xff) << 8) | (b & 0xff)
|
|
1436
|
+
currentStyle.colonFg = true
|
|
1437
|
+
}
|
|
1438
|
+
} else if (mainCode === 48) {
|
|
1439
|
+
// SGR 48:2::r:g:b or 48:5:N format (colon-separated)
|
|
1440
|
+
if (subparts[1] === 5 && subparts[2] !== undefined) {
|
|
1441
|
+
currentStyle.bg = subparts[2]
|
|
1442
|
+
currentStyle.colonBg = true
|
|
1443
|
+
} else if (subparts[1] === 2) {
|
|
1444
|
+
const r = subparts[3] ?? subparts[2] ?? 0
|
|
1445
|
+
const g = subparts[4] ?? subparts[3] ?? 0
|
|
1446
|
+
const b = subparts[5] ?? subparts[4] ?? 0
|
|
1447
|
+
currentStyle.bg = 0x1000000 | ((r & 0xff) << 16) | ((g & 0xff) << 8) | (b & 0xff)
|
|
1448
|
+
currentStyle.colonBg = true
|
|
1449
|
+
}
|
|
1450
|
+
}
|
|
1451
|
+
continue
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
// Standard semicolon-separated params
|
|
1455
|
+
const code = Number(param)
|
|
1456
|
+
switch (code) {
|
|
1457
|
+
case 0:
|
|
1458
|
+
// Reset
|
|
1459
|
+
currentStyle = {}
|
|
1460
|
+
break
|
|
1461
|
+
case 1:
|
|
1462
|
+
currentStyle.bold = true
|
|
1463
|
+
break
|
|
1464
|
+
case 2:
|
|
1465
|
+
currentStyle.dim = true
|
|
1466
|
+
break
|
|
1467
|
+
case 3:
|
|
1468
|
+
currentStyle.italic = true
|
|
1469
|
+
break
|
|
1470
|
+
case 4:
|
|
1471
|
+
// Plain SGR 4 - simple underline (no subparam)
|
|
1472
|
+
currentStyle.underline = true
|
|
1473
|
+
currentStyle.underlineStyle = "single"
|
|
1474
|
+
break
|
|
1475
|
+
case 7:
|
|
1476
|
+
currentStyle.inverse = true
|
|
1477
|
+
break
|
|
1478
|
+
case 22:
|
|
1479
|
+
currentStyle.bold = false
|
|
1480
|
+
currentStyle.dim = false
|
|
1481
|
+
break
|
|
1482
|
+
case 23:
|
|
1483
|
+
currentStyle.italic = false
|
|
1484
|
+
break
|
|
1485
|
+
case 24:
|
|
1486
|
+
// SGR 24 - underline off
|
|
1487
|
+
currentStyle.underline = false
|
|
1488
|
+
currentStyle.underlineStyle = false
|
|
1489
|
+
break
|
|
1490
|
+
case 27:
|
|
1491
|
+
currentStyle.inverse = false
|
|
1492
|
+
break
|
|
1493
|
+
case 30:
|
|
1494
|
+
case 31:
|
|
1495
|
+
case 32:
|
|
1496
|
+
case 33:
|
|
1497
|
+
case 34:
|
|
1498
|
+
case 35:
|
|
1499
|
+
case 36:
|
|
1500
|
+
case 37:
|
|
1501
|
+
currentStyle.fg = code
|
|
1502
|
+
break
|
|
1503
|
+
case 38: {
|
|
1504
|
+
// Extended color: 38;5;N (256 color) or 38;2;r;g;b (true color)
|
|
1505
|
+
// Semicolon-separated — clear colonFg flag
|
|
1506
|
+
const nextParams = params.slice(i + 1).map(Number)
|
|
1507
|
+
currentStyle.colonFg = undefined
|
|
1508
|
+
if (nextParams[0] === 5 && nextParams[1] !== undefined) {
|
|
1509
|
+
currentStyle.fg = nextParams[1]
|
|
1510
|
+
i += 2
|
|
1511
|
+
} else if (nextParams[0] === 2 && nextParams[3] !== undefined) {
|
|
1512
|
+
// True color - store as RGB values packed
|
|
1513
|
+
currentStyle.fg =
|
|
1514
|
+
0x1000000 | ((nextParams[1]! & 0xff) << 16) | ((nextParams[2]! & 0xff) << 8) | (nextParams[3]! & 0xff)
|
|
1515
|
+
i += 4
|
|
1516
|
+
}
|
|
1517
|
+
break
|
|
1518
|
+
}
|
|
1519
|
+
case 39:
|
|
1520
|
+
currentStyle.fg = null // Default foreground
|
|
1521
|
+
break
|
|
1522
|
+
case 40:
|
|
1523
|
+
case 41:
|
|
1524
|
+
case 42:
|
|
1525
|
+
case 43:
|
|
1526
|
+
case 44:
|
|
1527
|
+
case 45:
|
|
1528
|
+
case 46:
|
|
1529
|
+
case 47:
|
|
1530
|
+
currentStyle.bg = code
|
|
1531
|
+
break
|
|
1532
|
+
case 48: {
|
|
1533
|
+
// Extended color: 48;5;N (256 color) or 48;2;r;g;b (true color)
|
|
1534
|
+
// Semicolon-separated — clear colonBg flag
|
|
1535
|
+
const nextParams = params.slice(i + 1).map(Number)
|
|
1536
|
+
currentStyle.colonBg = undefined
|
|
1537
|
+
if (nextParams[0] === 5 && nextParams[1] !== undefined) {
|
|
1538
|
+
currentStyle.bg = nextParams[1]
|
|
1539
|
+
i += 2
|
|
1540
|
+
} else if (nextParams[0] === 2 && nextParams[3] !== undefined) {
|
|
1541
|
+
// True color - store as RGB values packed
|
|
1542
|
+
currentStyle.bg =
|
|
1543
|
+
0x1000000 | ((nextParams[1]! & 0xff) << 16) | ((nextParams[2]! & 0xff) << 8) | (nextParams[3]! & 0xff)
|
|
1544
|
+
i += 4
|
|
1545
|
+
}
|
|
1546
|
+
break
|
|
1547
|
+
}
|
|
1548
|
+
case 49:
|
|
1549
|
+
currentStyle.bg = null // Default background
|
|
1550
|
+
break
|
|
1551
|
+
case 58: {
|
|
1552
|
+
// Underline color: 58;5;N (256 color) or 58;2;r;g;b (true color)
|
|
1553
|
+
const nextParams = params.slice(i + 1).map(Number)
|
|
1554
|
+
if (nextParams[0] === 5 && nextParams[1] !== undefined) {
|
|
1555
|
+
currentStyle.underlineColor = nextParams[1]
|
|
1556
|
+
i += 2
|
|
1557
|
+
} else if (nextParams[0] === 2 && nextParams[3] !== undefined) {
|
|
1558
|
+
// True color - store as RGB values packed
|
|
1559
|
+
currentStyle.underlineColor =
|
|
1560
|
+
0x1000000 | ((nextParams[1]! & 0xff) << 16) | ((nextParams[2]! & 0xff) << 8) | (nextParams[3]! & 0xff)
|
|
1561
|
+
i += 4
|
|
1562
|
+
}
|
|
1563
|
+
break
|
|
1564
|
+
}
|
|
1565
|
+
case 59:
|
|
1566
|
+
currentStyle.underlineColor = null // Default underline color
|
|
1567
|
+
break
|
|
1568
|
+
case 90:
|
|
1569
|
+
case 91:
|
|
1570
|
+
case 92:
|
|
1571
|
+
case 93:
|
|
1572
|
+
case 94:
|
|
1573
|
+
case 95:
|
|
1574
|
+
case 96:
|
|
1575
|
+
case 97:
|
|
1576
|
+
currentStyle.fg = code // Bright foreground colors
|
|
1577
|
+
break
|
|
1578
|
+
case 100:
|
|
1579
|
+
case 101:
|
|
1580
|
+
case 102:
|
|
1581
|
+
case 103:
|
|
1582
|
+
case 104:
|
|
1583
|
+
case 105:
|
|
1584
|
+
case 106:
|
|
1585
|
+
case 107:
|
|
1586
|
+
currentStyle.bg = code // Bright background colors
|
|
1587
|
+
break
|
|
1588
|
+
case BG_OVERRIDE_CODE:
|
|
1589
|
+
// Private code: signals intentional bg override, skip conflict detection
|
|
1590
|
+
currentStyle.bgOverride = true
|
|
1591
|
+
break
|
|
1592
|
+
}
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1595
|
+
lastIndex = match.index + match[0].length
|
|
1596
|
+
}
|
|
1597
|
+
|
|
1598
|
+
// Add remaining text
|
|
1599
|
+
if (lastIndex < processText.length) {
|
|
1600
|
+
const content = processText.slice(lastIndex)
|
|
1601
|
+
if (content.length > 0) {
|
|
1602
|
+
if (hyperlinkRanges.length > 0) {
|
|
1603
|
+
// Split remaining content by hyperlink URL
|
|
1604
|
+
let segStart = 0
|
|
1605
|
+
for (let ci = 0; ci < content.length; ci++) {
|
|
1606
|
+
const hl = getHyperlinkAt(lastIndex + ci)
|
|
1607
|
+
const prevHl = ci > 0 ? getHyperlinkAt(lastIndex + ci - 1) : undefined
|
|
1608
|
+
if (ci > 0 && hl !== prevHl) {
|
|
1609
|
+
const sub = content.slice(segStart, ci)
|
|
1610
|
+
if (sub.length > 0) {
|
|
1611
|
+
const seg: StyledSegment = { text: sub, ...currentStyle }
|
|
1612
|
+
if (prevHl) seg.hyperlink = prevHl
|
|
1613
|
+
segments.push(seg)
|
|
1614
|
+
}
|
|
1615
|
+
segStart = ci
|
|
1616
|
+
}
|
|
1617
|
+
}
|
|
1618
|
+
const sub = content.slice(segStart)
|
|
1619
|
+
if (sub.length > 0) {
|
|
1620
|
+
const hl = getHyperlinkAt(lastIndex + segStart)
|
|
1621
|
+
const seg: StyledSegment = { text: sub, ...currentStyle }
|
|
1622
|
+
if (hl) seg.hyperlink = hl
|
|
1623
|
+
segments.push(seg)
|
|
1624
|
+
}
|
|
1625
|
+
} else {
|
|
1626
|
+
segments.push({ text: content, ...currentStyle })
|
|
1627
|
+
}
|
|
1628
|
+
}
|
|
1629
|
+
}
|
|
1630
|
+
|
|
1631
|
+
return segments
|
|
1632
|
+
}
|
|
1633
|
+
|
|
1634
|
+
const ANSI_TEST_REGEX = /\x1b(?:\[[0-9;:]*[A-Za-z]|\])|\x9b[\x30-\x3f]*[\x40-\x7e]|\x9d/
|
|
1635
|
+
|
|
1636
|
+
/**
|
|
1637
|
+
* Check if text contains ANSI escape sequences (SGR or OSC).
|
|
1638
|
+
* Detects both ESC-based (7-bit) and C1 (8-bit) forms:
|
|
1639
|
+
* - ESC [ params final (CSI)
|
|
1640
|
+
* - ESC ] (OSC)
|
|
1641
|
+
* - U+009B params final (C1 CSI)
|
|
1642
|
+
* - U+009D (C1 OSC)
|
|
1643
|
+
*/
|
|
1644
|
+
export function hasAnsi(text: string): boolean {
|
|
1645
|
+
// Use a non-global regex for testing to avoid lastIndex issues
|
|
1646
|
+
return ANSI_TEST_REGEX.test(text)
|
|
1647
|
+
}
|
|
1648
|
+
|
|
1649
|
+
// ============================================================================
|
|
1650
|
+
// Measurement Utilities
|
|
1651
|
+
// ============================================================================
|
|
1652
|
+
|
|
1653
|
+
/**
|
|
1654
|
+
* Measure the dimensions of multi-line text.
|
|
1655
|
+
*
|
|
1656
|
+
* @param text - Text to measure (may contain newlines)
|
|
1657
|
+
* @returns { width, height } in display columns and rows
|
|
1658
|
+
*/
|
|
1659
|
+
export function measureText(text: string): { width: number; height: number } {
|
|
1660
|
+
const lines = text.split("\n")
|
|
1661
|
+
let maxWidth = 0
|
|
1662
|
+
|
|
1663
|
+
for (const line of lines) {
|
|
1664
|
+
const lineWidth = displayWidth(line)
|
|
1665
|
+
if (lineWidth > maxWidth) {
|
|
1666
|
+
maxWidth = lineWidth
|
|
1667
|
+
}
|
|
1668
|
+
}
|
|
1669
|
+
|
|
1670
|
+
return {
|
|
1671
|
+
width: maxWidth,
|
|
1672
|
+
height: lines.length,
|
|
1673
|
+
}
|
|
1674
|
+
}
|
|
1675
|
+
|
|
1676
|
+
/**
|
|
1677
|
+
* Check if a string contains any wide characters.
|
|
1678
|
+
*/
|
|
1679
|
+
export function hasWideCharacters(text: string): boolean {
|
|
1680
|
+
const graphemes = splitGraphemes(text)
|
|
1681
|
+
return graphemes.some(isWideGrapheme)
|
|
1682
|
+
}
|
|
1683
|
+
|
|
1684
|
+
/**
|
|
1685
|
+
* Check if a string contains any combining/zero-width characters.
|
|
1686
|
+
*/
|
|
1687
|
+
export function hasZeroWidthCharacters(text: string): boolean {
|
|
1688
|
+
const graphemes = splitGraphemes(text)
|
|
1689
|
+
return graphemes.some(isZeroWidthGrapheme)
|
|
1690
|
+
}
|
|
1691
|
+
|
|
1692
|
+
/**
|
|
1693
|
+
* Normalize string for consistent handling.
|
|
1694
|
+
* Applies Unicode NFC normalization.
|
|
1695
|
+
*/
|
|
1696
|
+
export function normalizeText(text: string): string {
|
|
1697
|
+
return text.normalize("NFC")
|
|
1698
|
+
}
|
|
1699
|
+
|
|
1700
|
+
// ============================================================================
|
|
1701
|
+
// Character Detection
|
|
1702
|
+
// ============================================================================
|
|
1703
|
+
|
|
1704
|
+
/**
|
|
1705
|
+
* Common character ranges for quick checks.
|
|
1706
|
+
*/
|
|
1707
|
+
const CHAR_RANGES = {
|
|
1708
|
+
// Basic Latin (ASCII)
|
|
1709
|
+
isBasicLatin: (cp: number) => cp >= 0x0020 && cp <= 0x007f,
|
|
1710
|
+
|
|
1711
|
+
// CJK Unified Ideographs
|
|
1712
|
+
isCJK: (cp: number) =>
|
|
1713
|
+
(cp >= 0x4e00 && cp <= 0x9fff) || // CJK Unified Ideographs
|
|
1714
|
+
(cp >= 0x3400 && cp <= 0x4dbf) || // CJK Unified Ideographs Extension A
|
|
1715
|
+
(cp >= 0x20000 && cp <= 0x2a6df) || // CJK Unified Ideographs Extension B
|
|
1716
|
+
(cp >= 0xf900 && cp <= 0xfaff) || // CJK Compatibility Ideographs
|
|
1717
|
+
(cp >= 0x2f800 && cp <= 0x2fa1f), // CJK Compatibility Ideographs Supplement
|
|
1718
|
+
|
|
1719
|
+
// Japanese Hiragana/Katakana
|
|
1720
|
+
isJapaneseKana: (cp: number) =>
|
|
1721
|
+
(cp >= 0x3040 && cp <= 0x309f) || // Hiragana
|
|
1722
|
+
(cp >= 0x30a0 && cp <= 0x30ff), // Katakana
|
|
1723
|
+
|
|
1724
|
+
// Korean Hangul
|
|
1725
|
+
isHangul: (cp: number) =>
|
|
1726
|
+
(cp >= 0xac00 && cp <= 0xd7a3) || // Hangul Syllables
|
|
1727
|
+
(cp >= 0x1100 && cp <= 0x11ff), // Hangul Jamo
|
|
1728
|
+
|
|
1729
|
+
// Emoji ranges (simplified)
|
|
1730
|
+
isEmoji: (cp: number) =>
|
|
1731
|
+
(cp >= 0x1f600 && cp <= 0x1f64f) || // Emoticons
|
|
1732
|
+
(cp >= 0x1f300 && cp <= 0x1f5ff) || // Misc Symbols and Pictographs
|
|
1733
|
+
(cp >= 0x1f680 && cp <= 0x1f6ff) || // Transport and Map
|
|
1734
|
+
(cp >= 0x1f700 && cp <= 0x1f77f) || // Alchemical Symbols
|
|
1735
|
+
(cp >= 0x1f900 && cp <= 0x1f9ff) || // Supplemental Symbols and Pictographs
|
|
1736
|
+
(cp >= 0x2600 && cp <= 0x26ff) || // Misc symbols
|
|
1737
|
+
(cp >= 0x2700 && cp <= 0x27bf), // Dingbats
|
|
1738
|
+
} as const
|
|
1739
|
+
|
|
1740
|
+
/**
|
|
1741
|
+
* Get the first code point of a string.
|
|
1742
|
+
*/
|
|
1743
|
+
export function getFirstCodePoint(str: string): number {
|
|
1744
|
+
const cp = str.codePointAt(0)
|
|
1745
|
+
return cp ?? 0
|
|
1746
|
+
}
|
|
1747
|
+
|
|
1748
|
+
/**
|
|
1749
|
+
* Check if a grapheme is likely an emoji.
|
|
1750
|
+
* Note: This is a heuristic, not comprehensive.
|
|
1751
|
+
*/
|
|
1752
|
+
export function isLikelyEmoji(grapheme: string): boolean {
|
|
1753
|
+
const cp = getFirstCodePoint(grapheme)
|
|
1754
|
+
return CHAR_RANGES.isEmoji(cp) || grapheme.includes("\u200d") // Contains ZWJ
|
|
1755
|
+
}
|
|
1756
|
+
|
|
1757
|
+
/**
|
|
1758
|
+
* Check if a grapheme is a CJK character.
|
|
1759
|
+
*/
|
|
1760
|
+
export function isCJK(grapheme: string): boolean {
|
|
1761
|
+
const cp = getFirstCodePoint(grapheme)
|
|
1762
|
+
return CHAR_RANGES.isCJK(cp) || CHAR_RANGES.isJapaneseKana(cp) || CHAR_RANGES.isHangul(cp)
|
|
1763
|
+
}
|