/ src / app / mod.rs
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("&amp;"),
1204              '<' => out.push_str("&lt;"),
1205              '>' => out.push_str("&gt;"),
1206              '"' => out.push_str("&quot;"),
1207              '\'' => out.push_str("&#39;"),
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 &lt;title&gt;"));
3081          assert!(html.contains("Oops &amp; broken"));
3082          assert!(html.contains("Line &quot;1&quot;"));
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  }