Skip to content

Commit eefc54c

Browse files
montfortclaude
andauthored
fix(cli): code-block background no longer fragments on narrow panels (cli-3.4.1) (#58)
The fenced-code renderer used to pad each line to the longest line of its block and emit it as a single Line, letting `Paragraph::wrap` re-flow on render. Ratatui's word-wrap drops trailing styled whitespace at the wrap point, so when a code line was wider than the document panel the gray background broke into truncated stripes — visibly worst on terminal resizes and on narrow initial layouts (reported via screenshot). Pre-wrap inside markdown_to_lines using a new visual-column-aware hard-wrap helper. Each emitted Line is now bounded by `available_width`, so ratatui never re-wraps it and the gutter background paints uniformly on every visual row. Indentation is preserved (no whitespace trim) and double-wide CJK chars never split mid-glyph. Adds wrap_visual_columns + 7 regression tests covering hard-wrap math, CJK boundary safety, blank-line preservation, and end-to-end pipeline output bounded by panel width. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 1187e53 commit eefc54c

10 files changed

Lines changed: 192 additions & 33 deletions

File tree

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,13 @@ and this project uses [independent versioning](README.md#versioning) for Framewo
77

88
---
99

10+
## CLI 3.4.1 — Code-Block Background No Longer Fragments on Narrow Panels
11+
12+
### Fixed (CLI)
13+
- Fix the gray background of fenced code blocks in `devtrail explore` breaking into truncated stripes when the document panel is narrower than the longest code line. The renderer used to pad each code line to the longest line and let `Paragraph::wrap` re-flow it, which dropped trailing styled whitespace at the wrap point and left visible gaps between content rows. The code-block renderer now hard-wraps lines into chunks no wider than the panel itself (visual-column aware, UTF-8 / CJK safe, indentation preserved), so each visual row paints its own uninterrupted gray gutter regardless of terminal size or live resizes. Blank lines inside code blocks also keep their background.
14+
15+
---
16+
1017
## CLI 3.4.0 — Language-Aware `devtrail explore`
1118

1219
### Added (CLI)

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -207,7 +207,7 @@ DevTrail uses independent version tags for each component:
207207
| Component | Tag prefix | Example | Includes |
208208
|-----------|-----------|---------|----------|
209209
| Framework | `fw-` | `fw-4.3.0` | Templates (12 types), governance, directives |
210-
| CLI | `cli-` | `cli-3.4.0` | The `devtrail` binary |
210+
| CLI | `cli-` | `cli-3.4.1` | The `devtrail` binary |
211211

212212
Check installed versions with `devtrail status` or `devtrail about`.
213213

cli/Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

cli/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "devtrail-cli"
3-
version = "3.4.0"
3+
version = "3.4.1"
44
edition = "2021"
55
description = "CLI tool for DevTrail - Documentation Governance for AI-Assisted Development"
66
license = "MIT"

cli/src/tui/markdown.rs

Lines changed: 162 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -153,26 +153,39 @@ pub fn markdown_to_lines(markdown: &str, available_width: usize) -> Vec<Line<'st
153153
}
154154
TagEnd::CodeBlock => {
155155
in_code_block = false;
156-
// Measure in visual columns so CJK/emoji don't break alignment.
157-
let max_cols = code_block_lines
156+
// Compute a per-Line target width that fits inside the
157+
// panel after subtracting the heading indent and the
158+
// 2-column gutter we paint on each side. Pre-wrapping
159+
// here (instead of letting `Paragraph::wrap` do it later)
160+
// is what keeps the gray background uniform: ratatui's
161+
// word-wrap drops trailing styled whitespace at the
162+
// wrap point, which leaves the gutter stripes broken on
163+
// narrow terminals.
164+
let usable_width = available_width.saturating_sub(content_indent);
165+
let inner_width = usable_width.saturating_sub(4).max(1);
166+
let max_natural = code_block_lines
158167
.iter()
159168
.map(|l| UnicodeWidthStr::width(l.as_str()))
160169
.max()
161170
.unwrap_or(0);
171+
let target_width = max_natural.min(inner_width).max(1);
162172
let code_bg = Style::default()
163173
.fg(Color::Rgb(210, 215, 235))
164174
.bg(Color::Rgb(45, 45, 60));
165175

166176
for code_line in &code_block_lines {
167-
let w = UnicodeWidthStr::width(code_line.as_str());
168-
let pad = max_cols.saturating_sub(w);
169-
let padded = format!(" {}{} ", code_line, " ".repeat(pad));
170-
let mut spans: Vec<Span<'static>> = Vec::new();
171-
if content_indent > 0 {
172-
spans.push(Span::raw(" ".repeat(content_indent)));
177+
for chunk in wrap_visual_columns(code_line, inner_width) {
178+
let chunk_width = UnicodeWidthStr::width(chunk.as_str());
179+
let pad = target_width.saturating_sub(chunk_width);
180+
let padded =
181+
format!(" {}{} ", chunk, " ".repeat(pad));
182+
let mut spans: Vec<Span<'static>> = Vec::new();
183+
if content_indent > 0 {
184+
spans.push(Span::raw(" ".repeat(content_indent)));
185+
}
186+
spans.push(Span::styled(padded, code_bg));
187+
lines.push(Line::from(spans));
173188
}
174-
spans.push(Span::styled(padded, code_bg));
175-
lines.push(Line::from(spans));
176189
}
177190
code_block_lines.clear();
178191
lines.push(Line::from(""));
@@ -409,6 +422,42 @@ fn compute_column_widths(
409422
/// slice offsets are always taken at `char_indices()` boundaries, and
410423
/// widths are measured with `unicode-width` so CJK and other double-wide
411424
/// characters account for two visual columns.
425+
/// Hard-wrap a string into chunks each fitting within `width` visual columns.
426+
/// Unlike `wrap_cell_text`, this never breaks on word boundaries and never
427+
/// trims whitespace — preserving leading indentation is essential for code.
428+
/// UTF-8 safe: every cut lands on a char boundary, and a double-wide char
429+
/// (CJK / emoji) at the boundary moves whole to the next chunk rather than
430+
/// being split. Empty input yields a single empty chunk so that a blank
431+
/// line in the source still gets rendered as one styled line.
432+
fn wrap_visual_columns(s: &str, width: usize) -> Vec<String> {
433+
if width == 0 || s.is_empty() {
434+
return vec![s.to_string()];
435+
}
436+
if UnicodeWidthStr::width(s) <= width {
437+
return vec![s.to_string()];
438+
}
439+
440+
let mut chunks: Vec<String> = Vec::new();
441+
let mut current = String::new();
442+
let mut current_width = 0usize;
443+
for ch in s.chars() {
444+
let w = UnicodeWidthChar::width(ch).unwrap_or(0);
445+
if current_width + w > width && !current.is_empty() {
446+
chunks.push(std::mem::take(&mut current));
447+
current_width = 0;
448+
}
449+
current.push(ch);
450+
current_width += w;
451+
}
452+
if !current.is_empty() {
453+
chunks.push(current);
454+
}
455+
if chunks.is_empty() {
456+
chunks.push(String::new());
457+
}
458+
chunks
459+
}
460+
412461
fn wrap_cell_text(text: &str, width: usize) -> Vec<String> {
413462
if width == 0 {
414463
return vec![text.to_string()];
@@ -794,4 +843,107 @@ mod tests {
794843
assert!(*w <= naturals[i].max(3), "col {i} exceeded its natural");
795844
}
796845
}
846+
847+
#[test]
848+
fn wrap_visual_columns_short_returns_single_chunk() {
849+
let out = wrap_visual_columns("hello world", 80);
850+
assert_eq!(out, vec!["hello world".to_string()]);
851+
}
852+
853+
#[test]
854+
fn wrap_visual_columns_empty_yields_one_empty_chunk() {
855+
// A blank line in a code block must still emit one styled line so
856+
// the gray gutter is uninterrupted; otherwise the renderer would
857+
// skip it and the bg would have a one-row gap.
858+
let out = wrap_visual_columns("", 40);
859+
assert_eq!(out, vec![String::new()]);
860+
}
861+
862+
#[test]
863+
fn wrap_visual_columns_hard_wraps_long_line() {
864+
let out = wrap_visual_columns(
865+
"System(ecommerce, \"E-Commerce Platform\", \"Allows customers\")",
866+
20,
867+
);
868+
for chunk in &out {
869+
assert!(
870+
UnicodeWidthStr::width(chunk.as_str()) <= 20,
871+
"chunk {chunk:?} exceeds 20 cols",
872+
);
873+
}
874+
assert_eq!(out.concat().len(), "System(ecommerce, \"E-Commerce Platform\", \"Allows customers\")".len());
875+
}
876+
877+
#[test]
878+
fn wrap_visual_columns_preserves_leading_indentation() {
879+
// Code indentation must survive: a 4-space-indented line should
880+
// not be trimmed (which would corrupt Python/YAML/etc. semantics).
881+
let out = wrap_visual_columns(" indented_call(arg)", 40);
882+
assert!(out[0].starts_with(" "));
883+
}
884+
885+
#[test]
886+
fn wrap_visual_columns_cjk_does_not_split_double_wide_chars() {
887+
// Width=3 with three double-wide chars: each chunk should hold one
888+
// ideogram (visual width 2), never half of one. Forward progress
889+
// is guaranteed even when no char fits within a strict width<2.
890+
let out = wrap_visual_columns("数据表", 3);
891+
for chunk in &out {
892+
// Every chunk must be a valid UTF-8 string with whole ideograms.
893+
assert!(std::str::from_utf8(chunk.as_bytes()).is_ok());
894+
assert!(UnicodeWidthStr::width(chunk.as_str()) <= 3);
895+
}
896+
assert_eq!(out.concat(), "数据表");
897+
}
898+
899+
/// Regression: a code block whose longest line exceeds the available
900+
/// width must produce one Line per visual row, none wider than
901+
/// `available_width`. This is what keeps the gray background uniform
902+
/// — without pre-wrapping, ratatui's `Paragraph::wrap` re-flows our
903+
/// padded line and drops trailing styled spaces, leaving stripes.
904+
#[test]
905+
fn code_block_wraps_within_panel_width() {
906+
let md = "```\nSystem(ecommerce, \"E-Commerce Platform\", \"Allows customers to browse and purchase products\")\n```\n";
907+
let body_width = 40;
908+
let lines = markdown_to_lines(md, body_width);
909+
for line in &lines {
910+
let w: usize = line
911+
.spans
912+
.iter()
913+
.map(|s| UnicodeWidthStr::width(s.content.as_ref()))
914+
.sum();
915+
assert!(
916+
w <= body_width,
917+
"line wider than panel: {w} > {body_width} ({:?})",
918+
line.spans
919+
.iter()
920+
.map(|s| s.content.as_ref())
921+
.collect::<Vec<_>>(),
922+
);
923+
}
924+
}
925+
926+
/// Blank lines inside a code block must still emit a styled Line so the
927+
/// gutter background runs continuously. Without this, the screenshot
928+
/// the user reported showed truncated stripes between content rows.
929+
#[test]
930+
fn code_block_blank_lines_keep_background() {
931+
let md = "```\nfirst\n\nthird\n```\n";
932+
let lines = markdown_to_lines(md, 80);
933+
// First, blank, third → 3 styled lines.
934+
let styled: Vec<_> = lines
935+
.iter()
936+
.filter(|l| {
937+
l.spans
938+
.iter()
939+
.any(|s| s.style.bg == Some(Color::Rgb(45, 45, 60)))
940+
})
941+
.collect();
942+
assert_eq!(
943+
styled.len(),
944+
3,
945+
"expected 3 styled code lines (incl. the blank), got {}",
946+
styled.len()
947+
);
948+
}
797949
}

docs/adopters/CLI-REFERENCE.md

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ DevTrail uses **independent version tags** for each component:
4949
| Component | Tag prefix | Example | What it includes |
5050
|-----------|-----------|---------|------------------|
5151
| Framework | `fw-` | `fw-4.3.0` | Templates (12 types), governance docs, directives |
52-
| CLI | `cli-` | `cli-3.4.0` | The `devtrail` binary |
52+
| CLI | `cli-` | `cli-3.4.1` | The `devtrail` binary |
5353

5454
Framework and CLI are released independently. A framework update does not require a CLI update, and vice versa.
5555

@@ -110,7 +110,7 @@ $ devtrail update
110110
Updating framework...
111111
✔ Framework updated to fw-4.3.0
112112
Updating CLI...
113-
✔ CLI updated to cli-3.4.0
113+
✔ CLI updated to cli-3.4.1
114114
```
115115

116116
---
@@ -143,11 +143,11 @@ Use `--method` to override auto-detection: `--method=github` or `--method=cargo`
143143

144144
```bash
145145
$ devtrail update-cli
146-
✔ CLI updated to cli-3.4.0
146+
✔ CLI updated to cli-3.4.1
147147

148148
$ devtrail update-cli --method=cargo
149149
Compiling from source, this may take a few minutes...
150-
✔ CLI updated to cli-3.4.0
150+
✔ CLI updated to cli-3.4.1
151151
```
152152

153153
---
@@ -210,7 +210,7 @@ $ devtrail status
210210
┌───────────┬──────────────────────────┐
211211
│ Path │ /home/user/my-project │
212212
│ Framework │ fw-4.3.0 │
213-
│ CLI │ cli-3.4.0
213+
│ CLI │ cli-3.4.1
214214
│ Language │ en │
215215
└───────────┴──────────────────────────┘
216216
@@ -687,7 +687,7 @@ Show version, authorship, and license information.
687687
```bash
688688
$ devtrail about
689689
DevTrail CLI
690-
CLI version: cli-3.4.0
690+
CLI version: cli-3.4.1
691691
Framework version: fw-4.3.0
692692
Author: Strange Days Tech, S.A.S.
693693
License: MIT

docs/i18n/es/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,7 @@ DevTrail usa tags de versión independientes para cada componente:
150150
| Componente | Prefijo de tag | Ejemplo | Incluye |
151151
|------------|---------------|---------|---------|
152152
| Framework | `fw-` | `fw-4.3.0` | Plantillas (12 tipos), gobernanza, directivas |
153-
| CLI | `cli-` | `cli-3.4.0` | El binario `devtrail` |
153+
| CLI | `cli-` | `cli-3.4.1` | El binario `devtrail` |
154154

155155
Verifica las versiones instaladas con `devtrail status` o `devtrail about`.
156156

docs/i18n/es/adopters/CLI-REFERENCE.md

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ DevTrail usa **tags de versión independientes** para cada componente:
4949
| Componente | Prefijo de tag | Ejemplo | Qué incluye |
5050
|------------|---------------|---------|-------------|
5151
| Framework | `fw-` | `fw-4.3.0` | Plantillas (12 tipos), docs de gobernanza, directivas |
52-
| CLI | `cli-` | `cli-3.4.0` | El binario `devtrail` |
52+
| CLI | `cli-` | `cli-3.4.1` | El binario `devtrail` |
5353

5454
Framework y CLI se publican de forma independiente. Una actualización del framework no requiere actualización del CLI, y viceversa.
5555

@@ -109,7 +109,7 @@ $ devtrail update
109109
Updating framework...
110110
✔ Framework updated to fw-4.3.0
111111
Updating CLI...
112-
✔ CLI updated to cli-3.4.0
112+
✔ CLI updated to cli-3.4.1
113113
```
114114

115115
---
@@ -142,11 +142,11 @@ Usa `--method` para forzar el método: `--method=github` o `--method=cargo`.
142142

143143
```bash
144144
$ devtrail update-cli
145-
✔ CLI updated to cli-3.4.0
145+
✔ CLI updated to cli-3.4.1
146146

147147
$ devtrail update-cli --method=cargo
148148
Compiling from source, this may take a few minutes...
149-
✔ CLI updated to cli-3.4.0
149+
✔ CLI updated to cli-3.4.1
150150
```
151151

152152
---
@@ -204,7 +204,7 @@ DevTrail Status
204204
───────────────
205205
Path: /home/user/my-project
206206
Framework version: fw-4.3.0
207-
CLI version: cli-3.4.0
207+
CLI version: cli-3.4.1
208208
Language: en
209209
Structure: ✔ Complete
210210

@@ -559,7 +559,7 @@ Muestra información de versión, autoría y licencia.
559559
```bash
560560
$ devtrail about
561561
DevTrail CLI
562-
CLI version: cli-3.4.0
562+
CLI version: cli-3.4.1
563563
Framework version: fw-4.3.0
564564
Author: Strange Days Tech, S.A.S.
565565
License: MIT

docs/i18n/zh-CN/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,7 @@ DevTrail 为每个组件使用独立的版本标签:
150150
| 组件 | 标签前缀 | 示例 | 包含内容 |
151151
|------|----------|------|----------|
152152
| Framework | `fw-` | `fw-4.3.0` | 模板(12 种类型)、治理文档、指令 |
153-
| CLI | `cli-` | `cli-3.4.0` | `devtrail` 二进制文件 |
153+
| CLI | `cli-` | `cli-3.4.1` | `devtrail` 二进制文件 |
154154

155155
使用 `devtrail status``devtrail about` 查看已安装的版本。
156156

0 commit comments

Comments
 (0)