Terminal Markdown previewer — GUI-like experience.
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request #37 from RivoLink/feat/path-viewer-popup

feat: path viewer popup

authored by

Rivo Link and committed by
GitHub
0019a8e5 a3415652

+209 -5
+14
src/app/mod.rs
··· 116 116 status_line: Line<'static>, 117 117 status_cache_key: Option<StatusCacheKey>, 118 118 pub(super) help_open: bool, 119 + pub(super) path_popup_open: bool, 119 120 pub(super) file_picker: FilePickerState, 120 121 pub(super) pending_picker: PendingPicker, 121 122 pub(super) picker_load_state: PickerLoadState, ··· 214 215 status_line: Line::default(), 215 216 status_cache_key: None, 216 217 help_open: false, 218 + path_popup_open: false, 217 219 file_picker: FilePickerState { 218 220 open: false, 219 221 mode: FilePickerMode::Browser, ··· 456 458 457 459 pub(crate) fn is_help_open(&self) -> bool { 458 460 self.help_open 461 + } 462 + 463 + pub(crate) fn open_path_popup(&mut self) { 464 + self.path_popup_open = true; 465 + } 466 + 467 + pub(crate) fn close_path_popup(&mut self) { 468 + self.path_popup_open = false; 469 + } 470 + 471 + pub(crate) fn is_path_popup_open(&self) -> bool { 472 + self.path_popup_open 459 473 } 460 474 461 475 pub(crate) fn clear_reload_flash(&mut self) {
+2
src/main.rs
··· 36 36 #[cfg(test)] 37 37 pub(crate) use read_stdin_limited as read_stdin_with_limit; 38 38 #[cfg(test)] 39 + pub(crate) use render::wrap_path_lines; 40 + #[cfg(test)] 39 41 pub(crate) use runtime::should_handle_key; 40 42 #[cfg(test)] 41 43 pub(crate) use theme::{parse_theme_preset, theme_preset_label, ThemePreset, THEME_PRESETS};
+4
src/render/mod.rs
··· 9 9 Frame, 10 10 }; 11 11 12 + #[cfg(test)] 13 + pub(crate) use modal::wrap_path_lines; 12 14 pub(crate) use status::build_status_bar; 13 15 pub(crate) use toc::{build_toc_line_with_index, toc_header_line}; 14 16 ··· 51 53 modal::render_theme_picker(f, app); 52 54 } else if app.is_editor_picker_open() { 53 55 modal::render_editor_picker(f, app); 56 + } else if app.is_path_popup_open() { 57 + modal::render_path_popup(f, app); 54 58 } 55 59 } 56 60
+96 -4
src/render/modal.rs
··· 72 72 73 73 pub(super) fn render_help_popup(f: &mut Frame) { 74 74 let theme = app_theme(); 75 - let area = centered_rect(54, 21, f.area()); 75 + let area = centered_rect(54, 22, f.area()); 76 76 let section_style = Style::default() 77 77 .fg(theme.ui.toc_primary_active) 78 78 .add_modifier(Modifier::BOLD); ··· 153 153 Span::styled("help", text_style), 154 154 ]), 155 155 Line::from(vec![ 156 - Span::styled("t ", key_style), 157 - Span::styled("toggle toc", text_style), 158 - Span::raw(" "), 156 + Span::styled("p ", key_style), 157 + Span::styled("path viewer", text_style), 158 + Span::raw(" "), 159 159 Span::styled("q ", key_style), 160 160 Span::styled("quit", text_style), 161 + ]), 162 + Line::from(vec![ 163 + Span::styled("t ", key_style), 164 + Span::styled("toggle toc", text_style), 161 165 ]), 162 166 Line::from(""), 163 167 modal_footer_line(&["esc close", "? close"], theme.ui.toc_bg), ··· 694 698 area, 695 699 ); 696 700 } 701 + 702 + pub(crate) fn wrap_path_lines( 703 + label: &str, 704 + path: &str, 705 + max_width: usize, 706 + label_style: Style, 707 + value_style: Style, 708 + ) -> Vec<Line<'static>> { 709 + let label_len = label.len(); 710 + let value_width = max_width.saturating_sub(label_len); 711 + if path.len() <= value_width { 712 + return vec![Line::from(vec![ 713 + Span::styled(label.to_string(), label_style), 714 + Span::styled(path.to_string(), value_style), 715 + ])]; 716 + } 717 + let indent = " ".repeat(label_len); 718 + let mut result = Vec::new(); 719 + let mut pos = 0; 720 + while pos < path.len() { 721 + let end = (pos + value_width).min(path.len()); 722 + if pos == 0 { 723 + result.push(Line::from(vec![ 724 + Span::styled(label.to_string(), label_style), 725 + Span::styled(path[..end].to_string(), value_style), 726 + ])); 727 + } else { 728 + result.push(Line::from(vec![ 729 + Span::raw(indent.clone()), 730 + Span::styled(path[pos..end].to_string(), value_style), 731 + ])); 732 + } 733 + pos = end; 734 + } 735 + result 736 + } 737 + 738 + pub(super) fn render_path_popup(f: &mut Frame, app: &App) { 739 + let theme = app_theme(); 740 + let label_style = Style::default() 741 + .fg(theme.ui.toc_accent) 742 + .add_modifier(Modifier::BOLD); 743 + let value_style = Style::default().fg(theme.ui.toc_primary_inactive); 744 + 745 + let (relative, absolute) = if let Some(path) = app.filepath() { 746 + let abs = path.canonicalize().unwrap_or_else(|_| path.to_path_buf()); 747 + let rel = std::env::current_dir() 748 + .ok() 749 + .and_then(|cwd| abs.strip_prefix(&cwd).ok().map(|p| p.to_path_buf())) 750 + .unwrap_or_else(|| path.to_path_buf()); 751 + (rel.display().to_string(), abs.display().to_string()) 752 + } else { 753 + ("(stdin)".to_string(), "(stdin)".to_string()) 754 + }; 755 + 756 + const POPUP_WIDTH: usize = 78; 757 + const CHROME: usize = 4; // 2 borders + 2 padding 758 + let max_width = POPUP_WIDTH - CHROME; 759 + let rel_lines = wrap_path_lines("Relative: ", &relative, max_width, label_style, value_style); 760 + let abs_lines = wrap_path_lines("Absolute: ", &absolute, max_width, label_style, value_style); 761 + let content_height = 1 + rel_lines.len() + abs_lines.len() + 1 + 1; // top pad + rel + abs + bottom pad + footer 762 + let popup_height = content_height + 2; // borders 763 + 764 + let area = centered_rect(POPUP_WIDTH as u16, popup_height as u16, f.area()); 765 + 766 + let mut lines: Vec<Line<'static>> = Vec::new(); 767 + lines.push(Line::from("")); 768 + lines.extend(rel_lines); 769 + lines.extend(abs_lines); 770 + lines.push(Line::from("")); 771 + lines.push(modal_footer_line( 772 + &["enter close", "esc close"], 773 + theme.ui.toc_bg, 774 + )); 775 + 776 + f.render_widget(Clear, area); 777 + f.render_widget( 778 + Paragraph::new(lines).block( 779 + Block::default() 780 + .title("─ File Path ") 781 + .borders(Borders::ALL) 782 + .border_style(Style::default().fg(theme.ui.toc_border)) 783 + .style(Style::default().bg(theme.ui.toc_bg)) 784 + .padding(Padding::new(1, 1, 0, 0)), 785 + ), 786 + area, 787 + ); 788 + }
+17 -1
src/runtime.rs
··· 316 316 KeyCode::Char('k') | KeyCode::Up => app.move_editor_picker_up(), 317 317 _ => state_changed = false, 318 318 } 319 + } else if app.is_path_popup_open() { 320 + match key.code { 321 + KeyCode::Enter | KeyCode::Esc | KeyCode::Char('p') => { 322 + app.close_path_popup() 323 + } 324 + KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { 325 + app.close_path_popup(); 326 + } 327 + _ => state_changed = false, 328 + } 319 329 } else if app.is_search_mode() { 320 330 match key.code { 321 331 KeyCode::Esc => app.cancel_search(), ··· 396 406 KeyCode::Char('P') => { 397 407 app.queue_file_picker(app.picker_dir()); 398 408 } 409 + KeyCode::Char('p') => { 410 + app.open_path_popup(); 411 + } 399 412 KeyCode::Char(c) if c.is_ascii_digit() && c != '0' => { 400 413 if let Some(n) = c.to_digit(10) { 401 414 app.jump_to_toc(n as usize - 1); ··· 414 427 Event::Mouse(mouse) => { 415 428 let prev_pos = app.mouse_position; 416 429 app.mouse_position = (mouse.column, mouse.row); 417 - let state_changed = if app.is_file_picker_open() || app.is_theme_picker_open() { 430 + let state_changed = if app.is_file_picker_open() 431 + || app.is_theme_picker_open() 432 + || app.is_path_popup_open() 433 + { 418 434 if matches!(mouse.kind, MouseEventKind::Up(..)) { 419 435 app.scrollbar_dragging = false; 420 436 }
+76
src/tests/render.rs
··· 1 1 use super::{find_symbol, render_buffer, test_assets}; 2 2 use crate::markdown::parse_markdown; 3 + use crate::wrap_path_lines; 4 + use ratatui::style::Style; 3 5 4 6 #[test] 5 7 fn code_block_box_renders_right_border_in_one_column() { ··· 57 59 ); 58 60 } 59 61 } 62 + 63 + fn plain(lines: &[ratatui::text::Line<'_>]) -> Vec<String> { 64 + lines 65 + .iter() 66 + .map(|l| { 67 + l.spans 68 + .iter() 69 + .map(|s| s.content.as_ref()) 70 + .collect::<String>() 71 + }) 72 + .collect() 73 + } 74 + 75 + #[test] 76 + fn wrap_path_lines_short_path_fits_single_line() { 77 + let s = Style::default(); 78 + let lines = wrap_path_lines("Relative: ", "src/main.rs", 74, s, s); 79 + assert_eq!(lines.len(), 1); 80 + assert_eq!(plain(&lines), vec!["Relative: src/main.rs"]); 81 + } 82 + 83 + #[test] 84 + fn wrap_path_lines_long_path_wraps_with_indent() { 85 + let s = Style::default(); 86 + let label = "Absolute: "; 87 + let path = "a".repeat(80); 88 + let lines = wrap_path_lines(label, &path, 30, s, s); 89 + let text = plain(&lines); 90 + assert!(lines.len() > 1); 91 + assert!(text[0].starts_with("Absolute: ")); 92 + for continuation in &text[1..] { 93 + assert!( 94 + continuation.starts_with(" "), 95 + "continuation should be indented by label width" 96 + ); 97 + } 98 + } 99 + 100 + #[test] 101 + fn wrap_path_lines_continuation_aligned_with_value_start() { 102 + let s = Style::default(); 103 + let label = "Relative: "; 104 + let path = "x".repeat(100); 105 + let lines = wrap_path_lines(label, &path, 40, s, s); 106 + let text = plain(&lines); 107 + let value_width = 40 - label.len(); 108 + assert_eq!(&text[0], &format!("Relative: {}", &path[..value_width])); 109 + assert_eq!( 110 + &text[1], 111 + &format!( 112 + "{}{}", 113 + " ".repeat(label.len()), 114 + &path[value_width..value_width * 2] 115 + ) 116 + ); 117 + } 118 + 119 + #[test] 120 + fn wrap_path_lines_exact_fit_no_wrap() { 121 + let s = Style::default(); 122 + let label = "Test: "; 123 + let path = "x".repeat(74 - label.len()); 124 + let lines = wrap_path_lines(label, &path, 74, s, s); 125 + assert_eq!(lines.len(), 1); 126 + } 127 + 128 + #[test] 129 + fn wrap_path_lines_one_char_over_wraps() { 130 + let s = Style::default(); 131 + let label = "Test: "; 132 + let path = "x".repeat(74 - label.len() + 1); 133 + let lines = wrap_path_lines(label, &path, 74, s, s); 134 + assert_eq!(lines.len(), 2); 135 + }