1use crate::pkg::{local, resolve};
2use anyhow::{Result, anyhow};
3use crossterm::{
4 event::{
5 self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind, MouseEventKind,
6 },
7 execute,
8 terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
9};
10use pulldown_cmark::{Event as CmarkEvent, HeadingLevel, Options, Parser, Tag, TagEnd};
11use ratatui::{
12 prelude::*,
13 widgets::{Block, Borders, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, Wrap},
14};
15use std::fs;
16use std::io;
17use syntect::{
18 easy::HighlightLines,
19 highlighting::{Style as SyntectStyle, ThemeSet},
20 parsing::SyntaxSet,
21 util::LinesWithEndings,
22};
23
24struct App<'a> {
25 lines: Vec<Line<'a>>,
26 scroll: u16,
27 content_height: u16,
28}
29
30impl<'a> App<'a> {
31 fn new(content: &'a str) -> Self {
32 let lines = parse_markdown(content);
33 let content_height = lines.len() as u16;
34 Self {
35 lines,
36 scroll: 0,
37 content_height,
38 }
39 }
40}
41
42pub fn run(package_name: &str, upstream: bool, raw: bool) -> Result<()> {
43 let (pkg, _version, _, _, registry_handle) =
44 resolve::resolve_package_and_version(package_name, false)?;
45
46 let fetch_from_upstream = || -> Result<String> {
47 if let Some(url) = pkg.man.as_ref() {
48 if !raw {
49 println!("Fetching manual from {}...", url);
50 }
51 Ok(reqwest::blocking::get(url)?.text()?)
52 } else {
53 Err(anyhow!(
54 "Package '{}' does not have a manual URL.",
55 package_name
56 ))
57 }
58 };
59
60 let content = if upstream {
61 fetch_from_upstream()?
62 } else {
63 let handle = registry_handle.as_deref().unwrap_or("local");
64 let scopes_to_check = [
65 crate::pkg::types::Scope::Project,
66 crate::pkg::types::Scope::User,
67 crate::pkg::types::Scope::System,
68 ];
69 let mut found_manual = None;
70
71 for scope in scopes_to_check {
72 if let Ok(package_dir) = local::get_package_dir(scope, handle, &pkg.repo, &pkg.name) {
73 let latest_dir = package_dir.join("latest");
74 if !latest_dir.exists() {
75 continue;
76 }
77
78 let man_md_path = latest_dir.join("man.md");
79 let man_txt_path = latest_dir.join("man.txt");
80
81 if man_md_path.exists() {
82 if !raw {
83 println!(
84 "Displaying locally installed manual (Markdown) from {:?} scope...",
85 scope
86 );
87 }
88 found_manual = Some(fs::read_to_string(man_md_path)?);
89 break;
90 } else if man_txt_path.exists() {
91 if !raw {
92 println!(
93 "Displaying locally installed manual (text) from {:?} scope...",
94 scope
95 );
96 }
97 found_manual = Some(fs::read_to_string(man_txt_path)?);
98 break;
99 }
100 }
101 }
102
103 if let Some(manual_content) = found_manual {
104 manual_content
105 } else {
106 fetch_from_upstream()?
107 }
108 };
109
110 if raw {
111 print!("{}", content);
112 return Ok(());
113 }
114
115 enable_raw_mode()?;
116 let mut stdout = io::stdout();
117 execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
118 let backend = CrosstermBackend::new(stdout);
119 let mut terminal = Terminal::new(backend)?;
120
121 let app = App::new(&content);
122 let res = run_app(&mut terminal, app);
123
124 disable_raw_mode()?;
125 execute!(
126 terminal.backend_mut(),
127 LeaveAlternateScreen,
128 DisableMouseCapture
129 )?;
130 terminal.show_cursor()?;
131
132 if let Err(err) = res {
133 eprintln!("{:?}", err)
134 }
135
136 Ok(())
137}
138
139fn run_app(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>, mut app: App) -> io::Result<()> {
140 loop {
141 terminal.draw(|f| ui(f, &mut app))?;
142
143 match event::read()? {
144 Event::Key(key) => {
145 if key.kind == KeyEventKind::Press {
146 match key.code {
147 KeyCode::Char('q') => return Ok(()),
148 KeyCode::Down | KeyCode::Char('j') => {
149 app.scroll = app.scroll.saturating_add(1);
150 }
151 KeyCode::Up | KeyCode::Char('k') => {
152 app.scroll = app.scroll.saturating_sub(1);
153 }
154 KeyCode::PageDown => {
155 app.scroll = app.scroll.saturating_add(terminal.size()?.height);
156 }
157 KeyCode::PageUp => {
158 app.scroll = app.scroll.saturating_sub(terminal.size()?.height);
159 }
160 KeyCode::Home => app.scroll = 0,
161 KeyCode::End => app.scroll = app.content_height,
162 _ => {}
163 }
164 }
165 }
166 Event::Mouse(mouse) => match mouse.kind {
167 MouseEventKind::ScrollUp => app.scroll = app.scroll.saturating_sub(3),
168 MouseEventKind::ScrollDown => app.scroll = app.scroll.saturating_add(3),
169 _ => {}
170 },
171 _ => {}
172 }
173 }
174}
175
176fn ui(f: &mut Frame, app: &mut App) {
177 let size = f.area();
178 let text = Text::from(app.lines.clone());
179
180 let paragraph = Paragraph::new(text)
181 .block(Block::default().borders(Borders::ALL).title("Manual"))
182 .wrap(Wrap { trim: true })
183 .scroll((app.scroll, 0));
184
185 f.render_widget(paragraph, size);
186
187 let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
188 .begin_symbol(Some("↑"))
189 .end_symbol(Some("↓"));
190
191 let mut scrollbar_state =
192 ScrollbarState::new(app.content_height as usize).position(app.scroll as usize);
193
194 f.render_stateful_widget(
195 scrollbar,
196 size.inner(Margin {
197 vertical: 1,
198 horizontal: 0,
199 }),
200 &mut scrollbar_state,
201 );
202}
203
204fn parse_markdown(content: &str) -> Vec<Line<'_>> {
205 let mut options = Options::empty();
206 options.insert(Options::ENABLE_STRIKETHROUGH);
207 let parser = Parser::new_ext(content, options);
208
209 let mut lines = Vec::new();
210 let mut current_line = Vec::new();
211 let mut style_stack = vec![Style::default()];
212 let mut list_stack: Vec<(u64, char)> = Vec::new();
213
214 let ss = SyntaxSet::load_defaults_newlines();
215 let ts = ThemeSet::load_defaults();
216 let mut highlighter: Option<(HighlightLines, String)> = None;
217 let mut link_url = String::new();
218
219 for event in parser {
220 match event {
221 CmarkEvent::Start(tag) => match tag {
222 Tag::Paragraph => {}
223 Tag::Heading { level, .. } => {
224 style_stack.push(
225 Style::default()
226 .add_modifier(Modifier::BOLD)
227 .fg(Color::Yellow),
228 );
229 let level_num = match level {
230 HeadingLevel::H1 => 1,
231 HeadingLevel::H2 => 2,
232 HeadingLevel::H3 => 3,
233 HeadingLevel::H4 => 4,
234 HeadingLevel::H5 => 5,
235 HeadingLevel::H6 => 6,
236 };
237 current_line.push(Span::raw("#".repeat(level_num) + " "));
238 }
239 Tag::BlockQuote(_) => {
240 style_stack.push(Style::default().fg(Color::Gray));
241 current_line.push(Span::styled("> ", *style_stack.last().unwrap()));
242 }
243 Tag::CodeBlock(kind) => {
244 let lang = if let pulldown_cmark::CodeBlockKind::Fenced(lang) = kind {
245 lang.into_string()
246 } else {
247 "text".to_string()
248 };
249 if let Some(syntax) = ss.find_syntax_by_extension(&lang) {
250 highlighter = Some((
251 HighlightLines::new(syntax, &ts.themes["base16-ocean.dark"]),
252 String::new(),
253 ));
254 } else {
255 highlighter = None;
256 }
257 }
258 Tag::List(start_index) => {
259 list_stack.push((start_index.unwrap_or(1), '*'));
260 }
261 Tag::Item => {
262 let list_len = list_stack.len();
263 if let Some((index, _)) = list_stack.last_mut() {
264 let marker = if *index > 0 {
265 format!("{}. ", index)
266 } else {
267 "* ".to_string()
268 };
269 current_line.push(Span::raw(" ".repeat(list_len - 1)));
270 current_line.push(Span::raw(marker));
271 *index += 1;
272 }
273 }
274 Tag::Emphasis => {
275 style_stack.push((*style_stack.last().unwrap()).add_modifier(Modifier::ITALIC));
276 }
277 Tag::Strong => {
278 style_stack.push((*style_stack.last().unwrap()).add_modifier(Modifier::BOLD));
279 }
280 Tag::Strikethrough => {
281 style_stack
282 .push((*style_stack.last().unwrap()).add_modifier(Modifier::CROSSED_OUT));
283 }
284 Tag::Link { dest_url, .. } => {
285 link_url = dest_url.to_string();
286 current_line.push(Span::styled("[", Style::default().fg(Color::DarkGray)));
287 style_stack.push(
288 Style::default()
289 .fg(Color::Cyan)
290 .add_modifier(Modifier::UNDERLINED),
291 );
292 }
293 _ => {}
294 },
295 CmarkEvent::End(tag) => {
296 match tag {
297 TagEnd::Paragraph
298 | TagEnd::Heading { .. }
299 | TagEnd::BlockQuote(_)
300 | TagEnd::Item => {
301 lines.push(Line::from(std::mem::take(&mut current_line)));
302 }
303 TagEnd::CodeBlock => {
304 if let Some((mut h, code)) = highlighter.take() {
305 for line in LinesWithEndings::from(&code) {
306 let ranges: Vec<(SyntectStyle, &str)> =
307 h.highlight_line(line, &ss).unwrap();
308 let spans: Vec<Span> = ranges
309 .into_iter()
310 .map(|(style, text)| {
311 Span::styled(
312 text.to_string(),
313 Style::default()
314 .fg(Color::Rgb(
315 style.foreground.r,
316 style.foreground.g,
317 style.foreground.b,
318 ))
319 .bg(Color::Rgb(
320 style.background.r,
321 style.background.g,
322 style.background.b,
323 )),
324 )
325 })
326 .collect();
327 lines.push(Line::from(spans));
328 }
329 }
330 lines.push(Line::from(vec![]));
331 }
332 TagEnd::Emphasis | TagEnd::Strong | TagEnd::Strikethrough => {
333 style_stack.pop();
334 }
335 TagEnd::Link => {
336 style_stack.pop();
337 current_line.push(Span::styled(
338 format!("]({})", link_url),
339 Style::default().fg(Color::DarkGray),
340 ));
341 link_url.clear();
342 }
343 TagEnd::List(_) => {
344 list_stack.pop();
345 if list_stack.is_empty() {
346 lines.push(Line::from(vec![]));
347 }
348 }
349 _ => {}
350 }
351 if let TagEnd::Heading { .. } | TagEnd::BlockQuote(_) = tag {
352 style_stack.pop();
353 }
354 }
355 CmarkEvent::Text(text) => {
356 if let Some((_, code)) = &mut highlighter {
357 code.push_str(&text);
358 } else {
359 current_line.push(Span::styled(text.to_string(), *style_stack.last().unwrap()));
360 }
361 }
362 CmarkEvent::Code(text) => {
363 current_line.push(Span::styled(
364 text.to_string(),
365 Style::default().fg(Color::Green).bg(Color::DarkGray),
366 ));
367 }
368 CmarkEvent::HardBreak => {
369 lines.push(Line::from(std::mem::take(&mut current_line)));
370 }
371 CmarkEvent::SoftBreak => {
372 current_line.push(Span::raw(" "));
373 }
374 CmarkEvent::Rule => {
375 lines.push(Line::from("---"));
376 }
377 _ => {}
378 }
379 }
380 if !current_line.is_empty() {
381 lines.push(Line::from(std::mem::take(&mut current_line)));
382 }
383
384 lines
385}