Terminal Markdown previewer — GUI-like experience.
1
fork

Configure Feed

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

feat: anytime fuzzy/browser picker

RivoLink d049467a 80d375cb

+200 -32
+4 -2
README.md
··· 128 128 | `t` | Toggle TOC sidebar | 129 129 | `Shift+T` | Open theme picker | 130 130 | `Shift+E` | Open editor picker | 131 + | `Shift+P` | Open file browser | 131 132 | `Ctrl+E` | Open in editor | 133 + | `Ctrl+P` | Open fuzzy picker | 132 134 | `Ctrl+F` / `/` | Find | 133 135 | `n` / `N` | Next / prev match | 134 136 | `?` | Show help popup | ··· 148 150 - ✅ YAML frontmatter is ignored in both preview and TOC 149 151 - ✅ Native stdin input with bounded size 150 152 - ✅ `leaf --update` to fetch, verify via published SHA256, and install the latest release on supported platforms 151 - - ✅ Fuzzy Markdown picker when launched without a file 152 - - ✅ Classic directory browser picker with `leaf --picker` 153 + - ✅ Fuzzy Markdown picker when launched without a file, or anytime with `Ctrl+P` 154 + - ✅ Classic directory browser picker with `leaf --picker`, or anytime with `Shift+P` 153 155 - ✅ Theme picker with runtime preview 154 156 - ✅ Help modal with in-app shortcuts 155 157
+15
src/app/file_picker.rs
··· 596 596 self.file_picker.open 597 597 } 598 598 599 + pub(crate) fn close_file_picker(&mut self) { 600 + self.file_picker.open = false; 601 + self.file_picker.query.clear(); 602 + self.file_picker.entries.clear(); 603 + self.file_picker.filtered.clear(); 604 + self.file_picker.match_positions.clear(); 605 + self.file_picker.index = 0; 606 + self.file_picker.truncation = None; 607 + } 608 + 609 + pub(crate) fn cancel_picker_loading(&mut self) { 610 + self.picker_load_state = PickerLoadState::Idle; 611 + self.pending_picker = PendingPicker::None; 612 + } 613 + 599 614 pub(crate) fn file_picker_dir(&self) -> &std::path::Path { 600 615 &self.file_picker.dir 601 616 }
+17
src/app/mod.rs
··· 273 273 !self.toc.is_empty() 274 274 } 275 275 276 + // Always >= 5 (scroll padding). 277 + // Use has_content() to check for actual content. 276 278 pub(crate) fn total(&self) -> usize { 277 279 self.lines.len() 278 280 } ··· 530 532 531 533 pub(crate) fn filepath(&self) -> Option<&std::path::Path> { 532 534 self.filepath.as_deref() 535 + } 536 + 537 + pub(crate) fn has_content(&self) -> bool { 538 + self.filepath.is_some() || !self.source.is_empty() 539 + } 540 + 541 + pub(crate) fn picker_dir(&self) -> PathBuf { 542 + std::env::current_dir() 543 + .ok() 544 + .or_else(|| { 545 + self.filepath 546 + .as_ref() 547 + .and_then(|p| p.parent().map(|d| d.to_path_buf())) 548 + }) 549 + .unwrap_or_default() 533 550 } 534 551 535 552 pub(crate) fn open_editor_picker(&mut self) {
+44 -16
src/render/modal.rs
··· 13 13 14 14 use super::centered_rect; 15 15 16 - const FUZZY_PICKER_FOOTER: &[&str] = &[ 16 + const FUZZY_PICKER_FOOTER_INIT: &[&str] = &[ 17 17 "↑/↓ move", 18 18 "<char> filter", 19 19 "enter open", ··· 21 21 "ctrl+c quit", 22 22 ]; 23 23 24 - const BROWSER_PICKER_FOOTER: &[&str] = &["↑/↓ move", "enter open", "esc parent", "q quit"]; 24 + const FUZZY_PICKER_FOOTER_PREVIEW: &[&str] = &[ 25 + "↑/↓ move", 26 + "<char> filter", 27 + "enter open", 28 + "esc clear", 29 + "ctrl+c close", 30 + ]; 31 + 32 + const BROWSER_PICKER_FOOTER_INIT: &[&str] = &["↑/↓ move", "enter open", "bsp parent", "q quit"]; 33 + 34 + const BROWSER_PICKER_FOOTER_PREVIEW: &[&str] = 35 + &["↑/↓ move", "enter open", "bsp parent", "ctrl+c close"]; 25 36 26 - const PICKER_FAILED_FOOTER: &[&str] = &["esc quit", "enter quit", "q quit"]; 37 + const PICKER_FAILED_FOOTER_INIT: &[&str] = &["esc quit", "enter quit", "q quit"]; 38 + 39 + const PICKER_FAILED_FOOTER_PREVIEW: &[&str] = &["esc close", "enter close", "ctrl+c close"]; 40 + 41 + fn picker_footer(has_content: bool, is_fuzzy: bool, is_failed: bool) -> &'static [&'static str] { 42 + if has_content { 43 + if is_failed { 44 + PICKER_FAILED_FOOTER_PREVIEW 45 + } else if is_fuzzy { 46 + FUZZY_PICKER_FOOTER_PREVIEW 47 + } else { 48 + BROWSER_PICKER_FOOTER_PREVIEW 49 + } 50 + } else if is_failed { 51 + PICKER_FAILED_FOOTER_INIT 52 + } else if is_fuzzy { 53 + FUZZY_PICKER_FOOTER_INIT 54 + } else { 55 + BROWSER_PICKER_FOOTER_INIT 56 + } 57 + } 27 58 28 59 fn modal_footer_line(segments: &[&'static str], bg: Color) -> Line<'static> { 29 60 let theme = app_theme(); ··· 41 72 42 73 pub(super) fn render_help_popup(f: &mut Frame) { 43 74 let theme = app_theme(); 44 - let area = centered_rect(54, 19, f.area()); 75 + let area = centered_rect(54, 20, f.area()); 45 76 let section_style = Style::default() 46 77 .fg(theme.ui.toc_primary_active) 47 78 .add_modifier(Modifier::BOLD); ··· 99 130 Span::raw(" "), 100 131 Span::styled("Ctrl+E ", key_style), 101 132 Span::styled("edit", text_style), 133 + ]), 134 + Line::from(vec![ 135 + Span::styled("Shift+P ", key_style), 136 + Span::styled("file browser", text_style), 137 + Span::raw(" "), 138 + Span::styled("Ctrl+P ", key_style), 139 + Span::styled("pick", text_style), 102 140 ]), 103 141 Line::from(vec![ 104 142 Span::styled("Shift+T ", key_style), ··· 358 396 } 359 397 360 398 lines.push(modal_footer_line( 361 - if app.is_fuzzy_file_picker() { 362 - FUZZY_PICKER_FOOTER 363 - } else { 364 - BROWSER_PICKER_FOOTER 365 - }, 399 + picker_footer(app.has_content(), app.is_fuzzy_file_picker(), false), 366 400 theme.ui.toc_bg, 367 401 )); 368 402 ··· 437 471 438 472 lines.push(Line::from("")); 439 473 lines.push(modal_footer_line( 440 - if is_failed { 441 - PICKER_FAILED_FOOTER 442 - } else if is_fuzzy { 443 - FUZZY_PICKER_FOOTER 444 - } else { 445 - BROWSER_PICKER_FOOTER 446 - }, 474 + picker_footer(app.has_content(), is_fuzzy, is_failed), 447 475 theme.ui.toc_bg, 448 476 )); 449 477
+1 -1
src/render/status.rs
··· 217 217 left_section.extend(section); 218 218 } 219 219 220 - let file_open = !app.is_file_picker_open() && !app.is_picker_loading(); 220 + let file_open = app.has_content() || (!app.is_file_picker_open() && !app.is_picker_loading()); 221 221 if file_open { 222 222 if let Some(section) = status_watch_section(app) { 223 223 left_section.extend(section);
+77 -13
src/runtime.rs
··· 77 77 sync_render_width(terminal, app, ss, themes)?; 78 78 79 79 loop { 80 + if app.has_pending_picker() && !app.is_picker_loading() { 81 + let _ = app.start_pending_picker_loading(); 82 + needs_redraw = true; 83 + } 80 84 if app.poll_picker_loading() { 81 85 needs_redraw = true; 82 86 } ··· 154 158 _ => state_changed = false, 155 159 } 156 160 } else if app.is_picker_loading() { 161 + let has_content = app.has_content(); 157 162 match key.code { 158 - KeyCode::Char('q') => break, 159 - KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { 160 - break; 163 + KeyCode::Char('q') | KeyCode::Char('c') 164 + if key.modifiers.contains(KeyModifiers::CONTROL) => 165 + { 166 + if has_content { 167 + app.cancel_picker_loading(); 168 + } else { 169 + break; 170 + } 171 + } 172 + KeyCode::Char('p') if key.modifiers.contains(KeyModifiers::CONTROL) => { 173 + if has_content { 174 + app.cancel_picker_loading(); 175 + } 176 + state_changed = has_content; 177 + } 178 + KeyCode::Char('P') => { 179 + if has_content { 180 + app.cancel_picker_loading(); 181 + } 182 + state_changed = has_content; 161 183 } 162 184 _ => state_changed = false, 163 185 } 164 186 } else if app.is_picker_load_failed() { 187 + let has_content = app.has_content(); 165 188 match key.code { 166 - KeyCode::Esc | KeyCode::Enter | KeyCode::Char('q') => break, 167 - KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { 168 - break; 189 + KeyCode::Esc 190 + | KeyCode::Enter 191 + | KeyCode::Char('q') 192 + | KeyCode::Char('c') 193 + if key.modifiers.contains(KeyModifiers::CONTROL) => 194 + { 195 + if has_content { 196 + app.cancel_picker_loading(); 197 + } else { 198 + break; 199 + } 169 200 } 170 201 _ => state_changed = false, 171 202 } 172 203 } else if app.is_file_picker_open() { 204 + let has_content = app.has_content(); 173 205 match key.code { 174 206 KeyCode::Char('?') => app.open_help(), 175 207 KeyCode::Enter => { 176 208 state_changed = app.activate_file_picker_selection(ss, themes); 177 209 } 178 - KeyCode::Char('q') if app.is_browser_file_picker() => break, 210 + KeyCode::Char('q') if app.is_browser_file_picker() => { 211 + if has_content { 212 + app.close_file_picker(); 213 + } else { 214 + break; 215 + } 216 + } 179 217 KeyCode::Char('j') | KeyCode::Down if app.is_browser_file_picker() => { 180 218 app.move_file_picker_down() 181 219 } ··· 187 225 } 188 226 KeyCode::Up if app.is_fuzzy_file_picker() => app.move_file_picker_up(), 189 227 KeyCode::Esc => { 190 - if app.is_browser_file_picker() { 191 - state_changed = app.open_file_picker_parent(); 192 - } else if app.file_picker_query().is_empty() { 193 - state_changed = false; 194 - } else { 228 + if app.is_fuzzy_file_picker() && !app.file_picker_query().is_empty() 229 + { 195 230 app.clear_file_picker_query(); 231 + } else if has_content { 232 + app.close_file_picker(); 233 + } else { 234 + break; 196 235 } 197 236 } 198 237 KeyCode::Char('h') | KeyCode::Left if app.is_browser_file_picker() => { ··· 203 242 } 204 243 KeyCode::Backspace => app.pop_file_picker_query(), 205 244 KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { 206 - break; 245 + if has_content { 246 + app.close_file_picker(); 247 + } else { 248 + break; 249 + } 250 + } 251 + KeyCode::Char('p') 252 + if key.modifiers.contains(KeyModifiers::CONTROL) 253 + && app.is_fuzzy_file_picker() => 254 + { 255 + if has_content { 256 + app.close_file_picker(); 257 + } 258 + state_changed = has_content; 259 + } 260 + KeyCode::Char('P') if app.is_browser_file_picker() => { 261 + if has_content { 262 + app.close_file_picker(); 263 + } 264 + state_changed = has_content; 207 265 } 208 266 KeyCode::Char(c) 209 267 if app.is_fuzzy_file_picker() ··· 331 389 KeyCode::Char('N') => app.prev_match(), 332 390 KeyCode::Char('e') if key.modifiers.contains(KeyModifiers::CONTROL) => { 333 391 handle_open_in_editor(terminal, app, ss, themes)?; 392 + } 393 + KeyCode::Char('p') if key.modifiers.contains(KeyModifiers::CONTROL) => { 394 + app.queue_fuzzy_file_picker(app.picker_dir()); 395 + } 396 + KeyCode::Char('P') => { 397 + app.queue_file_picker(app.picker_dir()); 334 398 } 335 399 KeyCode::Char(c) if c.is_ascii_digit() && c != '0' => { 336 400 if let Some(n) = c.to_digit(10) {
+42
src/tests/app.rs
··· 380 380 parse_markdown_with_width(source, &ss, &theme, 20).0.len() 381 381 ); 382 382 } 383 + 384 + #[test] 385 + fn initial_mode_has_no_content() { 386 + let (ss, theme) = test_assets(); 387 + let (lines, toc) = parse_markdown("", &ss, &theme); 388 + let app = App::new_with_source( 389 + lines, 390 + toc, 391 + AppConfig { 392 + filename: "test".to_string(), 393 + source: String::new(), 394 + debug_input: false, 395 + watch: false, 396 + filepath: None, 397 + last_file_state: None, 398 + }, 399 + ); 400 + assert!(!app.has_content(), "initial mode should have no content"); 401 + } 402 + 403 + #[test] 404 + fn preview_mode_has_content() { 405 + let src = "# Hello"; 406 + let (ss, theme) = test_assets(); 407 + let (lines, toc) = parse_markdown(src, &ss, &theme); 408 + let app = App::new_with_source( 409 + lines, 410 + toc, 411 + AppConfig { 412 + filename: "test".to_string(), 413 + source: src.to_string(), 414 + debug_input: false, 415 + watch: false, 416 + filepath: None, 417 + last_file_state: None, 418 + }, 419 + ); 420 + assert!( 421 + app.has_content(), 422 + "preview mode with source should have content" 423 + ); 424 + }