Terminal Markdown previewer — GUI-like experience.
1
fork

Configure Feed

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

chore: split render into modules

RivoLink 3147e80a 20893047

+983 -945
-945
src/render.rs
··· 1 - use crate::{ 2 - app::App, 3 - cli::version_text, 4 - theme::{app_theme, theme_preset_label, THEME_PRESETS}, 5 - }; 6 - use ratatui::{ 7 - layout::{Constraint, Direction, Layout, Rect}, 8 - style::{Color, Modifier, Style}, 9 - text::{Line, Span}, 10 - widgets::{ 11 - Block, Borders, Clear, Padding, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, 12 - Wrap, 13 - }, 14 - Frame, 15 - }; 16 - 17 - pub(crate) const CONTENT_HORIZONTAL_PADDING: u16 = 1; 18 - pub(crate) const SCROLLBAR_WIDTH: u16 = 1; 19 - 20 - pub(crate) fn ui(f: &mut Frame, app: &mut App) { 21 - let area = f.area(); 22 - let root = Layout::default() 23 - .direction(Direction::Vertical) 24 - .constraints([Constraint::Min(0), Constraint::Length(1)]) 25 - .split(area); 26 - 27 - let (toc_area, content_area): (Option<Rect>, Rect) = if app.is_toc_visible() && app.has_toc() { 28 - let cols = Layout::default() 29 - .direction(Direction::Horizontal) 30 - .constraints([Constraint::Length(30), Constraint::Min(0)]) 31 - .split(root[0]); 32 - (Some(cols[0]), cols[1]) 33 - } else { 34 - (None, root[0]) 35 - }; 36 - 37 - if let Some(ta) = toc_area { 38 - render_toc_panel(f, app, ta); 39 - } 40 - 41 - let viewport_height = content_area.height as usize; 42 - render_content_panel(f, app, content_area, viewport_height); 43 - render_status_bar(f, app, root[1], viewport_height); 44 - 45 - if app.is_help_open() { 46 - render_help_popup(f); 47 - } else if app.is_picker_loading() || app.is_picker_load_failed() { 48 - render_picker_loading(f, app); 49 - } else if app.is_file_picker_open() { 50 - render_file_picker(f, app); 51 - } else if app.is_theme_picker_open() { 52 - render_theme_picker(f, app); 53 - } 54 - } 55 - 56 - fn render_toc_panel(f: &mut Frame, app: &mut App, area: Rect) { 57 - let theme = app_theme(); 58 - app.refresh_toc_cache(); 59 - let toc_chunks = Layout::default() 60 - .direction(Direction::Vertical) 61 - .constraints([Constraint::Length(3), Constraint::Min(0)]) 62 - .split(area); 63 - 64 - f.render_widget( 65 - Paragraph::new("") 66 - .style(Style::default().bg(theme.ui.toc_bg)) 67 - .block( 68 - Block::default() 69 - .borders(Borders::RIGHT | Borders::BOTTOM) 70 - .border_style(Style::default().fg(theme.ui.toc_border)) 71 - .style(Style::default().bg(theme.ui.toc_bg)), 72 - ), 73 - toc_chunks[0], 74 - ); 75 - f.render_widget( 76 - Paragraph::new(app.toc_display_lines().to_vec()) 77 - .style(Style::default().bg(theme.ui.toc_bg)) 78 - .block( 79 - Block::default() 80 - .borders(Borders::RIGHT) 81 - .border_style(Style::default().fg(theme.ui.toc_border)) 82 - .style(Style::default().bg(theme.ui.toc_bg)), 83 - ), 84 - toc_chunks[1], 85 - ); 86 - f.render_widget( 87 - Paragraph::new(vec![app.toc_header_line().clone()]) 88 - .style(Style::default().bg(theme.ui.toc_bg)), 89 - Rect { 90 - x: toc_chunks[0].x, 91 - y: toc_chunks[0].y.saturating_add(1), 92 - width: toc_chunks[0].width.saturating_sub(1), 93 - height: 1, 94 - }, 95 - ); 96 - } 97 - 98 - fn render_content_panel(f: &mut Frame, app: &mut App, area: Rect, viewport_height: usize) { 99 - let theme = app_theme(); 100 - f.render_widget( 101 - Paragraph::new("").style(Style::default().bg(theme.ui.content_bg)), 102 - area, 103 - ); 104 - let content_area = inner_content_area(area); 105 - let scroll = app.scroll(); 106 - let active_highlight_line = app.active_highlight_line(); 107 - if let Some(line_idx) = active_highlight_line { 108 - let _ = app.refresh_highlighted_line_cache(line_idx); 109 - } 110 - 111 - let visible_end = (scroll + viewport_height).min(app.total()); 112 - let mut visible_lines = app.visible_lines(scroll, visible_end).to_vec(); 113 - 114 - if let Some(line_idx) = active_highlight_line { 115 - if (scroll..visible_end).contains(&line_idx) { 116 - if let Some((_, highlighted_line)) = app.highlighted_line_cache() { 117 - visible_lines[line_idx - scroll] = highlighted_line.clone(); 118 - } 119 - } 120 - } 121 - 122 - f.render_widget( 123 - Paragraph::new(visible_lines) 124 - .style(Style::default().bg(theme.ui.content_bg)) 125 - .wrap(Wrap { trim: false }), 126 - content_area, 127 - ); 128 - 129 - let mut scrollbar_state = ScrollbarState::new(app.total()).position(app.scroll()); 130 - f.render_stateful_widget( 131 - Scrollbar::new(ScrollbarOrientation::VerticalRight) 132 - .begin_symbol(None) 133 - .end_symbol(None) 134 - .track_symbol(Some("│")) 135 - .thumb_symbol("█"), 136 - area, 137 - &mut scrollbar_state, 138 - ); 139 - } 140 - 141 - fn inner_content_area(area: Rect) -> Rect { 142 - Rect { 143 - x: area.x.saturating_add(CONTENT_HORIZONTAL_PADDING), 144 - y: area.y, 145 - width: area 146 - .width 147 - .saturating_sub(CONTENT_HORIZONTAL_PADDING.saturating_mul(2)) 148 - .saturating_sub(SCROLLBAR_WIDTH), 149 - height: area.height, 150 - } 151 - } 152 - 153 - fn render_status_bar(f: &mut Frame, app: &mut App, area: Rect, viewport_height: usize) { 154 - let pct = app.scroll_percent(viewport_height); 155 - let bar_bg = status_bar_bg(); 156 - app.refresh_status_cache(pct); 157 - 158 - f.render_widget( 159 - Paragraph::new(vec![app.status_line().clone()]).style(Style::default().bg(bar_bg)), 160 - area, 161 - ); 162 - } 163 - 164 - pub(crate) fn status_bar_bg() -> Color { 165 - app_theme().ui.status_bg 166 - } 167 - 168 - pub(crate) fn status_separator_style(bar_bg: Color) -> Style { 169 - Style::default() 170 - .fg(app_theme().ui.status_separator) 171 - .bg(bar_bg) 172 - } 173 - 174 - pub(crate) fn join_span_sections( 175 - sections: Vec<Vec<Span<'static>>>, 176 - separator: Span<'static>, 177 - ) -> Vec<Span<'static>> { 178 - let mut joined = Vec::new(); 179 - for (idx, section) in sections.into_iter().enumerate() { 180 - if idx > 0 { 181 - joined.push(separator.clone()); 182 - } 183 - joined.extend(section); 184 - } 185 - joined 186 - } 187 - 188 - pub(crate) fn status_brand_section() -> Vec<Span<'static>> { 189 - let theme = app_theme(); 190 - vec![Span::styled( 191 - " leaf ", 192 - Style::default() 193 - .fg(theme.ui.status_brand_fg) 194 - .bg(theme.ui.status_brand_bg) 195 - .add_modifier(Modifier::BOLD), 196 - )] 197 - } 198 - 199 - pub(crate) fn status_filename_section(filename: &str) -> Vec<Span<'static>> { 200 - let theme = app_theme(); 201 - vec![Span::styled( 202 - format!(" {} ", filename), 203 - Style::default() 204 - .fg(theme.ui.status_filename_fg) 205 - .bg(theme.ui.status_filename_bg), 206 - )] 207 - } 208 - 209 - pub(crate) fn status_watch_section(app: &App) -> Option<Vec<Span<'static>>> { 210 - let theme = app_theme(); 211 - if !app.is_watch_enabled() { 212 - return None; 213 - } 214 - 215 - let flash_active = app 216 - .reload_flash_started() 217 - .map(|t| t.elapsed() < std::time::Duration::from_millis(1500)) 218 - .unwrap_or(false); 219 - let span = if flash_active { 220 - Span::styled( 221 - " ⟳ reloaded ", 222 - Style::default() 223 - .fg(theme.ui.status_reloaded_fg) 224 - .bg(theme.ui.status_reloaded_bg) 225 - .add_modifier(Modifier::BOLD), 226 - ) 227 - } else { 228 - Span::styled( 229 - " ⟳ watch ", 230 - Style::default() 231 - .fg(theme.ui.status_watch_fg) 232 - .bg(theme.ui.status_watch_bg), 233 - ) 234 - }; 235 - Some(vec![span]) 236 - } 237 - 238 - pub(crate) fn status_search_section(app: &App) -> Option<Vec<Span<'static>>> { 239 - let theme = app_theme(); 240 - if app.is_search_mode() { 241 - return Some(vec![Span::styled( 242 - format!(" /{} ", app.search_draft()), 243 - Style::default() 244 - .fg(theme.ui.status_search_fg) 245 - .bg(theme.ui.status_search_bg), 246 - )]); 247 - } 248 - 249 - if app.search_query().is_empty() { 250 - return None; 251 - } 252 - 253 - let span = if app.search_match_count() == 0 { 254 - Span::styled( 255 - format!(" ✗ {} ", app.search_query()), 256 - Style::default() 257 - .fg(theme.ui.status_search_error_fg) 258 - .bg(theme.ui.status_search_bg), 259 - ) 260 - } else { 261 - Span::styled( 262 - format!(" {}/{} ", app.search_index() + 1, app.search_match_count()), 263 - Style::default() 264 - .fg(theme.ui.status_search_match_fg) 265 - .bg(theme.ui.status_search_bg), 266 - ) 267 - }; 268 - Some(vec![span]) 269 - } 270 - 271 - pub(crate) fn status_hint_segments(app: &App) -> &'static [&'static str] { 272 - if app.is_search_mode() { 273 - &["enter confirm", "esc cancel"] 274 - } else if app.is_file_picker_open() { 275 - if app.is_fuzzy_file_picker() { 276 - &["↑/↓ move", "enter open", "backspace delete", "ctrl+c quit"] 277 - } else { 278 - &["j/k move", "enter open", "backspace up", "ctrl+c quit"] 279 - } 280 - } else if app.is_theme_picker_open() { 281 - &["j/k preview", "enter keep", "esc restore"] 282 - } else if app.is_help_open() { 283 - &["esc close", "? close"] 284 - } else if app.has_active_search() { 285 - &[ 286 - "enter next", 287 - "n/N next/prev", 288 - "/ search", 289 - "? help", 290 - "T theme", 291 - "esc clear", 292 - "q quit", 293 - ] 294 - } else { 295 - &[ 296 - "j/k scroll", 297 - "g/G top/bot", 298 - "t toc", 299 - "T theme", 300 - "/ search", 301 - "? help", 302 - "n/N next/prev", 303 - "q quit", 304 - ] 305 - } 306 - } 307 - 308 - fn render_help_popup(f: &mut Frame) { 309 - let theme = app_theme(); 310 - let area = centered_rect(56, 16, f.area()); 311 - let section_style = Style::default() 312 - .fg(theme.ui.toc_primary_active) 313 - .add_modifier(Modifier::BOLD); 314 - let key_style = Style::default() 315 - .fg(theme.ui.toc_accent) 316 - .add_modifier(Modifier::BOLD); 317 - let text_style = Style::default().fg(theme.ui.toc_primary_inactive); 318 - let footer_style = Style::default().fg(theme.ui.status_shortcut_fg); 319 - let title_style = Style::default() 320 - .fg(theme.markdown.heading_2) 321 - .add_modifier(Modifier::BOLD); 322 - let lines = vec![ 323 - Line::from(vec![Span::styled(version_text().to_string(), title_style)]), 324 - Line::from(vec![Span::styled( 325 - "Keyboard shortcuts", 326 - Style::default().fg(theme.ui.status_shortcut_fg), 327 - )]), 328 - Line::from(""), 329 - Line::from(vec![Span::styled( 330 - "Navigation Search", 331 - section_style, 332 - )]), 333 - Line::from(vec![ 334 - Span::styled("j/k, ↑/↓ ", key_style), 335 - Span::styled("scroll", text_style), 336 - Span::raw(" "), 337 - Span::styled("/, Ctrl+F ", key_style), 338 - Span::styled("search", text_style), 339 - ]), 340 - Line::from(vec![ 341 - Span::styled("PgUp/PgDn ", key_style), 342 - Span::styled("page", text_style), 343 - Span::raw(" "), 344 - Span::styled("n/N ", key_style), 345 - Span::styled("next/prev", text_style), 346 - ]), 347 - Line::from(vec![ 348 - Span::styled("g/G ", key_style), 349 - Span::styled("top/bottom", text_style), 350 - ]), 351 - Line::from(""), 352 - Line::from(vec![Span::styled("Actions", section_style)]), 353 - Line::from(vec![ 354 - Span::styled("r ", key_style), 355 - Span::styled("reload (watch)", text_style), 356 - Span::raw(" "), 357 - Span::styled("? ", key_style), 358 - Span::styled("show help", text_style), 359 - ]), 360 - Line::from(vec![ 361 - Span::styled("t ", key_style), 362 - Span::styled("toggle toc", text_style), 363 - Span::raw(" "), 364 - Span::styled("q ", key_style), 365 - Span::styled("quit", text_style), 366 - ]), 367 - Line::from(vec![ 368 - Span::styled("T ", key_style), 369 - Span::styled("theme picker", text_style), 370 - ]), 371 - Line::from(""), 372 - Line::from(vec![Span::styled("Esc or ? to close", footer_style)]), 373 - ]; 374 - 375 - f.render_widget(Clear, area); 376 - f.render_widget( 377 - Paragraph::new(lines).block( 378 - Block::default() 379 - .title("─ Help ") 380 - .borders(Borders::ALL) 381 - .border_style(Style::default().fg(theme.ui.toc_border)) 382 - .style(Style::default().bg(theme.ui.toc_bg)) 383 - .padding(Padding::new(1, 1, 0, 0)), 384 - ), 385 - area, 386 - ); 387 - } 388 - 389 - fn render_theme_picker(f: &mut Frame, app: &App) { 390 - let theme = app_theme(); 391 - let area = centered_rect(38, 10, f.area()); 392 - let active = app.theme_picker_reference_preset(); 393 - let footer_style = Style::default().fg(theme.ui.status_shortcut_fg); 394 - 395 - let mut lines = vec![ 396 - Line::from(vec![Span::styled( 397 - "Choose a theme", 398 - Style::default().fg(theme.ui.status_shortcut_fg), 399 - )]), 400 - Line::from(""), 401 - ]; 402 - for (idx, preset) in THEME_PRESETS.iter().enumerate() { 403 - let selected = idx == app.theme_picker_index(); 404 - let is_active = *preset == active; 405 - let bg = if selected { 406 - theme.ui.toc_active_bg 407 - } else { 408 - theme.ui.toc_bg 409 - }; 410 - let marker = if selected { "▸ " } else { " " }; 411 - let name = if is_active { 412 - format!("{} ✓", theme_preset_label(*preset)) 413 - } else { 414 - theme_preset_label(*preset).to_string() 415 - }; 416 - lines.push(Line::from(vec![ 417 - Span::styled( 418 - marker, 419 - Style::default() 420 - .fg(theme.ui.toc_accent) 421 - .bg(bg) 422 - .add_modifier(if selected { 423 - Modifier::BOLD 424 - } else { 425 - Modifier::empty() 426 - }), 427 - ), 428 - Span::styled( 429 - name, 430 - Style::default() 431 - .fg(if selected { 432 - theme.ui.toc_primary_active 433 - } else { 434 - theme.ui.toc_primary_inactive 435 - }) 436 - .bg(bg) 437 - .add_modifier(if is_active || selected { 438 - Modifier::BOLD 439 - } else { 440 - Modifier::empty() 441 - }), 442 - ), 443 - ])); 444 - } 445 - lines.push(Line::from("")); 446 - lines.push(Line::from(vec![Span::styled( 447 - "Enter keep • Esc restore", 448 - footer_style.bg(theme.ui.toc_bg), 449 - )])); 450 - 451 - f.render_widget(Clear, area); 452 - f.render_widget( 453 - Paragraph::new(lines).block( 454 - Block::default() 455 - .title("─ Theme ") 456 - .borders(Borders::ALL) 457 - .border_style(Style::default().fg(theme.ui.toc_border)) 458 - .style(Style::default().bg(theme.ui.toc_bg)) 459 - .padding(Padding::new(1, 1, 0, 0)), 460 - ), 461 - area, 462 - ); 463 - } 464 - 465 - fn render_file_picker(f: &mut Frame, app: &App) { 466 - let theme = app_theme(); 467 - let area = centered_rect(78, 20, f.area()); 468 - let title_style = Style::default() 469 - .fg(theme.markdown.heading_2) 470 - .add_modifier(Modifier::BOLD); 471 - let section_style = Style::default().fg(theme.ui.status_shortcut_fg); 472 - let footer_style = Style::default().fg(theme.ui.status_shortcut_fg); 473 - let inner_height = area.height.saturating_sub(2) as usize; 474 - let header_lines = if app.is_fuzzy_file_picker() { 4 } else { 3 }; 475 - let total = app.file_picker_filtered_indices().len(); 476 - let truncation_message = picker_truncation_message(app.file_picker_truncation()); 477 - let max_visible_slots = if app.is_fuzzy_file_picker() { 478 - if truncation_message.is_some() { 479 - 11 480 - } else { 481 - 12 482 - } 483 - } else { 484 - 13 485 - }; 486 - let reserved_footer_lines = if truncation_message.is_some() { 3 } else { 2 }; 487 - let visible_slots = inner_height 488 - .saturating_sub(header_lines + reserved_footer_lines) 489 - .min(max_visible_slots); 490 - let start = if visible_slots == 0 || app.file_picker_index() < visible_slots { 491 - 0 492 - } else { 493 - app.file_picker_index() + 1 - visible_slots 494 - }; 495 - let end = (start + visible_slots).min(total); 496 - 497 - let mut lines = vec![ 498 - Line::from(vec![Span::styled("Open a Markdown file", title_style)]), 499 - Line::from(vec![ 500 - Span::styled("Dir: ", section_style), 501 - Span::styled( 502 - app.file_picker_dir().display().to_string(), 503 - Style::default().fg(theme.ui.toc_primary_inactive), 504 - ), 505 - ]), 506 - ]; 507 - 508 - if app.is_fuzzy_file_picker() { 509 - lines.push(Line::from(vec![ 510 - Span::styled("Query: ", section_style), 511 - Span::styled( 512 - if app.file_picker_query().is_empty() { 513 - " type to filter ".to_string() 514 - } else { 515 - format!(" {} ", app.file_picker_query()) 516 - }, 517 - Style::default() 518 - .fg(if app.file_picker_query().is_empty() { 519 - theme.ui.toc_primary_inactive 520 - } else { 521 - theme.ui.toc_primary_active 522 - }) 523 - .bg(theme.markdown.inline_code_bg), 524 - ), 525 - ])); 526 - } 527 - 528 - lines.push(Line::from("")); 529 - 530 - if app.file_picker_entries().is_empty() { 531 - lines.push(Line::from(vec![Span::styled( 532 - if app.is_fuzzy_file_picker() { 533 - "No Markdown file found in this directory or its subdirectories" 534 - } else { 535 - "No folders or Markdown files here" 536 - }, 537 - Style::default().fg(theme.ui.toc_primary_inactive), 538 - )])); 539 - } else if total == 0 { 540 - lines.push(Line::from(vec![Span::styled( 541 - "No match for the current query", 542 - Style::default().fg(theme.ui.toc_primary_inactive), 543 - )])); 544 - } else { 545 - for (idx, entry_idx) in app.file_picker_filtered_indices()[start..end] 546 - .iter() 547 - .enumerate() 548 - { 549 - let actual_idx = start + idx; 550 - let selected = actual_idx == app.file_picker_index(); 551 - let entry = &app.file_picker_entries()[*entry_idx]; 552 - let bg = if selected { 553 - theme.ui.toc_active_bg 554 - } else { 555 - theme.ui.toc_bg 556 - }; 557 - let marker = if selected { "▸ " } else { " " }; 558 - let label_spans = if app.is_fuzzy_file_picker() { 559 - highlighted_picker_label( 560 - entry.label(), 561 - app.file_picker_match_positions(actual_idx), 562 - bg, 563 - selected, 564 - ) 565 - } else { 566 - vec![Span::styled( 567 - entry.label().to_string(), 568 - Style::default() 569 - .fg(theme.ui.toc_primary_inactive) 570 - .bg(bg) 571 - .add_modifier(if selected { 572 - Modifier::BOLD 573 - } else { 574 - Modifier::empty() 575 - }), 576 - )] 577 - }; 578 - let mut spans = vec![Span::styled( 579 - marker, 580 - Style::default() 581 - .fg(theme.ui.toc_accent) 582 - .bg(bg) 583 - .add_modifier(if selected { 584 - Modifier::BOLD 585 - } else { 586 - Modifier::empty() 587 - }), 588 - )]; 589 - spans.extend(label_spans); 590 - lines.push(Line::from(spans)); 591 - } 592 - } 593 - 594 - while lines.len() < inner_height.saturating_sub(reserved_footer_lines) { 595 - lines.push(Line::from("")); 596 - } 597 - 598 - if let Some(message) = truncation_message { 599 - lines.push(Line::from(vec![Span::styled( 600 - "", 601 - Style::default().fg(theme.ui.toc_primary_inactive), 602 - )])); 603 - lines.push(Line::from(vec![Span::styled( 604 - message, 605 - Style::default().fg(theme.markdown.heading_3), 606 - )])); 607 - } else { 608 - lines.push(Line::from("")); 609 - } 610 - 611 - lines.push(Line::from(vec![Span::styled( 612 - if app.is_fuzzy_file_picker() { 613 - "↑/↓ move • enter open • type filter • esc clear • ctrl+c quit" 614 - } else { 615 - "enter open • backspace up • ctrl+c quit" 616 - }, 617 - footer_style.bg(theme.ui.toc_bg), 618 - )])); 619 - 620 - f.render_widget(Clear, area); 621 - f.render_widget( 622 - Paragraph::new(lines).block( 623 - Block::default() 624 - .title("─ Files ") 625 - .borders(Borders::ALL) 626 - .border_style(Style::default().fg(theme.ui.toc_border)) 627 - .style(Style::default().bg(theme.ui.toc_bg)) 628 - .padding(Padding::new(1, 1, 0, 0)), 629 - ), 630 - area, 631 - ); 632 - } 633 - 634 - fn render_picker_loading(f: &mut Frame, app: &App) { 635 - let theme = app_theme(); 636 - let area = centered_rect(78, 20, f.area()); 637 - let title_style = Style::default() 638 - .fg(theme.markdown.heading_2) 639 - .add_modifier(Modifier::BOLD); 640 - let section_style = Style::default().fg(theme.ui.status_shortcut_fg); 641 - let footer_style = Style::default().fg(theme.ui.status_shortcut_fg); 642 - let is_failed = app.is_picker_load_failed(); 643 - let is_fuzzy = matches!( 644 - app.pending_picker_mode(), 645 - Some(crate::app::FilePickerMode::Fuzzy) 646 - ); 647 - let inner_height = area.height.saturating_sub(2) as usize; 648 - let message = if is_failed { 649 - app.picker_load_error().unwrap_or("Failed to load files") 650 - } else { 651 - "Indexing markdown files..." 652 - }; 653 - 654 - let mut lines = vec![ 655 - Line::from(vec![Span::styled("Open a Markdown file", title_style)]), 656 - Line::from(vec![ 657 - Span::styled("Dir: ", section_style), 658 - Span::styled( 659 - app.pending_picker_dir() 660 - .map(|dir| dir.display().to_string()) 661 - .unwrap_or_else(|| ".".to_string()), 662 - Style::default().fg(theme.ui.toc_primary_inactive), 663 - ), 664 - ]), 665 - ]; 666 - 667 - if is_fuzzy { 668 - lines.push(Line::from(vec![ 669 - Span::styled("Query: ", section_style), 670 - Span::styled( 671 - " type to filter ".to_string(), 672 - Style::default() 673 - .fg(theme.ui.toc_primary_inactive) 674 - .bg(theme.markdown.inline_code_bg), 675 - ), 676 - ])); 677 - } 678 - 679 - lines.push(Line::from("")); 680 - lines.push(Line::from(vec![Span::styled( 681 - message, 682 - Style::default().fg(theme.ui.toc_primary_inactive), 683 - )])); 684 - 685 - while lines.len() < inner_height.saturating_sub(2) { 686 - lines.push(Line::from("")); 687 - } 688 - 689 - lines.push(Line::from("")); 690 - lines.push(Line::from(vec![Span::styled( 691 - if is_fuzzy { 692 - "↑/↓ move • enter open • type filter • esc clear • ctrl+c quit" 693 - } else { 694 - "enter open • backspace up • ctrl+c quit" 695 - }, 696 - footer_style.bg(theme.ui.toc_bg), 697 - )])); 698 - 699 - f.render_widget(Clear, area); 700 - f.render_widget( 701 - Paragraph::new(lines).block( 702 - Block::default() 703 - .title("─ Files ") 704 - .borders(Borders::ALL) 705 - .border_style(Style::default().fg(theme.ui.toc_border)) 706 - .style(Style::default().bg(theme.ui.toc_bg)) 707 - .padding(Padding::new(1, 1, 0, 0)), 708 - ), 709 - area, 710 - ); 711 - } 712 - 713 - fn picker_truncation_message( 714 - truncation: Option<crate::app::PickerIndexTruncation>, 715 - ) -> Option<&'static str> { 716 - match truncation { 717 - Some(crate::app::PickerIndexTruncation::Directory) => { 718 - Some("Indexing limited: directory limit reached") 719 - } 720 - Some(crate::app::PickerIndexTruncation::File) => { 721 - Some("Indexing limited: file limit reached") 722 - } 723 - Some(crate::app::PickerIndexTruncation::Time) => { 724 - Some("Indexing limited: time limit reached") 725 - } 726 - None => None, 727 - } 728 - } 729 - 730 - fn highlighted_picker_label( 731 - label: &str, 732 - match_positions: &[usize], 733 - bg: Color, 734 - selected: bool, 735 - ) -> Vec<Span<'static>> { 736 - let theme = app_theme(); 737 - let default_style = Style::default() 738 - .fg(theme.ui.toc_primary_inactive) 739 - .bg(bg) 740 - .add_modifier(if selected { 741 - Modifier::BOLD 742 - } else { 743 - Modifier::empty() 744 - }); 745 - let matched_style = Style::default() 746 - .fg(theme.ui.toc_accent) 747 - .bg(bg) 748 - .add_modifier(if selected { 749 - Modifier::BOLD 750 - } else { 751 - Modifier::empty() 752 - }); 753 - 754 - if match_positions.is_empty() { 755 - return vec![Span::styled(label.to_string(), default_style)]; 756 - } 757 - 758 - let match_set = match_positions 759 - .iter() 760 - .copied() 761 - .collect::<std::collections::BTreeSet<_>>(); 762 - let mut spans = Vec::new(); 763 - let mut buffer = String::new(); 764 - let mut current_matched = None; 765 - 766 - for (idx, ch) in label.chars().enumerate() { 767 - let is_matched = match_set.contains(&idx); 768 - if current_matched == Some(is_matched) || current_matched.is_none() { 769 - buffer.push(ch); 770 - current_matched = Some(is_matched); 771 - continue; 772 - } 773 - 774 - spans.push(Span::styled( 775 - std::mem::take(&mut buffer), 776 - if current_matched == Some(true) { 777 - matched_style 778 - } else { 779 - default_style 780 - }, 781 - )); 782 - buffer.push(ch); 783 - current_matched = Some(is_matched); 784 - } 785 - 786 - if !buffer.is_empty() { 787 - spans.push(Span::styled( 788 - buffer, 789 - if current_matched == Some(true) { 790 - matched_style 791 - } else { 792 - default_style 793 - }, 794 - )); 795 - } 796 - 797 - spans 798 - } 799 - 800 - fn centered_rect(width: u16, height: u16, area: Rect) -> Rect { 801 - let popup_width = width.min(area.width.saturating_sub(2)).max(1); 802 - let popup_height = height.min(area.height.saturating_sub(2)).max(1); 803 - Rect { 804 - x: area.x + area.width.saturating_sub(popup_width) / 2, 805 - y: area.y + area.height.saturating_sub(popup_height) / 2, 806 - width: popup_width, 807 - height: popup_height, 808 - } 809 - } 810 - 811 - pub(crate) fn status_shortcuts_section(app: &App, bar_bg: Color) -> Vec<Span<'static>> { 812 - let theme = app_theme(); 813 - let separator = Span::styled(" · ", status_separator_style(bar_bg)); 814 - let sections = status_hint_segments(app) 815 - .iter() 816 - .map(|segment| { 817 - vec![Span::styled( 818 - (*segment).to_string(), 819 - Style::default().fg(theme.ui.status_shortcut_fg).bg(bar_bg), 820 - )] 821 - }) 822 - .collect(); 823 - join_span_sections(sections, separator) 824 - } 825 - 826 - pub(crate) fn status_percent_section(pct: u16, bar_bg: Color) -> Vec<Span<'static>> { 827 - let theme = app_theme(); 828 - vec![Span::styled( 829 - format!("{:>3}% ", pct), 830 - Style::default().fg(theme.ui.status_percent_fg).bg(bar_bg), 831 - )] 832 - } 833 - 834 - pub(crate) fn build_status_bar(app: &App, pct: u16) -> Vec<Span<'static>> { 835 - let bar_bg = status_bar_bg(); 836 - let outer_separator = Span::raw(" "); 837 - 838 - let mut left_section = status_brand_section(); 839 - left_section.extend(status_filename_section(app.filename())); 840 - 841 - if let Some(section) = status_search_section(app) { 842 - left_section.extend(section); 843 - } 844 - 845 - if let Some(section) = status_watch_section(app) { 846 - left_section.extend(section); 847 - } 848 - 849 - let mut sections = vec![left_section, status_shortcuts_section(app, bar_bg)]; 850 - if !app.is_file_picker_open() && !app.is_picker_loading() { 851 - sections.push(status_percent_section(pct, bar_bg)); 852 - } 853 - 854 - join_span_sections(sections, outer_separator) 855 - } 856 - 857 - pub(crate) fn toc_header_line() -> Line<'static> { 858 - let theme = app_theme(); 859 - Line::from(vec![Span::styled( 860 - " TABLE OF CONTENTS", 861 - Style::default() 862 - .fg(theme.ui.toc_header_fg) 863 - .bg(theme.ui.toc_bg) 864 - .add_modifier(Modifier::BOLD), 865 - )]) 866 - } 867 - 868 - pub(crate) fn build_toc_line_with_index( 869 - entry: &crate::markdown::toc::TocEntry, 870 - display_level: u8, 871 - top_level_index: Option<usize>, 872 - active: bool, 873 - ) -> Line<'static> { 874 - let theme = app_theme(); 875 - let active_bg = theme.ui.toc_active_bg; 876 - let inactive_bg = theme.ui.toc_inactive_bg; 877 - 878 - match display_level { 879 - 1 => { 880 - let index = top_level_index.unwrap_or(0) + 1; 881 - let title = crate::markdown::truncate_display_width(&entry.title, 18); 882 - let bg = if active { active_bg } else { inactive_bg }; 883 - Line::from(vec![ 884 - Span::styled( 885 - if active { "▎" } else { " " }, 886 - Style::default().fg(theme.ui.toc_accent).bg(bg), 887 - ), 888 - Span::styled(" ", Style::default().bg(bg)), 889 - Span::styled( 890 - format!("{index:02}"), 891 - Style::default() 892 - .fg(if active { 893 - theme.ui.toc_accent 894 - } else { 895 - theme.ui.toc_index_inactive 896 - }) 897 - .bg(bg) 898 - .add_modifier(Modifier::BOLD), 899 - ), 900 - Span::styled(" ", Style::default().bg(bg)), 901 - Span::styled( 902 - title, 903 - Style::default() 904 - .fg(if active { 905 - theme.ui.toc_primary_active 906 - } else { 907 - theme.ui.toc_primary_inactive 908 - }) 909 - .bg(bg) 910 - .add_modifier(Modifier::BOLD), 911 - ), 912 - ]) 913 - } 914 - _ => Line::from(vec![ 915 - Span::styled( 916 - if active { "▎" } else { " " }, 917 - Style::default().fg(theme.ui.toc_accent), 918 - ), 919 - Span::raw(" "), 920 - Span::styled( 921 - "•", 922 - Style::default().fg(if active { 923 - theme.ui.toc_accent 924 - } else { 925 - theme.ui.toc_secondary_inactive 926 - }), 927 - ), 928 - Span::raw(" "), 929 - Span::styled( 930 - crate::markdown::truncate_display_width(&entry.title, 18), 931 - Style::default() 932 - .fg(if active { 933 - theme.ui.toc_secondary_text_active 934 - } else { 935 - theme.ui.toc_secondary_text_inactive 936 - }) 937 - .add_modifier(if active { 938 - Modifier::BOLD 939 - } else { 940 - Modifier::empty() 941 - }), 942 - ), 943 - ]), 944 - } 945 - }
+80
src/render/content.rs
··· 1 + use crate::{app::App, theme::app_theme}; 2 + use ratatui::{ 3 + layout::Rect, 4 + style::Style, 5 + widgets::{Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, Wrap}, 6 + Frame, 7 + }; 8 + 9 + use super::{CONTENT_HORIZONTAL_PADDING, SCROLLBAR_WIDTH}; 10 + 11 + pub(super) fn render_content_panel( 12 + f: &mut Frame, 13 + app: &mut App, 14 + area: Rect, 15 + viewport_height: usize, 16 + ) { 17 + let theme = app_theme(); 18 + f.render_widget( 19 + Paragraph::new("").style(Style::default().bg(theme.ui.content_bg)), 20 + area, 21 + ); 22 + let content_area = inner_content_area(area); 23 + let scroll = app.scroll(); 24 + let active_highlight_line = app.active_highlight_line(); 25 + if let Some(line_idx) = active_highlight_line { 26 + let _ = app.refresh_highlighted_line_cache(line_idx); 27 + } 28 + 29 + let visible_end = (scroll + viewport_height).min(app.total()); 30 + let mut visible_lines = app.visible_lines(scroll, visible_end).to_vec(); 31 + 32 + if let Some(line_idx) = active_highlight_line { 33 + if (scroll..visible_end).contains(&line_idx) { 34 + if let Some((_, highlighted_line)) = app.highlighted_line_cache() { 35 + visible_lines[line_idx - scroll] = highlighted_line.clone(); 36 + } 37 + } 38 + } 39 + 40 + f.render_widget( 41 + Paragraph::new(visible_lines) 42 + .style(Style::default().bg(theme.ui.content_bg)) 43 + .wrap(Wrap { trim: false }), 44 + content_area, 45 + ); 46 + 47 + let mut scrollbar_state = ScrollbarState::new(app.total()).position(app.scroll()); 48 + f.render_stateful_widget( 49 + Scrollbar::new(ScrollbarOrientation::VerticalRight) 50 + .begin_symbol(None) 51 + .end_symbol(None) 52 + .track_symbol(Some("│")) 53 + .thumb_symbol("█"), 54 + area, 55 + &mut scrollbar_state, 56 + ); 57 + } 58 + 59 + fn inner_content_area(area: Rect) -> Rect { 60 + Rect { 61 + x: area.x.saturating_add(CONTENT_HORIZONTAL_PADDING), 62 + y: area.y, 63 + width: area 64 + .width 65 + .saturating_sub(CONTENT_HORIZONTAL_PADDING.saturating_mul(2)) 66 + .saturating_sub(SCROLLBAR_WIDTH), 67 + height: area.height, 68 + } 69 + } 70 + 71 + pub(super) fn render_status_bar(f: &mut Frame, app: &mut App, area: Rect, viewport_height: usize) { 72 + let pct = app.scroll_percent(viewport_height); 73 + let bar_bg = super::status::status_bar_bg(); 74 + app.refresh_status_cache(pct); 75 + 76 + f.render_widget( 77 + Paragraph::new(vec![app.status_line().clone()]).style(Style::default().bg(bar_bg)), 78 + area, 79 + ); 80 + }
+63
src/render/mod.rs
··· 1 + mod content; 2 + mod modal; 3 + mod status; 4 + mod toc; 5 + 6 + use crate::app::App; 7 + use ratatui::{ 8 + layout::{Constraint, Direction, Layout, Rect}, 9 + Frame, 10 + }; 11 + 12 + pub(crate) use status::build_status_bar; 13 + pub(crate) use toc::{build_toc_line_with_index, toc_header_line}; 14 + 15 + pub(crate) const CONTENT_HORIZONTAL_PADDING: u16 = 1; 16 + pub(crate) const SCROLLBAR_WIDTH: u16 = 1; 17 + 18 + pub(crate) fn ui(f: &mut Frame, app: &mut App) { 19 + let area = f.area(); 20 + let root = Layout::default() 21 + .direction(Direction::Vertical) 22 + .constraints([Constraint::Min(0), Constraint::Length(1)]) 23 + .split(area); 24 + 25 + let (toc_area, content_area): (Option<Rect>, Rect) = if app.is_toc_visible() && app.has_toc() { 26 + let cols = Layout::default() 27 + .direction(Direction::Horizontal) 28 + .constraints([Constraint::Length(30), Constraint::Min(0)]) 29 + .split(root[0]); 30 + (Some(cols[0]), cols[1]) 31 + } else { 32 + (None, root[0]) 33 + }; 34 + 35 + if let Some(ta) = toc_area { 36 + toc::render_toc_panel(f, app, ta); 37 + } 38 + 39 + let viewport_height = content_area.height as usize; 40 + content::render_content_panel(f, app, content_area, viewport_height); 41 + content::render_status_bar(f, app, root[1], viewport_height); 42 + 43 + if app.is_help_open() { 44 + modal::render_help_popup(f); 45 + } else if app.is_picker_loading() || app.is_picker_load_failed() { 46 + modal::render_picker_loading(f, app); 47 + } else if app.is_file_picker_open() { 48 + modal::render_file_picker(f, app); 49 + } else if app.is_theme_picker_open() { 50 + modal::render_theme_picker(f, app); 51 + } 52 + } 53 + 54 + pub(super) fn centered_rect(width: u16, height: u16, area: Rect) -> Rect { 55 + let popup_width = width.min(area.width.saturating_sub(2)).max(1); 56 + let popup_height = height.min(area.height.saturating_sub(2)).max(1); 57 + Rect { 58 + x: area.x + area.width.saturating_sub(popup_width) / 2, 59 + y: area.y + area.height.saturating_sub(popup_height) / 2, 60 + width: popup_width, 61 + height: popup_height, 62 + } 63 + }
+505
src/render/modal.rs
··· 1 + use crate::{ 2 + app::App, 3 + cli::version_text, 4 + theme::{app_theme, theme_preset_label, THEME_PRESETS}, 5 + }; 6 + use ratatui::{ 7 + style::{Color, Modifier, Style}, 8 + text::{Line, Span}, 9 + widgets::{Block, Borders, Clear, Padding, Paragraph}, 10 + Frame, 11 + }; 12 + 13 + use super::centered_rect; 14 + 15 + pub(super) fn render_help_popup(f: &mut Frame) { 16 + let theme = app_theme(); 17 + let area = centered_rect(56, 16, f.area()); 18 + let section_style = Style::default() 19 + .fg(theme.ui.toc_primary_active) 20 + .add_modifier(Modifier::BOLD); 21 + let key_style = Style::default() 22 + .fg(theme.ui.toc_accent) 23 + .add_modifier(Modifier::BOLD); 24 + let text_style = Style::default().fg(theme.ui.toc_primary_inactive); 25 + let footer_style = Style::default().fg(theme.ui.status_shortcut_fg); 26 + let title_style = Style::default() 27 + .fg(theme.markdown.heading_2) 28 + .add_modifier(Modifier::BOLD); 29 + let lines = vec![ 30 + Line::from(vec![Span::styled(version_text().to_string(), title_style)]), 31 + Line::from(vec![Span::styled( 32 + "Keyboard shortcuts", 33 + Style::default().fg(theme.ui.status_shortcut_fg), 34 + )]), 35 + Line::from(""), 36 + Line::from(vec![Span::styled( 37 + "Navigation Search", 38 + section_style, 39 + )]), 40 + Line::from(vec![ 41 + Span::styled("j/k, ↑/↓ ", key_style), 42 + Span::styled("scroll", text_style), 43 + Span::raw(" "), 44 + Span::styled("/, Ctrl+F ", key_style), 45 + Span::styled("search", text_style), 46 + ]), 47 + Line::from(vec![ 48 + Span::styled("PgUp/PgDn ", key_style), 49 + Span::styled("page", text_style), 50 + Span::raw(" "), 51 + Span::styled("n/N ", key_style), 52 + Span::styled("next/prev", text_style), 53 + ]), 54 + Line::from(vec![ 55 + Span::styled("g/G ", key_style), 56 + Span::styled("top/bottom", text_style), 57 + ]), 58 + Line::from(""), 59 + Line::from(vec![Span::styled("Actions", section_style)]), 60 + Line::from(vec![ 61 + Span::styled("r ", key_style), 62 + Span::styled("reload (watch)", text_style), 63 + Span::raw(" "), 64 + Span::styled("? ", key_style), 65 + Span::styled("show help", text_style), 66 + ]), 67 + Line::from(vec![ 68 + Span::styled("t ", key_style), 69 + Span::styled("toggle toc", text_style), 70 + Span::raw(" "), 71 + Span::styled("q ", key_style), 72 + Span::styled("quit", text_style), 73 + ]), 74 + Line::from(vec![ 75 + Span::styled("T ", key_style), 76 + Span::styled("theme picker", text_style), 77 + ]), 78 + Line::from(""), 79 + Line::from(vec![Span::styled("Esc or ? to close", footer_style)]), 80 + ]; 81 + 82 + f.render_widget(Clear, area); 83 + f.render_widget( 84 + Paragraph::new(lines).block( 85 + Block::default() 86 + .title("─ Help ") 87 + .borders(Borders::ALL) 88 + .border_style(Style::default().fg(theme.ui.toc_border)) 89 + .style(Style::default().bg(theme.ui.toc_bg)) 90 + .padding(Padding::new(1, 1, 0, 0)), 91 + ), 92 + area, 93 + ); 94 + } 95 + 96 + pub(super) fn render_theme_picker(f: &mut Frame, app: &App) { 97 + let theme = app_theme(); 98 + let area = centered_rect(38, 10, f.area()); 99 + let active = app.theme_picker_reference_preset(); 100 + let footer_style = Style::default().fg(theme.ui.status_shortcut_fg); 101 + 102 + let mut lines = vec![ 103 + Line::from(vec![Span::styled( 104 + "Choose a theme", 105 + Style::default().fg(theme.ui.status_shortcut_fg), 106 + )]), 107 + Line::from(""), 108 + ]; 109 + for (idx, preset) in THEME_PRESETS.iter().enumerate() { 110 + let selected = idx == app.theme_picker_index(); 111 + let is_active = *preset == active; 112 + let bg = if selected { 113 + theme.ui.toc_active_bg 114 + } else { 115 + theme.ui.toc_bg 116 + }; 117 + let marker = if selected { "▸ " } else { " " }; 118 + let name = if is_active { 119 + format!("{} ✓", theme_preset_label(*preset)) 120 + } else { 121 + theme_preset_label(*preset).to_string() 122 + }; 123 + lines.push(Line::from(vec![ 124 + Span::styled( 125 + marker, 126 + Style::default() 127 + .fg(theme.ui.toc_accent) 128 + .bg(bg) 129 + .add_modifier(if selected { 130 + Modifier::BOLD 131 + } else { 132 + Modifier::empty() 133 + }), 134 + ), 135 + Span::styled( 136 + name, 137 + Style::default() 138 + .fg(if selected { 139 + theme.ui.toc_primary_active 140 + } else { 141 + theme.ui.toc_primary_inactive 142 + }) 143 + .bg(bg) 144 + .add_modifier(if is_active || selected { 145 + Modifier::BOLD 146 + } else { 147 + Modifier::empty() 148 + }), 149 + ), 150 + ])); 151 + } 152 + lines.push(Line::from("")); 153 + lines.push(Line::from(vec![Span::styled( 154 + "Enter keep • Esc restore", 155 + footer_style.bg(theme.ui.toc_bg), 156 + )])); 157 + 158 + f.render_widget(Clear, area); 159 + f.render_widget( 160 + Paragraph::new(lines).block( 161 + Block::default() 162 + .title("─ Theme ") 163 + .borders(Borders::ALL) 164 + .border_style(Style::default().fg(theme.ui.toc_border)) 165 + .style(Style::default().bg(theme.ui.toc_bg)) 166 + .padding(Padding::new(1, 1, 0, 0)), 167 + ), 168 + area, 169 + ); 170 + } 171 + 172 + pub(super) fn render_file_picker(f: &mut Frame, app: &App) { 173 + let theme = app_theme(); 174 + let area = centered_rect(78, 20, f.area()); 175 + let title_style = Style::default() 176 + .fg(theme.markdown.heading_2) 177 + .add_modifier(Modifier::BOLD); 178 + let section_style = Style::default().fg(theme.ui.status_shortcut_fg); 179 + let footer_style = Style::default().fg(theme.ui.status_shortcut_fg); 180 + let inner_height = area.height.saturating_sub(2) as usize; 181 + let header_lines = if app.is_fuzzy_file_picker() { 4 } else { 3 }; 182 + let total = app.file_picker_filtered_indices().len(); 183 + let truncation_message = picker_truncation_message(app.file_picker_truncation()); 184 + let max_visible_slots = if app.is_fuzzy_file_picker() { 185 + if truncation_message.is_some() { 186 + 11 187 + } else { 188 + 12 189 + } 190 + } else { 191 + 13 192 + }; 193 + let reserved_footer_lines = if truncation_message.is_some() { 3 } else { 2 }; 194 + let visible_slots = inner_height 195 + .saturating_sub(header_lines + reserved_footer_lines) 196 + .min(max_visible_slots); 197 + let start = if visible_slots == 0 || app.file_picker_index() < visible_slots { 198 + 0 199 + } else { 200 + app.file_picker_index() + 1 - visible_slots 201 + }; 202 + let end = (start + visible_slots).min(total); 203 + 204 + let mut lines = vec![ 205 + Line::from(vec![Span::styled("Open a Markdown file", title_style)]), 206 + Line::from(vec![ 207 + Span::styled("Dir: ", section_style), 208 + Span::styled( 209 + app.file_picker_dir().display().to_string(), 210 + Style::default().fg(theme.ui.toc_primary_inactive), 211 + ), 212 + ]), 213 + ]; 214 + 215 + if app.is_fuzzy_file_picker() { 216 + lines.push(Line::from(vec![ 217 + Span::styled("Query: ", section_style), 218 + Span::styled( 219 + if app.file_picker_query().is_empty() { 220 + " type to filter ".to_string() 221 + } else { 222 + format!(" {} ", app.file_picker_query()) 223 + }, 224 + Style::default() 225 + .fg(if app.file_picker_query().is_empty() { 226 + theme.ui.toc_primary_inactive 227 + } else { 228 + theme.ui.toc_primary_active 229 + }) 230 + .bg(theme.markdown.inline_code_bg), 231 + ), 232 + ])); 233 + } 234 + 235 + lines.push(Line::from("")); 236 + 237 + if app.file_picker_entries().is_empty() { 238 + lines.push(Line::from(vec![Span::styled( 239 + if app.is_fuzzy_file_picker() { 240 + "No Markdown file found in this directory or its subdirectories" 241 + } else { 242 + "No folders or Markdown files here" 243 + }, 244 + Style::default().fg(theme.ui.toc_primary_inactive), 245 + )])); 246 + } else if total == 0 { 247 + lines.push(Line::from(vec![Span::styled( 248 + "No match for the current query", 249 + Style::default().fg(theme.ui.toc_primary_inactive), 250 + )])); 251 + } else { 252 + for (idx, entry_idx) in app.file_picker_filtered_indices()[start..end] 253 + .iter() 254 + .enumerate() 255 + { 256 + let actual_idx = start + idx; 257 + let selected = actual_idx == app.file_picker_index(); 258 + let entry = &app.file_picker_entries()[*entry_idx]; 259 + let bg = if selected { 260 + theme.ui.toc_active_bg 261 + } else { 262 + theme.ui.toc_bg 263 + }; 264 + let marker = if selected { "▸ " } else { " " }; 265 + let label_spans = if app.is_fuzzy_file_picker() { 266 + highlighted_picker_label( 267 + entry.label(), 268 + app.file_picker_match_positions(actual_idx), 269 + bg, 270 + selected, 271 + ) 272 + } else { 273 + vec![Span::styled( 274 + entry.label().to_string(), 275 + Style::default() 276 + .fg(theme.ui.toc_primary_inactive) 277 + .bg(bg) 278 + .add_modifier(if selected { 279 + Modifier::BOLD 280 + } else { 281 + Modifier::empty() 282 + }), 283 + )] 284 + }; 285 + let mut spans = vec![Span::styled( 286 + marker, 287 + Style::default() 288 + .fg(theme.ui.toc_accent) 289 + .bg(bg) 290 + .add_modifier(if selected { 291 + Modifier::BOLD 292 + } else { 293 + Modifier::empty() 294 + }), 295 + )]; 296 + spans.extend(label_spans); 297 + lines.push(Line::from(spans)); 298 + } 299 + } 300 + 301 + while lines.len() < inner_height.saturating_sub(reserved_footer_lines) { 302 + lines.push(Line::from("")); 303 + } 304 + 305 + if let Some(message) = truncation_message { 306 + lines.push(Line::from(vec![Span::styled( 307 + "", 308 + Style::default().fg(theme.ui.toc_primary_inactive), 309 + )])); 310 + lines.push(Line::from(vec![Span::styled( 311 + message, 312 + Style::default().fg(theme.markdown.heading_3), 313 + )])); 314 + } else { 315 + lines.push(Line::from("")); 316 + } 317 + 318 + lines.push(Line::from(vec![Span::styled( 319 + if app.is_fuzzy_file_picker() { 320 + "↑/↓ move • enter open • type filter • esc clear • ctrl+c quit" 321 + } else { 322 + "enter open • backspace up • ctrl+c quit" 323 + }, 324 + footer_style.bg(theme.ui.toc_bg), 325 + )])); 326 + 327 + f.render_widget(Clear, area); 328 + f.render_widget( 329 + Paragraph::new(lines).block( 330 + Block::default() 331 + .title("─ Files ") 332 + .borders(Borders::ALL) 333 + .border_style(Style::default().fg(theme.ui.toc_border)) 334 + .style(Style::default().bg(theme.ui.toc_bg)) 335 + .padding(Padding::new(1, 1, 0, 0)), 336 + ), 337 + area, 338 + ); 339 + } 340 + 341 + pub(super) fn render_picker_loading(f: &mut Frame, app: &App) { 342 + let theme = app_theme(); 343 + let area = centered_rect(78, 20, f.area()); 344 + let title_style = Style::default() 345 + .fg(theme.markdown.heading_2) 346 + .add_modifier(Modifier::BOLD); 347 + let section_style = Style::default().fg(theme.ui.status_shortcut_fg); 348 + let footer_style = Style::default().fg(theme.ui.status_shortcut_fg); 349 + let is_failed = app.is_picker_load_failed(); 350 + let is_fuzzy = matches!( 351 + app.pending_picker_mode(), 352 + Some(crate::app::FilePickerMode::Fuzzy) 353 + ); 354 + let inner_height = area.height.saturating_sub(2) as usize; 355 + let message = if is_failed { 356 + app.picker_load_error().unwrap_or("Failed to load files") 357 + } else { 358 + "Indexing markdown files..." 359 + }; 360 + 361 + let mut lines = vec![ 362 + Line::from(vec![Span::styled("Open a Markdown file", title_style)]), 363 + Line::from(vec![ 364 + Span::styled("Dir: ", section_style), 365 + Span::styled( 366 + app.pending_picker_dir() 367 + .map(|dir| dir.display().to_string()) 368 + .unwrap_or_else(|| ".".to_string()), 369 + Style::default().fg(theme.ui.toc_primary_inactive), 370 + ), 371 + ]), 372 + ]; 373 + 374 + if is_fuzzy { 375 + lines.push(Line::from(vec![ 376 + Span::styled("Query: ", section_style), 377 + Span::styled( 378 + " type to filter ".to_string(), 379 + Style::default() 380 + .fg(theme.ui.toc_primary_inactive) 381 + .bg(theme.markdown.inline_code_bg), 382 + ), 383 + ])); 384 + } 385 + 386 + lines.push(Line::from("")); 387 + lines.push(Line::from(vec![Span::styled( 388 + message, 389 + Style::default().fg(theme.ui.toc_primary_inactive), 390 + )])); 391 + 392 + while lines.len() < inner_height.saturating_sub(2) { 393 + lines.push(Line::from("")); 394 + } 395 + 396 + lines.push(Line::from("")); 397 + lines.push(Line::from(vec![Span::styled( 398 + if is_fuzzy { 399 + "↑/↓ move • enter open • type filter • esc clear • ctrl+c quit" 400 + } else { 401 + "enter open • backspace up • ctrl+c quit" 402 + }, 403 + footer_style.bg(theme.ui.toc_bg), 404 + )])); 405 + 406 + f.render_widget(Clear, area); 407 + f.render_widget( 408 + Paragraph::new(lines).block( 409 + Block::default() 410 + .title("─ Files ") 411 + .borders(Borders::ALL) 412 + .border_style(Style::default().fg(theme.ui.toc_border)) 413 + .style(Style::default().bg(theme.ui.toc_bg)) 414 + .padding(Padding::new(1, 1, 0, 0)), 415 + ), 416 + area, 417 + ); 418 + } 419 + 420 + fn picker_truncation_message( 421 + truncation: Option<crate::app::PickerIndexTruncation>, 422 + ) -> Option<&'static str> { 423 + match truncation { 424 + Some(crate::app::PickerIndexTruncation::Directory) => { 425 + Some("Indexing limited: directory limit reached") 426 + } 427 + Some(crate::app::PickerIndexTruncation::File) => { 428 + Some("Indexing limited: file limit reached") 429 + } 430 + Some(crate::app::PickerIndexTruncation::Time) => { 431 + Some("Indexing limited: time limit reached") 432 + } 433 + None => None, 434 + } 435 + } 436 + 437 + fn highlighted_picker_label( 438 + label: &str, 439 + match_positions: &[usize], 440 + bg: Color, 441 + selected: bool, 442 + ) -> Vec<Span<'static>> { 443 + let theme = app_theme(); 444 + let default_style = Style::default() 445 + .fg(theme.ui.toc_primary_inactive) 446 + .bg(bg) 447 + .add_modifier(if selected { 448 + Modifier::BOLD 449 + } else { 450 + Modifier::empty() 451 + }); 452 + let matched_style = Style::default() 453 + .fg(theme.ui.toc_accent) 454 + .bg(bg) 455 + .add_modifier(if selected { 456 + Modifier::BOLD 457 + } else { 458 + Modifier::empty() 459 + }); 460 + 461 + if match_positions.is_empty() { 462 + return vec![Span::styled(label.to_string(), default_style)]; 463 + } 464 + 465 + let match_set = match_positions 466 + .iter() 467 + .copied() 468 + .collect::<std::collections::BTreeSet<_>>(); 469 + let mut spans = Vec::new(); 470 + let mut buffer = String::new(); 471 + let mut current_matched = None; 472 + 473 + for (idx, ch) in label.chars().enumerate() { 474 + let is_matched = match_set.contains(&idx); 475 + if current_matched == Some(is_matched) || current_matched.is_none() { 476 + buffer.push(ch); 477 + current_matched = Some(is_matched); 478 + continue; 479 + } 480 + 481 + spans.push(Span::styled( 482 + std::mem::take(&mut buffer), 483 + if current_matched == Some(true) { 484 + matched_style 485 + } else { 486 + default_style 487 + }, 488 + )); 489 + buffer.push(ch); 490 + current_matched = Some(is_matched); 491 + } 492 + 493 + if !buffer.is_empty() { 494 + spans.push(Span::styled( 495 + buffer, 496 + if current_matched == Some(true) { 497 + matched_style 498 + } else { 499 + default_style 500 + }, 501 + )); 502 + } 503 + 504 + spans 505 + }
+195
src/render/status.rs
··· 1 + use crate::{app::App, theme::app_theme}; 2 + use ratatui::{ 3 + style::{Color, Modifier, Style}, 4 + text::Span, 5 + }; 6 + 7 + pub(crate) fn status_bar_bg() -> Color { 8 + app_theme().ui.status_bg 9 + } 10 + 11 + pub(crate) fn status_separator_style(bar_bg: Color) -> Style { 12 + Style::default() 13 + .fg(app_theme().ui.status_separator) 14 + .bg(bar_bg) 15 + } 16 + 17 + pub(crate) fn join_span_sections( 18 + sections: Vec<Vec<Span<'static>>>, 19 + separator: Span<'static>, 20 + ) -> Vec<Span<'static>> { 21 + let mut joined = Vec::new(); 22 + for (idx, section) in sections.into_iter().enumerate() { 23 + if idx > 0 { 24 + joined.push(separator.clone()); 25 + } 26 + joined.extend(section); 27 + } 28 + joined 29 + } 30 + 31 + pub(crate) fn status_brand_section() -> Vec<Span<'static>> { 32 + let theme = app_theme(); 33 + vec![Span::styled( 34 + " leaf ", 35 + Style::default() 36 + .fg(theme.ui.status_brand_fg) 37 + .bg(theme.ui.status_brand_bg) 38 + .add_modifier(Modifier::BOLD), 39 + )] 40 + } 41 + 42 + pub(crate) fn status_filename_section(filename: &str) -> Vec<Span<'static>> { 43 + let theme = app_theme(); 44 + vec![Span::styled( 45 + format!(" {} ", filename), 46 + Style::default() 47 + .fg(theme.ui.status_filename_fg) 48 + .bg(theme.ui.status_filename_bg), 49 + )] 50 + } 51 + 52 + pub(crate) fn status_watch_section(app: &App) -> Option<Vec<Span<'static>>> { 53 + let theme = app_theme(); 54 + if !app.is_watch_enabled() { 55 + return None; 56 + } 57 + 58 + let flash_active = app 59 + .reload_flash_started() 60 + .map(|t| t.elapsed() < std::time::Duration::from_millis(1500)) 61 + .unwrap_or(false); 62 + let span = if flash_active { 63 + Span::styled( 64 + " ⟳ reloaded ", 65 + Style::default() 66 + .fg(theme.ui.status_reloaded_fg) 67 + .bg(theme.ui.status_reloaded_bg) 68 + .add_modifier(Modifier::BOLD), 69 + ) 70 + } else { 71 + Span::styled( 72 + " ⟳ watch ", 73 + Style::default() 74 + .fg(theme.ui.status_watch_fg) 75 + .bg(theme.ui.status_watch_bg), 76 + ) 77 + }; 78 + Some(vec![span]) 79 + } 80 + 81 + pub(crate) fn status_search_section(app: &App) -> Option<Vec<Span<'static>>> { 82 + let theme = app_theme(); 83 + if app.is_search_mode() { 84 + return Some(vec![Span::styled( 85 + format!(" /{} ", app.search_draft()), 86 + Style::default() 87 + .fg(theme.ui.status_search_fg) 88 + .bg(theme.ui.status_search_bg), 89 + )]); 90 + } 91 + 92 + if app.search_query().is_empty() { 93 + return None; 94 + } 95 + 96 + let span = if app.search_match_count() == 0 { 97 + Span::styled( 98 + format!(" ✗ {} ", app.search_query()), 99 + Style::default() 100 + .fg(theme.ui.status_search_error_fg) 101 + .bg(theme.ui.status_search_bg), 102 + ) 103 + } else { 104 + Span::styled( 105 + format!(" {}/{} ", app.search_index() + 1, app.search_match_count()), 106 + Style::default() 107 + .fg(theme.ui.status_search_match_fg) 108 + .bg(theme.ui.status_search_bg), 109 + ) 110 + }; 111 + Some(vec![span]) 112 + } 113 + 114 + pub(crate) fn status_hint_segments(app: &App) -> &'static [&'static str] { 115 + if app.is_search_mode() { 116 + &["enter confirm", "esc cancel"] 117 + } else if app.is_file_picker_open() { 118 + if app.is_fuzzy_file_picker() { 119 + &["↑/↓ move", "enter open", "backspace delete", "ctrl+c quit"] 120 + } else { 121 + &["j/k move", "enter open", "backspace up", "ctrl+c quit"] 122 + } 123 + } else if app.is_theme_picker_open() { 124 + &["j/k preview", "enter keep", "esc restore"] 125 + } else if app.is_help_open() { 126 + &["esc close", "? close"] 127 + } else if app.has_active_search() { 128 + &[ 129 + "enter next", 130 + "n/N next/prev", 131 + "/ search", 132 + "? help", 133 + "T theme", 134 + "esc clear", 135 + "q quit", 136 + ] 137 + } else { 138 + &[ 139 + "j/k scroll", 140 + "g/G top/bot", 141 + "t toc", 142 + "T theme", 143 + "/ search", 144 + "? help", 145 + "n/N next/prev", 146 + "q quit", 147 + ] 148 + } 149 + } 150 + 151 + pub(crate) fn status_shortcuts_section(app: &App, bar_bg: Color) -> Vec<Span<'static>> { 152 + let theme = app_theme(); 153 + let separator = Span::styled(" · ", status_separator_style(bar_bg)); 154 + let sections = status_hint_segments(app) 155 + .iter() 156 + .map(|segment| { 157 + vec![Span::styled( 158 + (*segment).to_string(), 159 + Style::default().fg(theme.ui.status_shortcut_fg).bg(bar_bg), 160 + )] 161 + }) 162 + .collect(); 163 + join_span_sections(sections, separator) 164 + } 165 + 166 + pub(crate) fn status_percent_section(pct: u16, bar_bg: Color) -> Vec<Span<'static>> { 167 + let theme = app_theme(); 168 + vec![Span::styled( 169 + format!("{:>3}% ", pct), 170 + Style::default().fg(theme.ui.status_percent_fg).bg(bar_bg), 171 + )] 172 + } 173 + 174 + pub(crate) fn build_status_bar(app: &App, pct: u16) -> Vec<Span<'static>> { 175 + let bar_bg = status_bar_bg(); 176 + let outer_separator = Span::raw(" "); 177 + 178 + let mut left_section = status_brand_section(); 179 + left_section.extend(status_filename_section(app.filename())); 180 + 181 + if let Some(section) = status_search_section(app) { 182 + left_section.extend(section); 183 + } 184 + 185 + if let Some(section) = status_watch_section(app) { 186 + left_section.extend(section); 187 + } 188 + 189 + let mut sections = vec![left_section, status_shortcuts_section(app, bar_bg)]; 190 + if !app.is_file_picker_open() && !app.is_picker_loading() { 191 + sections.push(status_percent_section(pct, bar_bg)); 192 + } 193 + 194 + join_span_sections(sections, outer_separator) 195 + }
+140
src/render/toc.rs
··· 1 + use crate::{app::App, theme::app_theme}; 2 + use ratatui::{ 3 + layout::{Constraint, Direction, Layout, Rect}, 4 + style::{Modifier, Style}, 5 + text::{Line, Span}, 6 + widgets::{Block, Borders, Paragraph}, 7 + Frame, 8 + }; 9 + 10 + pub(super) fn render_toc_panel(f: &mut Frame, app: &mut App, area: Rect) { 11 + let theme = app_theme(); 12 + app.refresh_toc_cache(); 13 + let toc_chunks = Layout::default() 14 + .direction(Direction::Vertical) 15 + .constraints([Constraint::Length(3), Constraint::Min(0)]) 16 + .split(area); 17 + 18 + f.render_widget( 19 + Paragraph::new("") 20 + .style(Style::default().bg(theme.ui.toc_bg)) 21 + .block( 22 + Block::default() 23 + .borders(Borders::RIGHT | Borders::BOTTOM) 24 + .border_style(Style::default().fg(theme.ui.toc_border)) 25 + .style(Style::default().bg(theme.ui.toc_bg)), 26 + ), 27 + toc_chunks[0], 28 + ); 29 + f.render_widget( 30 + Paragraph::new(app.toc_display_lines().to_vec()) 31 + .style(Style::default().bg(theme.ui.toc_bg)) 32 + .block( 33 + Block::default() 34 + .borders(Borders::RIGHT) 35 + .border_style(Style::default().fg(theme.ui.toc_border)) 36 + .style(Style::default().bg(theme.ui.toc_bg)), 37 + ), 38 + toc_chunks[1], 39 + ); 40 + f.render_widget( 41 + Paragraph::new(vec![app.toc_header_line().clone()]) 42 + .style(Style::default().bg(theme.ui.toc_bg)), 43 + Rect { 44 + x: toc_chunks[0].x, 45 + y: toc_chunks[0].y.saturating_add(1), 46 + width: toc_chunks[0].width.saturating_sub(1), 47 + height: 1, 48 + }, 49 + ); 50 + } 51 + 52 + pub(crate) fn toc_header_line() -> Line<'static> { 53 + let theme = app_theme(); 54 + Line::from(vec![Span::styled( 55 + " TABLE OF CONTENTS", 56 + Style::default() 57 + .fg(theme.ui.toc_header_fg) 58 + .bg(theme.ui.toc_bg) 59 + .add_modifier(Modifier::BOLD), 60 + )]) 61 + } 62 + 63 + pub(crate) fn build_toc_line_with_index( 64 + entry: &crate::markdown::toc::TocEntry, 65 + display_level: u8, 66 + top_level_index: Option<usize>, 67 + active: bool, 68 + ) -> Line<'static> { 69 + let theme = app_theme(); 70 + let active_bg = theme.ui.toc_active_bg; 71 + let inactive_bg = theme.ui.toc_inactive_bg; 72 + 73 + match display_level { 74 + 1 => { 75 + let index = top_level_index.unwrap_or(0) + 1; 76 + let title = crate::markdown::truncate_display_width(&entry.title, 18); 77 + let bg = if active { active_bg } else { inactive_bg }; 78 + Line::from(vec![ 79 + Span::styled( 80 + if active { "▎" } else { " " }, 81 + Style::default().fg(theme.ui.toc_accent).bg(bg), 82 + ), 83 + Span::styled(" ", Style::default().bg(bg)), 84 + Span::styled( 85 + format!("{index:02}"), 86 + Style::default() 87 + .fg(if active { 88 + theme.ui.toc_accent 89 + } else { 90 + theme.ui.toc_index_inactive 91 + }) 92 + .bg(bg) 93 + .add_modifier(Modifier::BOLD), 94 + ), 95 + Span::styled(" ", Style::default().bg(bg)), 96 + Span::styled( 97 + title, 98 + Style::default() 99 + .fg(if active { 100 + theme.ui.toc_primary_active 101 + } else { 102 + theme.ui.toc_primary_inactive 103 + }) 104 + .bg(bg) 105 + .add_modifier(Modifier::BOLD), 106 + ), 107 + ]) 108 + } 109 + _ => Line::from(vec![ 110 + Span::styled( 111 + if active { "▎" } else { " " }, 112 + Style::default().fg(theme.ui.toc_accent), 113 + ), 114 + Span::raw(" "), 115 + Span::styled( 116 + "•", 117 + Style::default().fg(if active { 118 + theme.ui.toc_accent 119 + } else { 120 + theme.ui.toc_secondary_inactive 121 + }), 122 + ), 123 + Span::raw(" "), 124 + Span::styled( 125 + crate::markdown::truncate_display_width(&entry.title, 18), 126 + Style::default() 127 + .fg(if active { 128 + theme.ui.toc_secondary_text_active 129 + } else { 130 + theme.ui.toc_secondary_text_inactive 131 + }) 132 + .add_modifier(if active { 133 + Modifier::BOLD 134 + } else { 135 + Modifier::empty() 136 + }), 137 + ), 138 + ]), 139 + } 140 + }