|
| 1 | +// Copyright 2026 Oxide Computer Company |
| 2 | + |
| 3 | +//! Style-aware text wrapping for compatibility output. |
| 4 | +//! |
| 5 | +//! The [`super::display`] layer builds each "logical line" of an issue |
| 6 | +//! header (the content after `at:` or `used by:`) as a [`Line`] of styled |
| 7 | +//! [`Span`]s, then hands it to [`write_wrapped`] to lay out at a target |
| 8 | +//! width. |
| 9 | +//! |
| 10 | +//! Wrapping is only performed at ASCII spaces between words. A "word" is a |
| 11 | +//! contiguous run of non-space characters across one or more spans. Words wider |
| 12 | +//! than the line extend past the boundary rather than being broken: endpoint |
| 13 | +//! paths like `/v1/instances/{instance}/…` need to remain copyable from the |
| 14 | +//! terminal as a single unit. |
| 15 | +//! |
| 16 | +//! Adapted from `wicket/src/ui/wrap.rs` in oxidecomputer/omicron. |
| 17 | +
|
| 18 | +use owo_colors::{OwoColorize, Style}; |
| 19 | +use std::{ |
| 20 | + borrow::Cow, |
| 21 | + fmt::{self, Write as _}, |
| 22 | +}; |
| 23 | +use textwrap::core::display_width; |
| 24 | + |
| 25 | +#[derive(Clone, Debug)] |
| 26 | +pub(super) struct Span<'a> { |
| 27 | + content: Cow<'a, str>, |
| 28 | + style: Style, |
| 29 | +} |
| 30 | + |
| 31 | +#[derive(Debug, Default)] |
| 32 | +pub(super) struct Line<'a> { |
| 33 | + spans: Vec<Span<'a>>, |
| 34 | +} |
| 35 | + |
| 36 | +impl<'a> Line<'a> { |
| 37 | + pub(super) fn new() -> Self { |
| 38 | + Self::default() |
| 39 | + } |
| 40 | + |
| 41 | + /// Append a styled span. |
| 42 | + pub(super) fn push( |
| 43 | + &mut self, |
| 44 | + content: impl Into<Cow<'a, str>>, |
| 45 | + style: Style, |
| 46 | + ) -> &mut Self { |
| 47 | + let content = content.into(); |
| 48 | + if !content.is_empty() { |
| 49 | + self.spans.push(Span { content, style }); |
| 50 | + } |
| 51 | + self |
| 52 | + } |
| 53 | + |
| 54 | + /// Append an unstyled span. |
| 55 | + /// |
| 56 | + /// Shorthand for `push(content, Style::default())`. |
| 57 | + pub(super) fn push_plain( |
| 58 | + &mut self, |
| 59 | + content: impl Into<Cow<'a, str>>, |
| 60 | + ) -> &mut Self { |
| 61 | + self.push(content, Style::default()) |
| 62 | + } |
| 63 | + |
| 64 | + /// Write every span to `f` with its style applied, with no wrapping. |
| 65 | + /// Useful inside tree-drawn rows where the surrounding scaffolding |
| 66 | + /// already constrains the layout. |
| 67 | + pub(super) fn write_inline( |
| 68 | + &self, |
| 69 | + f: &mut fmt::Formatter<'_>, |
| 70 | + ) -> fmt::Result { |
| 71 | + for span in &self.spans { |
| 72 | + write!(f, "{}", span.content.as_ref().style(span.style))?; |
| 73 | + } |
| 74 | + Ok(()) |
| 75 | + } |
| 76 | +} |
| 77 | + |
| 78 | +/// A subsequent-line indent: the literal string emitted at the start |
| 79 | +/// of each continuation line, paired with its visible column width. |
| 80 | +/// |
| 81 | +/// Width is supplied by the caller rather than measured so |
| 82 | +/// [`write_wrapped`] makes no assumption about the indent's content. |
| 83 | +/// Plain space indents (the only kind in use today) are constructed |
| 84 | +/// via [`Self::spaces`]. |
| 85 | +#[derive(Clone, Copy, Debug)] |
| 86 | +pub(super) struct Indent<'a> { |
| 87 | + pub(super) string: &'a str, |
| 88 | + pub(super) width: usize, |
| 89 | +} |
| 90 | + |
| 91 | +impl<'a> Indent<'a> { |
| 92 | + /// Construct an `Indent` from a string of ASCII spaces. |
| 93 | + pub(super) fn spaces(string: &'a str) -> Self { |
| 94 | + debug_assert!( |
| 95 | + string.bytes().all(|b| b == b' '), |
| 96 | + "Indent::spaces called with non-space content: {string:?}", |
| 97 | + ); |
| 98 | + Self { string, width: string.len() } |
| 99 | + } |
| 100 | +} |
| 101 | + |
| 102 | +/// A wrap-time unit: a sequence of styled body slices that move together |
| 103 | +/// across line breaks, plus the trailing space run that follows them in |
| 104 | +/// the source content. |
| 105 | +/// |
| 106 | +/// `body_width` is the visible width of the body (for fit decisions); |
| 107 | +/// `trailing_ws_width` is the width of the trailing space run, dropped |
| 108 | +/// when this word is the last on a wrapped line. |
| 109 | +#[derive(Debug, Default)] |
| 110 | +struct StyledWord<'a> { |
| 111 | + body: Vec<(&'a str, Style)>, |
| 112 | + body_width: usize, |
| 113 | + trailing_ws_width: usize, |
| 114 | +} |
| 115 | + |
| 116 | +impl StyledWord<'_> { |
| 117 | + fn is_empty(&self) -> bool { |
| 118 | + self.body.is_empty() && self.trailing_ws_width == 0 |
| 119 | + } |
| 120 | +} |
| 121 | + |
| 122 | +/// Walk `line`'s spans and split them into [`StyledWord`]s at ASCII-space |
| 123 | +/// boundaries. Non-space content from consecutive spans merges into a |
| 124 | +/// single word so style transitions within a word (e.g., `(` default → |
| 125 | +/// `op_id` purple → `)` default) don't introduce break candidates. |
| 126 | +fn collect_words<'a>(line: &'a Line<'a>) -> Vec<StyledWord<'a>> { |
| 127 | + let mut words = Vec::new(); |
| 128 | + let mut current = StyledWord::default(); |
| 129 | + // True once we've moved past the body of the current word into its |
| 130 | + // trailing whitespace run. The next non-space character commits the |
| 131 | + // current word and starts a new one. |
| 132 | + let mut in_trailing_ws = false; |
| 133 | + |
| 134 | + for span in &line.spans { |
| 135 | + let mut body_start = 0; |
| 136 | + let content = span.content.as_ref(); |
| 137 | + let bytes = content.as_bytes(); |
| 138 | + let mut i = 0; |
| 139 | + while i < bytes.len() { |
| 140 | + if bytes[i] == b' ' { |
| 141 | + if !in_trailing_ws { |
| 142 | + // Closing out the body portion of this span: commit |
| 143 | + // the slice [body_start, i) to the current word body |
| 144 | + // before we start counting trailing whitespace. |
| 145 | + if body_start < i { |
| 146 | + let slice = &content[body_start..i]; |
| 147 | + current.body.push((slice, span.style)); |
| 148 | + current.body_width += display_width(slice); |
| 149 | + } |
| 150 | + in_trailing_ws = true; |
| 151 | + } |
| 152 | + current.trailing_ws_width += 1; |
| 153 | + i += 1; |
| 154 | + } else { |
| 155 | + if in_trailing_ws { |
| 156 | + // Trailing whitespace just ended — commit the current |
| 157 | + // word and start a fresh one. The non-space character |
| 158 | + // we just saw is the first byte of the new word's body. |
| 159 | + words.push(std::mem::take(&mut current)); |
| 160 | + in_trailing_ws = false; |
| 161 | + body_start = i; |
| 162 | + } |
| 163 | + i += 1; |
| 164 | + } |
| 165 | + } |
| 166 | + // Anything left over in this span goes into the current bucket: |
| 167 | + // body if we're not in trailing whitespace yet, otherwise the |
| 168 | + // trailing run has already been counted above. |
| 169 | + if !in_trailing_ws && body_start < bytes.len() { |
| 170 | + let slice = &content[body_start..]; |
| 171 | + current.body.push((slice, span.style)); |
| 172 | + current.body_width += display_width(slice); |
| 173 | + } |
| 174 | + } |
| 175 | + if !current.is_empty() { |
| 176 | + words.push(current); |
| 177 | + } |
| 178 | + words |
| 179 | +} |
| 180 | + |
| 181 | +/// Write `line` to `f`, wrapping at `width` columns. |
| 182 | +/// |
| 183 | +/// `width` is the total visible width available; on continuation lines |
| 184 | +/// we emit `indent.string` first and the remaining `width - indent.width` |
| 185 | +/// columns are available for content. The first line is assumed to |
| 186 | +/// already be positioned at column `indent.width` by the caller |
| 187 | +/// (typically by writing a right-aligned label of the same visible |
| 188 | +/// width), so the same content width applies to every line of the block. |
| 189 | +/// |
| 190 | +/// Single words that overflow extend past width. The alternative would defeat |
| 191 | +/// the user's ability to copy it from the terminal in one piece (particularly |
| 192 | +/// for long endpoint paths). |
| 193 | +pub(super) fn write_wrapped( |
| 194 | + f: &mut fmt::Formatter<'_>, |
| 195 | + line: &Line<'_>, |
| 196 | + width: usize, |
| 197 | + indent: Indent<'_>, |
| 198 | +) -> fmt::Result { |
| 199 | + let words = collect_words(line); |
| 200 | + if words.is_empty() { |
| 201 | + return Ok(()); |
| 202 | + } |
| 203 | + |
| 204 | + let content_width = width.saturating_sub(indent.width); |
| 205 | + |
| 206 | + // Greedy first-fit: a word joins the current line if its body still |
| 207 | + // fits, otherwise it starts a new line. Width is measured against |
| 208 | + // body alone (not body + trailing whitespace) so a trailing space |
| 209 | + // that would overflow doesn't force a premature break — it just |
| 210 | + // gets dropped at the line end. |
| 211 | + let mut line_ranges: Vec<(usize, usize)> = Vec::new(); |
| 212 | + let mut start = 0; |
| 213 | + let mut current_width = 0; |
| 214 | + for (i, word) in words.iter().enumerate() { |
| 215 | + let cant_fit_here = current_width > 0 |
| 216 | + && current_width + word.body_width > content_width; |
| 217 | + if cant_fit_here { |
| 218 | + line_ranges.push((start, i)); |
| 219 | + start = i; |
| 220 | + current_width = 0; |
| 221 | + } |
| 222 | + current_width += word.body_width + word.trailing_ws_width; |
| 223 | + } |
| 224 | + line_ranges.push((start, words.len())); |
| 225 | + |
| 226 | + for (line_idx, (lo, hi)) in line_ranges.iter().copied().enumerate() { |
| 227 | + if line_idx > 0 { |
| 228 | + writeln!(f)?; |
| 229 | + f.write_str(indent.string)?; |
| 230 | + } |
| 231 | + let group = &words[lo..hi]; |
| 232 | + let last_idx = group.len().saturating_sub(1); |
| 233 | + for (j, word) in group.iter().enumerate() { |
| 234 | + for (slice, style) in &word.body { |
| 235 | + write!(f, "{}", slice.style(*style))?; |
| 236 | + } |
| 237 | + // Drop trailing whitespace at the end of a wrapped line so we |
| 238 | + // don't emit dangling spaces; preserve it between words on the |
| 239 | + // same output line. |
| 240 | + if j < last_idx && word.trailing_ws_width > 0 { |
| 241 | + for _ in 0..word.trailing_ws_width { |
| 242 | + f.write_char(' ')?; |
| 243 | + } |
| 244 | + } |
| 245 | + } |
| 246 | + } |
| 247 | + Ok(()) |
| 248 | +} |
| 249 | + |
| 250 | +#[cfg(test)] |
| 251 | +mod tests { |
| 252 | + use super::*; |
| 253 | + |
| 254 | + /// Render `line` at the given `width` with an indent of `indent` |
| 255 | + /// ASCII spaces, returning the resulting string. |
| 256 | + fn render(line: &Line<'_>, width: usize, indent: &str) -> String { |
| 257 | + struct Adapter<'a> { |
| 258 | + line: &'a Line<'a>, |
| 259 | + width: usize, |
| 260 | + indent: Indent<'a>, |
| 261 | + } |
| 262 | + impl fmt::Display for Adapter<'_> { |
| 263 | + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { |
| 264 | + write_wrapped(f, self.line, self.width, self.indent) |
| 265 | + } |
| 266 | + } |
| 267 | + Adapter { line, width, indent: Indent::spaces(indent) }.to_string() |
| 268 | + } |
| 269 | + |
| 270 | + #[test] |
| 271 | + fn test_fit_on_one_line() { |
| 272 | + let mut line = Line::new(); |
| 273 | + line.push_plain("GET ").push_plain("/short"); |
| 274 | + assert_eq!(render(&line, 80, " "), "GET /short"); |
| 275 | + } |
| 276 | + |
| 277 | + #[test] |
| 278 | + fn test_break_at_whitespace() { |
| 279 | + let mut line = Line::new(); |
| 280 | + line.push_plain("alpha beta"); |
| 281 | + // Width 7: "alpha" (5) fits, "beta" (4) won't fit alongside it |
| 282 | + // (5+1+4=10>7) and fits fine on a line of its own, so it |
| 283 | + // breaks. |
| 284 | + assert_eq!(render(&line, 7, " "), "alpha\n beta"); |
| 285 | + } |
| 286 | + |
| 287 | + #[test] |
| 288 | + fn test_adjacent_spans_form_one_word() { |
| 289 | + let mut line = Line::new(); |
| 290 | + line.push_plain("AB").push_plain("CD"); |
| 291 | + // "ABCD" is 4 chars; at width 3, a naive per-span breaker would |
| 292 | + // split between "AB" and "CD" — but they form one word here, so |
| 293 | + // the whole thing extends past the limit on a single line. |
| 294 | + assert_eq!(render(&line, 3, " "), "ABCD"); |
| 295 | + } |
| 296 | + |
| 297 | + #[test] |
| 298 | + fn test_long_word_overflows() { |
| 299 | + let mut line = Line::new(); |
| 300 | + line.push_plain("a ").push_plain("loooooooooong ").push_plain("z"); |
| 301 | + // Width 5: each word goes on its own line ("a" fits, the long |
| 302 | + // word overflows alone, "z" fits). |
| 303 | + assert_eq!(render(&line, 5, ""), "a\nloooooooooong\nz"); |
| 304 | + } |
| 305 | + |
| 306 | + #[test] |
| 307 | + fn test_preserve_styles_across_wrap() { |
| 308 | + let bold = Style::new().bold(); |
| 309 | + let mut line = Line::new(); |
| 310 | + line.push_plain("aa bb ").push("CC", bold).push_plain("-dd"); |
| 311 | + // Width 7 unindented: "aa bb" (5) fits on line 1; "CC-dd" (5) |
| 312 | + // doesn't fit alongside it (5+1+5=11>7) and starts line 2 with |
| 313 | + // the bold styling on CC preserved. |
| 314 | + let expected = format!("aa bb\n{}-dd", "CC".style(bold)); |
| 315 | + assert_eq!(render(&line, 7, ""), expected); |
| 316 | + } |
| 317 | +} |
0 commit comments