chkc_help/
renderer.rs

1//! takes a [`HelpPage`] and paints it with termimad, scrolling when the text is too long.
2
3use std::io::{stdout, Write};
4
5use termimad::crossterm::event::KeyCode::*;
6use termimad::crossterm::event::{self, Event};
7use termimad::crossterm::style::{Color, Stylize};
8use termimad::crossterm::{queue, QueueableCommand};
9use termimad::MadView;
10
11use crate::help_page::HelpPage;
12use crate::theme::HelpTheme;
13
14/// render a help page, falling back to a scrollable view if it doesn't fit on screen.
15pub fn render_command_help(theme: &HelpTheme, page: &HelpPage) {
16    let mut md = String::new();
17
18    render_header(&mut md, page);
19    render_usage(&mut md, page);
20    render_subcommands(&mut md, page);
21    render_positionals(&mut md, page);
22    render_options(&mut md, page);
23    render_examples(&mut md, page);
24    render_notes(&mut md, page);
25
26    let (_, rows) = termimad::crossterm::terminal::size().unwrap();
27    if md.lines().count() > rows.into() {
28        let _ = run_scrollable_help(theme, &page.app_name, md);
29    } else {
30        let mut doc_skin = theme.skin.clone();
31        doc_skin.headers[0].align = termimad::Alignment::Left;
32        println!("{}", doc_skin.term_text(&md));
33    }
34}
35
36fn render_header(md: &mut String, page: &HelpPage) {
37    if !page.path.is_empty() {
38        md.push_str(&format!("# {} {}\n", page.app_name, page.path))
39    } else if let Some(version) = &page.version {
40        md.push_str(&format!("# {} v{}\n", page.app_name, version));
41    } else {
42        md.push_str(&format!("# {}\n", page.app_name));
43    }
44
45    if let Some(summary) = &page.summary {
46        if !summary.is_empty() {
47            md.push_str(&format!("{}\n", summary));
48            md.push_str("\n");
49        }
50    }
51
52    if let Some(desc) = &page.description {
53        if !desc.is_empty() {
54            md.push_str(&format!("{}\n", desc));
55            md.push_str("\n");
56        }
57    }
58}
59
60fn render_usage(md: &mut String, page: &HelpPage) {
61    let mut usage = page.usage.replace("Usage:", "").trim().to_owned();
62    let binding = std::env::current_exe().expect("Failed to get executable path");
63    let exec_name = binding
64        .file_name()
65        .expect("Failed to get executable name")
66        .to_str()
67        .unwrap();
68
69    if !usage.starts_with(exec_name) {
70        usage.insert_str(0, &format!("{} ", exec_name));
71    }
72
73    md.push_str(&format!("**Usage:** `{}`\n", usage));
74}
75
76fn render_subcommands(md: &mut String, page: &HelpPage) {
77    if page.subcommands.is_empty() {
78        return;
79    }
80
81    md.push_str("**Subcommands:**\n");
82
83    md.push_str("|:-|:-\n");
84    md.push_str("| command | description |\n");
85    md.push_str("|:-|:-\n");
86
87    for sc in &page.subcommands {
88        md.push_str(&format!(
89            "| {} | {} |\n",
90            sc.name,
91            sc.summary.as_deref().unwrap_or("")
92        ));
93    }
94
95    md.push_str("|-\n");
96    md.push_str("\n");
97}
98
99fn render_positionals(md: &mut String, page: &HelpPage) {
100    if page.positionals.is_empty() {
101        return;
102    }
103
104    md.push_str("**Arguments:**\n");
105
106    for arg in &page.positionals {
107        md.push_str(&format!(
108            "* `{}`: {} *({}{})*\n",
109            arg.name,
110            arg.description.as_deref().unwrap_or(""),
111            if arg.required {
112                "~~required~~"
113            } else {
114                "~~optional~~"
115            },
116            if arg.multiple { ", ~~multiple~~" } else { "" }
117        ));
118    }
119
120    md.push_str("\n");
121}
122
123fn render_options(md: &mut String, page: &HelpPage) {
124    if page.options.is_empty() {
125        return;
126    }
127
128    md.push_str("**Options:**\n");
129
130    md.push_str("|:-:|:-:|-\n");
131    md.push_str("|short|long|description|\n");
132    md.push_str("|:-:|:-|-\n");
133
134    for opt in &page.options {
135        let mut name_short = String::new();
136        let mut name_long = String::new();
137        let mut desc = opt.description.clone();
138
139        if let Some(short) = opt.short {
140            name_short.push_str(&format!("-{}", short));
141        }
142
143        if let Some(long) = &opt.long {
144            name_long.push_str(&format!("--{}", long));
145        }
146
147        if let Some(val) = &opt.value {
148            if !name_short.is_empty() {
149                name_short.push_str(&format!(" ~~<{}>~~", val.to_ascii_lowercase()));
150            }
151
152            name_long.push_str(&format!(" ~~<{}>~~", val.to_ascii_lowercase()));
153        }
154
155        if !opt.default.is_empty() {
156            desc.push_str(&format!(" *(defaults to {})*", opt.default));
157        }
158
159        md.push_str(&format!("| {} | {} | {}\n", name_short, name_long, desc));
160    }
161
162    md.push_str("|-\n");
163
164    md.push_str("\n");
165}
166
167fn render_examples(md: &mut String, page: &HelpPage) {
168    if page.examples.is_empty() {
169        return;
170    }
171
172    md.push_str("**Examples:**\n");
173
174    let mark = page
175        .examples
176        .iter()
177        .enumerate()
178        .map(|(i, e)| format!("~~{})~~ {}", i + 1, e))
179        .collect::<Vec<_>>()
180        .join("\n");
181
182    md.push_str(&mark);
183    md.push_str("\n\n");
184}
185
186fn render_notes(md: &mut String, page: &HelpPage) {
187    if page.notes.is_empty() {
188        return;
189    }
190
191    md.push_str("**Notes:**\n");
192
193    let mark = page
194        .notes
195        .iter()
196        .map(|n| format!("- {}", n))
197        .collect::<Vec<_>>()
198        .join("\n");
199
200    md.push_str(&mark);
201}
202
203fn view_area() -> termimad::Area {
204    let mut area = termimad::Area::full_screen();
205    if area.width <= 120 {
206        area.height -= 1;
207    }
208    area.pad_for_max_width(80);
209    area
210}
211
212fn draw_vertical_legend<W: Write>(
213    out: &mut W,
214    app_name: &str,
215    accent: Color,
216) -> anyhow::Result<()> {
217    let legend = [
218        format!("{}", format!("{app_name} Help").with(accent).bold()),
219        format!(
220            "{} {} {} {}",
221            "↑".with(accent).bold(),
222            "/".dark_grey(),
223            "↓".with(accent).bold(),
224            "Scroll".dark_grey()
225        ),
226        format!(
227            "{}{}{} {}",
228            "PgUp".with(accent).bold(),
229            "/".dark_grey(),
230            "Dn".with(accent).bold(),
231            "Page".dark_grey()
232        ),
233        format!("{} {}", "Mouse".with(accent).bold(), "Scroll".dark_grey()),
234        format!(
235            "{} {} {} {}",
236            "q".red().bold(),
237            "/".dark_grey(),
238            "Esc".red().bold(),
239            "Quit".dark_grey()
240        ),
241    ];
242
243    let x = 0;
244    let y = 0;
245
246    for (i, line) in legend.iter().enumerate() {
247        out.queue(termimad::crossterm::style::SetBackgroundColor(
248            termimad::crossterm::style::Color::Black,
249        ))?;
250
251        out.queue(termimad::crossterm::cursor::MoveTo(x, y + i as u16))?;
252        out.queue(termimad::crossterm::style::Print(line))?;
253    }
254
255    out.queue(termimad::crossterm::style::ResetColor)?;
256
257    Ok(())
258}
259
260fn visible_width(s: &str) -> usize {
261    let stripped = strip_ansi_escapes::strip(s);
262
263    let stripped = std::str::from_utf8(&stripped).unwrap_or("");
264    unicode_width::UnicodeWidthStr::width(stripped)
265}
266
267fn join_justify_between(items: &[String], width: u16) -> String {
268    let visible_total: usize = items.iter().map(|s| visible_width(s)).sum();
269    let gaps = items.len().saturating_sub(1);
270
271    if gaps == 0 || visible_total >= width as usize {
272        return items.join(" ");
273    }
274
275    let remaining = width as usize - visible_total;
276    let space = remaining / gaps;
277    let extra = remaining % gaps;
278
279    let mut out = String::new();
280
281    for (i, item) in items.iter().enumerate() {
282        out.push_str(item);
283
284        if i < gaps {
285            let pad = space + if i < extra { 1 } else { 0 };
286            out.push_str(&" ".repeat(pad));
287        }
288    }
289
290    out
291}
292
293fn draw_horizontal_legend<W: Write>(
294    out: &mut W,
295    area: &termimad::Area,
296    app_name: &str,
297    accent: Color,
298) -> anyhow::Result<()> {
299    let items = [
300        format!("{}", format!("{app_name} Help").with(accent).bold()),
301        format!(
302            "{} {} {} {}",
303            "↑".with(accent).bold(),
304            "/".dark_grey(),
305            "↓".with(accent).bold(),
306            "Scroll".dark_grey()
307        ),
308        format!(
309            "{} {} {} {}",
310            "PgUp".with(accent).bold(),
311            "/".dark_grey(),
312            "Dn".with(accent).bold(),
313            "Page".dark_grey()
314        ),
315        format!("{} {}", "Mouse".with(accent).bold(), "Scroll".dark_grey()),
316        format!(
317            "{} {} {} {}",
318            "q".red().bold(),
319            "/".dark_grey(),
320            "Esc".red().bold(),
321            "Quit".dark_grey()
322        ),
323    ];
324
325    let line = join_justify_between(&items, area.width);
326
327    out.queue(termimad::crossterm::cursor::MoveTo(0, area.height - 1))?;
328    out.queue(termimad::crossterm::style::Print(line))?;
329
330    Ok(())
331}
332
333fn draw_legend<W: Write>(
334    out: &mut W,
335    area: &termimad::Area,
336    app_name: &str,
337    accent: Color,
338) -> anyhow::Result<()> {
339    if area.width > 120 {
340        draw_vertical_legend(out, app_name, accent)
341    } else {
342        draw_horizontal_legend(out, area, app_name, accent)
343    }
344}
345
346/// open a scrollable markdown view with keyboard and mouse shortcuts.
347pub fn run_scrollable_help(
348    theme: &HelpTheme,
349    app_name: &str,
350    markdown: String,
351) -> anyhow::Result<()> {
352    let mut w = stdout();
353    queue!(w, termimad::crossterm::terminal::EnterAlternateScreen)?;
354    termimad::crossterm::terminal::enable_raw_mode()?;
355    queue!(w, termimad::crossterm::cursor::Hide)?;
356    let mut view = MadView::from(markdown, view_area(), theme.skin.clone());
357
358    let mut term_area = termimad::Area::full_screen();
359
360    loop {
361        view.write_on(&mut w)?;
362        draw_legend(&mut stdout(), &term_area, app_name, theme.accent)?;
363        w.flush()?;
364        match event::read() {
365            Ok(Event::Key(event::KeyEvent { code, .. })) => match code {
366                Up => view.try_scroll_lines(-1),
367                Down => view.try_scroll_lines(1),
368                Char('j') => view.try_scroll_lines(-1),
369                Char('k') => view.try_scroll_lines(1),
370                Char('q') => break,
371                Esc => break,
372                PageUp => view.try_scroll_pages(-1),
373                PageDown => view.try_scroll_pages(1),
374                _ => {}
375            },
376            Ok(Event::Resize(..)) => {
377                term_area = termimad::Area::full_screen();
378
379                queue!(
380                    w,
381                    termimad::crossterm::terminal::Clear(
382                        termimad::crossterm::terminal::ClearType::All
383                    )
384                )?;
385                view.resize(&view_area());
386            }
387            _ => {}
388        }
389    }
390
391    termimad::crossterm::terminal::disable_raw_mode()?;
392    queue!(w, termimad::crossterm::cursor::Show)?;
393    queue!(w, termimad::crossterm::terminal::LeaveAlternateScreen)?;
394    w.flush()?;
395    Ok(())
396}