@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
|
@@ -0,0 +1,1255 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Text Rendering - Functions for rendering text content to the buffer.
|
|
3
|
+
*
|
|
4
|
+
* Contains:
|
|
5
|
+
* - ANSI text line rendering (renderAnsiTextLine)
|
|
6
|
+
* - Plain text line rendering (renderTextLine)
|
|
7
|
+
* - Text formatting (formatTextLines)
|
|
8
|
+
* - Text truncation (truncateText)
|
|
9
|
+
* - Text content collection (collectTextContent)
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
type CellAttrs,
|
|
14
|
+
type Color,
|
|
15
|
+
type Style,
|
|
16
|
+
type TerminalBuffer,
|
|
17
|
+
type UnderlineStyle,
|
|
18
|
+
createMutableCell,
|
|
19
|
+
} from "../buffer"
|
|
20
|
+
import type { TeaNode, TextProps } from "@silvery/tea/types"
|
|
21
|
+
import {
|
|
22
|
+
type StyledSegment,
|
|
23
|
+
ensureEmojiPresentation,
|
|
24
|
+
graphemeWidth,
|
|
25
|
+
hasAnsi,
|
|
26
|
+
parseAnsiText,
|
|
27
|
+
sliceByWidth,
|
|
28
|
+
sliceByWidthFromEnd,
|
|
29
|
+
splitGraphemes,
|
|
30
|
+
wrapText,
|
|
31
|
+
} from "../unicode"
|
|
32
|
+
import { getTextStyle, getTextWidth, parseColor } from "./render-helpers"
|
|
33
|
+
import type { BgConflictMode, NodeRenderState, PipelineContext } from "./types"
|
|
34
|
+
|
|
35
|
+
// ============================================================================
|
|
36
|
+
// Background Conflict Detection
|
|
37
|
+
// ============================================================================
|
|
38
|
+
|
|
39
|
+
/** Cached bg conflict mode. Read from env once at module load. */
|
|
40
|
+
let bgConflictMode: BgConflictMode = (() => {
|
|
41
|
+
const env = process.env.SILVERY_BG_CONFLICT?.toLowerCase()
|
|
42
|
+
if (env === "ignore" || env === "warn" || env === "throw") return env
|
|
43
|
+
return "throw" // default - fail fast on programming errors
|
|
44
|
+
})()
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Get the current background conflict detection mode.
|
|
48
|
+
*/
|
|
49
|
+
function getBgConflictMode(): BgConflictMode {
|
|
50
|
+
return bgConflictMode
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Set the background conflict detection mode. For tests.
|
|
55
|
+
*/
|
|
56
|
+
export function setBgConflictMode(mode: BgConflictMode): void {
|
|
57
|
+
bgConflictMode = mode
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Track warned conflicts to avoid spam (only used in 'warn' mode)
|
|
61
|
+
const warnedBgConflicts = new Set<string>()
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Clear the background conflict warning cache.
|
|
65
|
+
* Call this at the start of each render cycle to:
|
|
66
|
+
* - Prevent memory leaks in long-running apps
|
|
67
|
+
* - Allow warnings to repeat after user fixes issues
|
|
68
|
+
*/
|
|
69
|
+
export function clearBgConflictWarnings(): void {
|
|
70
|
+
warnedBgConflicts.clear()
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ============================================================================
|
|
74
|
+
// Text Content Collection
|
|
75
|
+
// ============================================================================
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Style context for nested Text elements.
|
|
79
|
+
* Tracks cumulative styles through the tree to enable proper push/pop behavior.
|
|
80
|
+
*/
|
|
81
|
+
interface StyleContext {
|
|
82
|
+
color?: string
|
|
83
|
+
backgroundColor?: string
|
|
84
|
+
bold?: boolean
|
|
85
|
+
dim?: boolean
|
|
86
|
+
italic?: boolean
|
|
87
|
+
underline?: boolean
|
|
88
|
+
inverse?: boolean
|
|
89
|
+
strikethrough?: boolean
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Build ANSI escape sequence for a style context.
|
|
94
|
+
*
|
|
95
|
+
* Note: backgroundColor is intentionally NOT embedded as ANSI codes.
|
|
96
|
+
* Background color is handled at the buffer level (via BgSegment tracking)
|
|
97
|
+
* to prevent bg bleed across wrapped text lines. See km-silvery.bg-bleed.
|
|
98
|
+
*/
|
|
99
|
+
function styleToAnsi(style: StyleContext): string {
|
|
100
|
+
const codes: number[] = []
|
|
101
|
+
|
|
102
|
+
// Foreground color - use parseColor directly instead of roundtripping through getTextStyle
|
|
103
|
+
if (style.color) {
|
|
104
|
+
const color = parseColor(style.color)
|
|
105
|
+
if (color !== null) {
|
|
106
|
+
if (typeof color === "number") {
|
|
107
|
+
codes.push(38, 5, color)
|
|
108
|
+
} else {
|
|
109
|
+
codes.push(38, 2, color.r, color.g, color.b)
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// backgroundColor is NOT embedded here - it is tracked separately via
|
|
115
|
+
// BgSegment and applied at the buffer level in renderText(). This prevents
|
|
116
|
+
// bg color from bleeding across wrapped lines. See collectTextWithBg().
|
|
117
|
+
|
|
118
|
+
// Attributes
|
|
119
|
+
if (style.bold) codes.push(1)
|
|
120
|
+
if (style.dim) codes.push(2)
|
|
121
|
+
if (style.italic) codes.push(3)
|
|
122
|
+
if (style.underline) codes.push(4)
|
|
123
|
+
if (style.inverse) codes.push(7)
|
|
124
|
+
if (style.strikethrough) codes.push(9)
|
|
125
|
+
|
|
126
|
+
if (codes.length === 0) {
|
|
127
|
+
return ""
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return `\x1b[${codes.join(";")}m`
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Merge child props into parent context.
|
|
135
|
+
* Child values override parent values when specified.
|
|
136
|
+
*/
|
|
137
|
+
function mergeStyleContext(parent: StyleContext, childProps: TextProps): StyleContext {
|
|
138
|
+
return {
|
|
139
|
+
color: childProps.color ?? parent.color,
|
|
140
|
+
backgroundColor: childProps.backgroundColor ?? parent.backgroundColor,
|
|
141
|
+
bold: childProps.bold ?? parent.bold,
|
|
142
|
+
dim: childProps.dim ?? childProps.dimColor ?? parent.dim,
|
|
143
|
+
italic: childProps.italic ?? parent.italic,
|
|
144
|
+
underline: childProps.underline ?? parent.underline,
|
|
145
|
+
inverse: childProps.inverse ?? parent.inverse,
|
|
146
|
+
strikethrough: childProps.strikethrough ?? parent.strikethrough,
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Apply text styles as ANSI escape codes with proper push/pop behavior.
|
|
152
|
+
* After the child text, restores the parent context's styles.
|
|
153
|
+
*
|
|
154
|
+
* @param text - The text content to wrap
|
|
155
|
+
* @param childStyle - The merged style for this child (child overrides parent)
|
|
156
|
+
* @param parentStyle - The parent's style context to restore after
|
|
157
|
+
*/
|
|
158
|
+
function applyTextStyleAnsi(text: string, childStyle: StyleContext, parentStyle: StyleContext): string {
|
|
159
|
+
if (!text) {
|
|
160
|
+
return text
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const childAnsi = styleToAnsi(childStyle)
|
|
164
|
+
const parentAnsi = styleToAnsi(parentStyle)
|
|
165
|
+
|
|
166
|
+
// If child has no style changes, just return text
|
|
167
|
+
if (!childAnsi) {
|
|
168
|
+
return text
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Apply child style, then reset and re-apply parent style
|
|
172
|
+
// We use \x1b[0m to reset, then re-apply parent styles
|
|
173
|
+
return `${childAnsi}${text}\x1b[0m${parentAnsi}`
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Recursively collect text content from a node and its children.
|
|
178
|
+
* Handles both raw text nodes (textContent set directly) and
|
|
179
|
+
* Text component wrappers (text in children).
|
|
180
|
+
*
|
|
181
|
+
* For nested Text nodes with style props (color, bold, etc.),
|
|
182
|
+
* applies ANSI codes so the styles are preserved when rendered.
|
|
183
|
+
* Uses a style stack to properly restore parent styles after nested elements.
|
|
184
|
+
*
|
|
185
|
+
* @param node - The node to collect text from
|
|
186
|
+
* @param parentContext - The inherited style context from parent (used for restoration)
|
|
187
|
+
*/
|
|
188
|
+
export function collectTextContent(node: TeaNode, parentContext: StyleContext = {}): string {
|
|
189
|
+
// If this node has direct text content, return it
|
|
190
|
+
if (node.textContent !== undefined) {
|
|
191
|
+
return node.textContent
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Otherwise, collect from children
|
|
195
|
+
// Matching Ink's squashTextNodes: apply internal_transform to the full text
|
|
196
|
+
// of each child node (not per-line), using the child index as the index argument.
|
|
197
|
+
let result = ""
|
|
198
|
+
for (let i = 0; i < node.children.length; i++) {
|
|
199
|
+
const child = node.children[i]!
|
|
200
|
+
// If child is a Text node (virtual/nested) with style props, apply ANSI codes
|
|
201
|
+
if (child.type === "silvery-text" && child.props && !child.layoutNode) {
|
|
202
|
+
const childProps = child.props as TextProps
|
|
203
|
+
// Merge child props with parent context to get effective child style
|
|
204
|
+
const childContext = mergeStyleContext(parentContext, childProps)
|
|
205
|
+
// Recursively collect with child's context
|
|
206
|
+
let childContent = collectTextContent(child, childContext)
|
|
207
|
+
// Apply internal_transform from virtual text nodes (nested Transform components).
|
|
208
|
+
// Matches Ink's squashTextNodes: transform is applied to the full concatenated
|
|
209
|
+
// text of the child, with index = child position in parent's children array.
|
|
210
|
+
const childTransform = (childProps as any).internal_transform
|
|
211
|
+
if (childTransform && childContent.length > 0) {
|
|
212
|
+
childContent = childTransform(childContent, i)
|
|
213
|
+
}
|
|
214
|
+
// Apply styles with proper push/pop (child style, then restore parent)
|
|
215
|
+
result += applyTextStyleAnsi(childContent, childContext, parentContext)
|
|
216
|
+
} else {
|
|
217
|
+
// Not a styled Text node, just collect recursively
|
|
218
|
+
result += collectTextContent(child, parentContext)
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return result
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// ============================================================================
|
|
225
|
+
// Background Segment Tracking
|
|
226
|
+
// ============================================================================
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* A background color segment in collected text.
|
|
230
|
+
* Tracks which character range has which background color,
|
|
231
|
+
* independent of ANSI codes. Used to apply bg at the buffer level
|
|
232
|
+
* after text wrapping, preventing bg bleed across wrapped lines.
|
|
233
|
+
*/
|
|
234
|
+
interface BgSegment {
|
|
235
|
+
/** Start character offset in the collected text (inclusive) */
|
|
236
|
+
start: number
|
|
237
|
+
/** End character offset in the collected text (exclusive) */
|
|
238
|
+
end: number
|
|
239
|
+
/** Background color to apply */
|
|
240
|
+
bg: Color
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Result of collecting text with background segments.
|
|
245
|
+
*/
|
|
246
|
+
interface TextWithBg {
|
|
247
|
+
/** The collected text string (with ANSI codes for fg/attrs, but NOT bg) */
|
|
248
|
+
text: string
|
|
249
|
+
/** Background color segments from nested Text elements */
|
|
250
|
+
bgSegments: BgSegment[]
|
|
251
|
+
/** Plain text character count (excluding ANSI codes). Used for DOM-level budget tracking. */
|
|
252
|
+
plainLen: number
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Collect plain text content from a node tree (no ANSI codes).
|
|
257
|
+
* Used to compute DOM-level truncation budget before ANSI serialization.
|
|
258
|
+
* Applies internal_transform on child nodes to match Ink's squashTextNodes.
|
|
259
|
+
*/
|
|
260
|
+
function collectPlainText(node: TeaNode): string {
|
|
261
|
+
if (node.textContent !== undefined) return node.textContent
|
|
262
|
+
let result = ""
|
|
263
|
+
for (let i = 0; i < node.children.length; i++) {
|
|
264
|
+
const child = node.children[i]!
|
|
265
|
+
let childText = collectPlainText(child)
|
|
266
|
+
if (childText.length > 0 && (child.props as any).internal_transform) {
|
|
267
|
+
childText = (child.props as any).internal_transform(childText, i)
|
|
268
|
+
}
|
|
269
|
+
result += childText
|
|
270
|
+
}
|
|
271
|
+
return result
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Collect text content and background color segments from a node tree.
|
|
276
|
+
*
|
|
277
|
+
* Like collectTextContent, but also tracks backgroundColor from nested Text
|
|
278
|
+
* elements as separate BgSegment entries. Background is NOT embedded as ANSI
|
|
279
|
+
* codes, preventing bg bleed when text wraps across lines.
|
|
280
|
+
*
|
|
281
|
+
* @param node - The node to collect text from
|
|
282
|
+
* @param parentContext - The inherited style context from parent
|
|
283
|
+
* @param offset - Current character offset in the collected text (for bg tracking)
|
|
284
|
+
* @param maxDisplayWidth - Maximum display width (columns) to collect. When set,
|
|
285
|
+
* stops collecting once this many display columns of content have been gathered.
|
|
286
|
+
* This truncates at the DOM level BEFORE ANSI serialization, so escape sequences
|
|
287
|
+
* (OSC 8, etc.) are never generated for content that won't be displayed.
|
|
288
|
+
* Uses getTextWidth (ANSI-aware) so pre-styled leaf text is handled correctly.
|
|
289
|
+
*/
|
|
290
|
+
function collectTextWithBg(
|
|
291
|
+
node: TeaNode,
|
|
292
|
+
parentContext: StyleContext = {},
|
|
293
|
+
offset = 0,
|
|
294
|
+
maxDisplayWidth?: number,
|
|
295
|
+
ctx?: PipelineContext,
|
|
296
|
+
): TextWithBg {
|
|
297
|
+
// If this node has direct text content, return it with no bg segments
|
|
298
|
+
if (node.textContent !== undefined) {
|
|
299
|
+
let text = node.textContent
|
|
300
|
+
// DOM-level truncation: trim leaf text to display width budget
|
|
301
|
+
if (maxDisplayWidth !== undefined) {
|
|
302
|
+
const textW = getTextWidth(text, ctx)
|
|
303
|
+
if (textW > maxDisplayWidth) {
|
|
304
|
+
const sliceFn = ctx ? ctx.measurer.sliceByWidth : sliceByWidth
|
|
305
|
+
text = sliceFn(text, maxDisplayWidth)
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
// plainLen tracks display width for budget and BgSegment offset tracking.
|
|
309
|
+
// Both use display-width coordinates consistently: collectTextWithBg uses
|
|
310
|
+
// getTextWidth for offsets, mapLinesToCharOffsets returns display-width,
|
|
311
|
+
// and applyBgSegmentsToLine compares via display-width (col - x).
|
|
312
|
+
const plainLen = getTextWidth(text, ctx)
|
|
313
|
+
return { text, bgSegments: [], plainLen }
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
let result = ""
|
|
317
|
+
const bgSegments: BgSegment[] = []
|
|
318
|
+
let currentOffset = offset
|
|
319
|
+
let displayWidthCollected = 0
|
|
320
|
+
|
|
321
|
+
for (let i = 0; i < node.children.length; i++) {
|
|
322
|
+
const child = node.children[i]!
|
|
323
|
+
// Stop collecting if budget exhausted
|
|
324
|
+
if (maxDisplayWidth !== undefined && displayWidthCollected >= maxDisplayWidth) break
|
|
325
|
+
|
|
326
|
+
// Compute remaining budget for this child
|
|
327
|
+
const childBudget = maxDisplayWidth !== undefined ? maxDisplayWidth - displayWidthCollected : undefined
|
|
328
|
+
|
|
329
|
+
if (child.type === "silvery-text" && child.props && !child.layoutNode) {
|
|
330
|
+
const childProps = child.props as TextProps
|
|
331
|
+
const childContext = mergeStyleContext(parentContext, childProps)
|
|
332
|
+
|
|
333
|
+
// Recursively collect with child's context and budget
|
|
334
|
+
const childResult = collectTextWithBg(child, childContext, currentOffset, childBudget, ctx)
|
|
335
|
+
|
|
336
|
+
// Apply internal_transform from virtual text nodes (nested Transform components).
|
|
337
|
+
// Matches Ink's squashTextNodes: transform is applied to the full concatenated
|
|
338
|
+
// text of the child, with index = child position in parent's children array.
|
|
339
|
+
const childTransform = (childProps as any).internal_transform
|
|
340
|
+
if (childTransform && childResult.text.length > 0) {
|
|
341
|
+
childResult.text = childTransform(childResult.text, i)
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Apply ANSI styles for fg/attrs (but NOT bg) with push/pop
|
|
345
|
+
const styledText = applyTextStyleAnsi(childResult.text, childContext, parentContext)
|
|
346
|
+
result += styledText
|
|
347
|
+
|
|
348
|
+
// Track bg segment if this child (or its ancestors) has backgroundColor.
|
|
349
|
+
// When backgroundColor is "" (empty string), create a null-bg segment to
|
|
350
|
+
// explicitly clear inherited background (e.g., from a parent Box).
|
|
351
|
+
if (childContext.backgroundColor) {
|
|
352
|
+
const bg = parseColor(childContext.backgroundColor)
|
|
353
|
+
if (bg !== null) {
|
|
354
|
+
if (childResult.plainLen > 0) {
|
|
355
|
+
bgSegments.push({
|
|
356
|
+
start: currentOffset,
|
|
357
|
+
end: currentOffset + childResult.plainLen,
|
|
358
|
+
bg,
|
|
359
|
+
})
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
} else if (childProps.backgroundColor === "" && childResult.plainLen > 0) {
|
|
363
|
+
// Explicit backgroundColor="" clears inherited bg (from parent Text
|
|
364
|
+
// or ancestor Box's inheritedBg). Push a null-bg segment so
|
|
365
|
+
// applyBgSegmentsToLine overrides inheritedBg to null for this range.
|
|
366
|
+
bgSegments.push({
|
|
367
|
+
start: currentOffset,
|
|
368
|
+
end: currentOffset + childResult.plainLen,
|
|
369
|
+
bg: null,
|
|
370
|
+
})
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// Include child's nested bg segments
|
|
374
|
+
bgSegments.push(...childResult.bgSegments)
|
|
375
|
+
|
|
376
|
+
// Track using plainLen (display width) — not text.length which includes ANSI codes
|
|
377
|
+
currentOffset += childResult.plainLen
|
|
378
|
+
displayWidthCollected += childResult.plainLen
|
|
379
|
+
} else {
|
|
380
|
+
// Not a styled Text node, just collect recursively
|
|
381
|
+
const childResult = collectTextWithBg(child, parentContext, currentOffset, childBudget, ctx)
|
|
382
|
+
result += childResult.text
|
|
383
|
+
bgSegments.push(...childResult.bgSegments)
|
|
384
|
+
currentOffset += childResult.plainLen
|
|
385
|
+
displayWidthCollected += childResult.plainLen
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
return { text: result, bgSegments, plainLen: displayWidthCollected }
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Apply background segments to buffer cells for a single rendered line.
|
|
394
|
+
*
|
|
395
|
+
* Maps character offsets from the original collected text to screen positions,
|
|
396
|
+
* accounting for text wrapping. Each bg segment fills only the cells that
|
|
397
|
+
* correspond to actual text characters, not trailing whitespace.
|
|
398
|
+
*
|
|
399
|
+
* @param buffer - The terminal buffer to write to
|
|
400
|
+
* @param x - Screen x position of the line start
|
|
401
|
+
* @param y - Screen y position of the line
|
|
402
|
+
* @param lineText - The rendered line text (may contain ANSI codes)
|
|
403
|
+
* @param lineCharStart - Character offset in original text where this line starts
|
|
404
|
+
* @param lineCharEnd - Character offset in original text where this line ends
|
|
405
|
+
* @param bgSegments - Background color segments to apply
|
|
406
|
+
*/
|
|
407
|
+
function applyBgSegmentsToLine(
|
|
408
|
+
buffer: TerminalBuffer,
|
|
409
|
+
x: number,
|
|
410
|
+
y: number,
|
|
411
|
+
lineText: string,
|
|
412
|
+
lineCharStart: number,
|
|
413
|
+
lineCharEnd: number,
|
|
414
|
+
bgSegments: BgSegment[],
|
|
415
|
+
ctx?: PipelineContext,
|
|
416
|
+
): void {
|
|
417
|
+
if (bgSegments.length === 0) return
|
|
418
|
+
if (y < 0 || y >= buffer.height) return
|
|
419
|
+
|
|
420
|
+
// Reusable cell for readCellInto to avoid per-character allocation
|
|
421
|
+
const bgCell = createMutableCell()
|
|
422
|
+
const gWidthFn = ctx ? ctx.measurer.graphemeWidth : graphemeWidth
|
|
423
|
+
|
|
424
|
+
// For each bg segment that overlaps this line's character range,
|
|
425
|
+
// calculate the screen columns and fill the bg
|
|
426
|
+
for (const seg of bgSegments) {
|
|
427
|
+
// Check overlap between segment [seg.start, seg.end) and line [lineCharStart, lineCharEnd)
|
|
428
|
+
const overlapStart = Math.max(seg.start, lineCharStart)
|
|
429
|
+
const overlapEnd = Math.min(seg.end, lineCharEnd)
|
|
430
|
+
if (overlapStart >= overlapEnd) continue
|
|
431
|
+
|
|
432
|
+
// Convert display-width offsets to column positions within the line.
|
|
433
|
+
// BgSegment offsets and lineCharStart/lineCharEnd are all in display-width
|
|
434
|
+
// coordinates, so relStart/relEnd are display-width offsets within the line.
|
|
435
|
+
const relStart = overlapStart - lineCharStart
|
|
436
|
+
const relEnd = overlapEnd - lineCharStart
|
|
437
|
+
|
|
438
|
+
// Walk through the line's visible characters to find screen columns.
|
|
439
|
+
// Use display-width offset (col - x) to match BgSegment coordinate system.
|
|
440
|
+
let col = x
|
|
441
|
+
const graphemes = splitGraphemes(hasAnsi(lineText) ? stripAnsiForBg(lineText) : lineText)
|
|
442
|
+
|
|
443
|
+
for (const grapheme of graphemes) {
|
|
444
|
+
const gWidth = gWidthFn(grapheme)
|
|
445
|
+
if (gWidth === 0) continue
|
|
446
|
+
|
|
447
|
+
const displayOffset = col - x
|
|
448
|
+
if (displayOffset >= relStart && displayOffset < relEnd) {
|
|
449
|
+
// This character is within the bg segment -- set bg on its cells.
|
|
450
|
+
// Use readCellInto to avoid allocating a new Cell per iteration.
|
|
451
|
+
buffer.readCellInto(col, y, bgCell)
|
|
452
|
+
bgCell.bg = seg.bg
|
|
453
|
+
buffer.setCell(col, y, bgCell)
|
|
454
|
+
if (gWidth === 2 && col + 1 < buffer.width) {
|
|
455
|
+
buffer.readCellInto(col + 1, y, bgCell)
|
|
456
|
+
bgCell.bg = seg.bg
|
|
457
|
+
buffer.setCell(col + 1, y, bgCell)
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
col += gWidth
|
|
462
|
+
if (col - x >= relEnd) break
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* Strip ANSI escape codes from text for character counting.
|
|
469
|
+
*/
|
|
470
|
+
function stripAnsiForBg(text: string): string {
|
|
471
|
+
return text
|
|
472
|
+
.replace(/\x1b\[[0-9;:?]*[A-Za-z]/g, "")
|
|
473
|
+
.replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, "")
|
|
474
|
+
.replace(/\x1b[DME78]/g, "")
|
|
475
|
+
.replace(/\x1b\(B/g, "")
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* Map formatted lines back to character offsets in the original text.
|
|
480
|
+
*
|
|
481
|
+
* After wrapping/truncation, each output line corresponds to a range
|
|
482
|
+
* of characters in the original text. This function computes those ranges
|
|
483
|
+
* by searching for each line's content in the normalized text.
|
|
484
|
+
*
|
|
485
|
+
* Handles characters consumed by word wrapping (spaces at break points,
|
|
486
|
+
* newlines) and characters added by truncation (ellipsis).
|
|
487
|
+
*
|
|
488
|
+
* Returns display-width offsets (not UTF-16 code units) to match BgSegment
|
|
489
|
+
* coordinate system. BgSegments use display-width via getTextWidth/plainLen.
|
|
490
|
+
*
|
|
491
|
+
* @param originalText - The original collected text (with ANSI, before wrapping)
|
|
492
|
+
* @param formattedLines - The wrapped/truncated output lines
|
|
493
|
+
* @param ctx - Pipeline context for width measurement
|
|
494
|
+
* @returns Array of { start, end } display-width offsets for each formatted line
|
|
495
|
+
*/
|
|
496
|
+
function mapLinesToCharOffsets(
|
|
497
|
+
originalText: string,
|
|
498
|
+
formattedLines: string[],
|
|
499
|
+
ctx?: PipelineContext,
|
|
500
|
+
): Array<{ start: number; end: number }> {
|
|
501
|
+
// Strip ANSI from the original to get the plain text character sequence
|
|
502
|
+
const plainOriginal = hasAnsi(originalText) ? stripAnsiForBg(originalText) : originalText
|
|
503
|
+
// Normalize tabs to match formatTextLines behavior
|
|
504
|
+
const normalized = plainOriginal.replace(/\t/g, " ")
|
|
505
|
+
|
|
506
|
+
const result: Array<{ start: number; end: number }> = []
|
|
507
|
+
let charOffset = 0 // UTF-16 offset for string matching (findLineStart)
|
|
508
|
+
let displayOffset = 0 // Display-width offset for BgSegment matching
|
|
509
|
+
|
|
510
|
+
for (const line of formattedLines) {
|
|
511
|
+
const plainLine = hasAnsi(line) ? stripAnsiForBg(line) : line
|
|
512
|
+
|
|
513
|
+
// Find where this line starts in the normalized text (UTF-16 matching).
|
|
514
|
+
const lineStart = findLineStart(normalized, plainLine, charOffset)
|
|
515
|
+
|
|
516
|
+
// Convert skipped characters (between previous line end and this line start)
|
|
517
|
+
// to display width. These are whitespace/newlines consumed by wrapping.
|
|
518
|
+
if (lineStart > charOffset) {
|
|
519
|
+
const skipped = normalized.slice(charOffset, lineStart)
|
|
520
|
+
displayOffset += getTextWidth(skipped, ctx)
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// Line content display width
|
|
524
|
+
const lineDisplayWidth = getTextWidth(plainLine, ctx)
|
|
525
|
+
result.push({ start: displayOffset, end: displayOffset + lineDisplayWidth })
|
|
526
|
+
|
|
527
|
+
// Advance both offset trackers
|
|
528
|
+
const lineLen = Math.min(plainLine.length, normalized.length - lineStart)
|
|
529
|
+
charOffset = lineStart + lineLen
|
|
530
|
+
displayOffset += lineDisplayWidth
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
return result
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* Find where a formatted line starts in the normalized original text.
|
|
538
|
+
*
|
|
539
|
+
* Scans forward from the given offset, matching the line content
|
|
540
|
+
* character by character. Skips newlines and whitespace that were
|
|
541
|
+
* consumed by wrapping between lines.
|
|
542
|
+
*/
|
|
543
|
+
function findLineStart(normalized: string, plainLine: string, fromOffset: number): number {
|
|
544
|
+
if (plainLine.length === 0) {
|
|
545
|
+
// Empty line -- skip to next newline
|
|
546
|
+
let pos = fromOffset
|
|
547
|
+
while (pos < normalized.length && normalized[pos] === "\n") {
|
|
548
|
+
pos++
|
|
549
|
+
}
|
|
550
|
+
return pos
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// Try exact match at current offset first (fast path for first line
|
|
554
|
+
// and for lines that follow explicit newlines without space trimming)
|
|
555
|
+
if (normalized.startsWith(plainLine, fromOffset)) {
|
|
556
|
+
return fromOffset
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// For truncated lines, extract prefix before ellipsis for matching.
|
|
560
|
+
// startsWith fails when the line has "…" that doesn't exist in the original.
|
|
561
|
+
const ELLIPSIS = "\u2026"
|
|
562
|
+
const ellipsisIdx = plainLine.indexOf(ELLIPSIS)
|
|
563
|
+
const truncatedPrefix = ellipsisIdx > 0 ? plainLine.slice(0, ellipsisIdx) : null
|
|
564
|
+
|
|
565
|
+
if (truncatedPrefix && normalized.startsWith(truncatedPrefix, fromOffset)) {
|
|
566
|
+
return fromOffset
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// Scan forward, skipping newlines and spaces consumed by wrapping
|
|
570
|
+
let pos = fromOffset
|
|
571
|
+
while (pos < normalized.length) {
|
|
572
|
+
const ch = normalized[pos]!
|
|
573
|
+
if (ch === "\n" || ch === " ") {
|
|
574
|
+
pos++
|
|
575
|
+
continue
|
|
576
|
+
}
|
|
577
|
+
// Found a non-whitespace character -- check if line starts here
|
|
578
|
+
if (normalized.startsWith(plainLine, pos)) {
|
|
579
|
+
return pos
|
|
580
|
+
}
|
|
581
|
+
// Check truncated prefix match (e.g. "abcde…" -> match "abcde")
|
|
582
|
+
if (truncatedPrefix && normalized.startsWith(truncatedPrefix, pos)) {
|
|
583
|
+
return pos
|
|
584
|
+
}
|
|
585
|
+
pos++
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// Fallback: return current position
|
|
589
|
+
return fromOffset
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// ============================================================================
|
|
593
|
+
// Text Formatting
|
|
594
|
+
// ============================================================================
|
|
595
|
+
|
|
596
|
+
/**
|
|
597
|
+
* Format text into lines based on wrap mode.
|
|
598
|
+
*
|
|
599
|
+
* @param trim - When true, trims trailing spaces on broken lines and skips leading
|
|
600
|
+
* spaces on continuation lines. When false (e.g., text has backgroundColor),
|
|
601
|
+
* preserves trailing spaces so background color covers them. Defaults to true.
|
|
602
|
+
*/
|
|
603
|
+
export function formatTextLines(
|
|
604
|
+
text: string,
|
|
605
|
+
width: number,
|
|
606
|
+
wrap: TextProps["wrap"],
|
|
607
|
+
ctx?: PipelineContext,
|
|
608
|
+
trim = true,
|
|
609
|
+
): string[] {
|
|
610
|
+
// Guard against width <= 0 to prevent infinite loops
|
|
611
|
+
// This can happen with display="none" nodes (0x0 dimensions)
|
|
612
|
+
if (width <= 0) {
|
|
613
|
+
return []
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// Convert tabs to spaces (tabs have 0 display width in string-width library)
|
|
617
|
+
const normalizedText = text.replace(/\t/g, " ")
|
|
618
|
+
const lines = normalizedText.split("\n")
|
|
619
|
+
|
|
620
|
+
// Hard clip: truncate without ellipsis (used by Fill component)
|
|
621
|
+
if (wrap === "clip") {
|
|
622
|
+
const sliceFn = ctx ? ctx.measurer.sliceByWidth : sliceByWidth
|
|
623
|
+
return lines.map((line) => {
|
|
624
|
+
if (getTextWidth(line, ctx) <= width) return line
|
|
625
|
+
return sliceFn(line, width)
|
|
626
|
+
})
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
// No wrapping, just truncate at end
|
|
630
|
+
if (wrap === false || wrap === "truncate-end" || wrap === "truncate") {
|
|
631
|
+
return lines.map((line) => truncateText(line, width, "end", ctx))
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
if (wrap === "truncate-start") {
|
|
635
|
+
return lines.map((line) => truncateText(line, width, "start", ctx))
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
if (wrap === "truncate-middle") {
|
|
639
|
+
return lines.map((line) => truncateText(line, width, "middle", ctx))
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
// wrap === true or wrap === 'wrap' - word-aware wrapping
|
|
643
|
+
// Uses wrapText from unicode.ts with trim for rendering
|
|
644
|
+
// (when trim=true, trims trailing spaces on broken lines, skips leading spaces
|
|
645
|
+
// on continuation lines; when trim=false, preserves spaces for bg-colored text)
|
|
646
|
+
if (ctx) return ctx.measurer.wrapText(normalizedText, width, true, trim)
|
|
647
|
+
return wrapText(normalizedText, width, true, trim)
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
/**
|
|
651
|
+
* Truncate text to fit within width.
|
|
652
|
+
*/
|
|
653
|
+
export function truncateText(
|
|
654
|
+
text: string,
|
|
655
|
+
width: number,
|
|
656
|
+
mode: "start" | "middle" | "end",
|
|
657
|
+
ctx?: PipelineContext,
|
|
658
|
+
): string {
|
|
659
|
+
const textWidth = getTextWidth(text, ctx)
|
|
660
|
+
if (textWidth <= width) return text
|
|
661
|
+
|
|
662
|
+
const ellipsis = "\u2026" // ...
|
|
663
|
+
const availableWidth = width - 1 // Reserve space for ellipsis
|
|
664
|
+
|
|
665
|
+
if (availableWidth <= 0) {
|
|
666
|
+
return width > 0 ? ellipsis : ""
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
const sliceFn = ctx ? ctx.measurer.sliceByWidth : sliceByWidth
|
|
670
|
+
const sliceEndFn = ctx ? ctx.measurer.sliceByWidthFromEnd : sliceByWidthFromEnd
|
|
671
|
+
|
|
672
|
+
if (mode === "end") {
|
|
673
|
+
return sliceFn(text, availableWidth) + ellipsis
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
if (mode === "start") {
|
|
677
|
+
return ellipsis + sliceEndFn(text, availableWidth)
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
// middle
|
|
681
|
+
const halfWidth = Math.floor(availableWidth / 2)
|
|
682
|
+
const startPart = sliceFn(text, halfWidth)
|
|
683
|
+
const endPart = sliceEndFn(text, availableWidth - halfWidth)
|
|
684
|
+
return startPart + ellipsis + endPart
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
// ============================================================================
|
|
688
|
+
// Text Line Rendering
|
|
689
|
+
// ============================================================================
|
|
690
|
+
|
|
691
|
+
/**
|
|
692
|
+
* Render a single line of text to the buffer.
|
|
693
|
+
*
|
|
694
|
+
* @param maxCol - Right edge of the text node's layout area. Wide characters
|
|
695
|
+
* whose continuation cell would exceed this boundary are replaced with a
|
|
696
|
+
* space, matching terminal behavior for wide chars at the screen edge.
|
|
697
|
+
* Without this, continuation cells overflow into adjacent containers and
|
|
698
|
+
* become stale during incremental rendering (the owning container's dirty
|
|
699
|
+
* tracking doesn't cover cells outside its layout bounds).
|
|
700
|
+
*/
|
|
701
|
+
export function renderTextLine(
|
|
702
|
+
buffer: TerminalBuffer,
|
|
703
|
+
x: number,
|
|
704
|
+
y: number,
|
|
705
|
+
text: string,
|
|
706
|
+
baseStyle: Style,
|
|
707
|
+
maxCol?: number,
|
|
708
|
+
inheritedBg?: Color,
|
|
709
|
+
ctx?: PipelineContext,
|
|
710
|
+
): void {
|
|
711
|
+
// Check if text contains ANSI escape sequences
|
|
712
|
+
if (hasAnsi(text)) {
|
|
713
|
+
renderAnsiTextLine(buffer, x, y, text, baseStyle, maxCol, inheritedBg, ctx)
|
|
714
|
+
return
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
renderGraphemes(buffer, splitGraphemes(text), x, y, baseStyle, maxCol, inheritedBg, ctx)
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
/**
|
|
721
|
+
* Like renderTextLine but returns the column position after the last rendered character.
|
|
722
|
+
* Used by renderText to know where to clear remaining cells.
|
|
723
|
+
*/
|
|
724
|
+
function renderTextLineReturn(
|
|
725
|
+
buffer: TerminalBuffer,
|
|
726
|
+
x: number,
|
|
727
|
+
y: number,
|
|
728
|
+
text: string,
|
|
729
|
+
baseStyle: Style,
|
|
730
|
+
maxCol?: number,
|
|
731
|
+
inheritedBg?: Color,
|
|
732
|
+
ctx?: PipelineContext,
|
|
733
|
+
): number {
|
|
734
|
+
if (hasAnsi(text)) {
|
|
735
|
+
return renderAnsiTextLineReturn(buffer, x, y, text, baseStyle, maxCol, inheritedBg, ctx)
|
|
736
|
+
}
|
|
737
|
+
return renderGraphemes(buffer, splitGraphemes(text), x, y, baseStyle, maxCol, inheritedBg, ctx)
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
/**
|
|
741
|
+
* Render graphemes to buffer cells with proper Unicode handling.
|
|
742
|
+
* Shared by renderTextLine (plain text) and renderAnsiTextLine (per-segment).
|
|
743
|
+
*
|
|
744
|
+
* @param maxCol - Right edge of the text node's layout area (exclusive).
|
|
745
|
+
* Wide characters whose continuation cell would reach or exceed this
|
|
746
|
+
* boundary are replaced with a space character. This matches terminal
|
|
747
|
+
* behavior for wide chars at the right edge of a container and prevents
|
|
748
|
+
* continuation cells from overflowing into adjacent containers, where
|
|
749
|
+
* they become stale during incremental rendering.
|
|
750
|
+
*
|
|
751
|
+
* Returns the column position after the last rendered grapheme.
|
|
752
|
+
*/
|
|
753
|
+
function renderGraphemes(
|
|
754
|
+
buffer: TerminalBuffer,
|
|
755
|
+
graphemes: string[],
|
|
756
|
+
startCol: number,
|
|
757
|
+
y: number,
|
|
758
|
+
style: Style,
|
|
759
|
+
maxCol?: number,
|
|
760
|
+
inheritedBg?: Color,
|
|
761
|
+
ctx?: PipelineContext,
|
|
762
|
+
): number {
|
|
763
|
+
let col = startCol
|
|
764
|
+
// Effective right boundary: text node's layout edge or buffer edge
|
|
765
|
+
const rightEdge = maxCol !== undefined ? Math.min(maxCol, buffer.width) : buffer.width
|
|
766
|
+
const gWidthFn = ctx ? ctx.measurer.graphemeWidth : graphemeWidth
|
|
767
|
+
|
|
768
|
+
for (const grapheme of graphemes) {
|
|
769
|
+
if (col >= rightEdge) break
|
|
770
|
+
|
|
771
|
+
const width = gWidthFn(grapheme)
|
|
772
|
+
if (width === 0) continue
|
|
773
|
+
|
|
774
|
+
// Determine background color for this cell.
|
|
775
|
+
// Priority: 1) Text's own bg, 2) inherited bg from ancestor Box, 3) buffer read (legacy fallback).
|
|
776
|
+
// Using inherited bg instead of getCellBg decouples text rendering from buffer state,
|
|
777
|
+
// which is critical for incremental rendering: the cloned buffer may have stale bg
|
|
778
|
+
// at positions outside the parent's bg-filled region (e.g., overflow text).
|
|
779
|
+
const existingBg = style.bg !== null ? style.bg : inheritedBg !== undefined ? inheritedBg : buffer.getCellBg(col, y)
|
|
780
|
+
|
|
781
|
+
// Wide character at the boundary: the continuation cell would overflow
|
|
782
|
+
// into an adjacent container. Replace with a space to match terminal
|
|
783
|
+
// behavior (real terminals leave the last column blank for wide chars
|
|
784
|
+
// that don't fit). Without this, the continuation cell extends outside
|
|
785
|
+
// the text node's layout bounds and becomes stale during incremental
|
|
786
|
+
// rendering — the owning container's dirty flag tracking doesn't cover
|
|
787
|
+
// cells outside its layout area.
|
|
788
|
+
if (width === 2 && col + 1 >= rightEdge) {
|
|
789
|
+
buffer.setCell(col, y, {
|
|
790
|
+
char: " ",
|
|
791
|
+
fg: style.fg,
|
|
792
|
+
bg: existingBg,
|
|
793
|
+
underlineColor: style.underlineColor ?? null,
|
|
794
|
+
attrs: style.attrs,
|
|
795
|
+
wide: false,
|
|
796
|
+
continuation: false,
|
|
797
|
+
hyperlink: style.hyperlink,
|
|
798
|
+
})
|
|
799
|
+
col += 1
|
|
800
|
+
continue
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
// For text-presentation emoji, add VS16 so terminals render at 2 columns
|
|
804
|
+
const outputChar = width === 2 ? ensureEmojiPresentation(grapheme) : grapheme
|
|
805
|
+
|
|
806
|
+
buffer.setCell(col, y, {
|
|
807
|
+
char: outputChar,
|
|
808
|
+
fg: style.fg,
|
|
809
|
+
bg: existingBg,
|
|
810
|
+
underlineColor: style.underlineColor ?? null,
|
|
811
|
+
attrs: style.attrs,
|
|
812
|
+
wide: width === 2,
|
|
813
|
+
continuation: false,
|
|
814
|
+
hyperlink: style.hyperlink,
|
|
815
|
+
})
|
|
816
|
+
|
|
817
|
+
if (width === 2 && col + 1 < buffer.width) {
|
|
818
|
+
const existingBg2 =
|
|
819
|
+
style.bg !== null ? style.bg : inheritedBg !== undefined ? inheritedBg : buffer.getCellBg(col + 1, y)
|
|
820
|
+
buffer.setCell(col + 1, y, {
|
|
821
|
+
char: "",
|
|
822
|
+
fg: style.fg,
|
|
823
|
+
bg: existingBg2,
|
|
824
|
+
underlineColor: style.underlineColor ?? null,
|
|
825
|
+
attrs: style.attrs,
|
|
826
|
+
wide: false,
|
|
827
|
+
continuation: true,
|
|
828
|
+
hyperlink: style.hyperlink,
|
|
829
|
+
})
|
|
830
|
+
col += 2
|
|
831
|
+
} else {
|
|
832
|
+
col += width
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
return col
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
/**
|
|
840
|
+
* Render text line with ANSI escape sequences.
|
|
841
|
+
* Parses ANSI codes and applies styles to individual segments.
|
|
842
|
+
*/
|
|
843
|
+
export function renderAnsiTextLine(
|
|
844
|
+
buffer: TerminalBuffer,
|
|
845
|
+
x: number,
|
|
846
|
+
y: number,
|
|
847
|
+
text: string,
|
|
848
|
+
baseStyle: Style,
|
|
849
|
+
maxCol?: number,
|
|
850
|
+
inheritedBg?: Color,
|
|
851
|
+
ctx?: PipelineContext,
|
|
852
|
+
): void {
|
|
853
|
+
renderAnsiTextLineReturn(buffer, x, y, text, baseStyle, maxCol, inheritedBg, ctx)
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
/**
|
|
857
|
+
* Like renderAnsiTextLine but returns the column position after the last rendered character.
|
|
858
|
+
*/
|
|
859
|
+
function renderAnsiTextLineReturn(
|
|
860
|
+
buffer: TerminalBuffer,
|
|
861
|
+
x: number,
|
|
862
|
+
y: number,
|
|
863
|
+
text: string,
|
|
864
|
+
baseStyle: Style,
|
|
865
|
+
maxCol?: number,
|
|
866
|
+
inheritedBg?: Color,
|
|
867
|
+
ctx?: PipelineContext,
|
|
868
|
+
): number {
|
|
869
|
+
const segments = parseAnsiText(text)
|
|
870
|
+
let col = x
|
|
871
|
+
|
|
872
|
+
for (const segment of segments) {
|
|
873
|
+
// Merge segment style with base style
|
|
874
|
+
const style = mergeAnsiStyle(baseStyle, segment)
|
|
875
|
+
|
|
876
|
+
// Detect background conflict: chalk.bg* overwrites existing silvery background
|
|
877
|
+
// Check both: 1) Text's own backgroundColor, 2) Parent Box's bg already in buffer
|
|
878
|
+
// Skip if segment has bgOverride flag (explicit opt-out via ansi.bgOverride)
|
|
879
|
+
const effectiveBgConflictMode = ctx?.bgConflictMode ?? getBgConflictMode()
|
|
880
|
+
if (
|
|
881
|
+
effectiveBgConflictMode !== "ignore" &&
|
|
882
|
+
!segment.bgOverride &&
|
|
883
|
+
segment.bg !== undefined &&
|
|
884
|
+
segment.bg !== null
|
|
885
|
+
) {
|
|
886
|
+
// Check if there's an existing background (from Text prop or parent Box fill)
|
|
887
|
+
const existingBufBg = col < buffer.width ? buffer.getCellBg(col, y) : null
|
|
888
|
+
const hasExistingBg = baseStyle.bg !== null || existingBufBg !== null
|
|
889
|
+
|
|
890
|
+
if (hasExistingBg) {
|
|
891
|
+
const preview = segment.text.slice(0, 30)
|
|
892
|
+
const msg = `[silvery] Background conflict: chalk.bg* on text that already has silvery background. Chalk bg will override only text characters, causing visual gaps in padding. Use ansi.bgOverride() to suppress if intentional. Text: "${preview}${segment.text.length > 30 ? "..." : ""}"`
|
|
893
|
+
|
|
894
|
+
if (effectiveBgConflictMode === "throw") {
|
|
895
|
+
throw new Error(msg)
|
|
896
|
+
}
|
|
897
|
+
// 'warn' mode - deduplicate
|
|
898
|
+
const effectiveWarnedBgConflicts = ctx?.warnedBgConflicts ?? warnedBgConflicts
|
|
899
|
+
const key = `${JSON.stringify(existingBufBg)}-${segment.bg}-${preview}`
|
|
900
|
+
if (!effectiveWarnedBgConflicts.has(key)) {
|
|
901
|
+
effectiveWarnedBgConflicts.add(key)
|
|
902
|
+
console.warn(msg)
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
col = renderGraphemes(buffer, splitGraphemes(segment.text), col, y, style, maxCol, inheritedBg, ctx)
|
|
908
|
+
}
|
|
909
|
+
return col
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
// ============================================================================
|
|
913
|
+
// Style Merging (Category-Based)
|
|
914
|
+
// ============================================================================
|
|
915
|
+
|
|
916
|
+
/**
|
|
917
|
+
* Options for category-based style merging.
|
|
918
|
+
*/
|
|
919
|
+
export interface MergeStylesOptions {
|
|
920
|
+
/**
|
|
921
|
+
* Preserve decoration attributes through layers (OR merge).
|
|
922
|
+
* Affects: underline, underlineStyle, underlineColor, strikethrough
|
|
923
|
+
* Default: true
|
|
924
|
+
*/
|
|
925
|
+
preserveDecorations?: boolean
|
|
926
|
+
/**
|
|
927
|
+
* Preserve emphasis attributes through layers (OR merge).
|
|
928
|
+
* Affects: bold, dim, italic
|
|
929
|
+
* Default: true
|
|
930
|
+
*/
|
|
931
|
+
preserveEmphasis?: boolean
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
/**
|
|
935
|
+
* Merge two styles using category-based semantics.
|
|
936
|
+
*
|
|
937
|
+
* Categories and their merge behavior:
|
|
938
|
+
* - Container (bg): overlay replaces base
|
|
939
|
+
* - Text (fg): overlay replaces base
|
|
940
|
+
* - Decorations (underline*, strikethrough): OR merge if preserveDecorations=true
|
|
941
|
+
* - Emphasis (bold, dim, italic): OR merge if preserveEmphasis=true
|
|
942
|
+
* - Transform (inverse, hidden, blink): overlay only, not inherited
|
|
943
|
+
*
|
|
944
|
+
* @param base - The base style (from parent/container)
|
|
945
|
+
* @param overlay - The overlay style (from child/content)
|
|
946
|
+
* @param options - Merge behavior options
|
|
947
|
+
*/
|
|
948
|
+
export function mergeStyles(base: Style, overlay: Partial<Style>, options: MergeStylesOptions = {}): Style {
|
|
949
|
+
const { preserveDecorations = true, preserveEmphasis = true } = options
|
|
950
|
+
|
|
951
|
+
const baseAttrs = base.attrs ?? {}
|
|
952
|
+
const overlayAttrs = overlay.attrs ?? {}
|
|
953
|
+
|
|
954
|
+
// Merge attributes by category
|
|
955
|
+
const attrs: CellAttrs = {}
|
|
956
|
+
|
|
957
|
+
// Decorations: OR if preserving, otherwise overlay takes precedence
|
|
958
|
+
if (preserveDecorations) {
|
|
959
|
+
// Underline: OR the boolean, but style from overlay wins if specified
|
|
960
|
+
const hasBaseUnderline = baseAttrs.underline || baseAttrs.underlineStyle
|
|
961
|
+
const hasOverlayUnderline = overlayAttrs.underline || overlayAttrs.underlineStyle
|
|
962
|
+
if (hasBaseUnderline || hasOverlayUnderline) {
|
|
963
|
+
attrs.underline = true
|
|
964
|
+
// Style: overlay wins if specified, else base
|
|
965
|
+
attrs.underlineStyle = overlayAttrs.underlineStyle ?? baseAttrs.underlineStyle ?? "single"
|
|
966
|
+
}
|
|
967
|
+
attrs.strikethrough = overlayAttrs.strikethrough || baseAttrs.strikethrough
|
|
968
|
+
} else {
|
|
969
|
+
attrs.underline = overlayAttrs.underline ?? baseAttrs.underline
|
|
970
|
+
attrs.underlineStyle = overlayAttrs.underlineStyle ?? baseAttrs.underlineStyle
|
|
971
|
+
attrs.strikethrough = overlayAttrs.strikethrough ?? baseAttrs.strikethrough
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
// Emphasis: OR if preserving
|
|
975
|
+
if (preserveEmphasis) {
|
|
976
|
+
attrs.bold = overlayAttrs.bold || baseAttrs.bold
|
|
977
|
+
attrs.dim = overlayAttrs.dim || baseAttrs.dim
|
|
978
|
+
attrs.italic = overlayAttrs.italic || baseAttrs.italic
|
|
979
|
+
} else {
|
|
980
|
+
attrs.bold = overlayAttrs.bold ?? baseAttrs.bold
|
|
981
|
+
attrs.dim = overlayAttrs.dim ?? baseAttrs.dim
|
|
982
|
+
attrs.italic = overlayAttrs.italic ?? baseAttrs.italic
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
// Transform: overlay only, not inherited from base
|
|
986
|
+
attrs.inverse = overlayAttrs.inverse
|
|
987
|
+
attrs.hidden = overlayAttrs.hidden
|
|
988
|
+
attrs.blink = overlayAttrs.blink
|
|
989
|
+
|
|
990
|
+
return {
|
|
991
|
+
// Container/Text: overlay wins if specified
|
|
992
|
+
fg: overlay.fg ?? base.fg,
|
|
993
|
+
bg: overlay.bg ?? base.bg,
|
|
994
|
+
// Underline color: always use overlay ?? base (part of decoration preservation)
|
|
995
|
+
underlineColor: overlay.underlineColor ?? base.underlineColor,
|
|
996
|
+
attrs,
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
// ============================================================================
|
|
1001
|
+
// ANSI Style Helpers
|
|
1002
|
+
// ============================================================================
|
|
1003
|
+
|
|
1004
|
+
/**
|
|
1005
|
+
* Merge ANSI segment style with base style.
|
|
1006
|
+
* Uses category-based merging to preserve decorations and emphasis.
|
|
1007
|
+
*/
|
|
1008
|
+
function mergeAnsiStyle(base: Style, segment: StyledSegment, options: MergeStylesOptions = {}): Style {
|
|
1009
|
+
const { preserveDecorations = true, preserveEmphasis = true } = options
|
|
1010
|
+
|
|
1011
|
+
// Convert ANSI SGR codes to overlay style
|
|
1012
|
+
let fg: Color = base.fg
|
|
1013
|
+
let bg: Color = base.bg
|
|
1014
|
+
let underlineColor: Color = base.underlineColor ?? null
|
|
1015
|
+
|
|
1016
|
+
if (segment.fg !== undefined && segment.fg !== null) {
|
|
1017
|
+
fg = ansiColorToColor(segment.fg)
|
|
1018
|
+
}
|
|
1019
|
+
if (segment.bg !== undefined && segment.bg !== null) {
|
|
1020
|
+
bg = ansiColorToColor(segment.bg)
|
|
1021
|
+
}
|
|
1022
|
+
if (segment.underlineColor !== undefined && segment.underlineColor !== null) {
|
|
1023
|
+
underlineColor = ansiColorToColor(segment.underlineColor)
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
// Build overlay attrs from segment
|
|
1027
|
+
const overlayAttrs: CellAttrs = {}
|
|
1028
|
+
if (segment.bold !== undefined) overlayAttrs.bold = segment.bold
|
|
1029
|
+
if (segment.dim !== undefined) overlayAttrs.dim = segment.dim
|
|
1030
|
+
if (segment.italic !== undefined) overlayAttrs.italic = segment.italic
|
|
1031
|
+
if (segment.underline !== undefined) {
|
|
1032
|
+
overlayAttrs.underline = segment.underline
|
|
1033
|
+
}
|
|
1034
|
+
if (segment.underlineStyle !== undefined) {
|
|
1035
|
+
overlayAttrs.underlineStyle = segment.underlineStyle as UnderlineStyle
|
|
1036
|
+
}
|
|
1037
|
+
if (segment.inverse !== undefined) overlayAttrs.inverse = segment.inverse
|
|
1038
|
+
|
|
1039
|
+
// Use mergeStyles for consistent category-based merging
|
|
1040
|
+
const merged = mergeStyles(
|
|
1041
|
+
base,
|
|
1042
|
+
{ fg, bg, underlineColor, attrs: overlayAttrs },
|
|
1043
|
+
{ preserveDecorations, preserveEmphasis },
|
|
1044
|
+
)
|
|
1045
|
+
|
|
1046
|
+
// Pass through OSC 8 hyperlink from segment (not an SGR attribute)
|
|
1047
|
+
if (segment.hyperlink) {
|
|
1048
|
+
merged.hyperlink = segment.hyperlink
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
return merged
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
/**
|
|
1055
|
+
* Convert ANSI SGR color code to our Color type.
|
|
1056
|
+
* Color is: number (256-color index) | { r, g, b } (true color) | null
|
|
1057
|
+
*/
|
|
1058
|
+
function ansiColorToColor(code: number): Color {
|
|
1059
|
+
// True color (packed RGB with 0x1000000 marker from parseAnsiText)
|
|
1060
|
+
if (code >= 0x1000000) {
|
|
1061
|
+
const r = (code >> 16) & 0xff
|
|
1062
|
+
const g = (code >> 8) & 0xff
|
|
1063
|
+
const b = code & 0xff
|
|
1064
|
+
return { r, g, b }
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
// 256 color palette index (0-255)
|
|
1068
|
+
if (code < 30 || (code >= 38 && code < 40) || (code >= 48 && code < 90)) {
|
|
1069
|
+
// Direct palette index (0-255) — return as-is
|
|
1070
|
+
return code
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
// Standard foreground colors (30-37) map to palette 0-7
|
|
1074
|
+
if (code >= 30 && code <= 37) {
|
|
1075
|
+
return code - 30
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
// Standard background colors (40-47) map to palette 0-7
|
|
1079
|
+
if (code >= 40 && code <= 47) {
|
|
1080
|
+
return code - 40
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
// Bright foreground colors (90-97) map to palette 8-15
|
|
1084
|
+
if (code >= 90 && code <= 97) {
|
|
1085
|
+
return code - 90 + 8
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
// Bright background colors (100-107) map to palette 8-15
|
|
1089
|
+
if (code >= 100 && code <= 107) {
|
|
1090
|
+
return code - 100 + 8
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
return null
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
// ============================================================================
|
|
1097
|
+
// Render Text Node (Main Entry Point)
|
|
1098
|
+
// ============================================================================
|
|
1099
|
+
|
|
1100
|
+
/**
|
|
1101
|
+
* Render a Text node.
|
|
1102
|
+
*
|
|
1103
|
+
* Background colors from nested Text elements are handled at the buffer level
|
|
1104
|
+
* (not via ANSI codes) to prevent bg bleed across wrapped text lines.
|
|
1105
|
+
* See km-silvery.bg-bleed for details.
|
|
1106
|
+
*/
|
|
1107
|
+
export function renderText(
|
|
1108
|
+
node: TeaNode,
|
|
1109
|
+
buffer: TerminalBuffer,
|
|
1110
|
+
layout: { x: number; y: number; width: number; height: number },
|
|
1111
|
+
props: TextProps,
|
|
1112
|
+
nodeState: NodeRenderState,
|
|
1113
|
+
inheritedBg?: Color,
|
|
1114
|
+
inheritedFg?: Color,
|
|
1115
|
+
ctx?: PipelineContext,
|
|
1116
|
+
): void {
|
|
1117
|
+
const { scrollOffset, clipBounds } = nodeState
|
|
1118
|
+
const { x, width, height } = layout
|
|
1119
|
+
let { y } = layout
|
|
1120
|
+
|
|
1121
|
+
// Apply scroll offset
|
|
1122
|
+
y -= scrollOffset
|
|
1123
|
+
|
|
1124
|
+
// Explicit backgroundColor="" on a Text node means "no background" — force
|
|
1125
|
+
// null bg to override both inherited bg from ancestor Boxes and any bg
|
|
1126
|
+
// already in the buffer cells (set by Box's renderBox fill). The sentinel
|
|
1127
|
+
// value `null` is used instead of `undefined` so renderGraphemes uses it
|
|
1128
|
+
// directly instead of falling back to buffer.getCellBg().
|
|
1129
|
+
if (props.backgroundColor === "") {
|
|
1130
|
+
inheritedBg = null
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
// Clip to bounds if specified
|
|
1134
|
+
if (clipBounds) {
|
|
1135
|
+
if (y + height <= clipBounds.top || y >= clipBounds.bottom) {
|
|
1136
|
+
return // Completely outside vertical clip bounds
|
|
1137
|
+
}
|
|
1138
|
+
if (clipBounds.left !== undefined && clipBounds.right !== undefined) {
|
|
1139
|
+
if (x + width <= clipBounds.left || x >= clipBounds.right) {
|
|
1140
|
+
return // Completely outside horizontal clip bounds
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
// Compute DOM-level display width budget for truncate-end modes.
|
|
1146
|
+
// This limits how much text collectTextWithBg gathers BEFORE ANSI serialization,
|
|
1147
|
+
// making OSC 8 hyperlinks and other escape sequences safe by construction.
|
|
1148
|
+
// Only applies to end-truncation (truncate, truncate-end, false) where we keep
|
|
1149
|
+
// text from the start. Start/middle truncation keep text from the end or both
|
|
1150
|
+
// ends, so they fall back to ANSI-level truncation in formatTextLines.
|
|
1151
|
+
// Budget is width + 1 display columns per line to ensure formatTextLines sees
|
|
1152
|
+
// text wider than the container and adds the ellipsis character.
|
|
1153
|
+
let maxDisplayWidth: number | undefined
|
|
1154
|
+
const isTruncateEnd =
|
|
1155
|
+
props.wrap === false || props.wrap === "truncate-end" || props.wrap === "truncate" || props.wrap === "clip"
|
|
1156
|
+
if (isTruncateEnd && width > 0) {
|
|
1157
|
+
const plainText = collectPlainText(node)
|
|
1158
|
+
const lineCount = (plainText.match(/\n/g)?.length ?? 0) + 1
|
|
1159
|
+
// Each line needs width+1 columns to trigger ellipsis. Multiply by line count.
|
|
1160
|
+
maxDisplayWidth = (width + 1) * lineCount
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
// Collect text content and background segments from this node and all children.
|
|
1164
|
+
// Background color from nested Text elements is tracked as BgSegments
|
|
1165
|
+
// (not embedded as ANSI codes) to survive text wrapping correctly.
|
|
1166
|
+
const { text, bgSegments } = collectTextWithBg(node, {}, 0, maxDisplayWidth, ctx)
|
|
1167
|
+
|
|
1168
|
+
// Get style for this Text node.
|
|
1169
|
+
// Inherit foreground from nearest ancestor Box with color prop (CSS semantics).
|
|
1170
|
+
const style = getTextStyle(props)
|
|
1171
|
+
if (style.fg === null && inheritedFg !== undefined) {
|
|
1172
|
+
style.fg = inheritedFg
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
// Handle wrapping/truncation
|
|
1176
|
+
// When text has background color (from own prop, nested children, or inherited
|
|
1177
|
+
// from parent Box), preserve trailing spaces on wrapped lines so the background
|
|
1178
|
+
// color covers them. Ink preserves these spaces for the same reason.
|
|
1179
|
+
const hasBg = style.bg !== null || bgSegments.length > 0 || (inheritedBg !== undefined && inheritedBg !== null)
|
|
1180
|
+
let lines = formatTextLines(text, width, props.wrap, ctx, !hasBg)
|
|
1181
|
+
|
|
1182
|
+
// Apply internal_transform if present (used by Transform component).
|
|
1183
|
+
// Transform is applied per-line after formatting, matching ink's behavior.
|
|
1184
|
+
// The transform may change the length of each line (e.g., adding brackets
|
|
1185
|
+
// or line numbers). We track whether a transform is active so the rendering
|
|
1186
|
+
// loop can expand maxCol to accommodate the transformed content.
|
|
1187
|
+
const internalTransform = props.internal_transform
|
|
1188
|
+
if (internalTransform) {
|
|
1189
|
+
lines = lines.map((line, index) => internalTransform(line, index))
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
// Map formatted lines back to display-width offsets for bg segment application
|
|
1193
|
+
const lineOffsets = bgSegments.length > 0 ? mapLinesToCharOffsets(text, lines, ctx) : []
|
|
1194
|
+
|
|
1195
|
+
// Render each line
|
|
1196
|
+
for (let lineIdx = 0; lineIdx < lines.length && lineIdx < height; lineIdx++) {
|
|
1197
|
+
const lineY = y + lineIdx
|
|
1198
|
+
// Skip lines outside clip bounds
|
|
1199
|
+
if (clipBounds && (lineY < clipBounds.top || lineY >= clipBounds.bottom)) {
|
|
1200
|
+
continue
|
|
1201
|
+
}
|
|
1202
|
+
const line = lines[lineIdx]!
|
|
1203
|
+
|
|
1204
|
+
// Pass maxCol to prevent wide characters from overflowing into adjacent
|
|
1205
|
+
// containers. Without this, continuation cells outside the text node's
|
|
1206
|
+
// layout bounds become stale during incremental rendering.
|
|
1207
|
+
// Clip right edge to horizontal clip bounds (overflow:hidden containers).
|
|
1208
|
+
// When internal_transform is active, expand maxCol to buffer width so the
|
|
1209
|
+
// transformed text (which may be wider than the original layout) is not clipped.
|
|
1210
|
+
const layoutRight = internalTransform ? buffer.width : x + width
|
|
1211
|
+
const maxCol =
|
|
1212
|
+
clipBounds && "right" in clipBounds && clipBounds.right !== undefined
|
|
1213
|
+
? Math.min(layoutRight, clipBounds.right)
|
|
1214
|
+
: layoutRight
|
|
1215
|
+
const endCol = renderTextLineReturn(buffer, x, lineY, line, style, maxCol, inheritedBg, ctx)
|
|
1216
|
+
|
|
1217
|
+
// Clear remaining cells after text to end of layout width (clipped).
|
|
1218
|
+
// When text content shrinks (e.g., breadcrumb changes from long to short path),
|
|
1219
|
+
// the parent Box may skip its bg fill (skipBgFill=true when only subtreeDirty).
|
|
1220
|
+
// Without explicit clearing here, stale chars from the previous longer text
|
|
1221
|
+
// survive in the cloned buffer. This is safe: we only clear within our own
|
|
1222
|
+
// layout area, writing spaces with the correct inherited background.
|
|
1223
|
+
if (endCol < maxCol) {
|
|
1224
|
+
const clearBg = inheritedBg ?? null
|
|
1225
|
+
for (let cx = endCol; cx < maxCol && cx < buffer.width; cx++) {
|
|
1226
|
+
buffer.setCell(cx, lineY, {
|
|
1227
|
+
char: " ",
|
|
1228
|
+
fg: style.fg,
|
|
1229
|
+
bg: clearBg,
|
|
1230
|
+
underlineColor: null,
|
|
1231
|
+
attrs: {
|
|
1232
|
+
bold: false,
|
|
1233
|
+
dim: false,
|
|
1234
|
+
italic: false,
|
|
1235
|
+
underline: false,
|
|
1236
|
+
inverse: false,
|
|
1237
|
+
strikethrough: false,
|
|
1238
|
+
blink: false,
|
|
1239
|
+
hidden: false,
|
|
1240
|
+
},
|
|
1241
|
+
wide: false,
|
|
1242
|
+
continuation: false,
|
|
1243
|
+
})
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
// Apply background segments from nested Text elements to the buffer.
|
|
1248
|
+
// This happens after renderTextLine so the bg is applied to cells
|
|
1249
|
+
// that already have the correct character/fg/attrs written.
|
|
1250
|
+
if (bgSegments.length > 0 && lineIdx < lineOffsets.length) {
|
|
1251
|
+
const { start, end } = lineOffsets[lineIdx]!
|
|
1252
|
+
applyBgSegmentsToLine(buffer, x, lineY, line, start, end, bgSegments, ctx)
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
}
|