bpaf/buffer/console.rs
1// help needs to support following features:
2// - linebreak - insert linebreak whenever
3// - newline - start text on a new line, don't start a new line if not already at one
4// - margin - start text at some offset at a new line
5// - tabstop - all tabstops are aligned within a section
6// - section title - "usage", "available options", "available positionals", etc. starts a new
7// section - resets tabstops
8// - literal text user needs to type - flags, command names, etc.
9// - metavar - meta placehoder user needs to write something
10// - subsection title - two spaces + text, used for adjacent groups
11
12// help might want to render it:
13// - monochrome - default mode
14// - bright/dull/custom colors
15// - export to markdown and groff
16//
17// monochrome and colors are rendered with different widths so tabstops are out of buffer rendering
18
19// text formatting rules:
20//
21// want to be able to produce both brief and full versions of the documentation, it only makes
22// sense to look for that in the plain text...
23// - "\n " => hard line break, inserted always
24// - "\n\n" => paragraphs are separated by this, only the first one in inserted unless in "full" mode
25// // - "\n" => converted to spaces, text flows to the current margin value
26//
27// tabstops are aligned the same position within a section, tabstop sets a temporary margin for all
28// the soft linebreaks, tabstop
29//
30// margin sets the minimal offset for any new text and retained until new margin is set:
31// "hello" [margin 8] "world" is rendered as "hello world"
32
33use super::{
34 splitter::{split, Chunk},
35 Block, Doc, Skip, Token,
36};
37
38#[cfg(feature = "color")]
39use super::Style;
40
41const MAX_TAB: usize = 24;
42pub(crate) const MAX_WIDTH: usize = 100;
43
44#[derive(Debug, Copy, Clone, Eq, PartialEq)]
45/// Default to dull color if colors are enabled,
46#[allow(dead_code)] // not fully used in without colors
47pub(crate) enum Color {
48 Monochrome,
49 #[cfg(feature = "color")]
50 Dull,
51 #[cfg(feature = "color")]
52 Bright,
53}
54
55impl Default for Color {
56 fn default() -> Self {
57 #![allow(clippy::let_and_return)]
58 #![allow(unused_mut)]
59 #![allow(unused_assignments)]
60 let mut res;
61 #[cfg(not(feature = "color"))]
62 {
63 res = Color::Monochrome;
64 }
65
66 #[cfg(feature = "color")]
67 {
68 res = Color::Dull;
69 }
70
71 #[cfg(feature = "bright-color")]
72 {
73 res = Color::Bright;
74 }
75
76 #[cfg(feature = "dull-color")]
77 {
78 res = Color::Dull;
79 }
80
81 #[cfg(feature = "color")]
82 {
83 use supports_color::{on, Stream};
84 if !(on(Stream::Stdout).is_some() && on(Stream::Stderr).is_some()) {
85 res = Color::Monochrome;
86 }
87 }
88 res
89 }
90}
91
92#[cfg(feature = "color")]
93impl Color {
94 pub(crate) fn push_str(self, style: Style, res: &mut String, item: &str) {
95 use owo_colors::OwoColorize;
96 use std::fmt::Write;
97 match self {
98 Color::Monochrome => {
99 res.push_str(item);
100 Ok(())
101 }
102 Color::Dull => match style {
103 Style::Text => {
104 res.push_str(item);
105 Ok(())
106 }
107 Style::Emphasis => write!(res, "{}", item.underline().bold()),
108 Style::Literal => write!(res, "{}", item.bold()),
109 Style::Metavar => write!(res, "{}", item.underline()),
110 Style::Invalid => write!(res, "{}", item.bold().red()),
111 },
112 Color::Bright => match style {
113 Style::Text => {
114 res.push_str(item);
115 Ok(())
116 }
117 Style::Emphasis => write!(res, "{}", item.yellow().bold()),
118 Style::Literal => write!(res, "{}", item.green().bold()),
119 Style::Metavar => write!(res, "{}", item.blue().bold()),
120 Style::Invalid => write!(res, "{}", item.red().bold()),
121 },
122 }
123 .unwrap();
124 }
125}
126
127const PADDING: &str = " ";
128
129impl Doc {
130 /// Render a monochrome version of the document
131 ///
132 /// `full` indicates if full message should be rendered, this makes
133 /// difference for rendered help message, otherwise you can pass `true`.
134 #[must_use]
135 pub fn monochrome(&self, full: bool) -> String {
136 self.render_console(full, Color::Monochrome, MAX_WIDTH)
137 }
138
139 #[allow(clippy::too_many_lines)] // it's a big ass match statement
140 pub(crate) fn render_console(&self, full: bool, color: Color, max_width: usize) -> String {
141 let mut res = String::new();
142 let mut tabstop = 0;
143 let mut byte_pos = 0;
144 {
145 let mut current = 0;
146 let mut in_term = false;
147 // looking for widest term below MAX_TAB
148 for token in self.tokens.iter().copied() {
149 match token {
150 Token::Text { bytes, style: _ } => {
151 if in_term {
152 current += self.payload[byte_pos..byte_pos + bytes].chars().count();
153 }
154 byte_pos += bytes;
155 }
156 Token::BlockStart(Block::ItemTerm) => {
157 in_term = true;
158 current = 0;
159 }
160 Token::BlockEnd(Block::ItemTerm) => {
161 in_term = false;
162 if current > tabstop && current <= MAX_TAB {
163 tabstop = current;
164 }
165 }
166 _ => {}
167 }
168 }
169 byte_pos = 0;
170 }
171 let tabstop = tabstop + 4;
172
173 #[cfg(test)]
174 let mut stack = Vec::new();
175 let mut skip = Skip::default();
176 let mut char_pos = 0;
177
178 let mut margins: Vec<usize> = Vec::new();
179
180 // a single new line, unless one exists
181 let mut pending_newline = false;
182 // a double newline, unless one exists
183 let mut pending_blank_line = false;
184
185 let mut pending_margin = false;
186
187 for token in self.tokens.iter().copied() {
188 match token {
189 Token::Text { bytes, style } => {
190 let input = &self.payload[byte_pos..byte_pos + bytes];
191 byte_pos += bytes;
192
193 if skip.enabled() {
194 continue;
195 }
196
197 for chunk in split(input) {
198 match chunk {
199 Chunk::Raw(s, w) => {
200 let margin = margins.last().copied().unwrap_or(0usize);
201 if !res.is_empty() {
202 if (pending_newline || pending_blank_line)
203 && !res.ends_with('\n')
204 {
205 char_pos = 0;
206 res.push('\n');
207 }
208 if pending_blank_line && !res.ends_with("\n\n") {
209 res.push('\n');
210 }
211 if char_pos + s.len() > max_width {
212 char_pos = 0;
213 res.truncate(res.trim_end().len());
214 res.push('\n');
215 if s == " " {
216 continue;
217 }
218 }
219 }
220
221 let mut pushed = 0;
222 if let Some(missing) = margin.checked_sub(char_pos) {
223 res.push_str(&PADDING[..missing]);
224 char_pos = margin;
225 pushed = missing;
226 }
227 if pending_margin && char_pos >= MAX_TAB + 4 && pushed < 2 {
228 let missing = 2 - pushed;
229 res.push_str(&PADDING[..missing]);
230 char_pos += missing;
231 }
232
233 pending_newline = false;
234 pending_blank_line = false;
235 pending_margin = false;
236
237 #[cfg(feature = "color")]
238 {
239 color.push_str(style, &mut res, s);
240 }
241 #[cfg(not(feature = "color"))]
242 {
243 let _ = style;
244 let _ = color;
245 res.push_str(s);
246 }
247 char_pos += w;
248 }
249 Chunk::Paragraph => {
250 res.push('\n');
251 char_pos = 0;
252 if !full {
253 skip.enable();
254 break;
255 }
256 }
257 Chunk::LineBreak => {
258 res.push('\n');
259 char_pos = 0;
260 }
261 }
262 }
263 }
264 Token::BlockStart(block) => {
265 #[cfg(test)]
266 stack.push(block);
267 let margin = margins.last().copied().unwrap_or(0usize);
268
269 match block {
270 Block::Header | Block::Section2 => {
271 pending_newline = true;
272 margins.push(margin);
273 }
274 Block::Section3 => {
275 pending_newline = true;
276 margins.push(margin + 2);
277 }
278 Block::ItemTerm => {
279 pending_newline = true;
280 margins.push(margin + 4);
281 }
282 Block::ItemBody => {
283 margins.push(margin + tabstop + 2);
284 pending_margin = true;
285 }
286 Block::InlineBlock => {
287 skip.push();
288 }
289 Block::Block => {
290 margins.push(margin);
291 }
292 Block::DefinitionList | Block::Meta | Block::Mono => {}
293 Block::TermRef => {
294 if color == Color::Monochrome {
295 res.push('`');
296 char_pos += 1;
297 }
298 }
299 }
300 }
301 Token::BlockEnd(block) => {
302 #[cfg(test)]
303 assert_eq!(stack.pop(), Some(block));
304
305 margins.pop();
306 match block {
307 Block::ItemBody => {
308 pending_margin = false;
309 }
310 Block::Header
311 | Block::Section2
312 | Block::Section3
313 | Block::ItemTerm
314 | Block::DefinitionList
315 | Block::Meta
316 | Block::Mono => {}
317 Block::InlineBlock => {
318 skip.pop();
319 }
320 Block::Block => {
321 pending_blank_line = true;
322 }
323 Block::TermRef => {
324 if color == Color::Monochrome {
325 res.push('`');
326 char_pos += 1;
327 }
328 }
329 }
330 }
331 }
332 }
333 if pending_newline || pending_blank_line {
334 res.push('\n');
335 }
336 #[cfg(test)]
337 assert_eq!(stack, &[]);
338 res
339 }
340}
341
342/*
343#[cfg(test)]
344mod test {
345 use super::*;
346
347 #[test]
348 fn tabstop_works() {
349 // tabstop followed by newline
350 let mut m = Buffer::default();
351 m.token(Token::TermStart);
352 m.text("aa");
353 m.token(Token::TermStop);
354 m.token(Token::LineBreak);
355
356 m.token(Token::TermStart);
357 m.text("b");
358 m.token(Token::TermStop);
359 m.text("c");
360 m.token(Token::LineBreak);
361 assert_eq!(m.monochrome(true), " aa\n b c\n");
362 m.clear();
363
364 // plain, narrow first
365 m.token(Token::TermStart);
366 m.text("1");
367 m.token(Token::TermStop);
368 m.text("22");
369 m.token(Token::LineBreak);
370
371 m.token(Token::TermStart);
372 m.text("33");
373 m.token(Token::TermStop);
374 m.text("4");
375 m.token(Token::LineBreak);
376 assert_eq!(m.monochrome(true), " 1 22\n 33 4\n");
377 m.clear();
378
379 // plain, wide first
380 m.token(Token::TermStart);
381 m.text("aa");
382 m.token(Token::TermStop);
383
384 m.text("b");
385 m.token(Token::LineBreak);
386
387 m.token(Token::TermStart);
388 m.text("c");
389 m.token(Token::TermStop);
390
391 m.text("dd");
392 m.token(Token::LineBreak);
393 assert_eq!(m.monochrome(true), " aa b\n c dd\n");
394 m.clear();
395
396 // two different styles first
397 m.token(Token::TermStart);
398 m.text("a");
399 m.literal("b");
400 m.token(Token::TermStop);
401
402 m.literal("c");
403 m.token(Token::LineBreak);
404 m.token(Token::TermStart);
405 m.text("d");
406 m.token(Token::TermStop);
407
408 m.literal("e");
409 m.token(Token::LineBreak);
410 assert_eq!(m.monochrome(true), " ab c\n d e\n");
411 }
412
413 #[test]
414 fn linewrap_works() {
415 let mut m = Buffer::default();
416 m.token(Token::TermStart);
417 m.write_str("--hello", Style::Literal);
418 m.token(Token::TermStop);
419 for i in 0..25 {
420 m.write_str(&format!("and word{i} "), Style::Text)
421 }
422 m.write_str("and last word", Style::Text);
423 m.token(Token::LineBreak);
424
425 let expected =
426" --hello and word0 and word1 and word2 and word3 and word4 and word5 and word6 and word7 and word8
427 and word9 and word10 and word11 and word12 and word13 and word14 and word15 and word16 and
428 word17 and word18 and word19 and word20 and word21 and word22 and word23 and word24 and last
429 word
430";
431
432 assert_eq!(m.monochrome(true), expected);
433 }
434
435 #[test]
436 fn very_long_tabstop() {
437 let mut m = Buffer::default();
438 m.token(Token::TermStart);
439 m.write_str(
440 "--this-is-a-very-long-option <DON'T DO THIS AT HOME>",
441 Style::Literal,
442 );
443 m.token(Token::TermStop);
444 for i in 0..15 {
445 m.write_str(&format!("and word{i} "), Style::Text)
446 }
447 m.write_str("and last word", Style::Text);
448 m.token(Token::LineBreak);
449
450 let expected =
451" --this-is-a-very-long-option <DON'T DO THIS AT HOME> and word0 and word1 and word2 and word3 and word4
452 and word5 and word6 and word7 and word8 and word9 and word10 and word11 and word12 and word13 and
453 word14 and last word
454";
455
456 assert_eq!(m.monochrome(true), expected);
457 }
458
459 #[test]
460 fn line_breaking_rules() {
461 let mut buffer = Buffer::default();
462 buffer.write_str("hello ", Style::Text);
463 assert_eq!(buffer.monochrome(true), "hello ");
464 buffer.clear();
465
466 buffer.write_str("hello\n world\n", Style::Text);
467 assert_eq!(buffer.monochrome(true), "hello\nworld ");
468 buffer.clear();
469
470 buffer.write_str("hello\nworld", Style::Text);
471 assert_eq!(buffer.monochrome(true), "hello world");
472 buffer.clear();
473
474 buffer.write_str("hello\nworld\n", Style::Text);
475 assert_eq!(buffer.monochrome(true), "hello world ");
476 buffer.clear();
477
478 buffer.write_str("hello\n\nworld", Style::Text);
479 assert_eq!(buffer.monochrome(false), "hello\n");
480 buffer.clear();
481
482 buffer.write_str("hello\n\nworld", Style::Text);
483 assert_eq!(buffer.monochrome(true), "hello\nworld");
484 buffer.clear();
485 }
486
487 #[test]
488 fn splitter_works() {
489 assert_eq!(
490 split("hello ").collect::<Vec<_>>(),
491 [Chunk::Raw("hello", 5), Chunk::Raw(" ", 1)]
492 );
493
494 assert_eq!(
495 split("hello\nworld").collect::<Vec<_>>(),
496 [
497 Chunk::Raw("hello", 5),
498 Chunk::Raw(" ", 1),
499 Chunk::Raw("world", 5)
500 ]
501 );
502
503 assert_eq!(
504 split("hello\n world").collect::<Vec<_>>(),
505 [
506 Chunk::Raw("hello", 5),
507 Chunk::HardLineBreak,
508 Chunk::Raw("world", 5)
509 ]
510 );
511
512 assert_eq!(
513 split("hello\n\nworld").collect::<Vec<_>>(),
514 [
515 Chunk::Raw("hello", 5),
516 Chunk::SoftLineBreak,
517 Chunk::Raw("world", 5)
518 ]
519 );
520 }
521}*/