1use 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
14pub 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 usage = page.usage.replace("Usage:", "").trim().to_owned();
62 md.push_str(&format!(
63 "**Usage:** `{}`\n",
64 usage
65 ));
66}
67
68fn render_subcommands(md: &mut String, page: &HelpPage) {
69 if page.subcommands.is_empty() {
70 return;
71 }
72
73 md.push_str("**Subcommands:**\n");
74
75 md.push_str("|:-|:-\n");
76 md.push_str("| command | description |\n");
77 md.push_str("|:-|:-\n");
78
79 for sc in &page.subcommands {
80 md.push_str(&format!(
81 "| {} | {} |\n",
82 sc.name,
83 sc.summary.as_deref().unwrap_or("")
84 ));
85 }
86
87 md.push_str("|-\n");
88 md.push_str("\n");
89}
90
91fn render_positionals(md: &mut String, page: &HelpPage) {
92 if page.positionals.is_empty() {
93 return;
94 }
95
96 md.push_str("**Arguments:**\n");
97
98 for arg in &page.positionals {
99 md.push_str(&format!(
100 "* `{}`: {} *({}{})*\n",
101 arg.name,
102 arg.description.as_deref().unwrap_or(""),
103 if arg.required { "~~required~~" } else { "~~optional~~" },
104 if arg.multiple { ", ~~multiple~~" } else { "" }
105 ));
106 }
107
108 md.push_str("\n");
109}
110
111fn render_options(md: &mut String, page: &HelpPage) {
112 if page.options.is_empty() {
113 return;
114 }
115
116 md.push_str("**Options:**\n");
117
118 md.push_str("|:-:|:-:|-\n");
119 md.push_str("|short|long|description|\n");
120 md.push_str("|:-:|:-|-\n");
121
122 for opt in &page.options {
123 let mut name_short = String::new();
124 let mut name_long = String::new();
125 let mut desc = opt.description.clone();
126
127 if let Some(short) = opt.short {
128 name_short.push_str(&format!("-{}", short));
129 }
130
131 if let Some(long) = &opt.long {
132 name_long.push_str(&format!("--{}", long));
133 }
134
135 if let Some(val) = &opt.value {
136 if !name_short.is_empty() {
137 name_short.push_str(&format!(" ~~<{}>~~", val.to_ascii_lowercase()));
138 }
139
140 name_long.push_str(&format!(" ~~<{}>~~", val.to_ascii_lowercase()));
141 }
142
143 if !opt.default.is_empty() {
144 desc.push_str(&format!(" *(defaults to {})*", opt.default));
145 }
146
147 md.push_str(&format!("| {} | {} | {}\n", name_short, name_long, desc));
148 }
149
150 md.push_str("|-\n");
151
152 md.push_str("\n");
153}
154
155fn render_examples(md: &mut String, page: &HelpPage) {
156 if page.examples.is_empty() {
157 return;
158 }
159
160 md.push_str("**Examples:**\n");
161
162 let mark = page
163 .examples
164 .iter()
165 .enumerate()
166 .map(|(i, e)| format!("~~{})~~ {}", i + 1, e))
167 .collect::<Vec<_>>()
168 .join("\n");
169
170 md.push_str(&mark);
171 md.push_str("\n\n");
172}
173
174fn render_notes(md: &mut String, page: &HelpPage) {
175 if page.notes.is_empty() {
176 return;
177 }
178
179 md.push_str("**Notes:**\n");
180
181 let mark = page
182 .notes
183 .iter()
184 .map(|n| format!("- {}", n))
185 .collect::<Vec<_>>()
186 .join("\n");
187
188 md.push_str(&mark);
189}
190
191fn view_area() -> termimad::Area {
192 let mut area = termimad::Area::full_screen();
193 if area.width <= 120 {
194 area.height -= 1;
195 }
196 area.pad_for_max_width(80);
197 area
198}
199
200fn draw_vertical_legend<W: Write>(
201 out: &mut W,
202 app_name: &str,
203 accent: Color,
204) -> anyhow::Result<()> {
205 let legend = [
206 format!("{}", format!("{app_name} Help").with(accent).bold()),
207 format!(
208 "{} {} {} {}",
209 "↑".with(accent).bold(),
210 "/".dark_grey(),
211 "↓".with(accent).bold(),
212 "Scroll".dark_grey()
213 ),
214 format!(
215 "{}{}{} {}",
216 "PgUp".with(accent).bold(),
217 "/".dark_grey(),
218 "Dn".with(accent).bold(),
219 "Page".dark_grey()
220 ),
221 format!("{} {}", "Mouse".with(accent).bold(), "Scroll".dark_grey()),
222 format!(
223 "{} {} {} {}",
224 "q".red().bold(),
225 "/".dark_grey(),
226 "Esc".red().bold(),
227 "Quit".dark_grey()
228 ),
229 ];
230
231 let x = 0;
232 let y = 0;
233
234 for (i, line) in legend.iter().enumerate() {
235 out.queue(termimad::crossterm::style::SetBackgroundColor(
236 termimad::crossterm::style::Color::Black,
237 ))?;
238
239 out.queue(termimad::crossterm::cursor::MoveTo(x, y + i as u16))?;
240 out.queue(termimad::crossterm::style::Print(line))?;
241 }
242
243 out.queue(termimad::crossterm::style::ResetColor)?;
244
245 Ok(())
246}
247
248fn visible_width(s: &str) -> usize {
249 let stripped = strip_ansi_escapes::strip(s);
250
251 let stripped = std::str::from_utf8(&stripped).unwrap_or("");
252 unicode_width::UnicodeWidthStr::width(stripped)
253}
254
255fn join_justify_between(items: &[String], width: u16) -> String {
256 let visible_total: usize = items.iter().map(|s| visible_width(s)).sum();
257 let gaps = items.len().saturating_sub(1);
258
259 if gaps == 0 || visible_total >= width as usize {
260 return items.join(" ");
261 }
262
263 let remaining = width as usize - visible_total;
264 let space = remaining / gaps;
265 let extra = remaining % gaps;
266
267 let mut out = String::new();
268
269 for (i, item) in items.iter().enumerate() {
270 out.push_str(item);
271
272 if i < gaps {
273 let pad = space + if i < extra { 1 } else { 0 };
274 out.push_str(&" ".repeat(pad));
275 }
276 }
277
278 out
279}
280
281fn draw_horizontal_legend<W: Write>(
282 out: &mut W,
283 area: &termimad::Area,
284 app_name: &str,
285 accent: Color,
286) -> anyhow::Result<()> {
287 let items = [
288 format!("{}", format!("{app_name} Help").with(accent).bold()),
289 format!(
290 "{} {} {} {}",
291 "↑".with(accent).bold(),
292 "/".dark_grey(),
293 "↓".with(accent).bold(),
294 "Scroll".dark_grey()
295 ),
296 format!(
297 "{} {} {} {}",
298 "PgUp".with(accent).bold(),
299 "/".dark_grey(),
300 "Dn".with(accent).bold(),
301 "Page".dark_grey()
302 ),
303 format!("{} {}", "Mouse".with(accent).bold(), "Scroll".dark_grey()),
304 format!(
305 "{} {} {} {}",
306 "q".red().bold(),
307 "/".dark_grey(),
308 "Esc".red().bold(),
309 "Quit".dark_grey()
310 ),
311 ];
312
313 let line = join_justify_between(&items, area.width);
314
315 out.queue(termimad::crossterm::cursor::MoveTo(0, area.height - 1))?;
316 out.queue(termimad::crossterm::style::Print(line))?;
317
318 Ok(())
319}
320
321fn draw_legend<W: Write>(
322 out: &mut W,
323 area: &termimad::Area,
324 app_name: &str,
325 accent: Color,
326) -> anyhow::Result<()> {
327 if area.width > 120 {
328 draw_vertical_legend(out, app_name, accent)
329 } else {
330 draw_horizontal_legend(out, area, app_name, accent)
331 }
332}
333
334pub fn run_scrollable_help(
336 theme: &HelpTheme,
337 app_name: &str,
338 markdown: String,
339) -> anyhow::Result<()> {
340 let mut w = stdout();
341 queue!(w, termimad::crossterm::terminal::EnterAlternateScreen)?;
342 termimad::crossterm::terminal::enable_raw_mode()?;
343 queue!(w, termimad::crossterm::cursor::Hide)?;
344 let mut view = MadView::from(markdown, view_area(), theme.skin.clone());
345
346 let mut term_area = termimad::Area::full_screen();
347
348 loop {
349 view.write_on(&mut w)?;
350 draw_legend(&mut stdout(), &term_area, app_name, theme.accent)?;
351 w.flush()?;
352 match event::read() {
353 Ok(Event::Key(event::KeyEvent { code, .. })) => match code {
354 Up => view.try_scroll_lines(-1),
355 Down => view.try_scroll_lines(1),
356 Char('j') => view.try_scroll_lines(-1),
357 Char('k') => view.try_scroll_lines(1),
358 Char('q') => break,
359 Esc => break,
360 PageUp => view.try_scroll_pages(-1),
361 PageDown => view.try_scroll_pages(1),
362 _ => {}
363 },
364 Ok(Event::Resize(..)) => {
365 term_area = termimad::Area::full_screen();
366
367 queue!(
368 w,
369 termimad::crossterm::terminal::Clear(
370 termimad::crossterm::terminal::ClearType::All
371 )
372 )?;
373 view.resize(&view_area());
374 }
375 _ => {}
376 }
377 }
378
379 termimad::crossterm::terminal::disable_raw_mode()?;
380 queue!(w, termimad::crossterm::cursor::Show)?;
381 queue!(w, termimad::crossterm::terminal::LeaveAlternateScreen)?;
382 w.flush()?;
383 Ok(())
384}