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 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
346pub 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}