mod.rs
1 //! App state + ratatui rendering. 2 //! 3 //! Screens: 4 //! - Page view: text + images list 5 //! - Search view: input + results + preview 6 7 use crate::{bundle, cache, net, parse, privacy, search, util}; 8 use anyhow::{anyhow, Context}; 9 use crossterm::cursor::{Hide, Show}; 10 use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers}; 11 use crossterm::execute; 12 use crossterm::terminal::{ 13 disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, 14 }; 15 use ratatui::backend::CrosstermBackend; 16 use ratatui::layout::{Constraint, Direction, Layout, Rect}; 17 use ratatui::style::{Color, Modifier, Style}; 18 use ratatui::text::{Line, Span, Text}; 19 use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Wrap}; 20 use ratatui::{Frame, Terminal}; 21 use reqwest::blocking::Client; 22 use std::collections::{HashMap, HashSet}; 23 use std::io::{self, Write}; 24 use std::path::{Path, PathBuf}; 25 use std::time::Duration; 26 use url::Url; 27 28 use base64::engine::general_purpose::STANDARD as BASE64; 29 use base64::Engine; 30 31 const BUNDLES_DIR: &str = "bundles"; 32 const EXPORTS_DIR: &str = "exports"; 33 const TAB_LABEL_MAX: usize = 18; 34 35 /// Options for running the app. 36 pub struct RunOpts { 37 pub start: StartMode, 38 pub download_dir: PathBuf, 39 pub searx_url: String, 40 pub proxy: Option<String>, 41 pub cache_dir: PathBuf, 42 pub cache_ttl_secs: u64, 43 pub allow_domains: Vec<String>, 44 pub deny_domains: Vec<String>, 45 } 46 47 /// Startup mode for the app. 48 pub enum StartMode { 49 Url(Url), 50 Bundle(PathBuf), 51 } 52 53 /// Immutable configuration for the app. 54 pub struct AppConfig { 55 pub download_dir: PathBuf, 56 pub searx_url: String, 57 pub cache: cache::CacheConfig, 58 pub allow_domains: HashSet<String>, 59 pub deny_domains: HashSet<String>, 60 } 61 62 /// Which screen the app is currently showing. 63 #[derive(Debug, Clone, Copy, PartialEq, Eq)] 64 pub enum Screen { 65 Page, 66 Links, 67 Toc, 68 Bookmarks, 69 Address, 70 Find, 71 Palette, 72 Search, 73 } 74 75 /// Search UI focus state. 76 #[derive(Debug, Clone, Copy, PartialEq, Eq)] 77 pub enum SearchFocus { 78 Input, 79 Results, 80 } 81 82 /// Search result row (from a SearXNG-like source). 83 #[derive(Debug, Clone)] 84 pub struct SearchResult { 85 pub index: usize, 86 pub title: String, 87 pub url: Url, 88 pub content: String, 89 pub engine: Option<String>, 90 } 91 92 /// Search UI state. 93 #[derive(Debug, Clone)] 94 pub struct SearchState { 95 pub query: String, 96 pub results: Vec<SearchResult>, 97 pub selected: usize, 98 pub focus: SearchFocus, 99 } 100 101 impl Default for SearchState { 102 fn default() -> Self { 103 Self { 104 query: String::new(), 105 results: Vec::new(), 106 selected: 0, 107 focus: SearchFocus::Input, 108 } 109 } 110 } 111 112 /// Address bar input state. 113 #[derive(Debug, Clone, Default)] 114 pub struct AddressState { 115 pub input: String, 116 } 117 118 /// Wrap strategy for the text pane. 119 #[derive(Debug, Clone, Copy, PartialEq, Eq)] 120 enum WrapMode { 121 Auto, 122 Fixed(usize), 123 } 124 125 /// Image item with optional download path. 126 #[derive(Debug, Clone)] 127 pub struct ImageItem { 128 pub index: usize, 129 pub url: Url, 130 pub alt: String, 131 pub domain: Option<String>, 132 pub third_party: bool, 133 pub downloaded_path: Option<PathBuf>, 134 } 135 136 /// Heading item for table-of-contents navigation. 137 #[derive(Debug, Clone)] 138 pub struct HeadingItem { 139 pub index: usize, 140 pub level: u8, 141 pub text: String, 142 pub line: Option<usize>, 143 } 144 145 /// Link item extracted from the document. 146 #[derive(Debug, Clone)] 147 pub struct LinkItem { 148 pub index: usize, 149 pub url: Url, 150 pub text: String, 151 pub title: Option<String>, 152 } 153 154 /// In-page find state. 155 #[derive(Debug, Clone, Default)] 156 pub struct FindState { 157 pub query: String, 158 pub matches: Vec<usize>, 159 pub selected: usize, 160 } 161 162 /// A saved bookmark. 163 #[derive(Debug, Clone)] 164 pub struct Bookmark { 165 pub title: String, 166 pub url: Url, 167 } 168 169 /// Visual theme for the UI. 170 #[derive(Debug, Clone)] 171 struct Theme { 172 name: &'static str, 173 border: Style, 174 title: Style, 175 highlight: Style, 176 status: Style, 177 tab_active: Style, 178 tab_inactive: Style, 179 tab_bar: Style, 180 hint: Style, 181 } 182 183 /// Palette command identifiers. 184 #[derive(Debug, Clone, Copy, PartialEq, Eq)] 185 enum PaletteCommand { 186 OpenAddress, 187 OpenLinks, 188 OpenToc, 189 OpenBookmarks, 190 OpenFind, 191 OpenSearch, 192 ToggleReader, 193 ToggleWrap, 194 WrapNarrower, 195 WrapWider, 196 SpacingTighter, 197 SpacingLooser, 198 Reload, 199 SaveBundle, 200 ExportMarkdown, 201 AddBookmark, 202 ThemeNext, 203 NewTab, 204 CloseTab, 205 NextTab, 206 PrevTab, 207 DownloadImage, 208 OpenImage, 209 PreviewImage, 210 } 211 212 /// Palette item metadata. 213 #[derive(Debug, Clone, Copy)] 214 struct PaletteItem { 215 command: PaletteCommand, 216 label: &'static str, 217 hint: &'static str, 218 keywords: &'static [&'static str], 219 } 220 221 /// Command palette state. 222 #[derive(Debug, Clone, Default)] 223 struct PaletteState { 224 query: String, 225 filtered: Vec<PaletteItem>, 226 selected: usize, 227 } 228 229 /// Navigation history for back/forward. 230 #[derive(Debug, Clone)] 231 struct History { 232 entries: Vec<Url>, 233 index: usize, 234 } 235 236 impl History { 237 fn new(start: Url) -> Self { 238 Self { 239 entries: vec![start], 240 index: 0, 241 } 242 } 243 244 fn push(&mut self, url: Url) { 245 if self.entries[self.index] == url { 246 return; 247 } 248 self.entries.truncate(self.index + 1); 249 self.entries.push(url); 250 self.index = self.entries.len().saturating_sub(1); 251 } 252 253 fn can_back(&self) -> bool { 254 self.index > 0 255 } 256 257 fn can_forward(&self) -> bool { 258 self.index + 1 < self.entries.len() 259 } 260 261 fn back(&mut self) -> Option<Url> { 262 if self.can_back() { 263 self.index -= 1; 264 Some(self.entries[self.index].clone()) 265 } else { 266 None 267 } 268 } 269 270 fn forward(&mut self) -> Option<Url> { 271 if self.can_forward() { 272 self.index += 1; 273 Some(self.entries[self.index].clone()) 274 } else { 275 None 276 } 277 } 278 } 279 280 /// Details for a renderable error page. 281 #[derive(Debug, Clone)] 282 struct ErrorPage { 283 title: String, 284 message: String, 285 details: Vec<String>, 286 } 287 288 /// Per-tab document state. 289 #[derive(Debug, Clone)] 290 struct TabState { 291 url: Url, 292 title: Option<String>, 293 html: String, 294 article_html: Option<String>, 295 error_page: Option<ErrorPage>, 296 error_html: Option<String>, 297 images: Vec<ImageItem>, 298 links: Vec<LinkItem>, 299 headings: Vec<HeadingItem>, 300 text_lines: Vec<String>, 301 selected_image: usize, 302 selected_link: usize, 303 selected_heading: usize, 304 scroll: usize, 305 history: History, 306 pending_download: Option<usize>, 307 find: FindState, 308 reader_mode: bool, 309 offline_bundle: Option<PathBuf>, 310 } 311 312 /// App state (tabs, shared config, and UI state). 313 pub struct AppState { 314 tabs: Vec<TabState>, 315 active_tab: usize, 316 pub download_dir: PathBuf, 317 pub status: String, 318 pub screen: Screen, 319 pub searx_url: String, 320 pub search: SearchState, 321 pub address: AddressState, 322 pub bookmarks: Vec<Bookmark>, 323 selected_bookmark: usize, 324 palette: PaletteState, 325 themes: Vec<Theme>, 326 theme_index: usize, 327 allow_domains: HashSet<String>, 328 deny_domains: HashSet<String>, 329 cache: cache::CacheConfig, 330 wrap_width: usize, 331 layout_width: usize, 332 wrap_mode: WrapMode, 333 paragraph_spacing: usize, 334 viewport_height: usize, 335 } 336 337 impl TabState { 338 fn new(url: Url) -> Self { 339 Self::new_with_message(url, "(loading...)") 340 } 341 342 fn new_empty(url: Url) -> Self { 343 Self::new_with_message(url, "(new tab)") 344 } 345 346 fn new_with_message(url: Url, message: &str) -> Self { 347 Self { 348 url: url.clone(), 349 title: None, 350 html: String::new(), 351 article_html: None, 352 error_page: None, 353 error_html: None, 354 images: Vec::new(), 355 links: Vec::new(), 356 headings: Vec::new(), 357 text_lines: vec![message.to_string()], 358 selected_image: 0, 359 selected_link: 0, 360 selected_heading: 0, 361 scroll: 0, 362 history: History::new(url), 363 pending_download: None, 364 find: FindState::default(), 365 reader_mode: false, 366 offline_bundle: None, 367 } 368 } 369 } 370 371 impl AppState { 372 pub fn new(url: Url, config: AppConfig) -> Self { 373 Self { 374 tabs: vec![TabState::new(url)], 375 active_tab: 0, 376 download_dir: config.download_dir, 377 status: "Ready.".to_string(), 378 screen: Screen::Page, 379 searx_url: config.searx_url, 380 search: SearchState::default(), 381 address: AddressState::default(), 382 bookmarks: Vec::new(), 383 selected_bookmark: 0, 384 palette: PaletteState::default(), 385 themes: build_themes(), 386 theme_index: 0, 387 allow_domains: config.allow_domains, 388 deny_domains: config.deny_domains, 389 cache: config.cache, 390 wrap_width: 80, 391 layout_width: 80, 392 wrap_mode: WrapMode::Auto, 393 paragraph_spacing: 0, 394 viewport_height: 1, 395 } 396 } 397 398 fn tab(&self) -> &TabState { 399 &self.tabs[self.active_tab] 400 } 401 402 fn tab_mut(&mut self) -> &mut TabState { 403 &mut self.tabs[self.active_tab] 404 } 405 406 fn theme(&self) -> &Theme { 407 &self.themes[self.theme_index] 408 } 409 410 fn cycle_theme(&mut self) { 411 if self.themes.is_empty() { 412 return; 413 } 414 self.theme_index = (self.theme_index + 1) % self.themes.len(); 415 self.status = format!("Theme: {}", self.theme().name); 416 } 417 418 fn new_tab(&mut self) { 419 let seed = self.tab().url.clone(); 420 let tab = TabState::new_empty(seed); 421 self.tabs.push(tab); 422 self.active_tab = self.tabs.len().saturating_sub(1); 423 self.address.input.clear(); 424 self.screen = Screen::Address; 425 self.status = format!( 426 "New tab {}/{}. Enter a URL.", 427 self.active_tab + 1, 428 self.tabs.len() 429 ); 430 } 431 432 fn close_tab(&mut self) { 433 if self.tabs.len() <= 1 { 434 self.status = "Cannot close the last tab.".to_string(); 435 return; 436 } 437 self.tabs.remove(self.active_tab); 438 if self.active_tab >= self.tabs.len() { 439 self.active_tab = self.tabs.len().saturating_sub(1); 440 } 441 self.screen = Screen::Page; 442 self.status = format!( 443 "Closed tab. {}/{} open.", 444 self.active_tab + 1, 445 self.tabs.len() 446 ); 447 } 448 449 fn next_tab(&mut self) { 450 if self.tabs.len() <= 1 { 451 self.status = "Only one tab open.".to_string(); 452 return; 453 } 454 self.active_tab = (self.active_tab + 1) % self.tabs.len(); 455 self.status = format!( 456 "Tab {}/{}: {}", 457 self.active_tab + 1, 458 self.tabs.len(), 459 tab_label(self.tab()) 460 ); 461 } 462 463 fn prev_tab(&mut self) { 464 if self.tabs.len() <= 1 { 465 self.status = "Only one tab open.".to_string(); 466 return; 467 } 468 if self.active_tab == 0 { 469 self.active_tab = self.tabs.len() - 1; 470 } else { 471 self.active_tab -= 1; 472 } 473 self.status = format!( 474 "Tab {}/{}: {}", 475 self.active_tab + 1, 476 self.tabs.len(), 477 tab_label(self.tab()) 478 ); 479 } 480 481 fn open_palette(&mut self) { 482 self.palette.query.clear(); 483 self.refresh_palette(); 484 self.screen = Screen::Palette; 485 self.status = "Command palette · Esc to close".to_string(); 486 } 487 488 fn refresh_palette(&mut self) { 489 let filtered = filter_palette_items(&self.palette.query, palette_items()); 490 self.palette.filtered = filtered; 491 if self.palette.filtered.is_empty() { 492 self.palette.selected = 0; 493 } else { 494 self.palette.selected = self.palette.selected.min(self.palette.filtered.len() - 1); 495 } 496 } 497 498 fn add_bookmark(&mut self) { 499 let url = privacy::strip_tracking_params(&self.tab().url); 500 let title = tab_title(self.tab()); 501 502 if let Some(existing) = self.bookmarks.iter_mut().find(|b| b.url == url) { 503 existing.title = title.clone(); 504 self.status = "Bookmark updated.".to_string(); 505 return; 506 } 507 508 self.bookmarks.push(Bookmark { 509 title: title.clone(), 510 url, 511 }); 512 self.selected_bookmark = self.bookmarks.len().saturating_sub(1); 513 self.status = format!("Bookmark added: {}", title); 514 } 515 516 fn remove_bookmark(&mut self, idx: usize) { 517 if self.bookmarks.is_empty() { 518 self.status = "No bookmarks to remove.".to_string(); 519 return; 520 } 521 if idx >= self.bookmarks.len() { 522 return; 523 } 524 let removed = self.bookmarks.remove(idx); 525 if self.selected_bookmark >= self.bookmarks.len() && !self.bookmarks.is_empty() { 526 self.selected_bookmark = self.bookmarks.len() - 1; 527 } else if self.bookmarks.is_empty() { 528 self.selected_bookmark = 0; 529 } 530 self.status = format!("Removed bookmark: {}", removed.title); 531 } 532 533 /// Ensure the text is wrapped to the current layout width, re-rendering if needed. 534 pub fn ensure_wrap_width(&mut self, width: usize) { 535 let width = width.max(20); 536 self.layout_width = width; 537 let target = match self.wrap_mode { 538 WrapMode::Auto => width, 539 WrapMode::Fixed(fixed) => fixed.min(width), 540 }; 541 if self.wrap_width != target { 542 self.wrap_width = target; 543 self.rebuild_render_state(); 544 } 545 } 546 547 /// Update the viewport height (used for page scroll calculations). 548 pub fn set_viewport_height(&mut self, height: usize) { 549 self.viewport_height = height.max(1); 550 self.clamp_scroll(); 551 } 552 553 /// Reload the current URL and rebuild derived state. 554 pub fn reload(&mut self, client: &Client) -> anyhow::Result<()> { 555 let offline = self.tab().offline_bundle.clone(); 556 if let Some(path) = offline { 557 return self.reload_bundle(&path); 558 } 559 let url = self.tab().url.clone(); 560 match cache::fetch_html_cached(client, &self.cache, &url) { 561 Ok((html, status)) => { 562 self.apply_html(html); 563 let cache_note = match status { 564 cache::CacheStatus::Hit => " (cached)", 565 cache::CacheStatus::Miss => "", 566 }; 567 let tab = self.tab(); 568 self.status = format!( 569 "Loaded {} ({} images · {} links){}", 570 tab.url, 571 tab.images.len(), 572 tab.links.len(), 573 cache_note 574 ); 575 Ok(()) 576 } 577 Err(err) => { 578 self.set_error_page("Load error", &err); 579 Err(err) 580 } 581 } 582 } 583 584 fn max_scroll(&self) -> usize { 585 self.tab() 586 .text_lines 587 .len() 588 .saturating_sub(self.viewport_height) 589 } 590 591 fn clamp_scroll(&mut self) { 592 let max = self.max_scroll(); 593 let tab = self.tab_mut(); 594 if tab.scroll > max { 595 tab.scroll = max; 596 } 597 } 598 599 fn apply_html(&mut self, html: String) { 600 let url = self.tab().url.clone(); 601 let article_html = parse::extract_readable_html(&html); 602 let title = parse::extract_title(&html); 603 604 let imgs = parse::extract_images(&html, &url); 605 let images = imgs 606 .into_iter() 607 .map(|img| { 608 let domain = privacy::domain_for_url(&img.url); 609 let third_party = privacy::is_third_party(&url, &img.url); 610 ImageItem { 611 index: img.index, 612 url: img.url, 613 alt: img.alt, 614 domain, 615 third_party, 616 downloaded_path: None, 617 } 618 }) 619 .collect(); 620 621 let links = parse::extract_links(&html, &url); 622 let links = links 623 .into_iter() 624 .map(|link| LinkItem { 625 index: link.index, 626 url: privacy::strip_tracking_params(&link.url), 627 text: link.text, 628 title: link.title, 629 }) 630 .collect(); 631 632 { 633 let tab = self.tab_mut(); 634 tab.error_page = None; 635 tab.error_html = None; 636 tab.html = html; 637 tab.article_html = article_html; 638 tab.title = title; 639 tab.images = images; 640 tab.links = links; 641 tab.find = FindState::default(); 642 tab.pending_download = None; 643 tab.selected_image = 0; 644 tab.selected_link = 0; 645 tab.selected_heading = 0; 646 tab.scroll = 0; 647 } 648 649 self.rebuild_render_state(); 650 } 651 652 fn apply_bundle(&mut self, bundle: bundle::BundleContents, path: Option<PathBuf>) { 653 { 654 let tab = self.tab_mut(); 655 tab.offline_bundle = path; 656 tab.url = bundle.url; 657 tab.history = History::new(tab.url.clone()); 658 } 659 self.apply_html(bundle.html); 660 self.apply_bundle_images(bundle.images); 661 self.status = "Loaded bundle (offline)".to_string(); 662 } 663 664 fn apply_bundle_images(&mut self, images: Vec<bundle::BundleImage>) { 665 if images.is_empty() { 666 return; 667 } 668 let mut map = HashMap::new(); 669 for img in images { 670 map.insert(img.url.to_string(), (img.path, img.alt)); 671 } 672 let tab = self.tab_mut(); 673 for image in &mut tab.images { 674 if let Some((path, alt)) = map.get(&image.url.to_string()) { 675 image.downloaded_path = Some(path.clone()); 676 if image.alt.trim().is_empty() && !alt.trim().is_empty() { 677 image.alt = alt.clone(); 678 } 679 } 680 } 681 } 682 683 fn reload_bundle(&mut self, path: &Path) -> anyhow::Result<()> { 684 match bundle::load_bundle(path) { 685 Ok(bundle) => { 686 self.apply_bundle(bundle, Some(path.to_path_buf())); 687 Ok(()) 688 } 689 Err(err) => { 690 self.set_error_page("Bundle load error", &err); 691 Err(err) 692 } 693 } 694 } 695 696 fn save_bundle(&mut self) -> anyhow::Result<()> { 697 let html = self.current_html(); 698 let inputs: Vec<bundle::BundleImageInput<'_>> = self 699 .tab() 700 .images 701 .iter() 702 .filter_map(|img| { 703 img.downloaded_path 704 .as_ref() 705 .map(|path| bundle::BundleImageInput { 706 url: &img.url, 707 alt: img.alt.as_str(), 708 path, 709 }) 710 }) 711 .collect(); 712 713 let url = &self.tab().url; 714 let dir = bundle::save_bundle(Path::new(BUNDLES_DIR), url, html, &inputs)?; 715 self.status = format!("Saved bundle to {}", dir.display()); 716 Ok(()) 717 } 718 719 fn export_markdown(&mut self) -> anyhow::Result<()> { 720 let html = self.current_html(); 721 let slug = timestamped_slug(&self.tab().url); 722 let dir = Path::new(EXPORTS_DIR); 723 std::fs::create_dir_all(dir)?; 724 let path = dir.join(format!("{slug}.md")); 725 bundle::export_markdown(&path, html)?; 726 self.status = format!("Exported Markdown to {}", path.display()); 727 Ok(()) 728 } 729 730 fn set_error_page(&mut self, title: &str, err: &anyhow::Error) { 731 let details = err 732 .chain() 733 .skip(1) 734 .map(|e| e.to_string()) 735 .collect::<Vec<_>>(); 736 let page = ErrorPage { 737 title: title.to_string(), 738 message: err.to_string(), 739 details, 740 }; 741 { 742 let tab = self.tab_mut(); 743 tab.error_page = Some(page.clone()); 744 tab.error_html = Some(error_page_html(&page)); 745 tab.html.clear(); 746 tab.article_html = None; 747 tab.title = Some(page.title.clone()); 748 tab.images.clear(); 749 tab.links.clear(); 750 tab.selected_image = 0; 751 tab.selected_link = 0; 752 tab.selected_heading = 0; 753 tab.pending_download = None; 754 tab.scroll = 0; 755 } 756 self.rebuild_render_state(); 757 self.status = format!("{title}: {err}"); 758 } 759 760 fn current_html(&self) -> &str { 761 let tab = self.tab(); 762 if let Some(html) = &tab.error_html { 763 return html; 764 } 765 if tab.reader_mode { 766 if let Some(article) = &tab.article_html { 767 return article; 768 } 769 } 770 &tab.html 771 } 772 773 fn rebuild_render_state(&mut self) { 774 let html = self.current_html().to_string(); 775 let lines = parse::render_text_lines(&html, self.wrap_width.max(20)); 776 let lines = apply_paragraph_spacing(lines, self.paragraph_spacing); 777 self.tab_mut().text_lines = lines; 778 self.rebuild_headings(&html); 779 self.rebuild_find_matches(); 780 self.clamp_scroll(); 781 } 782 783 fn rebuild_headings(&mut self, html: &str) { 784 let prev = self.tab().selected_heading; 785 let mut headings: Vec<HeadingItem> = parse::extract_headings(html) 786 .into_iter() 787 .enumerate() 788 .map(|(idx, h)| HeadingItem { 789 index: idx + 1, 790 level: h.level, 791 text: h.text, 792 line: None, 793 }) 794 .collect(); 795 { 796 let tab = self.tab(); 797 assign_heading_lines(&tab.text_lines, &mut headings); 798 } 799 let tab = self.tab_mut(); 800 tab.headings = headings; 801 tab.selected_heading = if tab.headings.is_empty() { 802 0 803 } else { 804 prev.min(tab.headings.len() - 1) 805 }; 806 } 807 808 fn rebuild_find_matches(&mut self) { 809 let (prev, query, lines) = { 810 let tab = self.tab(); 811 ( 812 tab.find.selected, 813 tab.find.query.clone(), 814 tab.text_lines.clone(), 815 ) 816 }; 817 let matches = build_find_matches(&lines, &query); 818 let tab = self.tab_mut(); 819 tab.find.matches = matches; 820 tab.find.selected = if tab.find.matches.is_empty() { 821 0 822 } else { 823 prev.min(tab.find.matches.len() - 1) 824 }; 825 } 826 827 fn toggle_reader_mode(&mut self) { 828 let (reader_mode, has_article) = { 829 let tab = self.tab_mut(); 830 tab.reader_mode = !tab.reader_mode; 831 (tab.reader_mode, tab.article_html.is_some()) 832 }; 833 if reader_mode && !has_article { 834 self.status = "Reader mode on (no extractable article)".to_string(); 835 } else if reader_mode { 836 self.status = "Reader mode on".to_string(); 837 } else { 838 self.status = "Reader mode off".to_string(); 839 } 840 self.rebuild_render_state(); 841 } 842 843 fn toggle_wrap_mode(&mut self) { 844 self.wrap_mode = match self.wrap_mode { 845 WrapMode::Auto => WrapMode::Fixed(self.wrap_width), 846 WrapMode::Fixed(_) => WrapMode::Auto, 847 }; 848 let target = match self.wrap_mode { 849 WrapMode::Auto => self.layout_width, 850 WrapMode::Fixed(fixed) => fixed.min(self.layout_width), 851 }; 852 self.wrap_width = target; 853 self.rebuild_render_state(); 854 self.status = match self.wrap_mode { 855 WrapMode::Auto => "Wrap: auto".to_string(), 856 WrapMode::Fixed(_) => format!("Wrap: fixed {}", self.wrap_width), 857 }; 858 } 859 860 fn adjust_wrap_width(&mut self, delta: i32) { 861 const MIN_WIDTH: usize = 40; 862 const MAX_WIDTH: usize = 81; 863 let base = match self.wrap_mode { 864 WrapMode::Auto => self.wrap_width, 865 WrapMode::Fixed(w) => w, 866 }; 867 let mut next = (base as i32 + delta).clamp(MIN_WIDTH as i32, MAX_WIDTH as i32) as usize; 868 if next > self.layout_width { 869 next = self.layout_width; 870 } 871 self.wrap_mode = WrapMode::Fixed(next); 872 self.wrap_width = next; 873 self.rebuild_render_state(); 874 self.status = format!("Wrap: fixed {}", self.wrap_width); 875 } 876 877 fn adjust_paragraph_spacing(&mut self, delta: i32) { 878 let next = (self.paragraph_spacing as i32 + delta).clamp(0, 2) as usize; 879 if next == self.paragraph_spacing { 880 return; 881 } 882 self.paragraph_spacing = next; 883 self.rebuild_render_state(); 884 self.status = format!("Paragraph spacing: {}", self.paragraph_spacing); 885 } 886 } 887 888 struct TerminalGuard; 889 890 impl Drop for TerminalGuard { 891 fn drop(&mut self) { 892 let _ = disable_raw_mode(); 893 let mut stdout = io::stdout(); 894 let _ = execute!(stdout, Show, LeaveAlternateScreen); 895 } 896 } 897 898 /// Run the app event loop. 899 pub fn run(opts: RunOpts) -> anyhow::Result<()> { 900 let client = net::build_client(opts.proxy.as_deref())?; 901 let config = AppConfig { 902 download_dir: opts.download_dir, 903 searx_url: opts.searx_url, 904 cache: cache::CacheConfig::new(opts.cache_dir, opts.cache_ttl_secs), 905 allow_domains: build_domain_set(&opts.allow_domains), 906 deny_domains: build_domain_set(&opts.deny_domains), 907 }; 908 909 let mut app = match opts.start { 910 StartMode::Url(url) => AppState::new(url, config), 911 StartMode::Bundle(path) => { 912 let bundle = bundle::load_bundle(&path)?; 913 let mut app = AppState::new(bundle.url.clone(), config); 914 app.apply_bundle(bundle, Some(path)); 915 app 916 } 917 }; 918 919 if app.tab().offline_bundle.is_none() { 920 app.status = format!("Loading {}", app.tab().url); 921 } 922 923 enable_raw_mode()?; 924 let _guard = TerminalGuard; 925 let mut stdout = io::stdout(); 926 execute!(stdout, EnterAlternateScreen, Hide)?; 927 928 let backend = CrosstermBackend::new(stdout); 929 let mut terminal = Terminal::new(backend)?; 930 terminal.clear()?; 931 932 let _ = app.reload(&client); 933 934 loop { 935 terminal.draw(|f| draw(f, &mut app))?; 936 937 if event::poll(Duration::from_millis(200))? { 938 match event::read()? { 939 Event::Key(key) if key.kind == KeyEventKind::Press => { 940 if key.code == KeyCode::Char('c') 941 && key.modifiers.contains(KeyModifiers::CONTROL) 942 { 943 break; 944 } 945 if key.code == KeyCode::Char('q') { 946 break; 947 } 948 949 if handle_global_key(&mut app, &client, key) { 950 continue; 951 } 952 953 match app.screen { 954 Screen::Page => handle_page_key(&mut app, &client, key), 955 Screen::Links => handle_links_key(&mut app, &client, key), 956 Screen::Toc => handle_toc_key(&mut app, key), 957 Screen::Bookmarks => handle_bookmarks_key(&mut app, &client, key), 958 Screen::Address => handle_address_key(&mut app, &client, key), 959 Screen::Find => handle_find_key(&mut app, key), 960 Screen::Palette => handle_palette_key(&mut app, &client, key), 961 Screen::Search => handle_search_key(&mut app, &client, key), 962 } 963 } 964 Event::Resize(_, _) => {} 965 _ => {} 966 } 967 } 968 } 969 970 Ok(()) 971 } 972 973 /// Download the currently selected image (if any). 974 fn download_selected(app: &mut AppState, client: &Client) { 975 let (idx, image) = { 976 let tab = app.tab(); 977 if tab.images.is_empty() { 978 app.status = "No images to download.".to_string(); 979 return; 980 } 981 let idx = tab.selected_image.min(tab.images.len() - 1); 982 (idx, tab.images[idx].clone()) 983 }; 984 985 if image.downloaded_path.is_some() { 986 app.status = "Image already downloaded.".to_string(); 987 return; 988 } 989 if app.tab().offline_bundle.is_some() { 990 app.status = "Offline bundle: downloads disabled.".to_string(); 991 return; 992 } 993 994 let policy = privacy::domain_policy( 995 image.domain.as_deref(), 996 image.third_party, 997 &app.allow_domains, 998 &app.deny_domains, 999 ); 1000 match policy { 1001 privacy::DomainPolicy::Deny => { 1002 app.tab_mut().pending_download = None; 1003 app.status = format!( 1004 "Download blocked for domain {}.", 1005 image.domain.as_deref().unwrap_or("(unknown)") 1006 ); 1007 return; 1008 } 1009 privacy::DomainPolicy::Confirm => { 1010 let pending = app.tab().pending_download; 1011 if pending == Some(idx) { 1012 app.tab_mut().pending_download = None; 1013 } else { 1014 app.tab_mut().pending_download = Some(idx); 1015 app.status = format!( 1016 "Third-party image from {}. Press d again to confirm.", 1017 image.domain.as_deref().unwrap_or("(unknown)") 1018 ); 1019 return; 1020 } 1021 } 1022 privacy::DomainPolicy::Allow => { 1023 app.tab_mut().pending_download = None; 1024 } 1025 } 1026 1027 let url = image.url.clone(); 1028 let fallback = format!("image_{}", image.index); 1029 1030 match cache::try_get_cached_image(&app.cache, &url, &app.download_dir, &fallback) { 1031 Ok(Some(result)) => { 1032 if let Some(img) = app.tab_mut().images.get_mut(idx) { 1033 img.downloaded_path = Some(result.path.clone()); 1034 } 1035 app.status = format!( 1036 "Used cached image {} ({})", 1037 result.path.display(), 1038 util::human_bytes(result.size_bytes) 1039 ); 1040 return; 1041 } 1042 Ok(None) => {} 1043 Err(err) => { 1044 app.status = format!("Cache lookup failed: {err}"); 1045 } 1046 } 1047 1048 match net::download_to_dir(client, &url, &app.download_dir, &fallback) { 1049 Ok(result) => { 1050 let _ = cache::record_image_download(&app.cache, &url, &result); 1051 if let Some(img) = app.tab_mut().images.get_mut(idx) { 1052 img.downloaded_path = Some(result.path.clone()); 1053 } 1054 app.status = format!( 1055 "Downloaded {} ({})", 1056 result.path.display(), 1057 util::human_bytes(result.size_bytes) 1058 ); 1059 } 1060 Err(err) => { 1061 app.status = format!("Download failed: {err}"); 1062 } 1063 } 1064 } 1065 1066 /// Open the currently selected downloaded image (if any). 1067 fn open_selected(app: &mut AppState) { 1068 let path = { 1069 let tab = app.tab(); 1070 if tab.images.is_empty() { 1071 app.status = "No images to open.".to_string(); 1072 return; 1073 } 1074 let idx = tab.selected_image.min(tab.images.len() - 1); 1075 match &tab.images[idx].downloaded_path { 1076 Some(p) => p.clone(), 1077 None => { 1078 app.status = "Image not downloaded.".to_string(); 1079 return; 1080 } 1081 } 1082 }; 1083 1084 match open::that(&path) { 1085 Ok(_) => { 1086 app.status = format!("Opened {}", path.display()); 1087 } 1088 Err(err) => { 1089 app.status = format!("Open failed: {err}"); 1090 } 1091 } 1092 } 1093 1094 /// Preview the currently selected image inline (terminal-dependent). 1095 fn preview_selected(app: &mut AppState) { 1096 let path = { 1097 let tab = app.tab(); 1098 if tab.images.is_empty() { 1099 app.status = "No images to preview.".to_string(); 1100 return; 1101 } 1102 let idx = tab.selected_image.min(tab.images.len() - 1); 1103 match &tab.images[idx].downloaded_path { 1104 Some(p) => p.clone(), 1105 None => { 1106 app.status = "Image not downloaded.".to_string(); 1107 return; 1108 } 1109 } 1110 }; 1111 1112 match inline_preview_support() { 1113 InlinePreviewSupport::Iterm => match std::fs::read(&path) { 1114 Ok(bytes) => { 1115 let name = path.file_name().and_then(|s| s.to_str()).unwrap_or("image"); 1116 let seq = build_iterm_inline_image(name, &bytes); 1117 let mut stdout = io::stdout(); 1118 if let Err(err) = stdout.write_all(seq.as_bytes()) { 1119 app.status = format!("Inline preview failed: {err}"); 1120 return; 1121 } 1122 let _ = stdout.write_all(b"\n"); 1123 let _ = stdout.flush(); 1124 app.status = format!("Inline preview sent for {}", path.display()); 1125 } 1126 Err(err) => { 1127 app.status = format!("Inline preview failed: {err}"); 1128 } 1129 }, 1130 InlinePreviewSupport::Unsupported => { 1131 app.status = "Inline preview supported only in iTerm-compatible terminals.".to_string(); 1132 } 1133 } 1134 } 1135 1136 #[derive(Debug, Clone, Copy, PartialEq, Eq)] 1137 enum InlinePreviewSupport { 1138 Iterm, 1139 Unsupported, 1140 } 1141 1142 fn inline_preview_support() -> InlinePreviewSupport { 1143 let term_program = std::env::var("TERM_PROGRAM").ok(); 1144 detect_inline_preview_support(term_program.as_deref()) 1145 } 1146 1147 fn detect_inline_preview_support(term_program: Option<&str>) -> InlinePreviewSupport { 1148 match term_program { 1149 Some("iTerm.app") | Some("WezTerm") => InlinePreviewSupport::Iterm, 1150 _ => InlinePreviewSupport::Unsupported, 1151 } 1152 } 1153 1154 fn build_iterm_inline_image(name: &str, bytes: &[u8]) -> String { 1155 let name_b64 = BASE64.encode(name.as_bytes()); 1156 let data_b64 = BASE64.encode(bytes); 1157 format!( 1158 "\u{1b}]1337;File=name={};size={};inline=1:{}\u{7}", 1159 name_b64, 1160 bytes.len(), 1161 data_b64 1162 ) 1163 } 1164 1165 /// Normalize a user-provided URL, defaulting to `https://` if missing. 1166 fn normalize_user_url(input: &str) -> anyhow::Result<Url> { 1167 let trimmed = input.trim(); 1168 if trimmed.is_empty() { 1169 return Err(anyhow!("URL is empty")); 1170 } 1171 if let Ok(url) = Url::parse(trimmed) { 1172 return Ok(url); 1173 } 1174 let with_scheme = format!("https://{trimmed}"); 1175 Url::parse(&with_scheme).with_context(|| format!("Invalid URL: {trimmed}")) 1176 } 1177 1178 /// Render an error page into HTML so it can reuse the text renderer. 1179 fn error_page_html(page: &ErrorPage) -> String { 1180 let mut html = String::new(); 1181 html.push_str("<h1>"); 1182 html.push_str(&escape_html(&page.title)); 1183 html.push_str("</h1>"); 1184 html.push_str("<p>"); 1185 html.push_str(&escape_html(&page.message)); 1186 html.push_str("</p>"); 1187 if !page.details.is_empty() { 1188 html.push_str("<pre>"); 1189 for line in &page.details { 1190 html.push_str(&escape_html(line)); 1191 html.push('\n'); 1192 } 1193 html.push_str("</pre>"); 1194 } 1195 html 1196 } 1197 1198 /// Escape text for HTML rendering. 1199 fn escape_html(input: &str) -> String { 1200 let mut out = String::with_capacity(input.len()); 1201 for ch in input.chars() { 1202 match ch { 1203 '&' => out.push_str("&"), 1204 '<' => out.push_str("<"), 1205 '>' => out.push_str(">"), 1206 '"' => out.push_str("""), 1207 '\'' => out.push_str("'"), 1208 _ => out.push(ch), 1209 } 1210 } 1211 out 1212 } 1213 1214 /// Convert search hits into UI results, reindexing sequentially. 1215 fn map_search_hits(hits: Vec<search::SearchHit>) -> Vec<SearchResult> { 1216 hits.into_iter() 1217 .enumerate() 1218 .map(|(idx, hit)| SearchResult { 1219 index: idx + 1, 1220 title: hit.title, 1221 url: privacy::strip_tracking_params(&hit.url), 1222 content: hit.content, 1223 engine: hit.engine, 1224 }) 1225 .collect() 1226 } 1227 1228 fn build_themes() -> Vec<Theme> { 1229 vec![ 1230 Theme { 1231 name: "Mono", 1232 border: Style::default().fg(Color::Gray), 1233 title: Style::default() 1234 .fg(Color::White) 1235 .add_modifier(Modifier::BOLD), 1236 highlight: Style::default().fg(Color::Black).bg(Color::White), 1237 status: Style::default().fg(Color::White), 1238 tab_active: Style::default() 1239 .fg(Color::Black) 1240 .bg(Color::White) 1241 .add_modifier(Modifier::BOLD), 1242 tab_inactive: Style::default().fg(Color::Gray), 1243 tab_bar: Style::default().fg(Color::Gray), 1244 hint: Style::default().fg(Color::DarkGray), 1245 }, 1246 Theme { 1247 name: "Amber", 1248 border: Style::default().fg(Color::Yellow), 1249 title: Style::default() 1250 .fg(Color::LightYellow) 1251 .add_modifier(Modifier::BOLD), 1252 highlight: Style::default().fg(Color::Black).bg(Color::Yellow), 1253 status: Style::default().fg(Color::LightYellow), 1254 tab_active: Style::default() 1255 .fg(Color::Black) 1256 .bg(Color::Yellow) 1257 .add_modifier(Modifier::BOLD), 1258 tab_inactive: Style::default().fg(Color::DarkGray), 1259 tab_bar: Style::default().fg(Color::Yellow), 1260 hint: Style::default().fg(Color::LightYellow), 1261 }, 1262 Theme { 1263 name: "Ocean", 1264 border: Style::default().fg(Color::Cyan), 1265 title: Style::default() 1266 .fg(Color::LightCyan) 1267 .add_modifier(Modifier::BOLD), 1268 highlight: Style::default().fg(Color::Black).bg(Color::Cyan), 1269 status: Style::default().fg(Color::LightCyan), 1270 tab_active: Style::default() 1271 .fg(Color::Black) 1272 .bg(Color::Cyan) 1273 .add_modifier(Modifier::BOLD), 1274 tab_inactive: Style::default().fg(Color::Blue), 1275 tab_bar: Style::default().fg(Color::Cyan), 1276 hint: Style::default().fg(Color::LightBlue), 1277 }, 1278 ] 1279 } 1280 1281 const PALETTE_ITEMS: &[PaletteItem] = &[ 1282 PaletteItem { 1283 command: PaletteCommand::OpenAddress, 1284 label: "Open address bar", 1285 hint: "g", 1286 keywords: &["url", "address", "go"], 1287 }, 1288 PaletteItem { 1289 command: PaletteCommand::OpenLinks, 1290 label: "Open links list", 1291 hint: "l", 1292 keywords: &["links", "navigate"], 1293 }, 1294 PaletteItem { 1295 command: PaletteCommand::OpenToc, 1296 label: "Open table of contents", 1297 hint: "t", 1298 keywords: &["headings", "toc"], 1299 }, 1300 PaletteItem { 1301 command: PaletteCommand::OpenBookmarks, 1302 label: "Open bookmarks", 1303 hint: "B", 1304 keywords: &["bookmarks", "marks"], 1305 }, 1306 PaletteItem { 1307 command: PaletteCommand::OpenFind, 1308 label: "Find in page", 1309 hint: "?", 1310 keywords: &["find", "search"], 1311 }, 1312 PaletteItem { 1313 command: PaletteCommand::OpenSearch, 1314 label: "Search the web", 1315 hint: "/", 1316 keywords: &["search", "searx"], 1317 }, 1318 PaletteItem { 1319 command: PaletteCommand::ToggleReader, 1320 label: "Toggle reader mode", 1321 hint: "m", 1322 keywords: &["reader", "article"], 1323 }, 1324 PaletteItem { 1325 command: PaletteCommand::ToggleWrap, 1326 label: "Toggle wrap mode", 1327 hint: "w", 1328 keywords: &["wrap", "width"], 1329 }, 1330 PaletteItem { 1331 command: PaletteCommand::WrapNarrower, 1332 label: "Narrower wrap width", 1333 hint: "[", 1334 keywords: &["wrap", "width", "narrow"], 1335 }, 1336 PaletteItem { 1337 command: PaletteCommand::WrapWider, 1338 label: "Wider wrap width", 1339 hint: "]", 1340 keywords: &["wrap", "width", "wide"], 1341 }, 1342 PaletteItem { 1343 command: PaletteCommand::SpacingTighter, 1344 label: "Tighter paragraph spacing", 1345 hint: "-", 1346 keywords: &["spacing", "paragraph"], 1347 }, 1348 PaletteItem { 1349 command: PaletteCommand::SpacingLooser, 1350 label: "Looser paragraph spacing", 1351 hint: "=", 1352 keywords: &["spacing", "paragraph"], 1353 }, 1354 PaletteItem { 1355 command: PaletteCommand::Reload, 1356 label: "Reload page", 1357 hint: "r", 1358 keywords: &["reload", "refresh"], 1359 }, 1360 PaletteItem { 1361 command: PaletteCommand::SaveBundle, 1362 label: "Save offline bundle", 1363 hint: "s", 1364 keywords: &["bundle", "offline", "save"], 1365 }, 1366 PaletteItem { 1367 command: PaletteCommand::ExportMarkdown, 1368 label: "Export Markdown", 1369 hint: "e", 1370 keywords: &["export", "markdown"], 1371 }, 1372 PaletteItem { 1373 command: PaletteCommand::AddBookmark, 1374 label: "Add bookmark", 1375 hint: "a", 1376 keywords: &["bookmark", "save"], 1377 }, 1378 PaletteItem { 1379 command: PaletteCommand::ThemeNext, 1380 label: "Cycle theme", 1381 hint: "T", 1382 keywords: &["theme", "style"], 1383 }, 1384 PaletteItem { 1385 command: PaletteCommand::NewTab, 1386 label: "New tab", 1387 hint: "Ctrl+t", 1388 keywords: &["tab", "new"], 1389 }, 1390 PaletteItem { 1391 command: PaletteCommand::CloseTab, 1392 label: "Close tab", 1393 hint: "Ctrl+w", 1394 keywords: &["tab", "close"], 1395 }, 1396 PaletteItem { 1397 command: PaletteCommand::NextTab, 1398 label: "Next tab", 1399 hint: "Tab", 1400 keywords: &["tab", "next"], 1401 }, 1402 PaletteItem { 1403 command: PaletteCommand::PrevTab, 1404 label: "Previous tab", 1405 hint: "Shift+Tab", 1406 keywords: &["tab", "previous", "prev"], 1407 }, 1408 PaletteItem { 1409 command: PaletteCommand::DownloadImage, 1410 label: "Download image", 1411 hint: "d", 1412 keywords: &["image", "download"], 1413 }, 1414 PaletteItem { 1415 command: PaletteCommand::OpenImage, 1416 label: "Open downloaded image", 1417 hint: "o", 1418 keywords: &["image", "open"], 1419 }, 1420 PaletteItem { 1421 command: PaletteCommand::PreviewImage, 1422 label: "Inline image preview", 1423 hint: "p", 1424 keywords: &["image", "preview", "inline"], 1425 }, 1426 ]; 1427 1428 fn palette_items() -> &'static [PaletteItem] { 1429 PALETTE_ITEMS 1430 } 1431 1432 fn filter_palette_items(query: &str, items: &[PaletteItem]) -> Vec<PaletteItem> { 1433 let terms: Vec<String> = query 1434 .trim() 1435 .to_ascii_lowercase() 1436 .split_whitespace() 1437 .map(|s| s.to_string()) 1438 .collect(); 1439 if terms.is_empty() { 1440 return items.to_vec(); 1441 } 1442 items 1443 .iter() 1444 .copied() 1445 .filter(|item| palette_item_matches(item, &terms)) 1446 .collect() 1447 } 1448 1449 fn palette_item_matches(item: &PaletteItem, terms: &[String]) -> bool { 1450 let mut haystack = String::new(); 1451 haystack.push_str(item.label); 1452 haystack.push(' '); 1453 haystack.push_str(item.hint); 1454 let haystack = haystack.to_ascii_lowercase(); 1455 1456 terms.iter().all(|term| { 1457 haystack.contains(term) 1458 || item 1459 .keywords 1460 .iter() 1461 .any(|kw| kw.to_ascii_lowercase().contains(term)) 1462 }) 1463 } 1464 1465 fn tab_title(tab: &TabState) -> String { 1466 if let Some(title) = &tab.title { 1467 let trimmed = title.trim(); 1468 if !trimmed.is_empty() { 1469 return trimmed.to_string(); 1470 } 1471 } 1472 fallback_title(&tab.url) 1473 } 1474 1475 fn tab_label(tab: &TabState) -> String { 1476 let title = tab_title(tab); 1477 truncate_label(&title, TAB_LABEL_MAX) 1478 } 1479 1480 fn fallback_title(url: &Url) -> String { 1481 url.host_str() 1482 .map(|s| s.to_string()) 1483 .unwrap_or_else(|| url.as_str().to_string()) 1484 } 1485 1486 fn truncate_label(input: &str, max: usize) -> String { 1487 let count = input.chars().count(); 1488 if count <= max { 1489 return input.to_string(); 1490 } 1491 if max <= 3 { 1492 return input.chars().take(max).collect(); 1493 } 1494 let take = max - 3; 1495 let prefix: String = input.chars().take(take).collect(); 1496 format!("{prefix}...") 1497 } 1498 1499 fn timestamped_slug(url: &Url) -> String { 1500 let host = url.host_str().unwrap_or("page"); 1501 let slug = util::sanitize_filename(host); 1502 let ts = std::time::SystemTime::now() 1503 .duration_since(std::time::UNIX_EPOCH) 1504 .unwrap_or_default() 1505 .as_secs(); 1506 format!("{slug}_{ts}") 1507 } 1508 1509 /// Build a normalized domain set from user input. 1510 fn build_domain_set(inputs: &[String]) -> HashSet<String> { 1511 inputs 1512 .iter() 1513 .filter_map(|s| privacy::parse_domain_input(s)) 1514 .collect() 1515 } 1516 1517 /// Apply extra blank lines after paragraph breaks. 1518 fn apply_paragraph_spacing(lines: Vec<String>, spacing: usize) -> Vec<String> { 1519 if spacing == 0 { 1520 return lines; 1521 } 1522 let mut out = Vec::with_capacity(lines.len() + spacing); 1523 for line in lines { 1524 let is_empty = line.is_empty(); 1525 out.push(line); 1526 if is_empty { 1527 for _ in 0..spacing { 1528 out.push(String::new()); 1529 } 1530 } 1531 } 1532 out 1533 } 1534 1535 /// Assign line numbers to headings by searching the rendered text. 1536 fn assign_heading_lines(lines: &[String], headings: &mut [HeadingItem]) { 1537 let mut start = 0; 1538 for heading in headings { 1539 let target = heading.text.to_ascii_lowercase(); 1540 if target.is_empty() { 1541 continue; 1542 } 1543 let mut found = None; 1544 for (idx, line) in lines.iter().enumerate().skip(start) { 1545 if line.to_ascii_lowercase().contains(&target) { 1546 found = Some(idx); 1547 start = idx + 1; 1548 break; 1549 } 1550 } 1551 heading.line = found; 1552 } 1553 } 1554 1555 /// Build a list of matching line indices for an in-page query. 1556 fn build_find_matches(lines: &[String], query: &str) -> Vec<usize> { 1557 let q = query.trim().to_ascii_lowercase(); 1558 if q.is_empty() { 1559 return Vec::new(); 1560 } 1561 lines 1562 .iter() 1563 .enumerate() 1564 .filter_map(|(idx, line)| { 1565 if line.to_ascii_lowercase().contains(&q) { 1566 Some(idx) 1567 } else { 1568 None 1569 } 1570 }) 1571 .collect() 1572 } 1573 1574 fn handle_global_key(app: &mut AppState, client: &Client, key: event::KeyEvent) -> bool { 1575 let input_screen = matches!( 1576 app.screen, 1577 Screen::Address | Screen::Find | Screen::Search | Screen::Palette 1578 ); 1579 let allow_tab_switch = matches!( 1580 app.screen, 1581 Screen::Page | Screen::Links | Screen::Toc | Screen::Bookmarks 1582 ); 1583 1584 match key.code { 1585 KeyCode::Char('t') if key.modifiers.contains(KeyModifiers::CONTROL) => { 1586 app.new_tab(); 1587 true 1588 } 1589 KeyCode::Char('w') if key.modifiers.contains(KeyModifiers::CONTROL) => { 1590 app.close_tab(); 1591 true 1592 } 1593 KeyCode::Tab if allow_tab_switch => { 1594 app.next_tab(); 1595 true 1596 } 1597 KeyCode::BackTab if allow_tab_switch => { 1598 app.prev_tab(); 1599 true 1600 } 1601 KeyCode::Char('p') if key.modifiers.contains(KeyModifiers::CONTROL) && !input_screen => { 1602 app.open_palette(); 1603 true 1604 } 1605 KeyCode::Char(':') if !input_screen => { 1606 app.open_palette(); 1607 true 1608 } 1609 KeyCode::Char('B') => { 1610 app.screen = Screen::Bookmarks; 1611 app.status = "Bookmarks · Enter open · d delete · Esc back".to_string(); 1612 true 1613 } 1614 KeyCode::Char('T') => { 1615 app.cycle_theme(); 1616 true 1617 } 1618 KeyCode::Char('b') if key.modifiers.contains(KeyModifiers::CONTROL) => { 1619 go_back(app, client); 1620 true 1621 } 1622 KeyCode::Char('f') if key.modifiers.contains(KeyModifiers::CONTROL) => { 1623 go_forward(app, client); 1624 true 1625 } 1626 _ => false, 1627 } 1628 } 1629 1630 /// Handle key events for the page screen. 1631 fn handle_page_key(app: &mut AppState, client: &Client, key: event::KeyEvent) { 1632 match key.code { 1633 KeyCode::Char('j') => { 1634 let max = app.max_scroll(); 1635 let tab = app.tab_mut(); 1636 tab.scroll = (tab.scroll + 1).min(max); 1637 } 1638 KeyCode::Char('k') => { 1639 let tab = app.tab_mut(); 1640 tab.scroll = tab.scroll.saturating_sub(1); 1641 } 1642 KeyCode::PageDown => { 1643 let delta = app.viewport_height.max(1); 1644 let max = app.max_scroll(); 1645 let tab = app.tab_mut(); 1646 tab.scroll = (tab.scroll + delta).min(max); 1647 } 1648 KeyCode::PageUp => { 1649 let delta = app.viewport_height.max(1); 1650 let tab = app.tab_mut(); 1651 tab.scroll = tab.scroll.saturating_sub(delta); 1652 } 1653 KeyCode::Down => { 1654 let tab = app.tab_mut(); 1655 if !tab.images.is_empty() { 1656 tab.selected_image = (tab.selected_image + 1).min(tab.images.len() - 1); 1657 tab.pending_download = None; 1658 } 1659 } 1660 KeyCode::Up => { 1661 let tab = app.tab_mut(); 1662 tab.selected_image = tab.selected_image.saturating_sub(1); 1663 tab.pending_download = None; 1664 } 1665 KeyCode::Char('d') => { 1666 download_selected(app, client); 1667 } 1668 KeyCode::Char('o') => { 1669 open_selected(app); 1670 } 1671 KeyCode::Char('p') => { 1672 preview_selected(app); 1673 } 1674 KeyCode::Char('r') => { 1675 if let Err(err) = app.reload(client) { 1676 app.status = format!("Reload failed: {err}"); 1677 } 1678 } 1679 KeyCode::Char('s') => { 1680 if let Err(err) = app.save_bundle() { 1681 app.status = format!("Save bundle failed: {err}"); 1682 } 1683 } 1684 KeyCode::Char('e') => { 1685 if let Err(err) = app.export_markdown() { 1686 app.status = format!("Export failed: {err}"); 1687 } 1688 } 1689 KeyCode::Char('m') => { 1690 app.toggle_reader_mode(); 1691 } 1692 KeyCode::Char('w') => { 1693 app.toggle_wrap_mode(); 1694 } 1695 KeyCode::Char('[') => { 1696 app.adjust_wrap_width(-4); 1697 } 1698 KeyCode::Char(']') => { 1699 app.adjust_wrap_width(4); 1700 } 1701 KeyCode::Char('-') => { 1702 app.adjust_paragraph_spacing(-1); 1703 } 1704 KeyCode::Char('=') | KeyCode::Char('+') => { 1705 app.adjust_paragraph_spacing(1); 1706 } 1707 KeyCode::Char('t') => { 1708 app.screen = Screen::Toc; 1709 } 1710 KeyCode::Char('b') => { 1711 go_back(app, client); 1712 } 1713 KeyCode::Char('f') => { 1714 go_forward(app, client); 1715 } 1716 KeyCode::Char('l') => { 1717 app.screen = Screen::Links; 1718 } 1719 KeyCode::Char('g') => { 1720 app.address.input = app.tab().url.to_string(); 1721 app.screen = Screen::Address; 1722 } 1723 KeyCode::Char('a') => { 1724 app.add_bookmark(); 1725 } 1726 KeyCode::Char('n') => { 1727 jump_find_match(app, 1); 1728 } 1729 KeyCode::Char('N') => { 1730 jump_find_match(app, -1); 1731 } 1732 KeyCode::Char('?') => { 1733 app.screen = Screen::Find; 1734 } 1735 KeyCode::Char('/') => { 1736 app.screen = Screen::Search; 1737 app.search.focus = SearchFocus::Input; 1738 app.status = "Search · Esc to return".to_string(); 1739 } 1740 _ => {} 1741 } 1742 } 1743 1744 /// Handle key events for the links screen. 1745 fn handle_links_key(app: &mut AppState, client: &Client, key: event::KeyEvent) { 1746 match key.code { 1747 KeyCode::Esc => { 1748 app.screen = Screen::Page; 1749 } 1750 KeyCode::Char('j') | KeyCode::Down => { 1751 let tab = app.tab_mut(); 1752 if !tab.links.is_empty() { 1753 tab.selected_link = (tab.selected_link + 1).min(tab.links.len() - 1); 1754 } 1755 } 1756 KeyCode::Char('k') | KeyCode::Up => { 1757 let tab = app.tab_mut(); 1758 tab.selected_link = tab.selected_link.saturating_sub(1); 1759 } 1760 KeyCode::Enter => { 1761 let link = { 1762 let tab = app.tab(); 1763 tab.links.get(tab.selected_link).cloned() 1764 }; 1765 if let Some(link) = link { 1766 navigate_to(app, client, link.url.clone()); 1767 app.screen = Screen::Page; 1768 } else { 1769 app.status = "No link selected.".to_string(); 1770 } 1771 } 1772 KeyCode::Char('b') => { 1773 go_back(app, client); 1774 } 1775 KeyCode::Char('f') => { 1776 go_forward(app, client); 1777 } 1778 KeyCode::Char('g') => { 1779 app.address.input = app.tab().url.to_string(); 1780 app.screen = Screen::Address; 1781 } 1782 _ => {} 1783 } 1784 } 1785 1786 /// Handle key events for the table-of-contents screen. 1787 fn handle_toc_key(app: &mut AppState, key: event::KeyEvent) { 1788 match key.code { 1789 KeyCode::Esc => { 1790 app.screen = Screen::Page; 1791 } 1792 KeyCode::Char('j') | KeyCode::Down => { 1793 let tab = app.tab_mut(); 1794 if !tab.headings.is_empty() { 1795 tab.selected_heading = (tab.selected_heading + 1).min(tab.headings.len() - 1); 1796 } 1797 } 1798 KeyCode::Char('k') | KeyCode::Up => { 1799 let tab = app.tab_mut(); 1800 tab.selected_heading = tab.selected_heading.saturating_sub(1); 1801 } 1802 KeyCode::Enter => { 1803 let heading = { 1804 let tab = app.tab(); 1805 tab.headings.get(tab.selected_heading).cloned() 1806 }; 1807 if let Some(heading) = heading { 1808 let line = heading.line; 1809 let index = heading.index; 1810 if let Some(line) = line { 1811 app.tab_mut().scroll = line; 1812 app.clamp_scroll(); 1813 app.screen = Screen::Page; 1814 app.status = format!("Jumped to heading {}", index); 1815 } else { 1816 app.status = "Heading not found in text.".to_string(); 1817 } 1818 } else { 1819 app.status = "No heading selected.".to_string(); 1820 } 1821 } 1822 _ => {} 1823 } 1824 } 1825 1826 /// Handle key events for the bookmarks screen. 1827 fn handle_bookmarks_key(app: &mut AppState, client: &Client, key: event::KeyEvent) { 1828 match key.code { 1829 KeyCode::Esc => { 1830 app.screen = Screen::Page; 1831 } 1832 KeyCode::Char('j') | KeyCode::Down => { 1833 if !app.bookmarks.is_empty() { 1834 app.selected_bookmark = (app.selected_bookmark + 1).min(app.bookmarks.len() - 1); 1835 } 1836 } 1837 KeyCode::Char('k') | KeyCode::Up => { 1838 app.selected_bookmark = app.selected_bookmark.saturating_sub(1); 1839 } 1840 KeyCode::Enter => { 1841 if let Some(bm) = app.bookmarks.get(app.selected_bookmark) { 1842 navigate_to(app, client, bm.url.clone()); 1843 app.screen = Screen::Page; 1844 } else { 1845 app.status = "No bookmark selected.".to_string(); 1846 } 1847 } 1848 KeyCode::Char('d') => { 1849 let idx = app.selected_bookmark; 1850 app.remove_bookmark(idx); 1851 } 1852 _ => {} 1853 } 1854 } 1855 1856 /// Handle key events for the address screen. 1857 fn handle_address_key(app: &mut AppState, client: &Client, key: event::KeyEvent) { 1858 match key.code { 1859 KeyCode::Esc => { 1860 app.screen = Screen::Page; 1861 } 1862 KeyCode::Enter => { 1863 let raw = app.address.input.trim(); 1864 if raw.is_empty() { 1865 app.screen = Screen::Page; 1866 return; 1867 } 1868 match normalize_user_url(raw) { 1869 Ok(url) => { 1870 navigate_to(app, client, url); 1871 app.screen = Screen::Page; 1872 } 1873 Err(err) => { 1874 app.set_error_page("Invalid URL", &err); 1875 app.screen = Screen::Page; 1876 } 1877 } 1878 } 1879 KeyCode::Backspace => { 1880 app.address.input.pop(); 1881 } 1882 KeyCode::Char(c) => { 1883 if !key.modifiers.contains(KeyModifiers::CONTROL) { 1884 app.address.input.push(c); 1885 } 1886 } 1887 _ => {} 1888 } 1889 } 1890 1891 /// Handle key events for the in-page find screen. 1892 fn handle_find_key(app: &mut AppState, key: event::KeyEvent) { 1893 match key.code { 1894 KeyCode::Esc => { 1895 app.screen = Screen::Page; 1896 } 1897 KeyCode::Enter => { 1898 perform_find(app); 1899 app.screen = Screen::Page; 1900 } 1901 KeyCode::Backspace => { 1902 app.tab_mut().find.query.pop(); 1903 app.rebuild_find_matches(); 1904 } 1905 KeyCode::Char(c) => { 1906 if !key.modifiers.contains(KeyModifiers::CONTROL) { 1907 app.tab_mut().find.query.push(c); 1908 app.rebuild_find_matches(); 1909 } 1910 } 1911 _ => {} 1912 } 1913 } 1914 1915 /// Navigate to the given URL, updating history and reloading. 1916 fn navigate_to(app: &mut AppState, client: &Client, url: Url) { 1917 let cleaned = privacy::strip_tracking_params(&url); 1918 { 1919 let tab = app.tab_mut(); 1920 tab.url = cleaned.clone(); 1921 tab.history.push(cleaned); 1922 tab.offline_bundle = None; 1923 tab.title = None; 1924 } 1925 let _ = app.reload(client); 1926 } 1927 1928 /// Navigate back in history (if possible). 1929 fn go_back(app: &mut AppState, client: &Client) { 1930 let next = { app.tab_mut().history.back() }; 1931 match next { 1932 Some(url) => { 1933 app.tab_mut().url = url; 1934 let _ = app.reload(client); 1935 } 1936 None => { 1937 app.status = "No back history.".to_string(); 1938 } 1939 } 1940 } 1941 1942 /// Navigate forward in history (if possible). 1943 fn go_forward(app: &mut AppState, client: &Client) { 1944 let next = { app.tab_mut().history.forward() }; 1945 match next { 1946 Some(url) => { 1947 app.tab_mut().url = url; 1948 let _ = app.reload(client); 1949 } 1950 None => { 1951 app.status = "No forward history.".to_string(); 1952 } 1953 } 1954 } 1955 1956 /// Handle key events for the search screen. 1957 fn handle_search_key(app: &mut AppState, client: &Client, key: event::KeyEvent) { 1958 match key.code { 1959 KeyCode::Esc => { 1960 app.screen = Screen::Page; 1961 app.status = "Back to page.".to_string(); 1962 } 1963 KeyCode::Tab => { 1964 app.search.focus = match app.search.focus { 1965 SearchFocus::Input => SearchFocus::Results, 1966 SearchFocus::Results => SearchFocus::Input, 1967 }; 1968 } 1969 KeyCode::Enter => match app.search.focus { 1970 SearchFocus::Input => perform_search(app, client), 1971 SearchFocus::Results => open_search_result(app, client), 1972 }, 1973 KeyCode::Backspace => { 1974 if app.search.focus == SearchFocus::Input { 1975 app.search.query.pop(); 1976 } 1977 } 1978 KeyCode::Up | KeyCode::Char('k') => { 1979 if app.search.focus == SearchFocus::Results { 1980 app.search.selected = app.search.selected.saturating_sub(1); 1981 } 1982 } 1983 KeyCode::Down | KeyCode::Char('j') => { 1984 if app.search.focus == SearchFocus::Results && !app.search.results.is_empty() { 1985 app.search.selected = (app.search.selected + 1).min(app.search.results.len() - 1); 1986 } 1987 } 1988 KeyCode::Char(c) => { 1989 if app.search.focus == SearchFocus::Input 1990 && !key.modifiers.contains(KeyModifiers::CONTROL) 1991 { 1992 app.search.query.push(c); 1993 } 1994 } 1995 _ => {} 1996 } 1997 } 1998 1999 /// Handle key events for the command palette screen. 2000 fn handle_palette_key(app: &mut AppState, client: &Client, key: event::KeyEvent) { 2001 match key.code { 2002 KeyCode::Esc => { 2003 app.screen = Screen::Page; 2004 app.status = "Back to page.".to_string(); 2005 } 2006 KeyCode::Enter => { 2007 if let Some(item) = app.palette.filtered.get(app.palette.selected) { 2008 execute_palette_command(app, client, item.command); 2009 } else { 2010 app.status = "No command selected.".to_string(); 2011 } 2012 } 2013 KeyCode::Backspace => { 2014 app.palette.query.pop(); 2015 app.refresh_palette(); 2016 } 2017 KeyCode::Up | KeyCode::Char('k') => { 2018 app.palette.selected = app.palette.selected.saturating_sub(1); 2019 } 2020 KeyCode::Down | KeyCode::Char('j') => { 2021 if !app.palette.filtered.is_empty() { 2022 app.palette.selected = 2023 (app.palette.selected + 1).min(app.palette.filtered.len() - 1); 2024 } 2025 } 2026 KeyCode::Char(c) => { 2027 if !key.modifiers.contains(KeyModifiers::CONTROL) { 2028 app.palette.query.push(c); 2029 app.refresh_palette(); 2030 } 2031 } 2032 _ => {} 2033 } 2034 } 2035 2036 fn execute_palette_command(app: &mut AppState, client: &Client, command: PaletteCommand) { 2037 match command { 2038 PaletteCommand::OpenAddress => { 2039 app.address.input = app.tab().url.to_string(); 2040 app.screen = Screen::Address; 2041 } 2042 PaletteCommand::OpenLinks => { 2043 app.screen = Screen::Links; 2044 } 2045 PaletteCommand::OpenToc => { 2046 app.screen = Screen::Toc; 2047 } 2048 PaletteCommand::OpenBookmarks => { 2049 app.screen = Screen::Bookmarks; 2050 } 2051 PaletteCommand::OpenFind => { 2052 app.screen = Screen::Find; 2053 } 2054 PaletteCommand::OpenSearch => { 2055 app.screen = Screen::Search; 2056 app.search.focus = SearchFocus::Input; 2057 app.status = "Search · Esc to return".to_string(); 2058 } 2059 PaletteCommand::ToggleReader => { 2060 app.toggle_reader_mode(); 2061 app.screen = Screen::Page; 2062 } 2063 PaletteCommand::ToggleWrap => { 2064 app.toggle_wrap_mode(); 2065 app.screen = Screen::Page; 2066 } 2067 PaletteCommand::WrapNarrower => { 2068 app.adjust_wrap_width(-4); 2069 app.screen = Screen::Page; 2070 } 2071 PaletteCommand::WrapWider => { 2072 app.adjust_wrap_width(4); 2073 app.screen = Screen::Page; 2074 } 2075 PaletteCommand::SpacingTighter => { 2076 app.adjust_paragraph_spacing(-1); 2077 app.screen = Screen::Page; 2078 } 2079 PaletteCommand::SpacingLooser => { 2080 app.adjust_paragraph_spacing(1); 2081 app.screen = Screen::Page; 2082 } 2083 PaletteCommand::Reload => { 2084 if let Err(err) = app.reload(client) { 2085 app.status = format!("Reload failed: {err}"); 2086 } 2087 app.screen = Screen::Page; 2088 } 2089 PaletteCommand::SaveBundle => { 2090 if let Err(err) = app.save_bundle() { 2091 app.status = format!("Save bundle failed: {err}"); 2092 } 2093 app.screen = Screen::Page; 2094 } 2095 PaletteCommand::ExportMarkdown => { 2096 if let Err(err) = app.export_markdown() { 2097 app.status = format!("Export failed: {err}"); 2098 } 2099 app.screen = Screen::Page; 2100 } 2101 PaletteCommand::AddBookmark => { 2102 app.add_bookmark(); 2103 app.screen = Screen::Page; 2104 } 2105 PaletteCommand::ThemeNext => { 2106 app.cycle_theme(); 2107 app.screen = Screen::Page; 2108 } 2109 PaletteCommand::NewTab => { 2110 app.new_tab(); 2111 } 2112 PaletteCommand::CloseTab => { 2113 app.close_tab(); 2114 } 2115 PaletteCommand::NextTab => { 2116 app.next_tab(); 2117 app.screen = Screen::Page; 2118 } 2119 PaletteCommand::PrevTab => { 2120 app.prev_tab(); 2121 app.screen = Screen::Page; 2122 } 2123 PaletteCommand::DownloadImage => { 2124 download_selected(app, client); 2125 app.screen = Screen::Page; 2126 } 2127 PaletteCommand::OpenImage => { 2128 open_selected(app); 2129 app.screen = Screen::Page; 2130 } 2131 PaletteCommand::PreviewImage => { 2132 preview_selected(app); 2133 app.screen = Screen::Page; 2134 } 2135 } 2136 } 2137 2138 /// Perform a SearXNG search and populate UI state. 2139 fn perform_search(app: &mut AppState, client: &Client) { 2140 let query = app.search.query.trim(); 2141 if query.is_empty() { 2142 app.status = "Search query is empty.".to_string(); 2143 return; 2144 } 2145 2146 let base = match Url::parse(app.searx_url.trim()) { 2147 Ok(url) => url, 2148 Err(err) => { 2149 app.status = format!("Invalid SearXNG URL: {err}"); 2150 return; 2151 } 2152 }; 2153 2154 let params = search::SearchParams::new(query); 2155 match search::search(client, &base, params) { 2156 Ok(hits) => { 2157 app.search.results = map_search_hits(hits); 2158 app.search.selected = 0; 2159 app.search.focus = if app.search.results.is_empty() { 2160 SearchFocus::Input 2161 } else { 2162 SearchFocus::Results 2163 }; 2164 app.status = format!("Search complete: {} results.", app.search.results.len()); 2165 } 2166 Err(err) => { 2167 app.status = format!("Search failed: {err}"); 2168 } 2169 } 2170 } 2171 2172 /// Open the selected search result in the main page view. 2173 fn open_search_result(app: &mut AppState, client: &Client) { 2174 if let Some(hit) = app.search.results.get(app.search.selected) { 2175 navigate_to(app, client, hit.url.clone()); 2176 app.screen = Screen::Page; 2177 } else { 2178 app.status = "No search result selected.".to_string(); 2179 } 2180 } 2181 2182 /// Run an in-page find and jump to the first match. 2183 fn perform_find(app: &mut AppState) { 2184 let query = app.tab().find.query.trim(); 2185 if query.is_empty() { 2186 let tab = app.tab_mut(); 2187 tab.find.matches.clear(); 2188 tab.find.selected = 0; 2189 app.status = "Find query is empty.".to_string(); 2190 return; 2191 } 2192 2193 app.tab_mut().find.selected = 0; 2194 app.rebuild_find_matches(); 2195 if let Some(line) = app.tab().find.matches.first().copied() { 2196 let tab = app.tab_mut(); 2197 tab.find.selected = 0; 2198 tab.scroll = line; 2199 app.clamp_scroll(); 2200 app.status = format!("Match 1/{}", app.tab().find.matches.len()); 2201 } else { 2202 app.status = "No matches found.".to_string(); 2203 } 2204 } 2205 2206 /// Move to the next/previous in-page find match. 2207 fn jump_find_match(app: &mut AppState, delta: i32) { 2208 let total = app.tab().find.matches.len(); 2209 if total == 0 { 2210 app.status = "No matches. Press ? to search.".to_string(); 2211 return; 2212 } 2213 2214 let mut next = app.tab().find.selected as i32 + delta; 2215 if next < 0 { 2216 next = total as i32 - 1; 2217 } else if next as usize >= total { 2218 next = 0; 2219 } 2220 app.tab_mut().find.selected = next as usize; 2221 let line = app.tab().find.matches[app.tab().find.selected]; 2222 app.tab_mut().scroll = line; 2223 app.clamp_scroll(); 2224 app.status = format!("Match {}/{}", app.tab().find.selected + 1, total); 2225 } 2226 2227 /// Compute the width for the right-hand image panel given terminal width. 2228 pub fn compute_images_panel_width(term_width: u16) -> u16 { 2229 let w = term_width as i32; 2230 let min_images = 24; 2231 let min_text = 30; 2232 2233 let mut img = (w / 3).max(min_images); 2234 if img > (w - min_text) { 2235 img = (w - min_text).max(10); 2236 } 2237 img.max(10) as u16 2238 } 2239 2240 fn split_root(area: Rect) -> (Rect, Rect, Rect) { 2241 let root = Layout::default() 2242 .direction(Direction::Vertical) 2243 .constraints([ 2244 Constraint::Length(1), 2245 Constraint::Min(1), 2246 Constraint::Length(1), 2247 ]) 2248 .split(area); 2249 (root[0], root[1], root[2]) 2250 } 2251 2252 fn draw_tabs_bar(f: &mut Frame, area: Rect, app: &AppState) { 2253 let theme = app.theme(); 2254 let mut spans: Vec<Span> = Vec::new(); 2255 for (idx, tab) in app.tabs.iter().enumerate() { 2256 if idx > 0 { 2257 spans.push(Span::raw(" ")); 2258 } 2259 let label = format!(" {}:{} ", idx + 1, tab_label(tab)); 2260 let style = if idx == app.active_tab { 2261 theme.tab_active 2262 } else { 2263 theme.tab_inactive 2264 }; 2265 spans.push(Span::styled(label, style)); 2266 } 2267 2268 let line = if spans.is_empty() { 2269 Line::raw("") 2270 } else { 2271 Line::from(spans) 2272 }; 2273 let para = Paragraph::new(line).style(theme.tab_bar); 2274 f.render_widget(para, area); 2275 } 2276 2277 /// Draw the whole UI (dispatch by screen). 2278 pub fn draw(f: &mut Frame, app: &mut AppState) { 2279 match app.screen { 2280 Screen::Page => draw_page(f, app), 2281 Screen::Links => draw_links(f, app), 2282 Screen::Toc => draw_toc(f, app), 2283 Screen::Bookmarks => draw_bookmarks(f, app), 2284 Screen::Address => draw_address(f, app), 2285 Screen::Find => draw_find(f, app), 2286 Screen::Palette => draw_palette(f, app), 2287 Screen::Search => draw_search(f, app), 2288 } 2289 } 2290 2291 /* ---------------------------- Page screen ---------------------------- */ 2292 2293 fn draw_page(f: &mut Frame, app: &mut AppState) { 2294 let area = f.size(); 2295 2296 let (tabs_area, main, status) = split_root(area); 2297 draw_tabs_bar(f, tabs_area, app); 2298 2299 // Split main into text + images. 2300 let img_w = compute_images_panel_width(main.width); 2301 let cols = Layout::default() 2302 .direction(Direction::Horizontal) 2303 .constraints([Constraint::Min(20), Constraint::Length(img_w)]) 2304 .split(main); 2305 2306 let text_chunk = cols[0]; 2307 let images_chunk = cols[1]; 2308 2309 // Compute inner sizes (minus borders). 2310 let text_inner_w = text_chunk.width.saturating_sub(2) as usize; 2311 let text_inner_h = text_chunk.height.saturating_sub(2) as usize; 2312 2313 app.ensure_wrap_width(text_inner_w); 2314 app.set_viewport_height(text_inner_h); 2315 2316 draw_text(f, text_chunk, app); 2317 draw_images(f, images_chunk, app); 2318 draw_status(f, status, app); 2319 } 2320 2321 fn draw_text(f: &mut Frame, area: Rect, app: &AppState) { 2322 let theme = app.theme(); 2323 let tab = app.tab(); 2324 let title = if tab.reader_mode { 2325 format!("Text · {} (Reader)", tab.url) 2326 } else { 2327 format!("Text · {}", tab.url) 2328 }; 2329 let block = Block::default() 2330 .title(title) 2331 .borders(Borders::ALL) 2332 .border_style(theme.border) 2333 .title_style(theme.title); 2334 2335 // Build ratatui Text from our already-wrapped lines. 2336 let lines: Vec<Line> = tab 2337 .text_lines 2338 .iter() 2339 .map(|l| Line::raw(l.clone())) 2340 .collect(); 2341 let text = Text::from(lines); 2342 2343 let para = Paragraph::new(text) 2344 .block(block) 2345 .scroll((tab.scroll as u16, 0)); 2346 2347 f.render_widget(para, area); 2348 } 2349 2350 fn draw_images(f: &mut Frame, area: Rect, app: &AppState) { 2351 let theme = app.theme(); 2352 let tab = app.tab(); 2353 let block = Block::default() 2354 .title("Images (manual)") 2355 .borders(Borders::ALL) 2356 .border_style(theme.border) 2357 .title_style(theme.title); 2358 2359 let items: Vec<ListItem> = if tab.images.is_empty() { 2360 vec![ListItem::new("(no images found)")] 2361 } else { 2362 tab.images 2363 .iter() 2364 .map(|img| { 2365 let mut label = String::new(); 2366 label.push_str(&format!("[{}] ", img.index)); 2367 if img.alt.trim().is_empty() { 2368 label.push_str("(no alt)"); 2369 } else { 2370 label.push_str(img.alt.trim()); 2371 } 2372 label.push_str(" · "); 2373 let domain = img.domain.as_deref().unwrap_or("(unknown)"); 2374 if img.third_party { 2375 label.push_str(&format!("{domain} (3P)")); 2376 } else { 2377 label.push_str(domain); 2378 } 2379 label.push_str(" · "); 2380 label.push_str(img.url.as_str()); 2381 2382 if img.downloaded_path.is_some() { 2383 label.push_str(" ✅"); 2384 } 2385 2386 ListItem::new(label) 2387 }) 2388 .collect() 2389 }; 2390 2391 let list = List::new(items) 2392 .block(block) 2393 .highlight_style(theme.highlight) 2394 .highlight_symbol("▶ "); 2395 2396 let mut state = ListState::default(); 2397 if !tab.images.is_empty() { 2398 state.select(Some(tab.selected_image.min(tab.images.len() - 1))); 2399 } 2400 2401 f.render_stateful_widget(list, area, &mut state); 2402 } 2403 2404 /* ---------------------------- Links screen ---------------------------- */ 2405 2406 /// Draw the links list screen. 2407 fn draw_links(f: &mut Frame, app: &mut AppState) { 2408 let area = f.size(); 2409 2410 let (tabs_area, main, status) = split_root(area); 2411 draw_tabs_bar(f, tabs_area, app); 2412 2413 let cols = Layout::default() 2414 .direction(Direction::Horizontal) 2415 .constraints([Constraint::Percentage(60), Constraint::Percentage(40)]) 2416 .split(main); 2417 2418 let list_area = cols[0]; 2419 let preview_area = cols[1]; 2420 2421 draw_links_list(f, list_area, app); 2422 draw_links_preview(f, preview_area, app); 2423 draw_status(f, status, app); 2424 } 2425 2426 /// Draw the list of extracted links. 2427 fn draw_links_list(f: &mut Frame, area: Rect, app: &AppState) { 2428 let theme = app.theme(); 2429 let tab = app.tab(); 2430 let block = Block::default() 2431 .title("Links (Enter open · j/k move · Esc back)") 2432 .borders(Borders::ALL) 2433 .border_style(theme.border) 2434 .title_style(theme.title); 2435 2436 let items: Vec<ListItem> = if tab.links.is_empty() { 2437 vec![ListItem::new("(no links found)")] 2438 } else { 2439 tab.links 2440 .iter() 2441 .map(|link| { 2442 let mut label = format!("[{}] {}", link.index, link.text.trim()); 2443 label.push_str(" · "); 2444 label.push_str(link.url.as_str()); 2445 ListItem::new(label) 2446 }) 2447 .collect() 2448 }; 2449 2450 let list = List::new(items) 2451 .block(block) 2452 .highlight_style(theme.highlight) 2453 .highlight_symbol("▶ "); 2454 2455 let mut state = ListState::default(); 2456 if !tab.links.is_empty() { 2457 state.select(Some(tab.selected_link.min(tab.links.len() - 1))); 2458 } 2459 2460 f.render_stateful_widget(list, area, &mut state); 2461 } 2462 2463 /// Draw a preview for the selected link. 2464 fn draw_links_preview(f: &mut Frame, area: Rect, app: &AppState) { 2465 let theme = app.theme(); 2466 let tab = app.tab(); 2467 let block = Block::default() 2468 .title("Preview") 2469 .borders(Borders::ALL) 2470 .border_style(theme.border) 2471 .title_style(theme.title); 2472 2473 let text = if let Some(link) = tab.links.get(tab.selected_link) { 2474 let mut lines: Vec<Line> = Vec::new(); 2475 lines.push(Line::raw(link.text.clone())); 2476 lines.push(Line::raw(link.url.to_string())); 2477 if let Some(title) = &link.title { 2478 if !title.trim().is_empty() { 2479 lines.push(Line::raw("")); 2480 lines.push(Line::raw(format!("title: {}", title.trim()))); 2481 } 2482 } 2483 Text::from(lines) 2484 } else { 2485 Text::from(vec![ 2486 Line::raw("No selection."), 2487 Line::raw(""), 2488 Line::raw("Tips:"), 2489 Line::raw("- j/k to move"), 2490 Line::raw("- Enter to open"), 2491 ]) 2492 }; 2493 2494 let para = Paragraph::new(text).block(block).wrap(Wrap { trim: false }); 2495 f.render_widget(para, area); 2496 } 2497 2498 /* ----------------------------- TOC screen ---------------------------- */ 2499 2500 /// Draw the table-of-contents screen. 2501 fn draw_toc(f: &mut Frame, app: &mut AppState) { 2502 let area = f.size(); 2503 2504 let (tabs_area, main, status) = split_root(area); 2505 draw_tabs_bar(f, tabs_area, app); 2506 2507 let theme = app.theme(); 2508 let tab = app.tab(); 2509 let block = Block::default() 2510 .title("Table of Contents (Enter jump · j/k move · Esc back)") 2511 .borders(Borders::ALL) 2512 .border_style(theme.border) 2513 .title_style(theme.title); 2514 2515 let items: Vec<ListItem> = if tab.headings.is_empty() { 2516 vec![ListItem::new("(no headings found)")] 2517 } else { 2518 tab.headings 2519 .iter() 2520 .map(|h| { 2521 let indent = " ".repeat(h.level.saturating_sub(1) as usize); 2522 let mut label = format!("[{}] {}{}", h.index, indent, h.text.trim()); 2523 if let Some(line) = h.line { 2524 label.push_str(&format!(" · line {}", line + 1)); 2525 } 2526 ListItem::new(label) 2527 }) 2528 .collect() 2529 }; 2530 2531 let list = List::new(items) 2532 .block(block) 2533 .highlight_style(theme.highlight) 2534 .highlight_symbol("▶ "); 2535 2536 let mut state = ListState::default(); 2537 if !tab.headings.is_empty() { 2538 state.select(Some(tab.selected_heading.min(tab.headings.len() - 1))); 2539 } 2540 2541 f.render_stateful_widget(list, main, &mut state); 2542 draw_status(f, status, app); 2543 } 2544 2545 /* -------------------------- Bookmarks screen -------------------------- */ 2546 2547 fn draw_bookmarks(f: &mut Frame, app: &mut AppState) { 2548 let area = f.size(); 2549 2550 let (tabs_area, main, status) = split_root(area); 2551 draw_tabs_bar(f, tabs_area, app); 2552 2553 let cols = Layout::default() 2554 .direction(Direction::Horizontal) 2555 .constraints([Constraint::Percentage(60), Constraint::Percentage(40)]) 2556 .split(main); 2557 2558 let list_area = cols[0]; 2559 let preview_area = cols[1]; 2560 2561 draw_bookmarks_list(f, list_area, app); 2562 draw_bookmarks_preview(f, preview_area, app); 2563 draw_status(f, status, app); 2564 } 2565 2566 fn draw_bookmarks_list(f: &mut Frame, area: Rect, app: &AppState) { 2567 let theme = app.theme(); 2568 let block = Block::default() 2569 .title("Bookmarks (Enter open · d delete · Esc back)") 2570 .borders(Borders::ALL) 2571 .border_style(theme.border) 2572 .title_style(theme.title); 2573 2574 let items: Vec<ListItem> = if app.bookmarks.is_empty() { 2575 vec![ListItem::new("(no bookmarks yet)")] 2576 } else { 2577 app.bookmarks 2578 .iter() 2579 .enumerate() 2580 .map(|(idx, bm)| { 2581 let mut label = format!("[{}] {}", idx + 1, bm.title.trim()); 2582 label.push_str(" · "); 2583 label.push_str(bm.url.as_str()); 2584 ListItem::new(label) 2585 }) 2586 .collect() 2587 }; 2588 2589 let list = List::new(items) 2590 .block(block) 2591 .highlight_style(theme.highlight) 2592 .highlight_symbol("▶ "); 2593 2594 let mut state = ListState::default(); 2595 if !app.bookmarks.is_empty() { 2596 let idx = app.selected_bookmark.min(app.bookmarks.len() - 1); 2597 state.select(Some(idx)); 2598 } 2599 2600 f.render_stateful_widget(list, area, &mut state); 2601 } 2602 2603 fn draw_bookmarks_preview(f: &mut Frame, area: Rect, app: &AppState) { 2604 let theme = app.theme(); 2605 let block = Block::default() 2606 .title("Preview") 2607 .borders(Borders::ALL) 2608 .border_style(theme.border) 2609 .title_style(theme.title); 2610 2611 let text = if let Some(bm) = app.bookmarks.get(app.selected_bookmark) { 2612 let lines = vec![Line::raw(bm.title.clone()), Line::raw(bm.url.to_string())]; 2613 Text::from(lines) 2614 } else { 2615 Text::from(vec![ 2616 Line::raw("No selection."), 2617 Line::raw(""), 2618 Line::raw("Tips:"), 2619 Line::raw("- j/k to move"), 2620 Line::raw("- Enter to open"), 2621 Line::raw("- d to delete"), 2622 ]) 2623 }; 2624 2625 let para = Paragraph::new(text).block(block).wrap(Wrap { trim: false }); 2626 f.render_widget(para, area); 2627 } 2628 2629 /* --------------------------- Address screen -------------------------- */ 2630 2631 /// Draw the address bar screen. 2632 fn draw_address(f: &mut Frame, app: &mut AppState) { 2633 let area = f.size(); 2634 2635 let (tabs_area, body, status_area) = split_root(area); 2636 draw_tabs_bar(f, tabs_area, app); 2637 2638 let root = Layout::default() 2639 .direction(Direction::Vertical) 2640 .constraints([Constraint::Length(3), Constraint::Min(1)]) 2641 .split(body); 2642 2643 let input_area = root[0]; 2644 let help_area = root[1]; 2645 2646 let theme = app.theme(); 2647 let mut block = Block::default() 2648 .title("Address (Enter go · Esc cancel)") 2649 .borders(Borders::ALL) 2650 .border_style(theme.border) 2651 .title_style(theme.title); 2652 block = block.style(theme.title); 2653 2654 let placeholder = "(type a URL)"; 2655 let shown = if app.address.input.is_empty() { 2656 placeholder.to_string() 2657 } else { 2658 app.address.input.clone() 2659 }; 2660 2661 let para = Paragraph::new(shown).block(block); 2662 f.render_widget(para, input_area); 2663 2664 let qlen = app.address.input.chars().count() as u16; 2665 let inner_x0 = input_area.x + 1; 2666 let inner_y0 = input_area.y + 1; 2667 let max_x = input_area.x + input_area.width.saturating_sub(2); 2668 let cx = (inner_x0 + qlen).min(max_x); 2669 f.set_cursor(cx, inner_y0); 2670 2671 let help = Paragraph::new( 2672 "Enter a URL. If you omit the scheme, https:// is assumed.\nUse Esc to cancel.", 2673 ) 2674 .style(theme.hint); 2675 f.render_widget(help, help_area); 2676 2677 draw_status(f, status_area, app); 2678 } 2679 2680 /* ---------------------------- Find screen ---------------------------- */ 2681 2682 /// Draw the in-page find screen. 2683 fn draw_find(f: &mut Frame, app: &mut AppState) { 2684 let area = f.size(); 2685 2686 let (tabs_area, body, status_area) = split_root(area); 2687 draw_tabs_bar(f, tabs_area, app); 2688 2689 let root = Layout::default() 2690 .direction(Direction::Vertical) 2691 .constraints([Constraint::Length(3), Constraint::Min(1)]) 2692 .split(body); 2693 2694 let input_area = root[0]; 2695 let help_area = root[1]; 2696 2697 let theme = app.theme(); 2698 let mut block = Block::default() 2699 .title("Find in Page (Enter search · Esc cancel)") 2700 .borders(Borders::ALL) 2701 .border_style(theme.border) 2702 .title_style(theme.title); 2703 block = block.style(theme.title); 2704 2705 let placeholder = "(type to find)"; 2706 let shown = if app.tab().find.query.is_empty() { 2707 placeholder.to_string() 2708 } else { 2709 app.tab().find.query.clone() 2710 }; 2711 2712 let para = Paragraph::new(shown).block(block); 2713 f.render_widget(para, input_area); 2714 2715 let qlen = app.tab().find.query.chars().count() as u16; 2716 let inner_x0 = input_area.x + 1; 2717 let inner_y0 = input_area.y + 1; 2718 let max_x = input_area.x + input_area.width.saturating_sub(2); 2719 let cx = (inner_x0 + qlen).min(max_x); 2720 f.set_cursor(cx, inner_y0); 2721 2722 let count = app.tab().find.matches.len(); 2723 let help = if count == 0 && app.tab().find.query.trim().is_empty() { 2724 "Type a query and press Enter." 2725 } else if count == 0 { 2726 "No matches yet. Press Enter to run find." 2727 } else { 2728 "Press Enter to jump to the first match." 2729 }; 2730 f.render_widget(Paragraph::new(help).style(theme.hint), help_area); 2731 2732 draw_status(f, status_area, app); 2733 } 2734 2735 /* --------------------------- Search screen --------------------------- */ 2736 2737 fn draw_search(f: &mut Frame, app: &mut AppState) { 2738 let area = f.size(); 2739 2740 let (tabs_area, body, status_area) = split_root(area); 2741 draw_tabs_bar(f, tabs_area, app); 2742 2743 let root = Layout::default() 2744 .direction(Direction::Vertical) 2745 .constraints([Constraint::Length(3), Constraint::Min(1)]) 2746 .split(body); 2747 2748 let input_area = root[0]; 2749 let main_area = root[1]; 2750 2751 let cols = Layout::default() 2752 .direction(Direction::Horizontal) 2753 .constraints([Constraint::Percentage(55), Constraint::Percentage(45)]) 2754 .split(main_area); 2755 2756 let results_area = cols[0]; 2757 let preview_area = cols[1]; 2758 2759 draw_search_input(f, input_area, app); 2760 draw_search_results(f, results_area, app); 2761 draw_search_preview(f, preview_area, app); 2762 draw_status(f, status_area, app); 2763 } 2764 2765 fn draw_search_input(f: &mut Frame, area: Rect, app: &AppState) { 2766 let theme = app.theme(); 2767 let mut block = Block::default() 2768 .title(format!("Search · SearXNG: {}", app.searx_url)) 2769 .borders(Borders::ALL) 2770 .border_style(theme.border) 2771 .title_style(theme.title); 2772 2773 if app.search.focus == SearchFocus::Input { 2774 block = block.style(theme.title); 2775 } 2776 2777 let placeholder = "(type and press Enter)"; 2778 let shown = if app.search.query.is_empty() { 2779 placeholder.to_string() 2780 } else { 2781 app.search.query.clone() 2782 }; 2783 2784 let para = Paragraph::new(shown).block(block); 2785 f.render_widget(para, area); 2786 2787 // Cursor at end of query (not end of placeholder). 2788 if app.search.focus == SearchFocus::Input { 2789 let qlen = app.search.query.chars().count() as u16; 2790 let inner_x0 = area.x + 1; 2791 let inner_y0 = area.y + 1; 2792 let max_x = area.x + area.width.saturating_sub(2); 2793 let cx = (inner_x0 + qlen).min(max_x); 2794 f.set_cursor(cx, inner_y0); 2795 } 2796 } 2797 2798 fn draw_search_results(f: &mut Frame, area: Rect, app: &AppState) { 2799 let theme = app.theme(); 2800 let mut block = Block::default() 2801 .title("Results (Enter open · j/k move · Tab focus · Esc back)") 2802 .borders(Borders::ALL) 2803 .border_style(theme.border) 2804 .title_style(theme.title); 2805 2806 if app.search.focus == SearchFocus::Results { 2807 block = block.style(theme.title); 2808 } 2809 2810 let items: Vec<ListItem> = if app.search.results.is_empty() { 2811 vec![ListItem::new("(no results yet)")] 2812 } else { 2813 app.search 2814 .results 2815 .iter() 2816 .map(|r| { 2817 let mut label = format!("[{}] {}", r.index, r.title.trim()); 2818 if let Some(engine) = &r.engine { 2819 if !engine.trim().is_empty() { 2820 label.push_str(&format!(" ({})", engine.trim())); 2821 } 2822 } 2823 label.push_str(" · "); 2824 label.push_str(r.url.as_str()); 2825 ListItem::new(label) 2826 }) 2827 .collect() 2828 }; 2829 2830 let list = List::new(items) 2831 .block(block) 2832 .highlight_style(theme.highlight) 2833 .highlight_symbol("▶ "); 2834 2835 let mut state = ListState::default(); 2836 if !app.search.results.is_empty() { 2837 state.select(Some(app.search.selected.min(app.search.results.len() - 1))); 2838 } 2839 2840 f.render_stateful_widget(list, area, &mut state); 2841 } 2842 2843 fn draw_search_preview(f: &mut Frame, area: Rect, app: &AppState) { 2844 let theme = app.theme(); 2845 let block = Block::default() 2846 .title("Preview") 2847 .borders(Borders::ALL) 2848 .border_style(theme.border) 2849 .title_style(theme.title); 2850 2851 let text = if let Some(hit) = app.search.results.get(app.search.selected) { 2852 let mut lines: Vec<Line> = Vec::new(); 2853 lines.push(Line::raw(hit.title.clone())); 2854 lines.push(Line::raw(hit.url.to_string())); 2855 if let Some(engine) = &hit.engine { 2856 if !engine.trim().is_empty() { 2857 lines.push(Line::raw(format!("engine: {}", engine.trim()))); 2858 } 2859 } 2860 lines.push(Line::raw("")); 2861 if hit.content.trim().is_empty() { 2862 lines.push(Line::raw("(no snippet)")); 2863 } else { 2864 lines.push(Line::raw(hit.content.clone())); 2865 } 2866 Text::from(lines) 2867 } else { 2868 Text::from(vec![ 2869 Line::raw("No selection."), 2870 Line::raw(""), 2871 Line::raw("Tips:"), 2872 Line::raw("- Type in the search box and press Enter"), 2873 Line::raw("- Tab to focus results, then Enter to open"), 2874 ]) 2875 }; 2876 2877 let para = Paragraph::new(text).block(block).wrap(Wrap { trim: false }); 2878 f.render_widget(para, area); 2879 } 2880 2881 /* -------------------------- Palette screen -------------------------- */ 2882 2883 fn draw_palette(f: &mut Frame, app: &mut AppState) { 2884 let area = f.size(); 2885 2886 let (tabs_area, body, status_area) = split_root(area); 2887 draw_tabs_bar(f, tabs_area, app); 2888 2889 let root = Layout::default() 2890 .direction(Direction::Vertical) 2891 .constraints([Constraint::Length(3), Constraint::Min(1)]) 2892 .split(body); 2893 2894 let input_area = root[0]; 2895 let main_area = root[1]; 2896 2897 let cols = Layout::default() 2898 .direction(Direction::Horizontal) 2899 .constraints([Constraint::Percentage(55), Constraint::Percentage(45)]) 2900 .split(main_area); 2901 2902 let list_area = cols[0]; 2903 let preview_area = cols[1]; 2904 2905 draw_palette_input(f, input_area, app); 2906 draw_palette_list(f, list_area, app); 2907 draw_palette_preview(f, preview_area, app); 2908 draw_status(f, status_area, app); 2909 } 2910 2911 fn draw_palette_input(f: &mut Frame, area: Rect, app: &AppState) { 2912 let theme = app.theme(); 2913 let block = Block::default() 2914 .title("Command palette (type to filter · Enter run · Esc close)") 2915 .borders(Borders::ALL) 2916 .border_style(theme.border) 2917 .title_style(theme.title) 2918 .style(theme.title); 2919 2920 let placeholder = "(type a command)"; 2921 let shown = if app.palette.query.is_empty() { 2922 placeholder.to_string() 2923 } else { 2924 app.palette.query.clone() 2925 }; 2926 2927 let para = Paragraph::new(shown).block(block); 2928 f.render_widget(para, area); 2929 2930 let qlen = app.palette.query.chars().count() as u16; 2931 let inner_x0 = area.x + 1; 2932 let inner_y0 = area.y + 1; 2933 let max_x = area.x + area.width.saturating_sub(2); 2934 let cx = (inner_x0 + qlen).min(max_x); 2935 f.set_cursor(cx, inner_y0); 2936 } 2937 2938 fn draw_palette_list(f: &mut Frame, area: Rect, app: &AppState) { 2939 let theme = app.theme(); 2940 let block = Block::default() 2941 .title("Commands") 2942 .borders(Borders::ALL) 2943 .border_style(theme.border) 2944 .title_style(theme.title); 2945 2946 let items: Vec<ListItem> = if app.palette.filtered.is_empty() { 2947 vec![ListItem::new("(no matches)")] 2948 } else { 2949 app.palette 2950 .filtered 2951 .iter() 2952 .map(|item| { 2953 let label = format!("{} · {}", item.label, item.hint); 2954 ListItem::new(label) 2955 }) 2956 .collect() 2957 }; 2958 2959 let list = List::new(items) 2960 .block(block) 2961 .highlight_style(theme.highlight) 2962 .highlight_symbol("▶ "); 2963 2964 let mut state = ListState::default(); 2965 if !app.palette.filtered.is_empty() { 2966 state.select(Some( 2967 app.palette.selected.min(app.palette.filtered.len() - 1), 2968 )); 2969 } 2970 2971 f.render_stateful_widget(list, area, &mut state); 2972 } 2973 2974 fn draw_palette_preview(f: &mut Frame, area: Rect, app: &AppState) { 2975 let theme = app.theme(); 2976 let block = Block::default() 2977 .title("Details") 2978 .borders(Borders::ALL) 2979 .border_style(theme.border) 2980 .title_style(theme.title); 2981 2982 let text = if let Some(item) = app.palette.filtered.get(app.palette.selected) { 2983 let mut lines = Vec::new(); 2984 lines.push(Line::raw(item.label)); 2985 lines.push(Line::raw(format!("hint: {}", item.hint))); 2986 if !item.keywords.is_empty() { 2987 lines.push(Line::raw("")); 2988 lines.push(Line::raw(format!("keywords: {}", item.keywords.join(", ")))); 2989 } 2990 Text::from(lines) 2991 } else { 2992 Text::from(vec![Line::raw("No selection.")]) 2993 }; 2994 2995 let para = Paragraph::new(text).block(block).wrap(Wrap { trim: false }); 2996 f.render_widget(para, area); 2997 } 2998 2999 /* ----------------------------- Status line ---------------------------- */ 3000 3001 fn draw_status(f: &mut Frame, area: Rect, app: &AppState) { 3002 let msg = app.status.clone(); 3003 let para = Paragraph::new(msg).style(app.theme().status); 3004 f.render_widget(para, area); 3005 } 3006 3007 #[cfg(test)] 3008 mod tests { 3009 use super::*; 3010 use std::collections::HashSet; 3011 use std::path::PathBuf; 3012 3013 #[test] 3014 fn history_back_forward_and_truncate() { 3015 let a = Url::parse("https://a.example/").unwrap(); 3016 let b = Url::parse("https://b.example/").unwrap(); 3017 let c = Url::parse("https://c.example/").unwrap(); 3018 3019 let mut history = History::new(a.clone()); 3020 history.push(b.clone()); 3021 history.push(c.clone()); 3022 assert!(history.can_back()); 3023 assert!(!history.can_forward()); 3024 3025 assert_eq!(history.back().unwrap(), b); 3026 assert!(history.can_forward()); 3027 3028 history.push(a.clone()); 3029 assert_eq!(history.entries[history.index], a); 3030 assert!(!history.can_forward()); 3031 } 3032 3033 #[test] 3034 fn normalize_user_url_adds_scheme() { 3035 let url = normalize_user_url("example.com/path").unwrap(); 3036 assert_eq!(url.as_str(), "https://example.com/path"); 3037 3038 let url = normalize_user_url("https://example.com").unwrap(); 3039 assert_eq!(url.as_str(), "https://example.com/"); 3040 } 3041 3042 #[test] 3043 fn map_search_hits_reindexes() { 3044 let hits = vec![ 3045 search::SearchHit { 3046 index: 10, 3047 title: "A".to_string(), 3048 url: Url::parse("https://example.com/a").unwrap(), 3049 content: "alpha".to_string(), 3050 engine: Some("e1".to_string()), 3051 }, 3052 search::SearchHit { 3053 index: 42, 3054 title: "B".to_string(), 3055 url: Url::parse("https://example.com/b").unwrap(), 3056 content: "beta".to_string(), 3057 engine: None, 3058 }, 3059 ]; 3060 3061 let mapped = map_search_hits(hits); 3062 assert_eq!(mapped.len(), 2); 3063 assert_eq!(mapped[0].index, 1); 3064 assert_eq!(mapped[1].index, 2); 3065 assert_eq!(mapped[0].title, "A"); 3066 assert_eq!(mapped[0].url.as_str(), "https://example.com/a"); 3067 assert_eq!(mapped[0].content, "alpha"); 3068 assert_eq!(mapped[0].engine.as_deref(), Some("e1")); 3069 } 3070 3071 #[test] 3072 fn error_page_html_escapes_content() { 3073 let page = ErrorPage { 3074 title: "Bad <title>".to_string(), 3075 message: "Oops & broken".to_string(), 3076 details: vec!["Line \"1\"".to_string()], 3077 }; 3078 3079 let html = error_page_html(&page); 3080 assert!(html.contains("Bad <title>")); 3081 assert!(html.contains("Oops & broken")); 3082 assert!(html.contains("Line "1"")); 3083 } 3084 3085 #[test] 3086 fn apply_paragraph_spacing_inserts_blank_lines() { 3087 let lines = vec!["A".to_string(), "".to_string(), "B".to_string()]; 3088 let spaced = apply_paragraph_spacing(lines, 1); 3089 assert_eq!( 3090 spaced, 3091 vec![ 3092 "A".to_string(), 3093 "".to_string(), 3094 "".to_string(), 3095 "B".to_string() 3096 ] 3097 ); 3098 } 3099 3100 #[test] 3101 fn build_find_matches_is_case_insensitive() { 3102 let lines = vec![ 3103 "Hello world".to_string(), 3104 "no match".to_string(), 3105 "WORLD again".to_string(), 3106 ]; 3107 let matches = build_find_matches(&lines, "world"); 3108 assert_eq!(matches, vec![0, 2]); 3109 } 3110 3111 #[test] 3112 fn assign_heading_lines_matches_in_order() { 3113 let lines = vec![ 3114 "Intro".to_string(), 3115 "Section One".to_string(), 3116 "Section Two".to_string(), 3117 ]; 3118 let mut headings = vec![ 3119 HeadingItem { 3120 index: 1, 3121 level: 1, 3122 text: "Intro".to_string(), 3123 line: None, 3124 }, 3125 HeadingItem { 3126 index: 2, 3127 level: 2, 3128 text: "Section Two".to_string(), 3129 line: None, 3130 }, 3131 ]; 3132 3133 assign_heading_lines(&lines, &mut headings); 3134 assert_eq!(headings[0].line, Some(0)); 3135 assert_eq!(headings[1].line, Some(2)); 3136 } 3137 3138 #[test] 3139 fn build_domain_set_normalizes_and_dedupes() { 3140 let inputs = vec![ 3141 "Example.com".to_string(), 3142 "https://www.example.com/path".to_string(), 3143 "sub.example.com".to_string(), 3144 ]; 3145 let set = build_domain_set(&inputs); 3146 assert!(set.contains("example.com")); 3147 assert!(set.contains("sub.example.com")); 3148 assert_eq!(set.len(), 2); 3149 } 3150 3151 #[test] 3152 fn truncate_label_adds_ellipsis() { 3153 assert_eq!(truncate_label("abcdefghij", 7), "abcd..."); 3154 assert_eq!(truncate_label("abc", 2), "ab"); 3155 } 3156 3157 #[test] 3158 fn tab_title_prefers_document_title() { 3159 let url = Url::parse("https://example.com/path").unwrap(); 3160 let mut tab = TabState::new(url); 3161 tab.title = Some("My Title".to_string()); 3162 assert_eq!(tab_title(&tab), "My Title"); 3163 } 3164 3165 #[test] 3166 fn filter_palette_items_matches_keywords() { 3167 const KEYWORDS: &[&str] = &["bookmark"]; 3168 let items = [ 3169 PaletteItem { 3170 command: PaletteCommand::OpenBookmarks, 3171 label: "Open bookmarks", 3172 hint: "B", 3173 keywords: KEYWORDS, 3174 }, 3175 PaletteItem { 3176 command: PaletteCommand::Reload, 3177 label: "Reload", 3178 hint: "r", 3179 keywords: &["refresh"], 3180 }, 3181 ]; 3182 3183 let filtered = filter_palette_items("bookmark", &items); 3184 assert_eq!(filtered.len(), 1); 3185 assert_eq!(filtered[0].label, "Open bookmarks"); 3186 } 3187 3188 #[test] 3189 fn add_bookmark_dedupes() { 3190 let url = Url::parse("https://example.com/").unwrap(); 3191 let config = AppConfig { 3192 download_dir: PathBuf::from("downloads"), 3193 searx_url: "https://searx.example.invalid".to_string(), 3194 cache: cache::CacheConfig::new(PathBuf::from("cache"), 0), 3195 allow_domains: HashSet::new(), 3196 deny_domains: HashSet::new(), 3197 }; 3198 let mut app = AppState::new(url, config); 3199 app.add_bookmark(); 3200 app.add_bookmark(); 3201 assert_eq!(app.bookmarks.len(), 1); 3202 } 3203 3204 #[test] 3205 fn detect_inline_preview_support_iterm() { 3206 assert_eq!( 3207 detect_inline_preview_support(Some("iTerm.app")), 3208 InlinePreviewSupport::Iterm 3209 ); 3210 assert_eq!( 3211 detect_inline_preview_support(Some("WezTerm")), 3212 InlinePreviewSupport::Iterm 3213 ); 3214 assert_eq!( 3215 detect_inline_preview_support(Some("Unknown")), 3216 InlinePreviewSupport::Unsupported 3217 ); 3218 } 3219 3220 #[test] 3221 fn build_iterm_inline_image_includes_metadata() { 3222 let seq = build_iterm_inline_image("cat.png", b"abc"); 3223 assert!(seq.starts_with("\u{1b}]1337;File=name=")); 3224 assert!(seq.contains("inline=1")); 3225 assert!(seq.ends_with('\u{7}')); 3226 } 3227 }