max / quasi
5 files changed,
+289 insertions,
-103 deletions
| @@ -786,7 +786,7 @@ | |||
| 786 | 786 | ||
| 787 | 787 | [[package]] | |
| 788 | 788 | name = "docengine" | |
| 789 | - | version = "0.5.0" | |
| 789 | + | version = "0.6.0" | |
| 790 | 790 | dependencies = [ | |
| 791 | 791 | "ammonia", | |
| 792 | 792 | "pulldown-cmark", |
| @@ -23,8 +23,10 @@ | |||
| 23 | 23 | makeover-tui = { version = "0.15.0", features = ["theme"] } | |
| 24 | 24 | ratatui = { version = "0.30", default-features = false } | |
| 25 | 25 | # `Node::Rich` carries markdown source. A terminal has no markup to hand it to, | |
| 26 | - | # so it takes the text: `render_plain` is docengine's own answer for exactly | |
| 27 | - | # this, and it is the third preset the same node already has two of. | |
| 26 | + | # so it takes the runs: `render_runs` is the words with the marks that were over | |
| 27 | + | # them still attached, which is what a cell can paint and a string could not | |
| 28 | + | # carry. Grown in docengine for this caller, so the markdown parsing stays in | |
| 29 | + | # the one crate that exists to do it. | |
| 28 | 30 | docengine = { git = "https://makenot.work/git/max/docengine.git" } | |
| 29 | 31 | ||
| 30 | 32 | [dev-dependencies] |
| @@ -20,7 +20,9 @@ | |||
| 20 | 20 | Node::Heading { text: content, .. } | Node::Text { text: content, .. } => { | |
| 21 | 21 | text::height(content, width) | |
| 22 | 22 | } | |
| 23 | - | Node::Rich { source } => text::height(&docengine::render_plain(source), width), | |
| 23 | + | Node::Rich { source } => { | |
| 24 | + | text::spans_height(&rich_spans(tui, source, rich_base(tui)), width) | |
| 25 | + | } | |
| 24 | 26 | Node::Act(act) => text::line_height(&act_line(tui, act), width), | |
| 25 | 27 | Node::Link { text: label, .. } => text::height(label, width), | |
| 26 | 28 | Node::Token(tag) => text::line_height(&Line::from(tag_span(tui, tag)), width), | |
| @@ -75,17 +77,17 @@ | |||
| 75 | 77 | } => text::draw(content, tui.tone(*tone), area, buf), | |
| 76 | 78 | ||
| 77 | 79 | // Markdown source, and a terminal has no markup to hand it to. It takes | |
| 78 | - | // the text, which is docengine's own answer for this and the third | |
| 79 | - | // preset the same node already has two of. What is lost is the | |
| 80 | - | // emphasis: a webview draws `**ship it**` bold and this draws the words. | |
| 81 | - | // Bold is a thing a cell can be, so this is a gap in the shared | |
| 82 | - | // markdown crate rather than a limit of the terminal, and it is filed. | |
| 83 | - | Node::Rich { source } => text::draw( | |
| 84 | - | &docengine::render_plain(source), | |
| 85 | - | Style::default().fg(tui.theme().content_primary), | |
| 86 | - | area, | |
| 87 | - | buf, | |
| 88 | - | ), | |
| 80 | + | // the runs: the words, each still carrying the marks that were over it, | |
| 81 | + | // which is the answer docengine grew for exactly this caller. A webview | |
| 82 | + | // draws `**ship it**` bold and so does this. | |
| 83 | + | // | |
| 84 | + | // What is still lost is block structure. A heading inside a rich node | |
| 85 | + | // comes through as its text at the weight of the prose around it, | |
| 86 | + | // because `render_runs` carries inline marks and nothing else, and a | |
| 87 | + | // terminal has no second type size to spend on the difference anyway. | |
| 88 | + | Node::Rich { source } => { | |
| 89 | + | text::draw_spans(&rich_spans(tui, source, rich_base(tui)), area, buf) | |
| 90 | + | } | |
| 89 | 91 | ||
| 90 | 92 | Node::Act(act) => text::draw_line(&act_line(tui, act), area, buf), | |
| 91 | 93 | ||
| @@ -317,7 +319,7 @@ | |||
| 317 | 319 | }; | |
| 318 | 320 | vec![Span::styled(text.clone(), style)] | |
| 319 | 321 | } | |
| 320 | - | Node::Rich { source } => vec![Span::styled(docengine::render_plain(source), inherited)], | |
| 322 | + | Node::Rich { source } => rich_spans(tui, source, inherited), | |
| 321 | 323 | Node::Token(tag) => vec![tag_span(tui, tag)], | |
| 322 | 324 | Node::Act(act) => act_line(tui, act).spans, | |
| 323 | 325 | Node::Link { text, .. } => vec![Span::styled( | |
| @@ -341,6 +343,51 @@ | |||
| 341 | 343 | } | |
| 342 | 344 | } | |
| 343 | 345 | ||
| 346 | + | /// The style a rich node's unmarked prose takes when it stands on its own, | |
| 347 | + | /// rather than inside a run that has already picked one. | |
| 348 | + | fn rich_base(tui: &Tui) -> Style { | |
| 349 | + | Style::default().fg(tui.theme().content_primary) | |
| 350 | + | } | |
| 351 | + | ||
| 352 | + | /// Markdown source as spans: the words, each under the marks that were over it. | |
| 353 | + | /// | |
| 354 | + | /// `base` is what the prose takes where the source said nothing, so the same | |
| 355 | + | /// function serves a rich node standing alone and one sitting inside a row's | |
| 356 | + | /// run, where the part's role has already decided the colour. | |
| 357 | + | fn rich_spans(tui: &Tui, source: &str, base: Style) -> Vec<Span<'static>> { | |
| 358 | + | docengine::render_runs(source) | |
| 359 | + | .into_iter() | |
| 360 | + | .map(|run| Span::styled(run.text, mark(tui, base, run.emphasis))) | |
| 361 | + | .collect() | |
| 362 | + | } | |
| 363 | + | ||
| 364 | + | /// One run's marks as a style over `base`. | |
| 365 | + | /// | |
| 366 | + | /// Three of the four are the modifier a terminal already has for them. Code is | |
| 367 | + | /// the one with no modifier to take -- every cell is monospace, so the thing a | |
| 368 | + | /// webview says with a typeface cannot be said that way here -- and it takes | |
| 369 | + | /// the sunken surface instead, which is what the theme has for "this is set | |
| 370 | + | /// into the page rather than on it". | |
| 371 | + | fn mark(tui: &Tui, base: Style, emphasis: docengine::Emphasis) -> Style { | |
| 372 | + | if emphasis.is_plain() { | |
| 373 | + | return base; | |
| 374 | + | } | |
| 375 | + | let mut style = base; | |
| 376 | + | if emphasis.strong { | |
| 377 | + | style = style.add_modifier(Modifier::BOLD); | |
| 378 | + | } | |
| 379 | + | if emphasis.italic { | |
| 380 | + | style = style.add_modifier(Modifier::ITALIC); | |
| 381 | + | } | |
| 382 | + | if emphasis.struck { | |
| 383 | + | style = style.add_modifier(Modifier::CROSSED_OUT); | |
| 384 | + | } | |
| 385 | + | if emphasis.code { | |
| 386 | + | style = style.bg(tui.theme().surface_sunken); | |
| 387 | + | } | |
| 388 | + | style | |
| 389 | + | } | |
| 390 | + | ||
| 344 | 391 | /// A tag as one span. | |
| 345 | 392 | fn tag_span(tui: &Tui, tag: &Tag) -> Span<'static> { | |
| 346 | 393 | let style = tui.tone(tag.tone); |
| @@ -13,6 +13,7 @@ | |||
| 13 | 13 | }; | |
| 14 | 14 | use ratatui::buffer::Buffer; | |
| 15 | 15 | use ratatui::layout::Rect; | |
| 16 | + | use ratatui::style::Modifier; | |
| 16 | 17 | ||
| 17 | 18 | use crate::Tui; | |
| 18 | 19 | ||
| @@ -45,11 +46,29 @@ | |||
| 45 | 46 | } | |
| 46 | 47 | ||
| 47 | 48 | /// Draw one node into a buffer of this size. | |
| 48 | - | fn drawn(node: &Node, width: u16, height: u16) -> Vec<String> { | |
| 49 | + | fn buffer(node: &Node, width: u16, height: u16) -> Buffer { | |
| 49 | 50 | let area = Rect::new(0, 0, width, height); | |
| 50 | 51 | let mut buf = Buffer::empty(area); | |
| 51 | 52 | tui().node(node, area, &mut buf); | |
| 52 | - | rows(&buf) | |
| 53 | + | buf | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | /// Draw one node into a buffer of this size. | |
| 57 | + | fn drawn(node: &Node, width: u16, height: u16) -> Vec<String> { | |
| 58 | + | rows(&buffer(node, width, height)) | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | /// The characters on row `y` whose cells carry `modifier`. | |
| 62 | + | /// | |
| 63 | + | /// The exception to the note at the top of this file, and a narrow one. A | |
| 64 | + | /// colour is the theme's answer, but bold is not: no theme decides which words | |
| 65 | + | /// in a paragraph are emphasised, so which cells carry it is this renderer's | |
| 66 | + | /// claim and the only way to assert it is to read it. | |
| 67 | + | fn marked(buf: &Buffer, y: u16, modifier: Modifier) -> String { | |
| 68 | + | (0..buf.area.width) | |
| 69 | + | .filter(|x| buf[(*x, y)].modifier.contains(modifier)) | |
| 70 | + | .map(|x| buf[(x, y)].symbol()) | |
| 71 | + | .collect() | |
| 53 | 72 | } | |
| 54 | 73 | ||
| 55 | 74 | /// Draw a whole screen. | |
| @@ -116,13 +135,77 @@ | |||
| 116 | 135 | } | |
| 117 | 136 | ||
| 118 | 137 | #[test] | |
| 119 | - | fn markdown_becomes_words_and_loses_its_emphasis() { | |
| 120 | - | // A finding, asserted so it stays visible: the node carries source so every | |
| 121 | - | // renderer can answer it its own way, and a terminal's own way is currently | |
| 122 | - | // to drop the emphasis a cell could have carried as bold. | |
| 123 | - | let out = drawn(&Node::rich("**ship it** now"), 40, 2); | |
| 124 | - | assert!(out[0].contains("ship it now"), "{out:?}"); | |
| 125 | - | assert!(!out[0].contains('*'), "{out:?}"); | |
| 138 | + | fn markdown_keeps_its_emphasis_and_loses_its_syntax() { | |
| 139 | + | // The node carries source so every renderer can answer it its own way, and | |
| 140 | + | // a terminal's own way is the run's marks on the cell: `**ship it**` is the | |
| 141 | + | // words in bold, not the words with the asterisks still on them and not the | |
| 142 | + | // words with the emphasis thrown away. | |
| 143 | + | let buf = buffer(&Node::rich("**ship it** now"), 40, 2); | |
| 144 | + | let out = rows(&buf); | |
| 145 | + | assert_eq!(out[0], "ship it now"); | |
| 146 | + | assert_eq!(marked(&buf, 0, Modifier::BOLD), "ship it"); | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | #[test] | |
| 150 | + | fn each_inline_mark_reaches_the_cell_that_has_it() { | |
| 151 | + | let buf = buffer(&Node::rich("*lean* and ~~gone~~"), 40, 2); | |
| 152 | + | assert_eq!(rows(&buf)[0], "lean and gone"); | |
| 153 | + | assert_eq!(marked(&buf, 0, Modifier::ITALIC), "lean"); | |
| 154 | + | assert_eq!(marked(&buf, 0, Modifier::CROSSED_OUT), "gone"); | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | #[test] | |
| 158 | + | fn a_code_span_is_set_into_the_page_rather_than_marked() { | |
| 159 | + | // Every cell is monospace, so the one thing a webview says with a typeface | |
| 160 | + | // is the one mark a terminal cannot repeat. It takes the sunken surface | |
| 161 | + | // instead. Asserted as a difference and not as a colour: which colour is | |
| 162 | + | // the theme's answer, that there is one is this renderer's. | |
| 163 | + | let buf = buffer(&Node::rich("run `cargo build` first"), 40, 2); | |
| 164 | + | assert_eq!(rows(&buf)[0], "run cargo build first"); | |
| 165 | + | let prose = buf[(0, 0)].bg; | |
| 166 | + | let code = buf[(4, 0)].bg; | |
| 167 | + | assert_ne!(code, prose, "a code span should not sit on the page"); | |
| 168 | + | assert_eq!(buf[(16, 0)].bg, prose, "and the prose after it should"); | |
| 169 | + | } | |
| 170 | + | ||
| 171 | + | #[test] | |
| 172 | + | fn a_rich_block_keeps_the_breaks_the_author_wrote() { | |
| 173 | + | // The reason a rich node cannot go through `draw_line`: that one wraps a | |
| 174 | + | // run that is one line by construction, and two paragraphs run together | |
| 175 | + | // read as one sentence that does not parse. | |
| 176 | + | let out = drawn(&Node::rich("one\n\ntwo"), 40, 4); | |
| 177 | + | assert_eq!(out[0], "one"); | |
| 178 | + | assert_eq!(out[1], ""); | |
| 179 | + | assert_eq!(out[2], "two"); | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | #[test] | |
| 183 | + | fn a_rich_node_inside_a_row_keeps_its_emphasis_too() { | |
| 184 | + | // The other path into the same runs. A row's parts are one line of spans, | |
| 185 | + | // so this goes through `inline_spans` rather than the block wrap, and the | |
| 186 | + | // marks have to survive both. | |
| 187 | + | let buf = buffer( | |
| 188 | + | &Node::list([Row::new("Ship it").part(layout::RowPart::Meta, Node::rich("**now**"))]), | |
| 189 | + | 40, | |
| 190 | + | 3, | |
| 191 | + | ); | |
| 192 | + | // Two spaces in the run, one on the row: `draw_line` breaks on whitespace, | |
| 193 | + | // so the gap between two parts is a separator and not a measure. | |
| 194 | + | assert_eq!(rows(&buf)[0], "Ship it now"); | |
| 195 | + | assert_eq!(marked(&buf, 0, Modifier::BOLD), "now"); | |
| 196 | + | } | |
| 197 | + | ||
| 198 | + | #[test] | |
| 199 | + | fn a_rich_block_wraps_without_losing_which_words_were_marked() { | |
| 200 | + | // The wrap breaks a run across rows, so the marks have to travel with the | |
| 201 | + | // words rather than with the run they arrived in. | |
| 202 | + | let buf = buffer(&Node::rich("plain **one two three** plain"), 12, 4); | |
| 203 | + | let out = rows(&buf); | |
| 204 | + | assert_eq!(out[0], "plain one"); | |
| 205 | + | assert_eq!(out[1], "two three"); | |
| 206 | + | assert_eq!(out[2], "plain"); | |
| 207 | + | assert_eq!(marked(&buf, 0, Modifier::BOLD), "one"); | |
| 208 | + | assert_eq!(marked(&buf, 1, Modifier::BOLD), "two three"); | |
| 126 | 209 | } | |
| 127 | 210 | ||
| 128 | 211 | #[test] |
| @@ -14,60 +14,119 @@ | |||
| 14 | 14 | use ratatui::buffer::Buffer; | |
| 15 | 15 | use ratatui::layout::Rect; | |
| 16 | 16 | use ratatui::style::Style; | |
| 17 | - | use ratatui::text::Line; | |
| 17 | + | use ratatui::text::{Line, Span}; | |
| 18 | 18 | ||
| 19 | - | /// Break `text` into lines no wider than `width`. | |
| 19 | + | /// Break `spans` into lines no wider than `width`, keeping each word under the | |
| 20 | + | /// style it arrived with. | |
| 20 | 21 | /// | |
| 21 | 22 | /// Breaks on whitespace, and breaks inside a word only when the word cannot fit | |
| 22 | 23 | /// on a line of its own. A word longer than the whole width is the case that | |
| 23 | 24 | /// has no good answer; cutting it is the least bad one, because the alternative | |
| 24 | 25 | /// is a line wider than the region and a buffer that swallows the overflow | |
| 25 | 26 | /// silently. | |
| 26 | - | pub(crate) fn wrap(text: &str, width: u16) -> Vec<String> { | |
| 27 | + | /// | |
| 28 | + | /// The one wrap in this crate. [`wrap`] is this with a single style over the | |
| 29 | + | /// whole string, rather than a second implementation that would be free to | |
| 30 | + | /// disagree with it about how many rows a paragraph takes -- and a disagreement | |
| 31 | + | /// there is a node drawing over the one under it. | |
| 32 | + | pub(crate) fn wrap_spans(spans: &[Span<'_>], width: u16) -> Vec<Line<'static>> { | |
| 27 | 33 | if width == 0 { | |
| 28 | 34 | return Vec::new(); | |
| 29 | 35 | } | |
| 30 | 36 | let width = width as usize; | |
| 31 | - | let mut lines = Vec::new(); | |
| 37 | + | let mut lines: Vec<Vec<Span<'static>>> = Vec::new(); | |
| 38 | + | let mut line: Vec<Span<'static>> = Vec::new(); | |
| 39 | + | let mut column = 0usize; | |
| 40 | + | // The style of the whitespace last passed over, held until a word turns up | |
| 41 | + | // to need a separator before it. Kept rather than taken from the word, | |
| 42 | + | // because the space between `*lean*` and `~~gone~~` belongs to the plain | |
| 43 | + | // run that held it: a strikethrough that starts one cell early is drawn | |
| 44 | + | // through a space the author never struck. | |
| 45 | + | let mut separator: Option<Style> = None; | |
| 32 | 46 | ||
| 33 | - | // Authored breaks are breaks. A description that put a newline in a string | |
| 34 | - | // meant it, and rewrapping across it would join two paragraphs. | |
| 35 | - | for paragraph in text.split('\n') { | |
| 36 | - | let mut line = String::new(); | |
| 37 | - | for word in paragraph.split_whitespace() { | |
| 38 | - | let mut word = word; | |
| 39 | - | // A word too long for any line, cut to fit rather than overflowed. | |
| 40 | - | while word.chars().count() > width { | |
| 41 | - | if !line.is_empty() { | |
| 47 | + | for span in spans { | |
| 48 | + | let mut rest: &str = span.content.as_ref(); | |
| 49 | + | while !rest.is_empty() { | |
| 50 | + | let gap = rest | |
| 51 | + | .find(|c: char| !c.is_whitespace()) | |
| 52 | + | .unwrap_or(rest.len()); | |
| 53 | + | if gap > 0 { | |
| 54 | + | // Authored breaks are breaks. A description that put a newline | |
| 55 | + | // in a string meant it, and rewrapping across it would join two | |
| 56 | + | // paragraphs. | |
| 57 | + | for _ in 0..rest[..gap].matches('\n').count() { | |
| 42 | 58 | lines.push(std::mem::take(&mut line)); | |
| 59 | + | column = 0; | |
| 60 | + | } | |
| 61 | + | separator = Some(span.style); | |
| 62 | + | rest = &rest[gap..]; | |
| 63 | + | continue; | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | let end = rest.find(char::is_whitespace).unwrap_or(rest.len()); | |
| 67 | + | let (mut word, after) = rest.split_at(end); | |
| 68 | + | rest = after; | |
| 69 | + | ||
| 70 | + | // A word too long for any line, cut to fit rather than overflowed. | |
| 71 | + | // The case has no good answer; cutting is the least bad one, | |
| 72 | + | // because the alternative is a line wider than the region and a | |
| 73 | + | // buffer that swallows the overflow silently. | |
| 74 | + | while word.chars().count() > width { | |
| 75 | + | if column > 0 { | |
| 76 | + | lines.push(std::mem::take(&mut line)); | |
| 77 | + | column = 0; | |
| 43 | 78 | } | |
| 44 | 79 | let cut = word | |
| 45 | 80 | .char_indices() | |
| 46 | 81 | .nth(width) | |
| 47 | 82 | .map_or(word.len(), |(index, _)| index); | |
| 48 | - | lines.push(word[..cut].to_string()); | |
| 83 | + | lines.push(vec![Span::styled(word[..cut].to_string(), span.style)]); | |
| 49 | 84 | word = &word[cut..]; | |
| 50 | 85 | } | |
| 51 | - | let room = width - line.chars().count(); | |
| 52 | - | let wanted = word.chars().count() + usize::from(!line.is_empty()); | |
| 53 | - | if wanted > room && !line.is_empty() { | |
| 54 | - | lines.push(std::mem::take(&mut line)); | |
| 55 | - | } | |
| 56 | - | if !line.is_empty() { | |
| 57 | - | line.push(' '); | |
| 58 | - | } | |
| 59 | - | line.push_str(word); | |
| 60 | - | } | |
| 61 | - | lines.push(line); | |
| 62 | - | } | |
| 63 | 86 | ||
| 64 | - | // An empty string is no rows rather than one blank one, so a node with | |
| 65 | - | // nothing to say costs nothing. A blank line inside a paragraph survives, | |
| 87 | + | let room = width - column; | |
| 88 | + | let wanted = word.chars().count() + usize::from(column > 0); | |
| 89 | + | if wanted > room && column > 0 { | |
| 90 | + | lines.push(std::mem::take(&mut line)); | |
| 91 | + | column = 0; | |
| 92 | + | } | |
| 93 | + | // Leading whitespace on a line is the wrap's own business and not | |
| 94 | + | // the author's, so a separator is drawn only between two words that | |
| 95 | + | // ended up on the same row. | |
| 96 | + | if column > 0 { | |
| 97 | + | line.push(Span::styled(" ", separator.unwrap_or(span.style))); | |
| 98 | + | column += 1; | |
| 99 | + | } | |
| 100 | + | separator = None; | |
| 101 | + | column += word.chars().count(); | |
| 102 | + | line.push(Span::styled(word.to_string(), span.style)); | |
| 103 | + | } | |
| 104 | + | } | |
| 105 | + | lines.push(line); | |
| 106 | + | ||
| 107 | + | // Nothing to say is no rows rather than one blank one, so a node with an | |
| 108 | + | // empty string costs nothing. A blank line inside a paragraph survives, | |
| 66 | 109 | // because that one was authored. | |
| 67 | 110 | if lines.len() == 1 && lines[0].is_empty() { | |
| 68 | 111 | return Vec::new(); | |
| 69 | 112 | } | |
| 70 | - | lines | |
| 113 | + | lines.into_iter().map(Line::from).collect() | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | /// Break `text` into lines no wider than `width`. | |
| 117 | + | /// | |
| 118 | + | /// [`wrap_spans`] under one style, flattened back to strings for the callers | |
| 119 | + | /// that have no styles to keep. | |
| 120 | + | pub(crate) fn wrap(text: &str, width: u16) -> Vec<String> { | |
| 121 | + | wrap_spans(&[Span::raw(text.to_string())], width) | |
| 122 | + | .into_iter() | |
| 123 | + | .map(|line| { | |
| 124 | + | line.spans | |
| 125 | + | .iter() | |
| 126 | + | .map(|span| span.content.as_ref()) | |
| 127 | + | .collect() | |
| 128 | + | }) | |
| 129 | + | .collect() | |
| 71 | 130 | } | |
| 72 | 131 | ||
| 73 | 132 | /// The rows `text` takes at `width`. | |
| @@ -75,6 +134,11 @@ | |||
| 75 | 134 | u16::try_from(wrap(text, width).len()).unwrap_or(u16::MAX) | |
| 76 | 135 | } | |
| 77 | 136 | ||
| 137 | + | /// The rows `spans` take at `width`, wrapped as a block. | |
| 138 | + | pub(crate) fn spans_height(spans: &[Span<'_>], width: u16) -> u16 { | |
| 139 | + | u16::try_from(wrap_spans(spans, width).len()).unwrap_or(u16::MAX) | |
| 140 | + | } | |
| 141 | + | ||
| 78 | 142 | /// Draw wrapped text at the top of `area`, and answer the rows it used. | |
| 79 | 143 | pub(crate) fn draw(text: &str, style: Style, area: Rect, buf: &mut Buffer) -> u16 { | |
| 80 | 144 | let mut used = 0; | |
| @@ -88,60 +152,50 @@ | |||
| 88 | 152 | used | |
| 89 | 153 | } | |
| 90 | 154 | ||
| 155 | + | /// Draw wrapped spans at the top of `area`, and answer the rows they used. | |
| 156 | + | /// | |
| 157 | + | /// The block counterpart to [`draw_line`]: that one takes a run that is one | |
| 158 | + | /// line by construction and wraps it because it might not fit, and this one | |
| 159 | + | /// takes a run with authored breaks in it and keeps them. | |
| 160 | + | pub(crate) fn draw_spans(spans: &[Span<'_>], area: Rect, buf: &mut Buffer) -> u16 { | |
| 161 | + | let mut used = 0; | |
| 162 | + | for line in wrap_spans(spans, area.width) { | |
| 163 | + | if used >= area.height { | |
| 164 | + | break; | |
| 165 | + | } | |
| 166 | + | let mut column = 0u16; | |
| 167 | + | for span in &line.spans { | |
| 168 | + | let room = area.width.saturating_sub(column) as usize; | |
| 169 | + | if room == 0 { | |
| 170 | + | break; | |
| 171 | + | } | |
| 172 | + | buf.set_stringn( | |
| 173 | + | area.x + column, | |
| 174 | + | area.y + used, | |
| 175 | + | &span.content, | |
| 176 | + | room, | |
| 177 | + | span.style, | |
| 178 | + | ); | |
| 179 | + | column += u16::try_from(span.content.chars().count().min(room)).unwrap_or(u16::MAX); | |
| 180 | + | } | |
| 181 | + | used += 1; | |
| 182 | + | } | |
| 183 | + | used | |
| 184 | + | } | |
| 185 | + | ||
| 91 | 186 | /// Draw a line of spans at the top of `area`, wrapping onto further rows. | |
| 92 | 187 | /// | |
| 93 | - | /// Spans carry their own styles, so this cannot go through [`wrap`]: the break | |
| 94 | - | /// has to be found without losing which span each word came from. It breaks | |
| 95 | - | /// between spans and, inside a span, on whitespace. | |
| 188 | + | /// A run that is one line by construction -- a row's parts, a control, a meter | |
| 189 | + | /// -- rather than a block that may carry breaks of its own. It is the same wrap | |
| 190 | + | /// either way, and was its own implementation until a rich node started putting | |
| 191 | + | /// styled runs inside a row: the separate copy drew the space before a struck | |
| 192 | + | /// word struck, because it took the separator's style from the word after it | |
| 193 | + | /// instead of from the whitespace it replaced. | |
| 96 | 194 | pub(crate) fn draw_line(line: &Line<'_>, area: Rect, buf: &mut Buffer) -> u16 { | |
| 97 | - | let mut row = 0; | |
| 98 | - | let mut column = 0u16; | |
| 99 | - | ||
| 100 | - | for span in &line.spans { | |
| 101 | - | for word in span.content.split_whitespace() { | |
| 102 | - | let wanted = u16::try_from(word.chars().count()).unwrap_or(u16::MAX); | |
| 103 | - | let space = u16::from(column > 0); | |
| 104 | - | if column + space + wanted > area.width && column > 0 { | |
| 105 | - | row += 1; | |
| 106 | - | column = 0; | |
| 107 | - | } | |
| 108 | - | if row >= area.height { | |
| 109 | - | return area.height; | |
| 110 | - | } | |
| 111 | - | if column > 0 { | |
| 112 | - | buf.set_stringn(area.x + column, area.y + row, " ", 1, span.style); | |
| 113 | - | column += 1; | |
| 114 | - | } | |
| 115 | - | let room = area.width.saturating_sub(column) as usize; | |
| 116 | - | buf.set_stringn(area.x + column, area.y + row, word, room, span.style); | |
| 117 | - | column += wanted.min(area.width.saturating_sub(column)); | |
| 118 | - | } | |
| 119 | - | } | |
| 120 | - | ||
| 121 | - | if column > 0 || row > 0 { row + 1 } else { 0 } | |
| 195 | + | draw_spans(&line.spans, area, buf) | |
| 122 | 196 | } | |
| 123 | 197 | ||
| 124 | 198 | /// The rows a line of spans takes at `width`. | |
| 125 | 199 | pub(crate) fn line_height(line: &Line<'_>, width: u16) -> u16 { | |
| 126 | - | if width == 0 { | |
| 127 | - | return 0; | |
| 128 | - | } | |
| 129 | - | let mut rows = 0u16; | |
| 130 | - | let mut column = 0u16; | |
| 131 | - | let mut any = false; | |
| 132 | - | ||
| 133 | - | for span in &line.spans { | |
| 134 | - | for word in span.content.split_whitespace() { | |
| 135 | - | any = true; | |
| 136 | - | let wanted = u16::try_from(word.chars().count()).unwrap_or(u16::MAX); | |
| 137 | - | let space = u16::from(column > 0); | |
| 138 | - | if column + space + wanted > width && column > 0 { | |
| 139 | - | rows += 1; | |
| 140 | - | column = 0; | |
| 141 | - | } | |
| 142 | - | column += space + wanted; | |
| 143 | - | } | |
| 144 | - | } | |
| 145 | - | ||
| 146 | - | if any { rows + 1 } else { 0 } | |
| 200 | + | spans_height(&line.spans, width) | |
| 147 | 201 | } |