@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,343 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Box Rendering - Functions for rendering box elements to the buffer.
|
|
3
|
+
*
|
|
4
|
+
* Contains:
|
|
5
|
+
* - Box rendering (renderBox)
|
|
6
|
+
* - Border rendering (renderBorder)
|
|
7
|
+
* - Scroll indicators (renderScrollIndicators)
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { Color, Style, TerminalBuffer } from "../buffer"
|
|
11
|
+
import type { BoxProps, TeaNode, Rect } from "@silvery/tea/types"
|
|
12
|
+
import { getPadding } from "./helpers"
|
|
13
|
+
import { getBorderChars, getBorderSize, parseColor } from "./render-helpers"
|
|
14
|
+
import { renderTextLine } from "./render-text"
|
|
15
|
+
import type { NodeRenderState, PipelineContext } from "./types"
|
|
16
|
+
|
|
17
|
+
// ============================================================================
|
|
18
|
+
// Box Rendering
|
|
19
|
+
// ============================================================================
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Render a Box node.
|
|
23
|
+
*/
|
|
24
|
+
export function renderBox(
|
|
25
|
+
_node: TeaNode,
|
|
26
|
+
buffer: TerminalBuffer,
|
|
27
|
+
layout: Rect,
|
|
28
|
+
props: BoxProps,
|
|
29
|
+
nodeState: NodeRenderState,
|
|
30
|
+
skipBgFill = false,
|
|
31
|
+
inheritedBg?: Color | null,
|
|
32
|
+
): void {
|
|
33
|
+
const { scrollOffset, clipBounds } = nodeState
|
|
34
|
+
const { x, width, height } = layout
|
|
35
|
+
// Apply scroll offset to y position
|
|
36
|
+
const y = layout.y - scrollOffset
|
|
37
|
+
|
|
38
|
+
// Skip if completely outside clip bounds
|
|
39
|
+
if (clipBounds) {
|
|
40
|
+
if (y + height <= clipBounds.top || y >= clipBounds.bottom) return
|
|
41
|
+
if (clipBounds.left !== undefined && clipBounds.right !== undefined) {
|
|
42
|
+
if (x + width <= clipBounds.left || x >= clipBounds.right) return
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Fill background if set.
|
|
47
|
+
// In incremental mode, skipBgFill=true when the box itself hasn't changed
|
|
48
|
+
// (only subtreeDirty). The cloned buffer already has the correct bg fill,
|
|
49
|
+
// and re-filling would destroy child pixels that won't be repainted.
|
|
50
|
+
if (props.backgroundColor && !skipBgFill) {
|
|
51
|
+
const bg = parseColor(props.backgroundColor)
|
|
52
|
+
// Clip background fill to bounds
|
|
53
|
+
if (clipBounds) {
|
|
54
|
+
const clippedY = Math.max(y, clipBounds.top)
|
|
55
|
+
const clippedHeight = Math.min(y + height, clipBounds.bottom) - clippedY
|
|
56
|
+
let clippedX = x
|
|
57
|
+
let clippedWidth = width
|
|
58
|
+
if (clipBounds.left !== undefined && clipBounds.right !== undefined) {
|
|
59
|
+
clippedX = Math.max(x, clipBounds.left)
|
|
60
|
+
clippedWidth = Math.min(x + width, clipBounds.right) - clippedX
|
|
61
|
+
}
|
|
62
|
+
if (clippedHeight > 0 && clippedWidth > 0) {
|
|
63
|
+
buffer.fill(clippedX, clippedY, clippedWidth, clippedHeight, { bg })
|
|
64
|
+
}
|
|
65
|
+
} else {
|
|
66
|
+
buffer.fill(x, y, width, height, { bg })
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Render border if set
|
|
71
|
+
if (props.borderStyle) {
|
|
72
|
+
renderBorder(buffer, x, y, width, height, props, clipBounds, inheritedBg)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ============================================================================
|
|
77
|
+
// Border Rendering
|
|
78
|
+
// ============================================================================
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Render a border around a box.
|
|
82
|
+
*/
|
|
83
|
+
export function renderBorder(
|
|
84
|
+
buffer: TerminalBuffer,
|
|
85
|
+
x: number,
|
|
86
|
+
y: number,
|
|
87
|
+
width: number,
|
|
88
|
+
height: number,
|
|
89
|
+
props: BoxProps,
|
|
90
|
+
clipBounds?: { top: number; bottom: number; left?: number; right?: number },
|
|
91
|
+
inheritedBg?: Color | null,
|
|
92
|
+
): void {
|
|
93
|
+
const chars = getBorderChars(props.borderStyle ?? "single")
|
|
94
|
+
const color = props.borderColor ? parseColor(props.borderColor) : null
|
|
95
|
+
// Preserve the box's background color on border cells. Falls back to
|
|
96
|
+
// inherited bg from the nearest ancestor with backgroundColor, ensuring
|
|
97
|
+
// border cells don't punch transparent holes through parent backgrounds.
|
|
98
|
+
const bg = props.backgroundColor ? parseColor(props.backgroundColor) : (inheritedBg ?? null)
|
|
99
|
+
|
|
100
|
+
const showTop = props.borderTop !== false
|
|
101
|
+
const showBottom = props.borderBottom !== false
|
|
102
|
+
const showLeft = props.borderLeft !== false
|
|
103
|
+
const showRight = props.borderRight !== false
|
|
104
|
+
|
|
105
|
+
// Helper to check if a row is visible within clip bounds
|
|
106
|
+
const isRowVisible = (row: number): boolean => {
|
|
107
|
+
if (!clipBounds) return row >= 0 && row < buffer.height
|
|
108
|
+
return row >= clipBounds.top && row < clipBounds.bottom && row < buffer.height
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Helper to check if a column is visible within clip bounds
|
|
112
|
+
const isColVisible = (col: number): boolean => {
|
|
113
|
+
if (clipBounds?.left === undefined || clipBounds.right === undefined) return col >= 0 && col < buffer.width
|
|
114
|
+
return col >= clipBounds.left && col < clipBounds.right && col < buffer.width
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Top border
|
|
118
|
+
if (showTop && isRowVisible(y)) {
|
|
119
|
+
if (showLeft && isColVisible(x)) buffer.setCell(x, y, { char: chars.topLeft, fg: color, bg })
|
|
120
|
+
const hStart = showLeft ? x + 1 : x
|
|
121
|
+
const hEnd = showRight ? x + width - 1 : x + width
|
|
122
|
+
for (let col = hStart; col < hEnd && col < buffer.width; col++) {
|
|
123
|
+
if (isColVisible(col)) buffer.setCell(col, y, { char: chars.horizontal, fg: color, bg })
|
|
124
|
+
}
|
|
125
|
+
if (showRight && x + width - 1 < buffer.width && isColVisible(x + width - 1)) {
|
|
126
|
+
buffer.setCell(x + width - 1, y, { char: chars.topRight, fg: color, bg })
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Side borders — extend range when top/bottom borders are hidden
|
|
131
|
+
const rightVertical = chars.rightVertical ?? chars.vertical
|
|
132
|
+
const sideStart = showTop ? y + 1 : y
|
|
133
|
+
const sideEnd = showBottom ? y + height - 1 : y + height
|
|
134
|
+
for (let row = sideStart; row < sideEnd; row++) {
|
|
135
|
+
if (!isRowVisible(row)) continue
|
|
136
|
+
if (showLeft && isColVisible(x)) buffer.setCell(x, row, { char: chars.vertical, fg: color, bg })
|
|
137
|
+
if (showRight && x + width - 1 < buffer.width && isColVisible(x + width - 1)) {
|
|
138
|
+
buffer.setCell(x + width - 1, row, { char: rightVertical, fg: color, bg })
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Bottom border
|
|
143
|
+
const bottomHorizontal = chars.bottomHorizontal ?? chars.horizontal
|
|
144
|
+
const bottomY = y + height - 1
|
|
145
|
+
if (showBottom && isRowVisible(bottomY)) {
|
|
146
|
+
if (showLeft && isColVisible(x)) {
|
|
147
|
+
buffer.setCell(x, bottomY, { char: chars.bottomLeft, fg: color, bg })
|
|
148
|
+
}
|
|
149
|
+
const bStart = showLeft ? x + 1 : x
|
|
150
|
+
const bEnd = showRight ? x + width - 1 : x + width
|
|
151
|
+
for (let col = bStart; col < bEnd && col < buffer.width; col++) {
|
|
152
|
+
if (isColVisible(col)) buffer.setCell(col, bottomY, { char: bottomHorizontal, fg: color, bg })
|
|
153
|
+
}
|
|
154
|
+
if (showRight && x + width - 1 < buffer.width && isColVisible(x + width - 1)) {
|
|
155
|
+
buffer.setCell(x + width - 1, bottomY, {
|
|
156
|
+
char: chars.bottomRight,
|
|
157
|
+
fg: color,
|
|
158
|
+
bg,
|
|
159
|
+
})
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ============================================================================
|
|
165
|
+
// Outline Rendering
|
|
166
|
+
// ============================================================================
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Render an outline around a box.
|
|
170
|
+
*
|
|
171
|
+
* Unlike borders, outlines do NOT affect layout dimensions. They draw border
|
|
172
|
+
* characters that OVERLAP the content area at the node's screen rect edges.
|
|
173
|
+
* This is the CSS `outline` equivalent for terminal UI.
|
|
174
|
+
*/
|
|
175
|
+
export function renderOutline(
|
|
176
|
+
buffer: TerminalBuffer,
|
|
177
|
+
x: number,
|
|
178
|
+
y: number,
|
|
179
|
+
width: number,
|
|
180
|
+
height: number,
|
|
181
|
+
props: BoxProps,
|
|
182
|
+
clipBounds?: { top: number; bottom: number; left?: number; right?: number },
|
|
183
|
+
inheritedBg?: Color | null,
|
|
184
|
+
): void {
|
|
185
|
+
const chars = getBorderChars(props.outlineStyle ?? "single")
|
|
186
|
+
const color = props.outlineColor ? parseColor(props.outlineColor) : null
|
|
187
|
+
const bg = props.backgroundColor ? parseColor(props.backgroundColor) : (inheritedBg ?? null)
|
|
188
|
+
const attrs = props.outlineDimColor ? { dim: true } : {}
|
|
189
|
+
|
|
190
|
+
// Helper to check if a row is visible within clip bounds
|
|
191
|
+
const isRowVisible = (row: number): boolean => {
|
|
192
|
+
if (!clipBounds) return row >= 0 && row < buffer.height
|
|
193
|
+
return row >= clipBounds.top && row < clipBounds.bottom && row < buffer.height
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Helper to check if a column is visible within clip bounds
|
|
197
|
+
const isColVisible = (col: number): boolean => {
|
|
198
|
+
if (clipBounds?.left === undefined || clipBounds.right === undefined) return col >= 0 && col < buffer.width
|
|
199
|
+
return col >= clipBounds.left && col < clipBounds.right && col < buffer.width
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const showTop = props.outlineTop !== false
|
|
203
|
+
const showBottom = props.outlineBottom !== false
|
|
204
|
+
const showLeft = props.outlineLeft !== false
|
|
205
|
+
const showRight = props.outlineRight !== false
|
|
206
|
+
|
|
207
|
+
// Top border
|
|
208
|
+
if (showTop && isRowVisible(y)) {
|
|
209
|
+
if (showLeft && isColVisible(x)) buffer.setCell(x, y, { char: chars.topLeft, fg: color, bg, attrs })
|
|
210
|
+
for (let col = x + 1; col < x + width - 1 && col < buffer.width; col++) {
|
|
211
|
+
if (isColVisible(col)) buffer.setCell(col, y, { char: chars.horizontal, fg: color, bg, attrs })
|
|
212
|
+
}
|
|
213
|
+
if (showRight && x + width - 1 < buffer.width && isColVisible(x + width - 1)) {
|
|
214
|
+
buffer.setCell(x + width - 1, y, { char: chars.topRight, fg: color, bg, attrs })
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Side borders — extend range when top/bottom are hidden
|
|
219
|
+
const outlineRightVertical = chars.rightVertical ?? chars.vertical
|
|
220
|
+
const sideStart = showTop ? y + 1 : y
|
|
221
|
+
const sideEnd = showBottom ? y + height - 1 : y + height
|
|
222
|
+
for (let row = sideStart; row < sideEnd; row++) {
|
|
223
|
+
if (!isRowVisible(row)) continue
|
|
224
|
+
if (showLeft && isColVisible(x)) buffer.setCell(x, row, { char: chars.vertical, fg: color, bg, attrs })
|
|
225
|
+
if (showRight && x + width - 1 < buffer.width && isColVisible(x + width - 1)) {
|
|
226
|
+
buffer.setCell(x + width - 1, row, { char: outlineRightVertical, fg: color, bg, attrs })
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Bottom border
|
|
231
|
+
const outlineBottomHorizontal = chars.bottomHorizontal ?? chars.horizontal
|
|
232
|
+
const bottomY = y + height - 1
|
|
233
|
+
if (showBottom && isRowVisible(bottomY)) {
|
|
234
|
+
if (showLeft && isColVisible(x)) {
|
|
235
|
+
buffer.setCell(x, bottomY, { char: chars.bottomLeft, fg: color, bg, attrs })
|
|
236
|
+
}
|
|
237
|
+
for (let col = x + 1; col < x + width - 1 && col < buffer.width; col++) {
|
|
238
|
+
if (isColVisible(col)) buffer.setCell(col, bottomY, { char: outlineBottomHorizontal, fg: color, bg, attrs })
|
|
239
|
+
}
|
|
240
|
+
if (showRight && x + width - 1 < buffer.width && isColVisible(x + width - 1)) {
|
|
241
|
+
buffer.setCell(x + width - 1, bottomY, {
|
|
242
|
+
char: chars.bottomRight,
|
|
243
|
+
fg: color,
|
|
244
|
+
bg,
|
|
245
|
+
attrs,
|
|
246
|
+
})
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// ============================================================================
|
|
252
|
+
// Scroll Indicators
|
|
253
|
+
// ============================================================================
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Render scroll indicators showing hidden items above/below viewport.
|
|
257
|
+
*
|
|
258
|
+
* Two rendering modes:
|
|
259
|
+
* 1. Bordered containers: Indicators appear on the border (e.g., "───▲42───")
|
|
260
|
+
* 2. Borderless containers with overflowIndicator: Indicators appear directly
|
|
261
|
+
* after the last visible child (not at the viewport bottom)
|
|
262
|
+
*
|
|
263
|
+
* Uses ▲N for items hidden above, ▼N for items hidden below.
|
|
264
|
+
*/
|
|
265
|
+
export function renderScrollIndicators(
|
|
266
|
+
_node: TeaNode,
|
|
267
|
+
buffer: TerminalBuffer,
|
|
268
|
+
layout: Rect,
|
|
269
|
+
props: BoxProps,
|
|
270
|
+
ss: NonNullable<TeaNode["scrollState"]>,
|
|
271
|
+
ctx?: PipelineContext,
|
|
272
|
+
): void {
|
|
273
|
+
const border = props.borderStyle ? getBorderSize(props) : { top: 0, bottom: 0, left: 0, right: 0 }
|
|
274
|
+
|
|
275
|
+
// Inverse bar style: white text on dark background
|
|
276
|
+
const indicatorStyle: Style = {
|
|
277
|
+
fg: 15, // Bright white
|
|
278
|
+
bg: 8, // Dark gray
|
|
279
|
+
attrs: {},
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Determine if we should show indicators for borderless containers
|
|
283
|
+
const showBorderless = props.overflowIndicator === true
|
|
284
|
+
|
|
285
|
+
// Top indicator
|
|
286
|
+
if (ss.hiddenAbove > 0) {
|
|
287
|
+
const indicator = `\u25b2${ss.hiddenAbove}`
|
|
288
|
+
|
|
289
|
+
if (border.top > 0) {
|
|
290
|
+
// Bordered: render centered inverse bar on top border line
|
|
291
|
+
const contentWidth = layout.width - border.left - border.right
|
|
292
|
+
const bar = padCenter(indicator, contentWidth)
|
|
293
|
+
const x = layout.x + border.left
|
|
294
|
+
const y = layout.y
|
|
295
|
+
const maxCol = x + contentWidth
|
|
296
|
+
renderTextLine(buffer, x, y, bar, indicatorStyle, maxCol, undefined, ctx)
|
|
297
|
+
} else if (showBorderless) {
|
|
298
|
+
// Borderless: render centered inverse bar on first content row
|
|
299
|
+
const padding = getPadding(props)
|
|
300
|
+
const contentWidth = layout.width - padding.left - padding.right
|
|
301
|
+
const bar = padCenter(indicator, contentWidth)
|
|
302
|
+
const x = layout.x + padding.left
|
|
303
|
+
const y = layout.y + padding.top
|
|
304
|
+
const maxCol = x + contentWidth
|
|
305
|
+
renderTextLine(buffer, x, y, bar, indicatorStyle, maxCol, undefined, ctx)
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Bottom indicator
|
|
310
|
+
if (ss.hiddenBelow > 0) {
|
|
311
|
+
const indicator = `\u25bc${ss.hiddenBelow}`
|
|
312
|
+
|
|
313
|
+
if (border.bottom > 0) {
|
|
314
|
+
// Bordered: render centered inverse bar on bottom border line
|
|
315
|
+
const contentWidth = layout.width - border.left - border.right
|
|
316
|
+
const bar = padCenter(indicator, contentWidth)
|
|
317
|
+
const x = layout.x + border.left
|
|
318
|
+
const y = layout.y + layout.height - 1
|
|
319
|
+
const maxCol = x + contentWidth
|
|
320
|
+
renderTextLine(buffer, x, y, bar, indicatorStyle, maxCol, undefined, ctx)
|
|
321
|
+
} else if (showBorderless) {
|
|
322
|
+
// Borderless: render indicator flush to viewport bottom
|
|
323
|
+
const padding = getPadding(props)
|
|
324
|
+
const contentWidth = layout.width - padding.left - padding.right
|
|
325
|
+
const bar = padCenter(indicator, contentWidth)
|
|
326
|
+
const x = layout.x + padding.left
|
|
327
|
+
const y = layout.y + layout.height - padding.bottom - 1
|
|
328
|
+
const maxCol = x + contentWidth
|
|
329
|
+
renderTextLine(buffer, x, y, bar, indicatorStyle, maxCol, undefined, ctx)
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** Center text within a fixed width, padding with spaces on both sides.
|
|
335
|
+
* Truncates from the right if text exceeds available width. */
|
|
336
|
+
function padCenter(text: string, width: number): string {
|
|
337
|
+
if (width <= 0) return ""
|
|
338
|
+
if (text.length > width) return text.slice(0, width)
|
|
339
|
+
if (text.length === width) return text
|
|
340
|
+
const leftPad = Math.floor((width - text.length) / 2)
|
|
341
|
+
const rightPad = width - text.length - leftPad
|
|
342
|
+
return " ".repeat(leftPad) + text + " ".repeat(rightPad)
|
|
343
|
+
}
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Render Helpers - Pure utility functions for content rendering.
|
|
3
|
+
*
|
|
4
|
+
* Contains:
|
|
5
|
+
* - Color parsing (parseColor)
|
|
6
|
+
* - Border character definitions (getBorderChars)
|
|
7
|
+
* - Style extraction (getTextStyle)
|
|
8
|
+
* - Text width utilities (getTextWidth)
|
|
9
|
+
*
|
|
10
|
+
* Re-exports layout helpers from helpers.ts:
|
|
11
|
+
* - getPadding, getBorderSize
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { DEFAULT_BG, type Color, type Style, type UnderlineStyle } from "../buffer"
|
|
15
|
+
import { getActiveTheme } from "@silvery/theme/state"
|
|
16
|
+
import { resolveThemeColor } from "@silvery/theme/resolve"
|
|
17
|
+
import type { BoxProps, TextProps } from "@silvery/tea/types"
|
|
18
|
+
import { displayWidthAnsi } from "../unicode"
|
|
19
|
+
import type { BorderChars, PipelineContext } from "./types"
|
|
20
|
+
|
|
21
|
+
// Re-export shared layout helpers
|
|
22
|
+
export { getBorderSize, getPadding } from "./helpers"
|
|
23
|
+
|
|
24
|
+
// ============================================================================
|
|
25
|
+
// Color Parsing
|
|
26
|
+
// ============================================================================
|
|
27
|
+
|
|
28
|
+
// Named colors map to 256-color indices (hoisted to module scope to avoid per-call allocation)
|
|
29
|
+
const namedColors: Record<string, number> = {
|
|
30
|
+
black: 0,
|
|
31
|
+
red: 1,
|
|
32
|
+
green: 2,
|
|
33
|
+
yellow: 3,
|
|
34
|
+
blue: 4,
|
|
35
|
+
magenta: 5,
|
|
36
|
+
cyan: 6,
|
|
37
|
+
white: 7,
|
|
38
|
+
gray: 8,
|
|
39
|
+
grey: 8,
|
|
40
|
+
blackBright: 8,
|
|
41
|
+
redBright: 9,
|
|
42
|
+
greenBright: 10,
|
|
43
|
+
yellowBright: 11,
|
|
44
|
+
blueBright: 12,
|
|
45
|
+
magentaBright: 13,
|
|
46
|
+
cyanBright: 14,
|
|
47
|
+
whiteBright: 15,
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Parse color string to Color type.
|
|
52
|
+
* Supports: $token (theme), named colors, hex (#rgb, #rrggbb), rgb(r,g,b)
|
|
53
|
+
*/
|
|
54
|
+
export function parseColor(color: string): Color {
|
|
55
|
+
// Special token: terminal's default background (SGR 49)
|
|
56
|
+
if (color === "$default") return DEFAULT_BG
|
|
57
|
+
|
|
58
|
+
// Resolve $token colors against the active theme
|
|
59
|
+
if (color.startsWith("$")) {
|
|
60
|
+
const resolved = resolveThemeColor(color, getActiveTheme())
|
|
61
|
+
if (resolved && resolved !== color) return parseColor(resolved)
|
|
62
|
+
return null
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (color in namedColors) {
|
|
66
|
+
return namedColors[color as keyof typeof namedColors]!
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Hex color
|
|
70
|
+
if (color.startsWith("#")) {
|
|
71
|
+
const hex = color.slice(1)
|
|
72
|
+
if (hex.length === 3) {
|
|
73
|
+
const r = Number.parseInt(hex[0]! + hex[0]!, 16)
|
|
74
|
+
const g = Number.parseInt(hex[1]! + hex[1]!, 16)
|
|
75
|
+
const b = Number.parseInt(hex[2]! + hex[2]!, 16)
|
|
76
|
+
return { r, g, b }
|
|
77
|
+
}
|
|
78
|
+
if (hex.length === 6) {
|
|
79
|
+
const r = Number.parseInt(hex.slice(0, 2), 16)
|
|
80
|
+
const g = Number.parseInt(hex.slice(2, 4), 16)
|
|
81
|
+
const b = Number.parseInt(hex.slice(4, 6), 16)
|
|
82
|
+
return { r, g, b }
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// rgb(r,g,b)
|
|
87
|
+
const rgbMatch = color.match(/^rgb\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)$/i)
|
|
88
|
+
if (rgbMatch) {
|
|
89
|
+
return {
|
|
90
|
+
r: Number.parseInt(rgbMatch[1]!, 10),
|
|
91
|
+
g: Number.parseInt(rgbMatch[2]!, 10),
|
|
92
|
+
b: Number.parseInt(rgbMatch[3]!, 10),
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ansi256(N) — 256-color palette index (0-255)
|
|
97
|
+
const ansi256Match = color.match(/^ansi256\s*\(\s*(\d+)\s*\)$/i)
|
|
98
|
+
if (ansi256Match) {
|
|
99
|
+
return Number.parseInt(ansi256Match[1]!, 10)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return null
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ============================================================================
|
|
106
|
+
// Border Characters
|
|
107
|
+
// ============================================================================
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Border character sets by style. Hoisted to module scope to avoid
|
|
111
|
+
* re-allocating 7 objects on every call.
|
|
112
|
+
*/
|
|
113
|
+
const borders: Record<NonNullable<BoxProps["borderStyle"]>, BorderChars> = {
|
|
114
|
+
single: {
|
|
115
|
+
topLeft: "\u250c",
|
|
116
|
+
topRight: "\u2510",
|
|
117
|
+
bottomLeft: "\u2514",
|
|
118
|
+
bottomRight: "\u2518",
|
|
119
|
+
horizontal: "\u2500",
|
|
120
|
+
vertical: "\u2502",
|
|
121
|
+
},
|
|
122
|
+
double: {
|
|
123
|
+
topLeft: "\u2554",
|
|
124
|
+
topRight: "\u2557",
|
|
125
|
+
bottomLeft: "\u255a",
|
|
126
|
+
bottomRight: "\u255d",
|
|
127
|
+
horizontal: "\u2550",
|
|
128
|
+
vertical: "\u2551",
|
|
129
|
+
},
|
|
130
|
+
round: {
|
|
131
|
+
topLeft: "\u256d",
|
|
132
|
+
topRight: "\u256e",
|
|
133
|
+
bottomLeft: "\u2570",
|
|
134
|
+
bottomRight: "\u256f",
|
|
135
|
+
horizontal: "\u2500",
|
|
136
|
+
vertical: "\u2502",
|
|
137
|
+
},
|
|
138
|
+
bold: {
|
|
139
|
+
topLeft: "\u250f",
|
|
140
|
+
topRight: "\u2513",
|
|
141
|
+
bottomLeft: "\u2517",
|
|
142
|
+
bottomRight: "\u251b",
|
|
143
|
+
horizontal: "\u2501",
|
|
144
|
+
vertical: "\u2503",
|
|
145
|
+
},
|
|
146
|
+
singleDouble: {
|
|
147
|
+
topLeft: "\u2553",
|
|
148
|
+
topRight: "\u2556",
|
|
149
|
+
bottomLeft: "\u2559",
|
|
150
|
+
bottomRight: "\u255c",
|
|
151
|
+
horizontal: "\u2500",
|
|
152
|
+
vertical: "\u2551",
|
|
153
|
+
},
|
|
154
|
+
doubleSingle: {
|
|
155
|
+
topLeft: "\u2552",
|
|
156
|
+
topRight: "\u2555",
|
|
157
|
+
bottomLeft: "\u2558",
|
|
158
|
+
bottomRight: "\u255b",
|
|
159
|
+
horizontal: "\u2550",
|
|
160
|
+
vertical: "\u2502",
|
|
161
|
+
},
|
|
162
|
+
classic: {
|
|
163
|
+
topLeft: "+",
|
|
164
|
+
topRight: "+",
|
|
165
|
+
bottomLeft: "+",
|
|
166
|
+
bottomRight: "+",
|
|
167
|
+
horizontal: "-",
|
|
168
|
+
vertical: "|",
|
|
169
|
+
},
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Get border characters for a style.
|
|
174
|
+
*/
|
|
175
|
+
export function getBorderChars(style: BoxProps["borderStyle"]): BorderChars {
|
|
176
|
+
if (style && typeof style === "object") {
|
|
177
|
+
// Custom border object (Ink compat): map Ink's top/bottom/left/right to
|
|
178
|
+
// silvery's horizontal/vertical format. Supports distinct chars per side.
|
|
179
|
+
const obj = style as Record<string, string>
|
|
180
|
+
const topHorizontal = obj.top ?? obj.horizontal ?? "-"
|
|
181
|
+
const leftVertical = obj.left ?? obj.vertical ?? "|"
|
|
182
|
+
return {
|
|
183
|
+
topLeft: obj.topLeft ?? "+",
|
|
184
|
+
topRight: obj.topRight ?? "+",
|
|
185
|
+
bottomLeft: obj.bottomLeft ?? "+",
|
|
186
|
+
bottomRight: obj.bottomRight ?? "+",
|
|
187
|
+
horizontal: topHorizontal,
|
|
188
|
+
vertical: leftVertical,
|
|
189
|
+
bottomHorizontal: obj.bottom && obj.bottom !== topHorizontal ? obj.bottom : undefined,
|
|
190
|
+
rightVertical: obj.right && obj.right !== leftVertical ? obj.right : undefined,
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return borders[style ?? "single"]
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ============================================================================
|
|
197
|
+
// Style Extraction
|
|
198
|
+
// ============================================================================
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Get text style from props.
|
|
202
|
+
*/
|
|
203
|
+
export function getTextStyle(props: TextProps): Style {
|
|
204
|
+
// Determine underline style: underlineStyle takes precedence over underline boolean
|
|
205
|
+
let underlineStyle: UnderlineStyle | undefined
|
|
206
|
+
if (props.underlineStyle !== undefined) {
|
|
207
|
+
underlineStyle = props.underlineStyle
|
|
208
|
+
} else if (props.underline) {
|
|
209
|
+
underlineStyle = "single"
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return {
|
|
213
|
+
fg: props.color ? parseColor(props.color) : null,
|
|
214
|
+
bg: props.backgroundColor ? parseColor(props.backgroundColor) : null,
|
|
215
|
+
underlineColor: props.underlineColor ? parseColor(props.underlineColor) : null,
|
|
216
|
+
attrs: {
|
|
217
|
+
bold: props.bold,
|
|
218
|
+
dim: props.dim || props.dimColor, // dimColor is Ink compatibility alias
|
|
219
|
+
italic: props.italic,
|
|
220
|
+
underline: props.underline || !!underlineStyle,
|
|
221
|
+
underlineStyle,
|
|
222
|
+
strikethrough: props.strikethrough,
|
|
223
|
+
inverse: props.inverse,
|
|
224
|
+
},
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ============================================================================
|
|
229
|
+
// Text Width Utilities
|
|
230
|
+
// ============================================================================
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Get text display width (accounting for wide characters and ANSI codes).
|
|
234
|
+
* Uses ANSI-aware width calculation to handle styled text.
|
|
235
|
+
*
|
|
236
|
+
* When a PipelineContext is provided, uses the context's measurer for
|
|
237
|
+
* terminal-capability-aware width calculation. Falls back to the module-level
|
|
238
|
+
* displayWidthAnsi (which reads the scoped measurer or default).
|
|
239
|
+
*/
|
|
240
|
+
export function getTextWidth(text: string, ctx?: PipelineContext): number {
|
|
241
|
+
if (ctx) return ctx.measurer.displayWidthAnsi(text)
|
|
242
|
+
return displayWidthAnsi(text)
|
|
243
|
+
}
|