inkferro_core/render/border.rs
1//! Border rendering — styled-frame slice.
2//!
3//! Port of ink's `render-border.ts`, including the `stylePiece` SGR coloring
4//! that the M1 char-only port dropped (M2-C restores it).
5//!
6//! # Design decisions
7//!
8//! ## Color (render-border.ts:7-20, 36-64)
9//! `stylePiece` wraps each border piece: colorize fg (innermost) → colorize bg
10//! → dim (outermost). Per-edge fg resolves `border{Edge}Color ?? borderColor`;
11//! per-edge dim resolves `border{Edge}DimColor ?? borderDimColor`, read from
12//! `Style` (these are style props that flow JS→core via Box's `...style` spread).
13//! With no color and no dim, `style_piece` is the identity transform, so plain
14//! borders stay byte-identical.
15//!
16//! ## Per-element wrapping (render-border.ts:74-131)
17//! Each corner+run top/bottom row is wrapped once (one SGR pair per row), and
18//! EACH vertical-bar cell is wrapped independently before the rows are joined
19//! with `\n`. The styled-grid `write` path (grid.rs) tokenizes each line with no
20//! carried SGR state, so a once-wrapped vertical strip would leave interior bars
21//! unstyled — every bar must carry its own open/close pair.
22//!
23//! ## Per-edge suppression (render-border.ts:66-69 citation)
24//! ```ts
25//! const showTopBorder = node.style.borderTop !== false;
26//! const showBottomBorder = node.style.borderBottom !== false;
27//! const showLeftBorder = node.style.borderLeft !== false;
28//! const showRightBorder = node.style.borderRight !== false;
29//! ```
30//! A missing/`true` value means "show"; only explicit `false` suppresses.
31//! In Rust: `style.border_top == Some(false)` → hide; anything else → show.
32//!
33//! ## Corner handling when an edge is off (render-border.ts:74-79 citation)
34//! ```ts
35//! let topBorder = showTopBorder
36//! ? (showLeftBorder ? box.topLeft : '') +
37//! box.top.repeat(contentWidth) +
38//! (showRightBorder ? box.topRight : '')
39//! : undefined;
40//! ```
41//! Corners appear only when BOTH the edge they terminate AND the perpendicular
42//! edge are shown. When the top edge is off the entire top row is omitted
43//! (topBorder = undefined). When the left edge is off, topLeft corner is ''.
44//! Same logic applies to bottom/right permutations.
45//!
46//! ## Vertical border height (render-border.ts:86-95 citation)
47//! ```ts
48//! let verticalBorderHeight = height;
49//! if (showTopBorder) verticalBorderHeight -= 1;
50//! if (showBottomBorder) verticalBorderHeight -= 1;
51//! ```
52//! Left/right border strings span only the interior rows (total height minus
53//! the rows consumed by top/bottom border lines).
54
55use crate::dom::{BorderStyle, Style};
56use crate::render::cli_boxes::{BoxChars, CustomChars, named as named_box};
57use crate::render::colorize::{ColorLevel, Kind, colorize, dim as dim_modifier};
58use crate::render::grid::Grid;
59
60/// Style one border piece: colorize fg (innermost) → colorize bg → dim
61/// (outermost), mirroring `stylePiece` (render-border.ts:7-20).
62///
63/// With `fg`/`bg` both `None`/empty and `dim` false this is the identity
64/// transform (`colorize` passes through and dim is skipped), so plain borders
65/// stay byte-identical to the char-only slice. `level` is the detected color
66/// level (chalk's `chalk.level`): at [`ColorLevel::None`] every colorize/dim is a
67/// no-op, so a colored border in a non-color terminal emits plain box chars.
68fn style_piece(
69 segment: &str,
70 fg: Option<&str>,
71 bg: Option<&str>,
72 dim: bool,
73 level: ColorLevel,
74) -> String {
75 let styled = colorize(segment, fg, Kind::Fg, level);
76 let styled = colorize(&styled, bg, Kind::Bg, level);
77 if dim {
78 dim_modifier(&styled, level)
79 } else {
80 styled
81 }
82}
83
84/// Resolve a `BorderStyle` to its `BoxChars`.
85///
86/// Named styles return `Cow::Borrowed` (zero-copy). Custom styles return
87/// `Cow::Owned` (no leaking — freed when the `BoxChars` is dropped).
88///
89/// **Unknown name fallback:** an unrecognised `Named` style silently falls
90/// back to `"single"`. Ink's JS equivalent throws a `TypeError`
91/// (`cliBoxes[name]` is `undefined`, so `box.topLeft` access crashes); Rust
92/// never panics — the caller always gets a valid frame.
93///
94/// Mirrors render-border.ts:32-34:
95/// ```ts
96/// const box = typeof node.style.borderStyle === 'string'
97/// ? cliBoxes[node.style.borderStyle]
98/// : node.style.borderStyle;
99/// ```
100fn resolve_box(style: &BorderStyle) -> BoxChars {
101 match style {
102 BorderStyle::Named(name) => named_box(name).unwrap_or_else(|| {
103 // Unknown named style: fall back to "single" so the renderer
104 // never panics. Document: unknown names behave like "single".
105 named_box("single").unwrap()
106 }),
107 BorderStyle::Custom {
108 top_left,
109 top,
110 top_right,
111 right,
112 bottom_right,
113 bottom,
114 bottom_left,
115 left,
116 } => BoxChars::from_custom(CustomChars {
117 top_left: top_left.clone(),
118 top: top.clone(),
119 top_right: top_right.clone(),
120 right: right.clone(),
121 bottom_right: bottom_right.clone(),
122 bottom: bottom.clone(),
123 bottom_left: bottom_left.clone(),
124 left: left.clone(),
125 }),
126 }
127}
128
129/// Draw the border of a box node into `grid`.
130///
131/// `(x, y)` is the top-left corner of the box (absolute grid coordinates).
132/// `width` / `height` are the full computed dimensions including border cells.
133///
134/// Mirrors `renderBorder` in render-border.ts:22-155, including `stylePiece`.
135pub fn render_border(
136 x: i32,
137 y: i32,
138 width: u16,
139 height: u16,
140 style: &Style,
141 grid: &mut Grid,
142 level: ColorLevel,
143) {
144 let Some(ref border_style) = style.border_style else {
145 return; // render-border.ts:28: early return if no borderStyle.
146 };
147
148 let bx = resolve_box(border_style);
149
150 // render-border.ts:36-42: per-edge fg color — border{Edge}Color ?? borderColor.
151 let top_color = style
152 .border_top_color
153 .as_deref()
154 .or(style.border_color.as_deref());
155 let bottom_color = style
156 .border_bottom_color
157 .as_deref()
158 .or(style.border_color.as_deref());
159 let left_color = style
160 .border_left_color
161 .as_deref()
162 .or(style.border_color.as_deref());
163 let right_color = style
164 .border_right_color
165 .as_deref()
166 .or(style.border_color.as_deref());
167
168 // render-border.ts:54-64: per-edge dim — border{Edge}DimColor ?? borderDimColor.
169 // Read from Style (these are style props, threaded JS→core via Box's `...style`).
170 let dim_top = style
171 .border_top_dim_color
172 .or(style.border_dim_color)
173 .unwrap_or(false);
174 let dim_bottom = style
175 .border_bottom_dim_color
176 .or(style.border_dim_color)
177 .unwrap_or(false);
178 let dim_left = style
179 .border_left_dim_color
180 .or(style.border_dim_color)
181 .unwrap_or(false);
182 let dim_right = style
183 .border_right_dim_color
184 .or(style.border_dim_color)
185 .unwrap_or(false);
186
187 // render-border.ts:44-52: per-edge bg — border{Edge}BackgroundColor ?? borderBackgroundColor.
188 let top_bg = style
189 .border_top_background_color
190 .as_deref()
191 .or(style.border_background_color.as_deref());
192 let bottom_bg = style
193 .border_bottom_background_color
194 .as_deref()
195 .or(style.border_background_color.as_deref());
196 let left_bg = style
197 .border_left_background_color
198 .as_deref()
199 .or(style.border_background_color.as_deref());
200 let right_bg = style
201 .border_right_background_color
202 .as_deref()
203 .or(style.border_background_color.as_deref());
204
205 // render-border.ts:66-69: edge visibility.
206 let show_top = style.border_top != Some(false);
207 let show_bottom = style.border_bottom != Some(false);
208 let show_left = style.border_left != Some(false);
209 let show_right = style.border_right != Some(false);
210
211 let w = width as i32;
212 let h = height as i32;
213
214 // render-border.ts:71-72: contentWidth = width − left_border − right_border.
215 let content_width = w - if show_left { 1 } else { 0 } - if show_right { 1 } else { 0 };
216
217 // render-border.ts:74-79: top border row.
218 // Top corners appear only when both the top AND the respective side are shown.
219 if show_top {
220 let mut top_str = String::new();
221 if show_left {
222 top_str.push_str(&bx.top_left);
223 }
224 for _ in 0..content_width.max(0) {
225 top_str.push_str(&bx.top);
226 }
227 if show_right {
228 top_str.push_str(&bx.top_right);
229 }
230 // render-border.ts:80-85: wrap the whole top row once (single line).
231 let top_str = style_piece(&top_str, top_color, top_bg, dim_top, level);
232 grid.write(x, y, &top_str);
233 }
234
235 // render-border.ts:86-95: vertical border height = total height minus
236 // the rows used by top and bottom borders.
237 let mut vert_height = h;
238 if show_top {
239 vert_height -= 1;
240 }
241 if show_bottom {
242 vert_height -= 1;
243 }
244
245 // render-border.ts:97-108: left border — one char per interior row.
246 // Written as a single multi-line string with \n separators.
247 // Borrow as &str so repeat clones cheap pointer+len, not the Cow itself.
248 if show_left && vert_height > 0 {
249 // render-border.ts:99-107: style ONE cell, then repeat — each line carries
250 // its own SGR pair so the per-line tokenizer styles every bar.
251 let one = style_piece(&bx.left, left_color, left_bg, dim_left, level);
252 let left_str = std::iter::repeat_n(one.as_str(), vert_height as usize)
253 .collect::<Vec<_>>()
254 .join("\n");
255 let offset_y = if show_top { 1 } else { 0 };
256 grid.write(x, y + offset_y, &left_str);
257 }
258
259 // render-border.ts:109-119: right border.
260 if show_right && vert_height > 0 {
261 // render-border.ts:111-118: style ONE cell, then repeat (see left edge).
262 let one = style_piece(&bx.right, right_color, right_bg, dim_right, level);
263 let right_str = std::iter::repeat_n(one.as_str(), vert_height as usize)
264 .collect::<Vec<_>>()
265 .join("\n");
266 let offset_y = if show_top { 1 } else { 0 };
267 // render-border.ts:143-146: x + width - 1.
268 grid.write(x + w - 1, y + offset_y, &right_str);
269 }
270
271 // render-border.ts:121-131: bottom border row.
272 if show_bottom {
273 let mut bot_str = String::new();
274 if show_left {
275 bot_str.push_str(&bx.bottom_left);
276 }
277 for _ in 0..content_width.max(0) {
278 bot_str.push_str(&bx.bottom);
279 }
280 if show_right {
281 bot_str.push_str(&bx.bottom_right);
282 }
283 // render-border.ts:126-131: wrap the whole bottom row once (single line).
284 let bot_str = style_piece(&bot_str, bottom_color, bottom_bg, dim_bottom, level);
285 // render-border.ts:148-151: y + height - 1.
286 grid.write(x, y + h - 1, &bot_str);
287 }
288}
289
290// ─── Tests ───────────────────────────────────────────────────────────────────
291
292#[cfg(test)]
293mod tests {
294 use super::*;
295 use crate::dom::Style;
296 use crate::render::grid::Grid;
297
298 fn style_with_border(name: &str) -> Style {
299 Style {
300 border_style: Some(BorderStyle::Named(name.to_owned())),
301 ..Style::default()
302 }
303 }
304
305 fn render_to_string(rows: usize, cols: usize, style: &Style, w: u16, h: u16) -> String {
306 let mut g = Grid::new(rows, cols);
307 // Truecolor (level 3) keeps these char-only border literals identical to
308 // the pre-#77 behavior (no color → identity transform either way).
309 render_border(0, 0, w, h, style, &mut g, ColorLevel::Truecolor);
310 g.get().0
311 }
312
313 // ── Named styles (pin exact frame literals from ink oracle) ──────────────
314
315 // ink: renderToString(<Box borderStyle="single" width={10} height={3}/>) ===
316 // "┌────────┐\n│ │\n└────────┘"
317 #[test]
318 fn single_border_10x3() {
319 let s = style_with_border("single");
320 let out = render_to_string(3, 10, &s, 10, 3);
321 assert_eq!(out, "┌────────┐\n│ │\n└────────┘");
322 }
323
324 // ink: renderToString(<Box borderStyle="double" width={10} height={3}/>) ===
325 // "╔════════╗\n║ ║\n╚════════╝"
326 #[test]
327 fn double_border_10x3() {
328 let s = style_with_border("double");
329 let out = render_to_string(3, 10, &s, 10, 3);
330 assert_eq!(out, "╔════════╗\n║ ║\n╚════════╝");
331 }
332
333 // ink: renderToString(<Box borderStyle="round" width={10} height={3}/>) ===
334 // "╭────────╮\n│ │\n╰────────╯"
335 #[test]
336 fn round_border_10x3() {
337 let s = style_with_border("round");
338 let out = render_to_string(3, 10, &s, 10, 3);
339 assert_eq!(out, "╭────────╮\n│ │\n╰────────╯");
340 }
341
342 // ink: renderToString(<Box borderStyle="bold" width={10} height={3}/>) ===
343 // "┏━━━━━━━━┓\n┃ ┃\n┗━━━━━━━━┛"
344 #[test]
345 fn bold_border_10x3() {
346 let s = style_with_border("bold");
347 let out = render_to_string(3, 10, &s, 10, 3);
348 assert_eq!(out, "┏━━━━━━━━┓\n┃ ┃\n┗━━━━━━━━┛");
349 }
350
351 // ink: renderToString(<Box borderStyle="classic" width={10} height={3}/>) ===
352 // "+--------+\n| |\n+--------+"
353 #[test]
354 fn classic_border_10x3() {
355 let s = style_with_border("classic");
356 let out = render_to_string(3, 10, &s, 10, 3);
357 assert_eq!(out, "+--------+\n| |\n+--------+");
358 }
359
360 // ink: renderToString(<Box borderStyle="singleDouble" width={10} height={3}/>) ===
361 // "╓────────╖\n║ ║\n╙────────╜"
362 #[test]
363 fn single_double_border_10x3() {
364 let s = style_with_border("singleDouble");
365 let out = render_to_string(3, 10, &s, 10, 3);
366 assert_eq!(out, "╓────────╖\n║ ║\n╙────────╜");
367 }
368
369 // ink: renderToString(<Box borderStyle="doubleSingle" width={10} height={3}/>) ===
370 // "╒════════╕\n│ │\n╘════════╛"
371 #[test]
372 fn double_single_border_10x3() {
373 let s = style_with_border("doubleSingle");
374 let out = render_to_string(3, 10, &s, 10, 3);
375 assert_eq!(out, "╒════════╕\n│ │\n╘════════╛");
376 }
377
378 // ink: renderToString(<Box borderStyle="arrow" width={10} height={3}/>) ===
379 // "↘↓↓↓↓↓↓↓↓↙\n→ ←\n↗↑↑↑↑↑↑↑↑↖"
380 #[test]
381 fn arrow_border_10x3() {
382 let s = style_with_border("arrow");
383 let out = render_to_string(3, 10, &s, 10, 3);
384 assert_eq!(out, "↘↓↓↓↓↓↓↓↓↙\n→ ←\n↗↑↑↑↑↑↑↑↑↖");
385 }
386
387 // ── Partial edges ────────────────────────────────────────────────────────
388
389 // ink: renderToString(<Box borderStyle="single" borderTop={false} width={10} height={3}/>) ===
390 // "│ │\n│ │\n└────────┘"
391 #[test]
392 fn single_no_top_10x3() {
393 let s = Style {
394 border_style: Some(BorderStyle::Named("single".to_owned())),
395 border_top: Some(false),
396 ..Style::default()
397 };
398 let out = render_to_string(3, 10, &s, 10, 3);
399 assert_eq!(out, "│ │\n│ │\n└────────┘");
400 }
401
402 // ink: renderToString(<Box borderStyle="single" borderLeft={false} width={10} height={3}/>) ===
403 // "─────────┐\n │\n─────────┘"
404 #[test]
405 fn single_no_left_10x3() {
406 let s = Style {
407 border_style: Some(BorderStyle::Named("single".to_owned())),
408 border_left: Some(false),
409 ..Style::default()
410 };
411 let out = render_to_string(3, 10, &s, 10, 3);
412 assert_eq!(out, "─────────┐\n │\n─────────┘");
413 }
414
415 // ink: renderToString(<Box borderStyle="single" borderRight={false} width={10} height={3}/>) ===
416 // "┌─────────\n│\n└─────────"
417 #[test]
418 fn single_no_right_10x3() {
419 let s = Style {
420 border_style: Some(BorderStyle::Named("single".to_owned())),
421 border_right: Some(false),
422 ..Style::default()
423 };
424 let out = render_to_string(3, 10, &s, 10, 3);
425 assert_eq!(out, "┌─────────\n│\n└─────────");
426 }
427
428 // ink: renderToString(<Box borderStyle="single" borderBottom={false} width={10} height={3}/>) ===
429 // "┌────────┐\n│ │\n│ │"
430 #[test]
431 fn single_no_bottom_10x3() {
432 let s = Style {
433 border_style: Some(BorderStyle::Named("single".to_owned())),
434 border_bottom: Some(false),
435 ..Style::default()
436 };
437 let out = render_to_string(3, 10, &s, 10, 3);
438 assert_eq!(out, "┌────────┐\n│ │\n│ │");
439 }
440
441 // No borderStyle → render_border is a no-op → grid stays all spaces.
442 #[test]
443 fn no_border_style_noop() {
444 let s = Style::default();
445 let out = render_to_string(3, 10, &s, 10, 3);
446 // All spaces trimmed → three empty lines joined by \n.
447 assert_eq!(out, "\n\n");
448 }
449
450 // Unknown named style falls back to "single" (ink throws TypeError; we never panic).
451 // "singel" is a deliberate misspelling — pins the fallback path.
452 // Expected frame matches the oracle for single at 10×3.
453 #[test]
454 fn unknown_named_falls_back_to_single() {
455 let s = style_with_border("singel"); // typo — not a known style
456 let out = render_to_string(3, 10, &s, 10, 3);
457 assert_eq!(out, "┌────────┐\n│ │\n└────────┘");
458 }
459
460 // ── M2-C: stylePiece SGR coloring ────────────────────────────────────────
461
462 fn render_styled(rows: usize, cols: usize, style: &Style, w: u16, h: u16) -> String {
463 let mut g = Grid::new(rows, cols);
464 // Level 3: the M2-C stylePiece SGR pins are all chalk@5 level-3 bytes.
465 render_border(0, 0, w, h, style, &mut g, ColorLevel::Truecolor);
466 g.get().0
467 }
468
469 // No color and no dim → style_piece is identity → plain frame is
470 // BYTE-IDENTICAL to the char-only slice (the ~37 plain tests must not move).
471 #[test]
472 fn plain_border_identity_no_sgr() {
473 let s = style_with_border("single");
474 let out = render_styled(3, 10, &s, 10, 3);
475 assert_eq!(out, "┌────────┐\n│ │\n└────────┘");
476 assert!(!out.contains('\x1b'), "plain border must emit no SGR bytes");
477 }
478
479 // #77: a hex-colored border honors the detected ColorLevel.
480 // - None (0): NO SGR — plain box chars (matches ink in a non-color terminal).
481 // - Basic (1): rgb→ansi16 downgrade (#ff8800 → 93 brightYellow).
482 // - Ansi256 (2): rgb→ansi256 downgrade (#ff8800 → 214).
483 // - Truecolor (3): 38;2 truecolor verbatim.
484 // Bytes are chalk@5/ansi-styles ground truth (oracle-pinned in colorize tests).
485 fn render_border_at(level: ColorLevel) -> String {
486 let s = Style {
487 border_style: Some(BorderStyle::Named("single".to_owned())),
488 border_color: Some("#ff8800".to_owned()),
489 ..Style::default()
490 };
491 let mut g = Grid::new(3, 10);
492 render_border(0, 0, 10, 3, &s, &mut g, level);
493 g.get().0
494 }
495
496 #[test]
497 fn hex_border_level_none_emits_no_sgr() {
498 let out = render_border_at(ColorLevel::None);
499 assert_eq!(out, "┌────────┐\n│ │\n└────────┘");
500 assert!(
501 !out.contains('\x1b'),
502 "level 0: a hex-colored border must emit NO SGR (plain chars)"
503 );
504 }
505
506 #[test]
507 fn hex_border_level_basic_downgrades_to_16() {
508 let out = render_border_at(ColorLevel::Basic);
509 // Top row, each bar, bottom row wrapped in 93/39 (brightYellow).
510 assert_eq!(
511 out,
512 "\x1b[93m┌────────┐\x1b[39m\n\x1b[93m│\x1b[39m \x1b[93m│\x1b[39m\n\x1b[93m└────────┘\x1b[39m"
513 );
514 }
515
516 #[test]
517 fn hex_border_level_ansi256_downgrades_to_256() {
518 let out = render_border_at(ColorLevel::Ansi256);
519 assert_eq!(
520 out,
521 "\x1b[38;5;214m┌────────┐\x1b[39m\n\x1b[38;5;214m│\x1b[39m \x1b[38;5;214m│\x1b[39m\n\x1b[38;5;214m└────────┘\x1b[39m"
522 );
523 }
524
525 #[test]
526 fn hex_border_level_truecolor_verbatim() {
527 let out = render_border_at(ColorLevel::Truecolor);
528 assert_eq!(
529 out,
530 "\x1b[38;2;255;136;0m┌────────┐\x1b[39m\n\x1b[38;2;255;136;0m│\x1b[39m \x1b[38;2;255;136;0m│\x1b[39m\n\x1b[38;2;255;136;0m└────────┘\x1b[39m"
531 );
532 }
533
534 // Per-edge fg color resolution cascade: border_top_color wins over
535 // border_color for the top; other edges fall back to border_color.
536 #[test]
537 fn per_edge_color_resolution_cascade() {
538 let s = Style {
539 border_style: Some(BorderStyle::Named("single".to_owned())),
540 border_color: Some("red".to_owned()), // base
541 border_top_color: Some("green".to_owned()), // top override
542 ..Style::default()
543 };
544 let out = render_styled(3, 10, &s, 10, 3);
545 // Top row green (32/39); each vertical bar and the bottom row red (31/39).
546 assert_eq!(
547 out,
548 "\x1b[32m┌────────┐\x1b[39m\n\x1b[31m│\x1b[39m \x1b[31m│\x1b[39m\n\x1b[31m└────────┘\x1b[39m"
549 );
550 }
551
552 // Per-edge bg color resolution cascade: border_top_background_color wins over
553 // border_background_color for the top; other edges fall back to the general
554 // border_background_color. Mirrors the fg cascade (render-border.ts:44-52).
555 // No fg set → colorize-fg is passthrough → only the bg SGR wraps each piece.
556 // chalk level-3 named bg: blue → bgBlue = 44/49; red → bgRed = 41/49.
557 #[test]
558 fn per_edge_background_resolution_cascade() {
559 let s = Style {
560 border_style: Some(BorderStyle::Named("single".to_owned())),
561 border_background_color: Some("red".to_owned()), // base
562 border_top_background_color: Some("blue".to_owned()), // top override
563 ..Style::default()
564 };
565 let out = render_styled(3, 10, &s, 10, 3);
566 // Top row blue bg (44/49); each vertical bar and the bottom row red bg (41/49).
567 assert_eq!(
568 out,
569 "\x1b[44m┌────────┐\x1b[49m\n\x1b[41m│\x1b[49m \x1b[41m│\x1b[49m\n\x1b[41m└────────┘\x1b[49m"
570 );
571 }
572
573 // Each vertical bar carries its OWN SGR pair — wrapping the strip once would
574 // leave the interior bar unstyled after the per-line tokenizer split.
575 #[test]
576 fn each_vertical_bar_wrapped_independently() {
577 let s = Style {
578 border_style: Some(BorderStyle::Named("single".to_owned())),
579 border_left_color: Some("green".to_owned()),
580 ..Style::default()
581 };
582 // height 4 → 2 interior rows on the left edge.
583 let out = render_styled(4, 10, &s, 10, 4);
584 assert_eq!(
585 out,
586 "┌────────┐\n\x1b[32m│\x1b[39m │\n\x1b[32m│\x1b[39m │\n└────────┘"
587 );
588 }
589
590 // Dim + color composition order: colorize fg (innermost) then dim (outermost).
591 // Bytes: \x1b[2m (dim open) \x1b[32m (green open) … \x1b[39m (green close)
592 // \x1b[22m (dim close) — color closes 39, dim closes 22, never crossed.
593 #[test]
594 fn dim_and_color_composition_order_bytes() {
595 let s = Style {
596 border_style: Some(BorderStyle::Named("single".to_owned())),
597 border_top_color: Some("green".to_owned()),
598 border_top_dim_color: Some(true),
599 ..Style::default()
600 };
601 let out = render_styled(3, 10, &s, 10, 3);
602 let top = out.lines().next().unwrap();
603 assert_eq!(top, "\x1b[2m\x1b[32m┌────────┐\x1b[39m\x1b[22m");
604 }
605
606 // Per-edge dim falls back to general borderDimColor (the ?? cascade), read
607 // from Style. General borderDimColor → every edge dims.
608 #[test]
609 fn dim_cascade_general_applies_to_all_edges() {
610 let s = Style {
611 border_style: Some(BorderStyle::Named("single".to_owned())),
612 border_dim_color: Some(true),
613 ..Style::default()
614 };
615 let out = render_styled(3, 10, &s, 10, 3);
616 assert_eq!(
617 out,
618 "\x1b[2m┌────────┐\x1b[22m\n\x1b[2m│\x1b[22m \x1b[2m│\x1b[22m\n\x1b[2m└────────┘\x1b[22m"
619 );
620 }
621
622 // Style dim cascade: per-edge border{Edge}DimColor wins over the general
623 // borderDimColor; general applies to every edge that has no per-edge value;
624 // a None/false edge with no general falls through to no-dim. Mirrors the
625 // `border{Edge}DimColor ?? borderDimColor` resolution (render-border.ts:54-64),
626 // now sourced from Style (the style props flow JS→core via Box's `...style`).
627 #[test]
628 fn dim_cascade_resolution_style_based() {
629 // Per-edge true on TOP only, no general → only the top row dims; the
630 // bottom row and the vertical bars stay plain (no SGR).
631 let per_edge = Style {
632 border_style: Some(BorderStyle::Named("single".to_owned())),
633 border_top_dim_color: Some(true),
634 ..Style::default()
635 };
636 let out = render_styled(3, 10, &per_edge, 10, 3);
637 assert_eq!(out, "\x1b[2m┌────────┐\x1b[22m\n│ │\n└────────┘");
638
639 // Per-edge wins over general: TOP explicitly false suppresses dim on the
640 // top even though the general borderDimColor is true; all other edges
641 // dim from the general fallback.
642 let per_edge_wins = Style {
643 border_style: Some(BorderStyle::Named("single".to_owned())),
644 border_dim_color: Some(true),
645 border_top_dim_color: Some(false),
646 ..Style::default()
647 };
648 let out = render_styled(3, 10, &per_edge_wins, 10, 3);
649 assert_eq!(
650 out,
651 "┌────────┐\n\x1b[2m│\x1b[22m \x1b[2m│\x1b[22m\n\x1b[2m└────────┘\x1b[22m"
652 );
653 }
654}