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 | Block::Section4 => {
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::Section4
314 | Block::ItemTerm
315 | Block::DefinitionList
316 | Block::Meta
317 | Block::Mono => {}
318 Block::InlineBlock => {
319 skip.pop();
320 }
321 Block::Block => {
322 pending_blank_line = true;
323 }
324 Block::TermRef => {
325 if color == Color::Monochrome {
326 res.push('`');
327 char_pos += 1;
328 }
329 }
330 }
331 }
332 }
333 }
334 if pending_newline || pending_blank_line {
335 res.push('\n');
336 }
337 #[cfg(test)]
338 assert_eq!(stack, &[]);
339 res
340 }
341}
342
343/*
344#[cfg(test)]
345mod test {
346 use super::*;
347
348 #[test]
349 fn tabstop_works() {
350 // tabstop followed by newline
351 let mut m = Buffer::default();
352 m.token(Token::TermStart);
353 m.text("aa");
354 m.token(Token::TermStop);
355 m.token(Token::LineBreak);
356
357 m.token(Token::TermStart);
358 m.text("b");
359 m.token(Token::TermStop);
360 m.text("c");
361 m.token(Token::LineBreak);
362 assert_eq!(m.monochrome(true), " aa\n b c\n");
363 m.clear();
364
365 // plain, narrow first
366 m.token(Token::TermStart);
367 m.text("1");
368 m.token(Token::TermStop);
369 m.text("22");
370 m.token(Token::LineBreak);
371
372 m.token(Token::TermStart);
373 m.text("33");
374 m.token(Token::TermStop);
375 m.text("4");
376 m.token(Token::LineBreak);
377 assert_eq!(m.monochrome(true), " 1 22\n 33 4\n");
378 m.clear();
379
380 // plain, wide first
381 m.token(Token::TermStart);
382 m.text("aa");
383 m.token(Token::TermStop);
384
385 m.text("b");
386 m.token(Token::LineBreak);
387
388 m.token(Token::TermStart);
389 m.text("c");
390 m.token(Token::TermStop);
391
392 m.text("dd");
393 m.token(Token::LineBreak);
394 assert_eq!(m.monochrome(true), " aa b\n c dd\n");
395 m.clear();
396
397 // two different styles first
398 m.token(Token::TermStart);
399 m.text("a");
400 m.literal("b");
401 m.token(Token::TermStop);
402
403 m.literal("c");
404 m.token(Token::LineBreak);
405 m.token(Token::TermStart);
406 m.text("d");
407 m.token(Token::TermStop);
408
409 m.literal("e");
410 m.token(Token::LineBreak);
411 assert_eq!(m.monochrome(true), " ab c\n d e\n");
412 }
413
414 #[test]
415 fn linewrap_works() {
416 let mut m = Buffer::default();
417 m.token(Token::TermStart);
418 m.write_str("--hello", Style::Literal);
419 m.token(Token::TermStop);
420 for i in 0..25 {
421 m.write_str(&format!("and word{i} "), Style::Text)
422 }
423 m.write_str("and last word", Style::Text);
424 m.token(Token::LineBreak);
425
426 let expected =
427" --hello and word0 and word1 and word2 and word3 and word4 and word5 and word6 and word7 and word8
428 and word9 and word10 and word11 and word12 and word13 and word14 and word15 and word16 and
429 word17 and word18 and word19 and word20 and word21 and word22 and word23 and word24 and last
430 word
431";
432
433 assert_eq!(m.monochrome(true), expected);
434 }
435
436 #[test]
437 fn very_long_tabstop() {
438 let mut m = Buffer::default();
439 m.token(Token::TermStart);
440 m.write_str(
441 "--this-is-a-very-long-option <DON'T DO THIS AT HOME>",
442 Style::Literal,
443 );
444 m.token(Token::TermStop);
445 for i in 0..15 {
446 m.write_str(&format!("and word{i} "), Style::Text)
447 }
448 m.write_str("and last word", Style::Text);
449 m.token(Token::LineBreak);
450
451 let expected =
452" --this-is-a-very-long-option <DON'T DO THIS AT HOME> and word0 and word1 and word2 and word3 and word4
453 and word5 and word6 and word7 and word8 and word9 and word10 and word11 and word12 and word13 and
454 word14 and last word
455";
456
457 assert_eq!(m.monochrome(true), expected);
458 }
459
460 #[test]
461 fn line_breaking_rules() {
462 let mut buffer = Buffer::default();
463 buffer.write_str("hello ", Style::Text);
464 assert_eq!(buffer.monochrome(true), "hello ");
465 buffer.clear();
466
467 buffer.write_str("hello\n world\n", Style::Text);
468 assert_eq!(buffer.monochrome(true), "hello\nworld ");
469 buffer.clear();
470
471 buffer.write_str("hello\nworld", Style::Text);
472 assert_eq!(buffer.monochrome(true), "hello world");
473 buffer.clear();
474
475 buffer.write_str("hello\nworld\n", Style::Text);
476 assert_eq!(buffer.monochrome(true), "hello world ");
477 buffer.clear();
478
479 buffer.write_str("hello\n\nworld", Style::Text);
480 assert_eq!(buffer.monochrome(false), "hello\n");
481 buffer.clear();
482
483 buffer.write_str("hello\n\nworld", Style::Text);
484 assert_eq!(buffer.monochrome(true), "hello\nworld");
485 buffer.clear();
486 }
487
488 #[test]
489 fn splitter_works() {
490 assert_eq!(
491 split("hello ").collect::<Vec<_>>(),
492 [Chunk::Raw("hello", 5), Chunk::Raw(" ", 1)]
493 );
494
495 assert_eq!(
496 split("hello\nworld").collect::<Vec<_>>(),
497 [
498 Chunk::Raw("hello", 5),
499 Chunk::Raw(" ", 1),
500 Chunk::Raw("world", 5)
501 ]
502 );
503
504 assert_eq!(
505 split("hello\n world").collect::<Vec<_>>(),
506 [
507 Chunk::Raw("hello", 5),
508 Chunk::HardLineBreak,
509 Chunk::Raw("world", 5)
510 ]
511 );
512
513 assert_eq!(
514 split("hello\n\nworld").collect::<Vec<_>>(),
515 [
516 Chunk::Raw("hello", 5),
517 Chunk::SoftLineBreak,
518 Chunk::Raw("world", 5)
519 ]
520 );
521 }
522}*/