Skip to main content

index_renderer/
lib.rs

1//! Terminal renderer for Index documents.
2//!
3//! The renderer consumes `IndexDocument` only. It never parses HTML and keeps
4//! viewport, overlays, command state, and terminal drawing local to this crate.
5
6use std::fs;
7use std::io;
8use std::path::Path;
9use std::sync::mpsc::{self, TryRecvError};
10use std::thread;
11use std::time::Duration;
12
13use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyModifiers};
14use crossterm::execute;
15use crossterm::terminal::{
16    EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
17};
18use index_ai::AiAction;
19use index_capture::{capture_document, preview_document, validate_capture_bundle};
20use index_core::{
21    DiagnosticAction, DiagnosticConfidence, DiagnosticRecord, DiagnosticSeverity, DiagnosticSource,
22    FailureDiagnostic, Form, FormSubmission, IndexDocument, IndexNode, Input, ReaderProfile,
23    ResponseLogEntry, SectionRole, SessionSidebarMode,
24};
25use index_extract::{ExtractFormat, PipeCommand, PipeDecision, classify_pipe_command};
26use ratatui::Terminal;
27use ratatui::backend::CrosstermBackend;
28use ratatui::layout::{Constraint, Direction, Layout, Rect};
29use ratatui::style::{Color, Modifier, Style};
30use ratatui::symbols::border;
31use ratatui::text::{Line, Span};
32use ratatui::widgets::{Block, Borders, Clear, List, ListItem, Paragraph, Wrap};
33use unicode_width::UnicodeWidthStr;
34
35const TUI_FRAME_DURATION: Duration = Duration::from_millis(100);
36const PROMPT_BLINK_TICKS: usize = 10;
37const STATUS_BLINK_CYCLE_TICKS: usize = 10;
38const STATUS_BLINK_ON_WINDOWS: [usize; 6] = [0, 1, 3, 4, 6, 7];
39const OPENING_VERSE_TICKS: usize = 20;
40const OPENING_TAO_VERSES: [&str; 8] = [
41    "The highest good is like water.",
42    "Yield, and remain whole.",
43    "Knowing yourself is true wisdom.",
44    "The Way acts without forcing.",
45    "Less and less, until stillness.",
46    "A long journey starts underfoot.",
47    "The soft and weak overcome.",
48    "True words are not ornate.",
49];
50const TABLE_MAX_COMPACT_ROWS: usize = 32;
51const TABLE_MAX_DETAIL_ROWS: usize = 24;
52const TABLE_OVERSIZED_CELL_LIMIT: usize = 240;
53const RESPONSE_LOG_LIMIT: usize = 20;
54const DOTTED_BORDER: border::Set = border::Set {
55    top_left: " ",
56    top_right: " ",
57    bottom_left: " ",
58    bottom_right: " ",
59    vertical_left: " ",
60    vertical_right: " ",
61    horizontal_top: " ",
62    horizontal_bottom: " ",
63};
64
65/// Rendering options.
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub struct RenderOptions {
68    /// Target text width.
69    pub width: usize,
70}
71
72/// Terminal color capability used to choose deterministic theme fallbacks.
73#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
74pub enum ColorSupport {
75    /// Full RGB colors are available.
76    #[default]
77    TrueColor,
78    /// Named ANSI colors are available.
79    Ansi,
80    /// Styling must avoid relying on color hue.
81    Monochrome,
82}
83
84/// Terminal animation capability used for accessibility fallbacks.
85#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
86pub enum AnimationMode {
87    /// Lightweight prompt and loading animation is enabled.
88    #[default]
89    Normal,
90    /// Blinking and loading animation affordances are disabled.
91    None,
92}
93
94/// Terminal glyph capability used for icon fallback.
95#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
96pub enum GlyphSupport {
97    /// Nerd Font and symbol glyphs are expected to render.
98    #[default]
99    Rich,
100    /// Use plain ASCII labels instead of private-use icons.
101    Plain,
102}
103
104/// Terminal capabilities that affect theme selection.
105#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
106pub struct TerminalCapabilities {
107    /// Color support for the active terminal.
108    pub color: ColorSupport,
109    /// Animation support for the active terminal.
110    pub animation: AnimationMode,
111    /// Glyph support for the active terminal.
112    pub glyphs: GlyphSupport,
113}
114
115impl Default for RenderOptions {
116    fn default() -> Self {
117        Self { width: 88 }
118    }
119}
120
121/// Table presentation mode local to the terminal renderer.
122#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
123pub enum TableMode {
124    /// Bounded row/column grid for scanning.
125    #[default]
126    Compact,
127    /// Header/value records for narrow screens and dense rows.
128    Detail,
129}
130
131impl TableMode {
132    fn toggled(self) -> Self {
133        match self {
134            Self::Compact => Self::Detail,
135            Self::Detail => Self::Compact,
136        }
137    }
138
139    fn as_str(self) -> &'static str {
140        match self {
141            Self::Compact => "compact",
142            Self::Detail => "detail",
143        }
144    }
145}
146
147#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
148struct TableRenderOptions {
149    mode: TableMode,
150    column_offset: usize,
151}
152
153/// Terminal theme colors.
154#[derive(Debug, Clone, Copy, PartialEq, Eq)]
155pub struct Theme {
156    /// Primary text color.
157    pub foreground: Color,
158    /// Secondary text color.
159    pub muted: Color,
160    /// Document title color.
161    pub document_title: Color,
162    /// Heading color.
163    pub heading: Color,
164    /// Link color.
165    pub link: Color,
166    /// List marker color.
167    pub list: Color,
168    /// Code block color.
169    pub code: Color,
170    /// Table row color.
171    pub table: Color,
172    /// Image proxy color.
173    pub image: Color,
174    /// Form color.
175    pub form: Color,
176    /// Quote color.
177    pub quote: Color,
178    /// Semantic page region marker color.
179    pub region: Color,
180    /// Bold inline text color.
181    pub bold: Color,
182    /// Italic inline text color.
183    pub italic: Color,
184    /// Error color.
185    pub error: Color,
186    /// Warning color.
187    pub warning: Color,
188    /// Success color.
189    pub success: Color,
190    /// Info color.
191    pub info: Color,
192    /// Mild current-line background color.
193    pub current_line: Color,
194    /// Highlight color.
195    pub accent: Color,
196    /// Always-visible command prompt color.
197    pub prompt: Color,
198    /// Status bar foreground color.
199    pub status: Color,
200}
201
202impl Default for Theme {
203    fn default() -> Self {
204        Self {
205            foreground: Color::Gray,
206            muted: Color::DarkGray,
207            document_title: Color::Cyan,
208            heading: Color::LightCyan,
209            link: Color::LightBlue,
210            list: Color::LightGreen,
211            code: Color::LightMagenta,
212            table: Color::LightYellow,
213            image: Color::LightYellow,
214            form: Color::LightGreen,
215            quote: Color::DarkGray,
216            region: Color::DarkGray,
217            bold: Color::White,
218            italic: Color::LightMagenta,
219            error: Color::LightRed,
220            warning: Color::Rgb(255, 165, 0),
221            success: Color::LightGreen,
222            info: Color::Yellow,
223            current_line: Color::Rgb(24, 28, 30),
224            accent: Color::Cyan,
225            prompt: Color::LightCyan,
226            status: Color::Yellow,
227        }
228    }
229}
230
231impl Theme {
232    /// Builds the default deterministic theme for a reader profile.
233    #[must_use]
234    pub fn for_profile(profile: ReaderProfile) -> Self {
235        Self::for_profile_with_capabilities(profile, TerminalCapabilities::default())
236    }
237
238    /// Builds a deterministic theme for a reader profile and terminal capability set.
239    #[must_use]
240    pub fn for_profile_with_capabilities(
241        profile: ReaderProfile,
242        capabilities: TerminalCapabilities,
243    ) -> Self {
244        let theme = match profile {
245            ReaderProfile::Reader => Self::default(),
246            ReaderProfile::Docs => Self {
247                document_title: Color::LightCyan,
248                heading: Color::Cyan,
249                code: Color::LightGreen,
250                table: Color::Yellow,
251                region: Color::LightCyan,
252                accent: Color::LightCyan,
253                ..Self::default()
254            },
255            ReaderProfile::Links => Self {
256                link: Color::LightCyan,
257                list: Color::Cyan,
258                accent: Color::LightBlue,
259                prompt: Color::LightCyan,
260                ..Self::default()
261            },
262            ReaderProfile::Research => Self {
263                quote: Color::LightYellow,
264                italic: Color::Yellow,
265                error: Color::LightRed,
266                warning: Color::Yellow,
267                success: Color::LightGreen,
268                info: Color::Yellow,
269                region: Color::LightMagenta,
270                accent: Color::LightMagenta,
271                ..Self::default()
272            },
273            ReaderProfile::Compact => Self {
274                foreground: Color::Gray,
275                muted: Color::DarkGray,
276                document_title: Color::LightBlue,
277                heading: Color::LightBlue,
278                current_line: Color::Black,
279                accent: Color::Blue,
280                status: Color::Gray,
281                ..Self::default()
282            },
283            ReaderProfile::Verbose => Self {
284                document_title: Color::LightCyan,
285                heading: Color::LightGreen,
286                link: Color::LightBlue,
287                list: Color::LightGreen,
288                code: Color::LightMagenta,
289                table: Color::LightYellow,
290                form: Color::LightGreen,
291                quote: Color::LightYellow,
292                region: Color::LightCyan,
293                accent: Color::LightGreen,
294                status: Color::LightYellow,
295                ..Self::default()
296            },
297        };
298
299        match capabilities.color {
300            ColorSupport::TrueColor => theme,
301            ColorSupport::Ansi => theme.with_ansi_fallbacks(),
302            ColorSupport::Monochrome => theme.with_monochrome_fallbacks(),
303        }
304    }
305
306    fn with_ansi_fallbacks(self) -> Self {
307        Self {
308            warning: Color::Yellow,
309            success: Color::LightGreen,
310            info: Color::Yellow,
311            current_line: Color::DarkGray,
312            ..self
313        }
314    }
315
316    fn with_monochrome_fallbacks(self) -> Self {
317        Self {
318            foreground: Color::Gray,
319            muted: Color::DarkGray,
320            document_title: Color::White,
321            heading: Color::White,
322            link: Color::White,
323            list: Color::Gray,
324            code: Color::White,
325            table: Color::Gray,
326            image: Color::Gray,
327            form: Color::White,
328            quote: Color::Gray,
329            region: Color::Gray,
330            bold: Color::White,
331            italic: Color::Gray,
332            error: Color::White,
333            warning: Color::White,
334            success: Color::White,
335            info: Color::Gray,
336            current_line: Color::Black,
337            accent: Color::White,
338            prompt: Color::White,
339            status: Color::Gray,
340        }
341    }
342}
343
344/// Viewport state for the document view.
345#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
346pub struct Viewport {
347    /// Top visible content line.
348    pub offset: usize,
349    /// Visible content height in terminal rows.
350    pub height: usize,
351}
352
353/// Current input mode.
354#[derive(Debug, Clone, PartialEq, Eq, Default)]
355pub enum InputMode {
356    /// Normal document navigation.
357    #[default]
358    Normal,
359    /// Command entry mode.
360    Command(String),
361    /// Search entry mode.
362    Search(String),
363    /// Interactive form field editing mode.
364    Form(FormEdit),
365}
366
367/// Current interactive form edit state.
368#[derive(Debug, Clone, PartialEq, Eq)]
369pub struct FormEdit {
370    /// One-based form address from the rendered document.
371    pub form_index: usize,
372    /// Zero-based field index inside the form.
373    pub field_index: usize,
374    /// Field name shown to the user.
375    pub field_name: String,
376    /// Field kind such as `text`, `search`, or `checkbox`.
377    pub field_kind: String,
378    /// Current editable field value.
379    pub value: String,
380}
381
382impl FormEdit {
383    fn from_input(form_index: usize, field_index: usize, input: &Input) -> Self {
384        Self {
385            form_index,
386            field_index,
387            field_name: input.name.clone(),
388            field_kind: input.kind.clone(),
389            value: input.value.clone().unwrap_or_default(),
390        }
391    }
392
393    fn field_label(&self) -> String {
394        if self.field_name.is_empty() {
395            format!("field {}", self.field_index + 1)
396        } else {
397            self.field_name.clone()
398        }
399    }
400
401    fn prompt_value(&self) -> String {
402        if is_secret_field(&self.field_kind) && !self.value.is_empty() {
403            "•".repeat(self.value.chars().count().max(1))
404        } else {
405            self.value.clone()
406        }
407    }
408}
409
410/// Result of handling a key event.
411#[derive(Debug, Clone, PartialEq, Eq)]
412pub enum AppAction {
413    /// No external action is required.
414    None,
415    /// The app should quit.
416    Quit,
417    /// A link or URL was requested for opening.
418    Open(String),
419    /// The previous document should be restored.
420    Back,
421    /// A form was submitted through command or interactive form controls.
422    Submit(FormSubmission),
423    /// A document extraction was requested through the command bar.
424    Extract(ExtractFormat),
425    /// A confirmed pipe command was requested through the command bar.
426    Pipe(PipeCommand),
427    /// An explicitly requested AI action.
428    Ai(AiAction),
429}
430
431/// Result returned by the host after navigation or form submission.
432#[derive(Debug, Clone, PartialEq, Eq)]
433pub struct TuiDocumentResult {
434    /// Document to display.
435    pub document: IndexDocument,
436    /// URL to add to local URL-history suggestions.
437    pub visited_url: Option<String>,
438    /// Redacted response log entry to add to the hidden logs sidebar.
439    pub response_log: Option<ResponseLogEntry>,
440}
441
442impl TuiDocumentResult {
443    /// Creates a result from a document.
444    #[must_use]
445    pub fn new(document: IndexDocument) -> Self {
446        Self {
447            document,
448            visited_url: None,
449            response_log: None,
450        }
451    }
452
453    /// Adds a URL-history entry.
454    #[must_use]
455    pub fn with_visited_url(mut self, url: impl Into<String>) -> Self {
456        self.visited_url = Some(url.into());
457        self
458    }
459
460    /// Adds a response log entry.
461    #[must_use]
462    pub fn with_response_log(mut self, log: ResponseLogEntry) -> Self {
463        self.response_log = Some(log);
464        self
465    }
466}
467
468impl From<IndexDocument> for TuiDocumentResult {
469    fn from(document: IndexDocument) -> Self {
470        Self::new(document)
471    }
472}
473
474#[derive(Debug)]
475enum WorkerRequest {
476    Open(String),
477    Submit(FormSubmission),
478    Stop,
479}
480
481#[derive(Debug)]
482enum WorkerResponse {
483    Progress {
484        message: String,
485    },
486    Open {
487        target: String,
488        result: Result<TuiDocumentResult, String>,
489    },
490    Submit {
491        submission: FormSubmission,
492        result: Result<TuiDocumentResult, String>,
493    },
494}
495
496/// Local presentation repair action.
497#[derive(Debug, Clone, PartialEq, Eq)]
498pub enum RepairAction {
499    /// Jump to the next plausible main region.
500    MainNext,
501    /// Jump to the previous plausible main region.
502    MainPrevious,
503    /// Collapse a noisy region by one-based region id.
504    HideRegion(usize),
505    /// Expand a hidden region by one-based region id.
506    ShowRegion(usize),
507    /// Promote a region by one-based id into the temporary main focus.
508    PromoteSection(usize),
509}
510
511impl RepairAction {
512    /// Parses a local repair command body.
513    #[must_use]
514    pub fn parse(input: &str) -> Option<Self> {
515        let input = input.trim();
516        match input {
517            "main next" => Some(Self::MainNext),
518            "main previous" | "main prev" => Some(Self::MainPrevious),
519            _ => parse_repair_region_command(input),
520        }
521    }
522
523    fn as_recipe_line(&self) -> String {
524        match self {
525            Self::MainNext => "main next".to_owned(),
526            Self::MainPrevious => "main previous".to_owned(),
527            Self::HideRegion(id) => format!("hide region {id}"),
528            Self::ShowRegion(id) => format!("show region {id}"),
529            Self::PromoteSection(id) => format!("promote section {id}"),
530        }
531    }
532}
533
534fn parse_repair_region_command(input: &str) -> Option<RepairAction> {
535    let (prefix, rest) = input.rsplit_once(' ')?;
536    let id = rest.parse::<usize>().ok().filter(|id| *id > 0)?;
537    match prefix {
538        "hide region" => Some(RepairAction::HideRegion(id)),
539        "show region" => Some(RepairAction::ShowRegion(id)),
540        "promote section" => Some(RepairAction::PromoteSection(id)),
541        _ => None,
542    }
543}
544
545#[derive(Debug, Clone, Copy, PartialEq, Eq)]
546enum DirectionStep {
547    Next,
548    Previous,
549}
550
551/// Local repair recipe for reproducing presentation-only fixes.
552#[derive(Debug, Clone, PartialEq, Eq, Default)]
553pub struct RepairRecipe {
554    actions: Vec<RepairAction>,
555}
556
557impl RepairRecipe {
558    /// Creates an empty repair recipe.
559    #[must_use]
560    pub fn new() -> Self {
561        Self::default()
562    }
563
564    /// Records a local repair action.
565    pub fn push(&mut self, action: RepairAction) {
566        self.actions.push(action);
567    }
568
569    /// Returns true when no repair actions were recorded.
570    #[must_use]
571    pub fn is_empty(&self) -> bool {
572        self.actions.is_empty()
573    }
574
575    /// Serializes the recipe to deterministic local text.
576    #[must_use]
577    pub fn to_text(&self) -> String {
578        let mut output = String::from("index-repair-v1\n");
579        for action in &self.actions {
580            output.push_str(&action.as_recipe_line());
581            output.push('\n');
582        }
583        output
584    }
585}
586
587/// Inferred page intent for automatic reader profile selection.
588#[derive(Debug, Clone, Copy, PartialEq, Eq)]
589pub enum ReaderProfileIntent {
590    /// Long-form prose and essays.
591    Essay,
592    /// Documentation, manuals, and code-oriented pages.
593    Documentation,
594    /// Link directories, search results, and listings.
595    LinkDirectory,
596    /// Reference, research, archival, or citation-heavy pages.
597    ResearchReference,
598}
599
600impl ReaderProfileIntent {
601    /// Returns the stable intent name.
602    #[must_use]
603    pub const fn as_str(self) -> &'static str {
604        match self {
605            Self::Essay => "essay",
606            Self::Documentation => "documentation",
607            Self::LinkDirectory => "link-directory",
608            Self::ResearchReference => "research-reference",
609        }
610    }
611}
612
613/// Automatic reader profile suggestion.
614#[derive(Debug, Clone, Copy, PartialEq, Eq)]
615pub struct ReaderProfileSuggestion {
616    /// Inferred page intent.
617    pub intent: ReaderProfileIntent,
618    /// Suggested presentation profile.
619    pub profile: ReaderProfile,
620}
621
622/// Returns a deterministic reader profile suggestion for a document.
623#[must_use]
624pub fn suggest_reader_profile(document: &IndexDocument) -> ReaderProfileSuggestion {
625    let stats = DocumentIntentStats::from_document(document);
626    let haystack = stats.text_haystack();
627    if stats.link_count >= stats.paragraph_count.saturating_mul(2).max(4)
628        || contains_any(
629            &haystack,
630            &["search results", "directory", "catalog", "listing"],
631        )
632    {
633        return ReaderProfileSuggestion {
634            intent: ReaderProfileIntent::LinkDirectory,
635            profile: ReaderProfile::Links,
636        };
637    }
638    if contains_any(
639        &haystack,
640        &[
641            "arxiv",
642            "paper",
643            "research",
644            "citation",
645            "bibliography",
646            "reference",
647        ],
648    ) || stats.table_count >= 2
649    {
650        return ReaderProfileSuggestion {
651            intent: ReaderProfileIntent::ResearchReference,
652            profile: ReaderProfile::Research,
653        };
654    }
655    if stats.code_count > 0
656        || contains_any(
657            &haystack,
658            &["docs", "documentation", "manual", "guide", "api", "crate"],
659        )
660    {
661        return ReaderProfileSuggestion {
662            intent: ReaderProfileIntent::Documentation,
663            profile: ReaderProfile::Docs,
664        };
665    }
666    ReaderProfileSuggestion {
667        intent: ReaderProfileIntent::Essay,
668        profile: ReaderProfile::Reader,
669    }
670}
671
672#[derive(Debug, Clone, Copy, PartialEq, Eq)]
673enum ReaderProfileMode {
674    Auto,
675    Manual,
676}
677
678#[derive(Debug, Default)]
679struct DocumentIntentStats {
680    paragraph_count: usize,
681    link_count: usize,
682    code_count: usize,
683    table_count: usize,
684    text: String,
685}
686
687impl DocumentIntentStats {
688    fn from_document(document: &IndexDocument) -> Self {
689        let mut stats = Self::default();
690        stats.push_text(&document.title);
691        if let Some(canonical_url) = &document.metadata.canonical_url {
692            stats.push_text(canonical_url);
693        }
694        if let Some(adapter_id) = &document.metadata.adapter_id {
695            stats.push_text(adapter_id.as_str());
696        }
697        for node in &document.nodes {
698            stats.visit_node(node);
699        }
700        stats
701    }
702
703    fn visit_node(&mut self, node: &IndexNode) {
704        match node {
705            IndexNode::Heading { text, .. } | IndexNode::Paragraph(text) => {
706                self.paragraph_count += matches!(node, IndexNode::Paragraph(_)) as usize;
707                self.push_text(text);
708            }
709            IndexNode::Link(link) => {
710                self.link_count += 1;
711                self.push_text(&link.text);
712                self.push_text(&link.href);
713            }
714            IndexNode::List { items, .. } => {
715                for item in items {
716                    self.push_text(item);
717                }
718            }
719            IndexNode::CodeBlock { .. } => self.code_count += 1,
720            IndexNode::Table { .. } => self.table_count += 1,
721            IndexNode::Spacer { .. } => {}
722            IndexNode::Section { title, nodes, .. } => {
723                if let Some(title) = title {
724                    self.push_text(title);
725                }
726                for node in nodes {
727                    self.visit_node(node);
728                }
729            }
730            IndexNode::Image { alt, src } => {
731                self.push_text(alt);
732                if let Some(src) = src {
733                    self.push_text(src);
734                }
735            }
736            IndexNode::Form(form) => {
737                self.push_text(&form.name);
738                self.push_text(&form.action);
739            }
740            IndexNode::Error(error) => self.push_text(error),
741        }
742    }
743
744    fn push_text(&mut self, text: &str) {
745        self.text.push(' ');
746        self.text.push_str(text);
747    }
748
749    fn text_haystack(&self) -> String {
750        self.text.to_ascii_lowercase()
751    }
752}
753
754fn contains_any(haystack: &str, needles: &[&str]) -> bool {
755    needles.iter().any(|needle| haystack.contains(needle))
756}
757
758/// Terminal document application state.
759#[derive(Debug, Clone)]
760pub struct TerminalApp {
761    document: IndexDocument,
762    layout_width: usize,
763    lines: Vec<String>,
764    links: Vec<RenderedLink>,
765    forms: Vec<RenderedForm>,
766    headings: Vec<RenderedHeading>,
767    regions: Vec<RenderedRegion>,
768    layout_cache: Vec<(LayoutCacheKey, DocumentLayout)>,
769    viewport: Viewport,
770    mode: InputMode,
771    show_link_hints: bool,
772    show_link_sidebar: bool,
773    sidebar_mode: SessionSidebarMode,
774    reader_profile: ReaderProfile,
775    reader_profile_mode: ReaderProfileMode,
776    selected_sidebar_item: usize,
777    table_mode: TableMode,
778    table_column_offset: usize,
779    last_search_query: Option<String>,
780    url_history: Vec<String>,
781    response_logs: Vec<ResponseLogEntry>,
782    back_stack: Vec<IndexDocument>,
783    repair_recipe: RepairRecipe,
784    status: String,
785    terminal_capabilities: TerminalCapabilities,
786    theme: Theme,
787    animation_mode: AnimationMode,
788    glyph_support: GlyphSupport,
789    loading_frame: usize,
790    opening_progress: Option<String>,
791    prompt_blink_frame: usize,
792    pending_g: bool,
793    should_quit: bool,
794}
795
796#[derive(Debug, Clone, PartialEq, Eq)]
797struct RenderedLink {
798    index: usize,
799    text: String,
800    href: String,
801    line: usize,
802}
803
804#[derive(Debug, Clone, PartialEq, Eq)]
805struct RenderedForm {
806    index: usize,
807    name: String,
808    line: usize,
809    form: Form,
810}
811
812#[derive(Debug, Clone, PartialEq, Eq)]
813struct RenderedHeading {
814    level: u8,
815    text: String,
816    line: usize,
817}
818
819#[derive(Debug, Clone, PartialEq, Eq)]
820struct RenderedRegion {
821    path: Vec<usize>,
822    role: SectionRole,
823    title: Option<String>,
824    collapsed: bool,
825    line: usize,
826    item_count: usize,
827}
828
829#[derive(Debug, Clone, Default)]
830struct DocumentLayout {
831    lines: Vec<String>,
832    links: Vec<RenderedLink>,
833    forms: Vec<RenderedForm>,
834    headings: Vec<RenderedHeading>,
835    regions: Vec<RenderedRegion>,
836}
837
838#[derive(Debug, Clone, Copy, PartialEq, Eq)]
839struct LayoutCacheKey {
840    document_hash: u64,
841    width: usize,
842    table_options: TableRenderOptions,
843}
844
845#[derive(Debug, Clone, Copy, PartialEq, Eq)]
846enum StatusSeverity {
847    Error,
848    Warning,
849    Success,
850    Info,
851}
852
853impl TerminalApp {
854    /// Creates terminal state from a document and initial wrapping width.
855    #[must_use]
856    pub fn new(document: IndexDocument, width: usize) -> Self {
857        Self::with_capabilities(document, width, TerminalCapabilities::default())
858    }
859
860    /// Creates terminal state from a document, width, and terminal capabilities.
861    #[must_use]
862    pub fn with_capabilities(
863        document: IndexDocument,
864        width: usize,
865        capabilities: TerminalCapabilities,
866    ) -> Self {
867        let table_options = TableRenderOptions::default();
868        let key = LayoutCacheKey::new(&document, width, table_options);
869        let layout = layout_document_with_options(&document, width, table_options);
870        let cached_layout = layout.clone();
871        let mut app = Self {
872            document,
873            layout_width: width,
874            lines: layout.lines,
875            links: layout.links,
876            forms: layout.forms,
877            headings: layout.headings,
878            regions: layout.regions,
879            layout_cache: vec![(key, cached_layout)],
880            viewport: Viewport {
881                offset: 0,
882                height: 1,
883            },
884            mode: InputMode::Normal,
885            show_link_hints: false,
886            show_link_sidebar: false,
887            sidebar_mode: SessionSidebarMode::Links,
888            reader_profile: ReaderProfile::Reader,
889            reader_profile_mode: ReaderProfileMode::Auto,
890            selected_sidebar_item: 0,
891            table_mode: TableMode::Compact,
892            table_column_offset: 0,
893            last_search_query: None,
894            url_history: Vec::new(),
895            response_logs: Vec::new(),
896            back_stack: Vec::new(),
897            repair_recipe: RepairRecipe::new(),
898            status: "NORMAL".to_owned(),
899            terminal_capabilities: capabilities,
900            theme: Theme::for_profile_with_capabilities(ReaderProfile::Reader, capabilities),
901            animation_mode: capabilities.animation,
902            glyph_support: capabilities.glyphs,
903            loading_frame: 0,
904            opening_progress: None,
905            prompt_blink_frame: 0,
906            pending_g: false,
907            should_quit: false,
908        };
909        app.apply_auto_reader_profile(None);
910        app
911    }
912
913    /// Returns the current viewport.
914    #[must_use]
915    pub const fn viewport(&self) -> Viewport {
916        self.viewport
917    }
918
919    /// Returns the current input mode.
920    #[must_use]
921    pub const fn mode(&self) -> &InputMode {
922        &self.mode
923    }
924
925    /// Returns whether link hints are visible.
926    #[must_use]
927    pub const fn show_link_hints(&self) -> bool {
928        self.show_link_hints
929    }
930
931    /// Returns whether the right-side structural sidebar is visible.
932    #[must_use]
933    pub const fn show_link_sidebar(&self) -> bool {
934        self.show_link_sidebar
935    }
936
937    /// Returns the active right-sidebar mode.
938    #[must_use]
939    pub const fn sidebar_mode(&self) -> SessionSidebarMode {
940        self.sidebar_mode
941    }
942
943    /// Returns the active reader profile.
944    #[must_use]
945    pub const fn reader_profile(&self) -> ReaderProfile {
946        self.reader_profile
947    }
948
949    /// Returns whether the reader profile is currently automatic.
950    #[must_use]
951    pub const fn reader_profile_is_auto(&self) -> bool {
952        matches!(self.reader_profile_mode, ReaderProfileMode::Auto)
953    }
954
955    /// Returns the active table presentation mode.
956    #[must_use]
957    pub const fn table_mode(&self) -> TableMode {
958        self.table_mode
959    }
960
961    /// Returns the zero-based horizontal table column offset.
962    #[must_use]
963    pub const fn table_column_offset(&self) -> usize {
964        self.table_column_offset
965    }
966
967    /// Sets the active right-sidebar mode from persisted session state.
968    pub fn set_sidebar_mode(&mut self, mode: SessionSidebarMode) {
969        self.sidebar_mode = mode;
970        self.selected_sidebar_item = self
971            .selected_sidebar_item
972            .min(self.active_sidebar_len().saturating_sub(1));
973        self.update_sidebar_status();
974    }
975
976    /// Replaces local URL-history suggestions.
977    pub fn set_url_history(&mut self, history: Vec<String>) {
978        self.url_history.clear();
979        for url in history {
980            self.record_visited_url(url);
981        }
982    }
983
984    /// Replaces local response logs.
985    pub fn set_response_logs(&mut self, logs: Vec<ResponseLogEntry>) {
986        self.response_logs = logs;
987        if self.response_logs.len() > RESPONSE_LOG_LIMIT {
988            let drain = self.response_logs.len() - RESPONSE_LOG_LIMIT;
989            self.response_logs.drain(0..drain);
990        }
991        self.selected_sidebar_item = self
992            .selected_sidebar_item
993            .min(self.active_sidebar_len().saturating_sub(1));
994    }
995
996    /// Sets the active reader profile from CLI input or persisted session state.
997    pub fn set_reader_profile(&mut self, profile: ReaderProfile) {
998        self.reader_profile_mode = ReaderProfileMode::Manual;
999        self.reader_profile = profile;
1000        self.theme = Theme::for_profile_with_capabilities(profile, self.terminal_capabilities);
1001        self.status = format!("PROFILE {profile}");
1002    }
1003
1004    /// Enables automatic reader profile selection for the current document.
1005    pub fn set_reader_profile_auto(&mut self) {
1006        self.reader_profile_mode = ReaderProfileMode::Auto;
1007        self.apply_auto_reader_profile(Some("PROFILE auto"));
1008    }
1009
1010    /// Returns whether the app should quit.
1011    #[must_use]
1012    pub const fn should_quit(&self) -> bool {
1013        self.should_quit
1014    }
1015
1016    /// Returns the status bar text.
1017    #[must_use]
1018    pub fn status(&self) -> &str {
1019        &self.status
1020    }
1021
1022    /// Returns the current local repair recipe.
1023    #[must_use]
1024    pub const fn repair_recipe(&self) -> &RepairRecipe {
1025        &self.repair_recipe
1026    }
1027
1028    /// Replaces the current document after an external navigation.
1029    pub fn replace_document(&mut self, document: IndexDocument, width: usize, status: String) {
1030        self.table_column_offset = 0;
1031        self.layout_cache.clear();
1032        let layout = self.cached_layout(&document, width);
1033        self.document = document;
1034        self.layout_width = width;
1035        self.lines = layout.lines;
1036        self.links = layout.links;
1037        self.forms = layout.forms;
1038        self.headings = layout.headings;
1039        self.regions = layout.regions;
1040        self.viewport.offset = 0;
1041        self.mode = InputMode::Normal;
1042        self.show_link_hints = false;
1043        self.selected_sidebar_item = 0;
1044        self.last_search_query = None;
1045        self.status = status;
1046        self.loading_frame = 0;
1047        self.opening_progress = None;
1048        self.pending_g = false;
1049        if matches!(self.reader_profile_mode, ReaderProfileMode::Auto) {
1050            self.apply_auto_reader_profile(None);
1051        }
1052        self.clamp_viewport();
1053    }
1054
1055    fn apply_auto_reader_profile(&mut self, status_prefix: Option<&str>) {
1056        let suggestion = suggest_reader_profile(&self.document);
1057        self.reader_profile = suggestion.profile;
1058        self.theme =
1059            Theme::for_profile_with_capabilities(suggestion.profile, self.terminal_capabilities);
1060        if let Some(prefix) = status_prefix {
1061            self.status = format!(
1062                "{prefix} profile {} for {}",
1063                suggestion.profile,
1064                suggestion.intent.as_str()
1065            );
1066        }
1067    }
1068
1069    /// Updates the viewport height and clamps the offset.
1070    pub fn set_viewport_height(&mut self, height: usize) {
1071        self.viewport.height = height.max(1);
1072        self.clamp_viewport();
1073    }
1074
1075    /// Handles a terminal key event.
1076    pub fn handle_key(&mut self, key: KeyEvent) -> AppAction {
1077        match std::mem::take(&mut self.mode) {
1078            InputMode::Normal => {
1079                self.mode = InputMode::Normal;
1080                self.handle_normal_key(key)
1081            }
1082            InputMode::Command(mut input) => {
1083                if key.code == KeyCode::Tab {
1084                    if let Some(suggestion) = self.open_command_suggestion(&input) {
1085                        input = format!("open {suggestion}");
1086                        self.status = format!("OPEN suggestion {suggestion}");
1087                    }
1088                    self.mode = InputMode::Command(input);
1089                    AppAction::None
1090                } else if let Some(submitted) = handle_text_mode_key(key, &mut input) {
1091                    self.submit_command(submitted)
1092                } else {
1093                    self.mode = InputMode::Command(input);
1094                    AppAction::None
1095                }
1096            }
1097            InputMode::Search(mut input) => {
1098                if let Some(submitted) = handle_text_mode_key(key, &mut input) {
1099                    self.submit_search(submitted)
1100                } else {
1101                    self.mode = InputMode::Search(input);
1102                    AppAction::None
1103                }
1104            }
1105            InputMode::Form(edit) => self.handle_form_edit_key(key, edit),
1106        }
1107    }
1108
1109    /// Renders the app into a ratatui frame.
1110    pub fn render(&mut self, frame: &mut ratatui::Frame<'_>) {
1111        let area = frame.area();
1112        let chunks = Layout::default()
1113            .direction(Direction::Vertical)
1114            .constraints([
1115                Constraint::Min(1),
1116                Constraint::Length(1),
1117                Constraint::Length(1),
1118            ])
1119            .split(area);
1120
1121        let content_chunks = if self.show_link_sidebar && area.width >= 56 {
1122            Layout::default()
1123                .direction(Direction::Horizontal)
1124                .constraints([Constraint::Min(24), Constraint::Length(34)])
1125                .split(chunks[0])
1126        } else {
1127            Layout::default()
1128                .direction(Direction::Horizontal)
1129                .constraints([Constraint::Percentage(100)])
1130                .split(chunks[0])
1131        };
1132
1133        self.set_viewport_height(usize::from(content_chunks[0].height));
1134        self.render_document(frame, content_chunks[0]);
1135        if self.show_link_sidebar && area.width >= 56 {
1136            self.render_sidebar(frame, content_chunks[1]);
1137        }
1138        self.render_status(frame, chunks[1]);
1139        self.render_input(frame, chunks[2]);
1140
1141        if self.show_link_hints {
1142            self.render_link_hints(frame, centered_rect(80, 50, area));
1143        }
1144    }
1145
1146    fn handle_normal_key(&mut self, key: KeyEvent) -> AppAction {
1147        if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
1148            self.should_quit = true;
1149            return AppAction::Quit;
1150        }
1151
1152        if self.show_link_sidebar {
1153            return self.handle_sidebar_key(key);
1154        }
1155
1156        match key.code {
1157            KeyCode::Char('j') | KeyCode::Down => {
1158                self.scroll_down(1);
1159                self.pending_g = false;
1160            }
1161            KeyCode::Char('k') | KeyCode::Up => {
1162                self.scroll_up(1);
1163                self.pending_g = false;
1164            }
1165            KeyCode::Char('G') | KeyCode::End => {
1166                self.scroll_bottom();
1167                self.pending_g = false;
1168            }
1169            KeyCode::Char('g') if self.pending_g => {
1170                self.scroll_top();
1171                self.pending_g = false;
1172            }
1173            KeyCode::Char('g') => {
1174                self.pending_g = true;
1175                self.status = "g".to_owned();
1176            }
1177            KeyCode::Char('/') => {
1178                self.mode = InputMode::Search(String::new());
1179                self.status = "SEARCH".to_owned();
1180                self.pending_g = false;
1181            }
1182            KeyCode::Char('f') => {
1183                self.show_link_hints = !self.show_link_hints;
1184                self.status = if self.show_link_hints {
1185                    "LINK HINTS".to_owned()
1186                } else {
1187                    "NORMAL".to_owned()
1188                };
1189                self.pending_g = false;
1190            }
1191            KeyCode::Char('l') => {
1192                self.toggle_link_sidebar();
1193                self.pending_g = false;
1194            }
1195            KeyCode::Char('t') => {
1196                self.toggle_table_mode();
1197                self.pending_g = false;
1198            }
1199            KeyCode::Char('e') => {
1200                self.pending_g = false;
1201                return self.edit_current_form();
1202            }
1203            KeyCode::Char('[') => {
1204                self.scroll_table_columns_left();
1205                self.pending_g = false;
1206            }
1207            KeyCode::Char(']') => {
1208                self.scroll_table_columns_right();
1209                self.pending_g = false;
1210            }
1211            KeyCode::Char('b') => {
1212                self.pending_g = false;
1213                return AppAction::Back;
1214            }
1215            KeyCode::Char(':') => {
1216                self.mode = InputMode::Command(String::new());
1217                self.status = "COMMAND".to_owned();
1218                self.pending_g = false;
1219            }
1220            KeyCode::Char('q') => {
1221                self.should_quit = true;
1222                return AppAction::Quit;
1223            }
1224            _ => {
1225                self.pending_g = false;
1226            }
1227        }
1228
1229        AppAction::None
1230    }
1231
1232    fn handle_sidebar_key(&mut self, key: KeyEvent) -> AppAction {
1233        match key.code {
1234            KeyCode::Char('j') | KeyCode::Down => {
1235                self.select_next_sidebar_item();
1236                self.pending_g = false;
1237            }
1238            KeyCode::Char('k') | KeyCode::Up => {
1239                self.select_previous_sidebar_item();
1240                self.pending_g = false;
1241            }
1242            KeyCode::Char(']') | KeyCode::Tab => {
1243                self.sidebar_mode = self.sidebar_mode.next();
1244                self.selected_sidebar_item = 0;
1245                self.update_sidebar_status();
1246                self.pending_g = false;
1247            }
1248            KeyCode::Char('[') | KeyCode::BackTab => {
1249                self.sidebar_mode = self.sidebar_mode.previous();
1250                self.selected_sidebar_item = 0;
1251                self.update_sidebar_status();
1252                self.pending_g = false;
1253            }
1254            KeyCode::Char('1') => self.set_sidebar_mode(SessionSidebarMode::Links),
1255            KeyCode::Char('2') => self.set_sidebar_mode(SessionSidebarMode::Outline),
1256            KeyCode::Char('3') => self.set_sidebar_mode(SessionSidebarMode::Forms),
1257            KeyCode::Char('4') => self.set_sidebar_mode(SessionSidebarMode::Regions),
1258            KeyCode::Char('5') => self.set_sidebar_mode(SessionSidebarMode::Search),
1259            KeyCode::Char('6') => self.set_sidebar_mode(SessionSidebarMode::Logs),
1260            KeyCode::Char(' ') | KeyCode::Char('x')
1261                if self.sidebar_mode == SessionSidebarMode::Regions =>
1262            {
1263                self.toggle_selected_region();
1264                self.pending_g = false;
1265            }
1266            KeyCode::Char('e') if self.sidebar_mode == SessionSidebarMode::Forms => {
1267                self.pending_g = false;
1268                return self.edit_selected_sidebar_form();
1269            }
1270            KeyCode::Enter | KeyCode::Char('o') => {
1271                self.pending_g = false;
1272                return self.activate_sidebar_selection();
1273            }
1274            KeyCode::Char('b') => {
1275                self.pending_g = false;
1276                return AppAction::Back;
1277            }
1278            KeyCode::Char('l') | KeyCode::Esc => {
1279                self.show_link_sidebar = false;
1280                self.status = "NORMAL".to_owned();
1281                self.pending_g = false;
1282            }
1283            KeyCode::Char('q') => {
1284                self.should_quit = true;
1285                return AppAction::Quit;
1286            }
1287            _ => {
1288                self.pending_g = false;
1289            }
1290        }
1291
1292        AppAction::None
1293    }
1294
1295    fn submit_command(&mut self, input: String) -> AppAction {
1296        self.mode = InputMode::Normal;
1297        let command = input.trim();
1298
1299        if command == "quit" || command == "q" {
1300            self.should_quit = true;
1301            self.status = "QUIT".to_owned();
1302            return AppAction::Quit;
1303        }
1304
1305        if command == "back" || command == "b" {
1306            self.status = "BACK".to_owned();
1307            return AppAction::Back;
1308        }
1309
1310        if command == "logs" {
1311            self.show_link_sidebar = true;
1312            self.set_sidebar_mode(SessionSidebarMode::Logs);
1313            return AppAction::None;
1314        }
1315
1316        if let Some(target) = command.strip_prefix("open ") {
1317            if let Some(href) = self.resolve_open_target(target.trim()) {
1318                self.status = format!("OPEN {href}");
1319                return AppAction::Open(href);
1320            }
1321            self.status = "OPEN target not found".to_owned();
1322            return AppAction::None;
1323        }
1324
1325        if let Some(input) = command.strip_prefix("submit ") {
1326            return self.submit_form_command(input.trim());
1327        }
1328
1329        if let Some(format) = command.strip_prefix("extract ") {
1330            return self.submit_extract_command(format.trim());
1331        }
1332
1333        if let Some(input) = command.strip_prefix("pipe ") {
1334            return self.submit_pipe_command(input.trim());
1335        }
1336
1337        if let Some(input) = command.strip_prefix("ai ") {
1338            return self.submit_ai_command(input.trim());
1339        }
1340
1341        if let Some(profile) = command.strip_prefix("profile ") {
1342            return self.submit_profile_command(profile.trim());
1343        }
1344
1345        if let Some(input) = command.strip_prefix("capture ") {
1346            return self.submit_capture_command(input.trim());
1347        }
1348
1349        if let Some(action) = RepairAction::parse(command) {
1350            return self.apply_repair_action(action);
1351        }
1352
1353        self.status = if command.is_empty() {
1354            "NORMAL".to_owned()
1355        } else {
1356            format!("Unknown command: {command}")
1357        };
1358        AppAction::None
1359    }
1360
1361    fn submit_extract_command(&mut self, input: &str) -> AppAction {
1362        let Some(format) = ExtractFormat::parse(input) else {
1363            self.status = format!("EXTRACT unsupported format: {input}");
1364            return AppAction::None;
1365        };
1366        self.status = format!("EXTRACT {format}");
1367        AppAction::Extract(format)
1368    }
1369
1370    fn submit_pipe_command(&mut self, input: &str) -> AppAction {
1371        match classify_pipe_command(input) {
1372            PipeDecision::Allowed(command) => {
1373                self.status = format!("PIPE {}", command.as_str());
1374                AppAction::Pipe(command)
1375            }
1376            PipeDecision::RequiresConfirmation(command) => {
1377                self.status = format!("PIPE confirm with :pipe --confirm {}", command.as_str());
1378                AppAction::None
1379            }
1380            PipeDecision::Denied(reason) => {
1381                self.status = format!("PIPE denied: {reason}");
1382                AppAction::None
1383            }
1384        }
1385    }
1386
1387    fn submit_ai_command(&mut self, input: &str) -> AppAction {
1388        let Some(action) = AiAction::parse(input) else {
1389            self.status = format!("AI unsupported action: {input}");
1390            return AppAction::None;
1391        };
1392        self.status = format!("AI {action}");
1393        AppAction::Ai(action)
1394    }
1395
1396    fn submit_profile_command(&mut self, input: &str) -> AppAction {
1397        if input == "auto" {
1398            self.set_reader_profile_auto();
1399            return AppAction::None;
1400        }
1401        let Some(profile) = ReaderProfile::parse(input) else {
1402            let names = ReaderProfile::all()
1403                .iter()
1404                .map(|profile| profile.as_str())
1405                .chain(std::iter::once("auto"))
1406                .collect::<Vec<_>>()
1407                .join("|");
1408            self.status = format!("PROFILE unsupported profile: {input} ({names})");
1409            return AppAction::None;
1410        };
1411        self.set_reader_profile(profile);
1412        AppAction::None
1413    }
1414
1415    fn submit_capture_command(&mut self, input: &str) -> AppAction {
1416        if input == "preview" {
1417            match preview_document(&self.document) {
1418                Ok(preview) => {
1419                    self.status = format!(
1420                        "CAPTURE preview: {} redactions; save with :capture save <path>",
1421                        preview.summary.total()
1422                    );
1423                }
1424                Err(error) => {
1425                    self.status = format!("CAPTURE preview failed: {error}");
1426                }
1427            }
1428            return AppAction::None;
1429        }
1430
1431        let Some(path) = input.strip_prefix("save ") else {
1432            self.status = format!("CAPTURE unsupported action: {input}");
1433            return AppAction::None;
1434        };
1435        let path = path.trim();
1436        match save_current_capture(&self.document, path) {
1437            Ok(()) => {
1438                self.status = format!("CAPTURE saved {path}");
1439            }
1440            Err(error) => {
1441                self.status = format!("CAPTURE save failed: {error}");
1442            }
1443        }
1444        AppAction::None
1445    }
1446
1447    fn apply_repair_action(&mut self, action: RepairAction) -> AppAction {
1448        let status = match action {
1449            RepairAction::MainNext => self.jump_to_main_region(DirectionStep::Next),
1450            RepairAction::MainPrevious => self.jump_to_main_region(DirectionStep::Previous),
1451            RepairAction::HideRegion(id) => self.set_region_collapsed_by_id(id, true),
1452            RepairAction::ShowRegion(id) => self.set_region_collapsed_by_id(id, false),
1453            RepairAction::PromoteSection(id) => self.promote_region_by_id(id),
1454        };
1455
1456        match status {
1457            Some(status) => {
1458                self.repair_recipe.push(action);
1459                self.status = format!("REPAIR {status}");
1460            }
1461            None => {
1462                self.status = "REPAIR target not found".to_owned();
1463            }
1464        }
1465        AppAction::None
1466    }
1467
1468    fn jump_to_main_region(&mut self, step: DirectionStep) -> Option<String> {
1469        let candidates = self.main_region_candidates();
1470        let target = match step {
1471            DirectionStep::Next => candidates
1472                .iter()
1473                .copied()
1474                .find(|index| self.regions[*index].line > self.viewport.offset)
1475                .or_else(|| candidates.first().copied()),
1476            DirectionStep::Previous => candidates
1477                .iter()
1478                .rev()
1479                .copied()
1480                .find(|index| self.regions[*index].line < self.viewport.offset)
1481                .or_else(|| candidates.last().copied()),
1482        }?;
1483        self.viewport.offset = self.regions[target].line;
1484        self.clamp_viewport();
1485        Some(format!("main {}", target + 1))
1486    }
1487
1488    fn main_region_candidates(&self) -> Vec<usize> {
1489        let main = self
1490            .regions
1491            .iter()
1492            .enumerate()
1493            .filter_map(|(index, region)| (region.role == SectionRole::Main).then_some(index))
1494            .collect::<Vec<_>>();
1495        if main.is_empty() {
1496            (0..self.regions.len()).collect()
1497        } else {
1498            main
1499        }
1500    }
1501
1502    fn set_region_collapsed_by_id(&mut self, id: usize, collapsed: bool) -> Option<String> {
1503        let index = id.checked_sub(1)?;
1504        self.set_region_collapsed(index, collapsed)
1505    }
1506
1507    fn set_region_collapsed(&mut self, index: usize, collapsed: bool) -> Option<String> {
1508        let path = self.regions.get(index)?.path.clone();
1509        let Some(IndexNode::Section {
1510            collapsed: section_collapsed,
1511            ..
1512        }) = section_at_path_mut(&mut self.document.nodes, &path)
1513        else {
1514            return None;
1515        };
1516        *section_collapsed = collapsed;
1517        self.relayout(self.layout_width);
1518        Some(format!(
1519            "{} region {}",
1520            if collapsed { "hid" } else { "showed" },
1521            index + 1
1522        ))
1523    }
1524
1525    fn promote_region_by_id(&mut self, id: usize) -> Option<String> {
1526        let index = id.checked_sub(1)?;
1527        if index >= self.regions.len() {
1528            return None;
1529        }
1530
1531        let paths = self
1532            .regions
1533            .iter()
1534            .map(|region| region.path.clone())
1535            .collect::<Vec<_>>();
1536        for (region_index, path) in paths.iter().enumerate() {
1537            if let Some(IndexNode::Section { collapsed, .. }) =
1538                section_at_path_mut(&mut self.document.nodes, path)
1539            {
1540                *collapsed = region_index != index;
1541            }
1542        }
1543        self.relayout(self.layout_width);
1544        self.viewport.offset = self.regions.get(index).map_or(0, |region| region.line);
1545        self.clamp_viewport();
1546        Some(format!("promoted section {id}"))
1547    }
1548
1549    fn submit_search(&mut self, input: String) -> AppAction {
1550        self.mode = InputMode::Normal;
1551        let query = input.trim();
1552
1553        if query.is_empty() {
1554            self.status = "NORMAL".to_owned();
1555            self.last_search_query = None;
1556            return AppAction::None;
1557        }
1558
1559        self.last_search_query = Some(query.to_owned());
1560        if let Some(line_index) = self
1561            .lines
1562            .iter()
1563            .position(|line| line.to_lowercase().contains(&query.to_lowercase()))
1564        {
1565            self.viewport.offset = line_index;
1566            self.clamp_viewport();
1567            self.status = format!("SEARCH {query}");
1568        } else {
1569            self.status = format!("No match: {query}");
1570        }
1571
1572        AppAction::None
1573    }
1574
1575    fn resolve_open_target(&self, target: &str) -> Option<String> {
1576        target
1577            .parse::<usize>()
1578            .ok()
1579            .and_then(|index| self.links.iter().find(|link| link.index == index))
1580            .map(|link| link.href.clone())
1581            .or_else(|| (!target.is_empty()).then_some(target.to_owned()))
1582    }
1583
1584    fn toggle_link_sidebar(&mut self) {
1585        self.show_link_sidebar = !self.show_link_sidebar;
1586        self.selected_sidebar_item = self
1587            .selected_sidebar_item
1588            .min(self.active_sidebar_len().saturating_sub(1));
1589        self.update_sidebar_status();
1590    }
1591
1592    fn select_next_sidebar_item(&mut self) {
1593        let len = self.active_sidebar_len();
1594        if len == 0 {
1595            self.update_sidebar_status();
1596            return;
1597        }
1598        self.selected_sidebar_item = (self.selected_sidebar_item + 1).min(len - 1);
1599        self.update_sidebar_status();
1600    }
1601
1602    fn select_previous_sidebar_item(&mut self) {
1603        if self.active_sidebar_len() == 0 {
1604            self.update_sidebar_status();
1605            return;
1606        }
1607        self.selected_sidebar_item = self.selected_sidebar_item.saturating_sub(1);
1608        self.update_sidebar_status();
1609    }
1610
1611    fn active_sidebar_len(&self) -> usize {
1612        match self.sidebar_mode {
1613            SessionSidebarMode::Links => self.links.len(),
1614            SessionSidebarMode::Outline => self.headings.len() + self.regions.len(),
1615            SessionSidebarMode::Forms => self.forms.len(),
1616            SessionSidebarMode::Regions => self.regions.len(),
1617            SessionSidebarMode::Search => self.search_result_lines().len(),
1618            SessionSidebarMode::Logs => self.response_logs.len(),
1619        }
1620    }
1621
1622    fn update_sidebar_status(&mut self) {
1623        if !self.show_link_sidebar {
1624            self.status = "NORMAL".to_owned();
1625            return;
1626        }
1627
1628        let label = sidebar_mode_title(self.sidebar_mode).to_ascii_uppercase();
1629        let len = self.active_sidebar_len();
1630        if len == 0 {
1631            self.status = format!("{label} empty");
1632        } else {
1633            self.selected_sidebar_item = self.selected_sidebar_item.min(len - 1);
1634            self.status = format!("{label} {}/{}", self.selected_sidebar_item + 1, len);
1635        }
1636    }
1637
1638    fn activate_sidebar_selection(&mut self) -> AppAction {
1639        match self.sidebar_mode {
1640            SessionSidebarMode::Links => {
1641                if let Some(link) = self.links.get(self.selected_sidebar_item) {
1642                    self.status = format!("OPEN {}", link.href);
1643                    return AppAction::Open(link.href.clone());
1644                }
1645            }
1646            SessionSidebarMode::Outline => {
1647                if let Some(line) = self.outline_item_line(self.selected_sidebar_item) {
1648                    self.jump_to_line(line, "OUTLINE");
1649                    return AppAction::None;
1650                }
1651            }
1652            SessionSidebarMode::Forms => {
1653                if let Some(form) = self.forms.get(self.selected_sidebar_item) {
1654                    self.jump_to_line(form.line, "FORMS");
1655                    return AppAction::None;
1656                }
1657            }
1658            SessionSidebarMode::Regions => {
1659                self.toggle_selected_region();
1660                return AppAction::None;
1661            }
1662            SessionSidebarMode::Search => {
1663                if let Some(line) = self
1664                    .search_result_lines()
1665                    .get(self.selected_sidebar_item)
1666                    .copied()
1667                {
1668                    self.jump_to_line(line, "SEARCH");
1669                    return AppAction::None;
1670                }
1671            }
1672            SessionSidebarMode::Logs => {
1673                self.update_sidebar_status();
1674                return AppAction::None;
1675            }
1676        }
1677
1678        self.update_sidebar_status();
1679        AppAction::None
1680    }
1681
1682    fn edit_current_form(&mut self) -> AppAction {
1683        let Some(position) = self
1684            .forms
1685            .iter()
1686            .position(|form| form.line >= self.viewport.offset)
1687            .or_else(|| self.forms.len().checked_sub(1))
1688        else {
1689            self.status = "FORM no forms on this page".to_owned();
1690            return AppAction::None;
1691        };
1692        self.begin_form_edit(position)
1693    }
1694
1695    fn edit_selected_sidebar_form(&mut self) -> AppAction {
1696        if self.forms.is_empty() {
1697            self.update_sidebar_status();
1698            return AppAction::None;
1699        }
1700        self.begin_form_edit(self.selected_sidebar_item)
1701    }
1702
1703    fn begin_form_edit(&mut self, rendered_form_position: usize) -> AppAction {
1704        let Some(rendered_form) = self.forms.get(rendered_form_position).cloned() else {
1705            self.status = "FORM target not found".to_owned();
1706            return AppAction::None;
1707        };
1708        let Some(field_index) = first_editable_field_index(&rendered_form.form) else {
1709            self.status = format!("FORM {} has no editable fields", rendered_form.index);
1710            return AppAction::None;
1711        };
1712        let Some(input) = rendered_form.form.inputs.get(field_index) else {
1713            self.status = "FORM field not found".to_owned();
1714            return AppAction::None;
1715        };
1716        let edit = FormEdit::from_input(rendered_form.index, field_index, input);
1717        self.viewport.offset = rendered_form.line;
1718        self.clamp_viewport();
1719        self.status = format!(
1720            "FORM {} editing {}",
1721            rendered_form.index,
1722            edit.field_label()
1723        );
1724        self.mode = InputMode::Form(edit);
1725        AppAction::None
1726    }
1727
1728    fn handle_form_edit_key(&mut self, key: KeyEvent, mut edit: FormEdit) -> AppAction {
1729        if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
1730            self.mode = InputMode::Normal;
1731            self.should_quit = true;
1732            return AppAction::Quit;
1733        }
1734
1735        match key.code {
1736            KeyCode::Esc => {
1737                self.status = "FORM cancelled".to_owned();
1738                self.mode = InputMode::Normal;
1739            }
1740            KeyCode::Enter => {
1741                self.persist_form_edit(&edit);
1742                self.mode = InputMode::Normal;
1743                return self.submit_form_index(edit.form_index);
1744            }
1745            KeyCode::Tab => {
1746                self.persist_form_edit(&edit);
1747                if let Some(next) = self.next_form_edit(&edit, DirectionStep::Next) {
1748                    self.status =
1749                        format!("FORM {} editing {}", next.form_index, next.field_label());
1750                    self.mode = InputMode::Form(next);
1751                } else {
1752                    self.status = "FORM field not found".to_owned();
1753                    self.mode = InputMode::Normal;
1754                }
1755            }
1756            KeyCode::BackTab => {
1757                self.persist_form_edit(&edit);
1758                if let Some(previous) = self.next_form_edit(&edit, DirectionStep::Previous) {
1759                    self.status = format!(
1760                        "FORM {} editing {}",
1761                        previous.form_index,
1762                        previous.field_label()
1763                    );
1764                    self.mode = InputMode::Form(previous);
1765                } else {
1766                    self.status = "FORM field not found".to_owned();
1767                    self.mode = InputMode::Normal;
1768                }
1769            }
1770            KeyCode::Backspace => {
1771                edit.value.pop();
1772                self.persist_form_edit(&edit);
1773                self.status = format!("FORM {} editing {}", edit.form_index, edit.field_label());
1774                self.mode = InputMode::Form(edit);
1775            }
1776            KeyCode::Char(' ') if is_toggle_field(&edit.field_kind) => {
1777                edit.value = if edit.value.is_empty() {
1778                    "on".to_owned()
1779                } else {
1780                    String::new()
1781                };
1782                self.persist_form_edit(&edit);
1783                self.status = format!("FORM {} editing {}", edit.form_index, edit.field_label());
1784                self.mode = InputMode::Form(edit);
1785            }
1786            KeyCode::Char(ch) => {
1787                edit.value.push(ch);
1788                self.persist_form_edit(&edit);
1789                self.status = format!("FORM {} editing {}", edit.form_index, edit.field_label());
1790                self.mode = InputMode::Form(edit);
1791            }
1792            _ => {
1793                self.status = format!("FORM {} editing {}", edit.form_index, edit.field_label());
1794                self.mode = InputMode::Form(edit);
1795            }
1796        }
1797
1798        AppAction::None
1799    }
1800
1801    fn persist_form_edit(&mut self, edit: &FormEdit) {
1802        if let Some(input) = form_input_mut_by_render_index(
1803            &mut self.document.nodes,
1804            edit.form_index,
1805            edit.field_index,
1806        ) {
1807            input.value = Some(edit.value.clone());
1808            self.relayout(self.layout_width);
1809        }
1810    }
1811
1812    fn next_form_edit(&self, edit: &FormEdit, step: DirectionStep) -> Option<FormEdit> {
1813        let form = self
1814            .forms
1815            .iter()
1816            .find(|form| form.index == edit.form_index)
1817            .map(|form| &form.form)?;
1818        let editable = editable_field_indices(form);
1819        let current = editable
1820            .iter()
1821            .position(|field_index| *field_index == edit.field_index)
1822            .unwrap_or(0);
1823        let next_position = match step {
1824            DirectionStep::Next => {
1825                if current + 1 >= editable.len() {
1826                    0
1827                } else {
1828                    current + 1
1829                }
1830            }
1831            DirectionStep::Previous => current.checked_sub(1).unwrap_or(editable.len() - 1),
1832        };
1833        let field_index = *editable.get(next_position)?;
1834        let input = form.inputs.get(field_index)?;
1835        Some(FormEdit::from_input(edit.form_index, field_index, input))
1836    }
1837
1838    fn submit_form_index(&mut self, form_index: usize) -> AppAction {
1839        let Some(form) = self
1840            .forms
1841            .iter()
1842            .find(|form| form.index == form_index)
1843            .map(|form| form.form.clone())
1844        else {
1845            self.status = "SUBMIT form not found".to_owned();
1846            return AppAction::None;
1847        };
1848
1849        match form.submit(None, &[]) {
1850            Ok(submission) => {
1851                self.status = format!(
1852                    "SUBMIT {} {}",
1853                    submission.method.as_str(),
1854                    submission.action
1855                );
1856                AppAction::Submit(submission)
1857            }
1858            Err(error) => {
1859                self.status = format!("SUBMIT {error}");
1860                AppAction::None
1861            }
1862        }
1863    }
1864
1865    fn jump_to_line(&mut self, line: usize, label: &str) {
1866        self.viewport.offset = line;
1867        self.clamp_viewport();
1868        self.status = format!("{label} line {}", line + 1);
1869    }
1870
1871    fn outline_item_line(&self, index: usize) -> Option<usize> {
1872        let mut items = self
1873            .headings
1874            .iter()
1875            .map(|heading| heading.line)
1876            .chain(self.regions.iter().map(|region| region.line))
1877            .collect::<Vec<_>>();
1878        items.sort_unstable();
1879        items.get(index).copied()
1880    }
1881
1882    fn toggle_selected_region(&mut self) {
1883        let Some(region) = self.regions.get(self.selected_sidebar_item) else {
1884            self.update_sidebar_status();
1885            return;
1886        };
1887        let path = region.path.clone();
1888        let Some(IndexNode::Section { collapsed, .. }) =
1889            section_at_path_mut(&mut self.document.nodes, &path)
1890        else {
1891            self.update_sidebar_status();
1892            return;
1893        };
1894
1895        *collapsed = !*collapsed;
1896        let status = if *collapsed {
1897            "REGIONS collapsed".to_owned()
1898        } else {
1899            "REGIONS expanded".to_owned()
1900        };
1901        self.relayout(self.layout_width);
1902        self.selected_sidebar_item = self
1903            .selected_sidebar_item
1904            .min(self.regions.len().saturating_sub(1));
1905        self.status = status;
1906    }
1907
1908    fn relayout(&mut self, width: usize) {
1909        let document = self.document.clone();
1910        let layout = self.cached_layout(&document, width);
1911        self.lines = layout.lines;
1912        self.links = layout.links;
1913        self.forms = layout.forms;
1914        self.headings = layout.headings;
1915        self.regions = layout.regions;
1916        self.clamp_viewport();
1917    }
1918
1919    fn cached_layout(&mut self, document: &IndexDocument, width: usize) -> DocumentLayout {
1920        let key = LayoutCacheKey::new(document, width, self.table_render_options());
1921        if let Some((_, layout)) = self
1922            .layout_cache
1923            .iter()
1924            .find(|(candidate, _)| *candidate == key)
1925        {
1926            return layout.clone();
1927        }
1928
1929        let layout = layout_document_with_options(document, width, self.table_render_options());
1930        self.layout_cache.push((key, layout.clone()));
1931        if self.layout_cache.len() > 4 {
1932            self.layout_cache.remove(0);
1933        }
1934        layout
1935    }
1936
1937    fn table_render_options(&self) -> TableRenderOptions {
1938        TableRenderOptions {
1939            mode: self.table_mode,
1940            column_offset: self.table_column_offset,
1941        }
1942    }
1943
1944    fn search_result_lines(&self) -> Vec<usize> {
1945        let Some(query) = self.last_search_query.as_deref() else {
1946            return Vec::new();
1947        };
1948        let query = query.to_lowercase();
1949        self.lines
1950            .iter()
1951            .enumerate()
1952            .filter_map(|(index, line)| line.to_lowercase().contains(&query).then_some(index))
1953            .collect()
1954    }
1955
1956    fn open_command_suggestion(&self, input: &str) -> Option<String> {
1957        let partial = input.strip_prefix("open ")?.trim_start();
1958        if partial.is_empty() {
1959            return self.url_history.first().cloned();
1960        }
1961        self.url_history
1962            .iter()
1963            .find(|url| url.starts_with(partial) && url.as_str() != partial)
1964            .cloned()
1965    }
1966
1967    fn start_open_loading(&mut self, target: &str) {
1968        self.status = format!("OPENING {target}");
1969        self.loading_frame = 0;
1970        self.opening_progress = Some(format!("queued {target}"));
1971    }
1972
1973    fn set_opening_progress(&mut self, progress: String) {
1974        if self.status.starts_with("OPENING ") {
1975            self.opening_progress = Some(progress);
1976        }
1977    }
1978
1979    fn open_document(&mut self, document: IndexDocument, width: usize, status: String) {
1980        self.back_stack.push(self.document.clone());
1981        self.repair_recipe = RepairRecipe::new();
1982        self.replace_document(document, width, status);
1983    }
1984
1985    fn record_visited_url(&mut self, url: impl Into<String>) {
1986        let url = url.into();
1987        if url.trim().is_empty() {
1988            return;
1989        }
1990        self.url_history.retain(|candidate| candidate != &url);
1991        self.url_history.insert(0, url);
1992        self.url_history.truncate(50);
1993    }
1994
1995    fn record_response_log(&mut self, log: ResponseLogEntry) {
1996        self.response_logs.push(log);
1997        if self.response_logs.len() > RESPONSE_LOG_LIMIT {
1998            let drain = self.response_logs.len() - RESPONSE_LOG_LIMIT;
1999            self.response_logs.drain(0..drain);
2000        }
2001        self.selected_sidebar_item = self
2002            .selected_sidebar_item
2003            .min(self.active_sidebar_len().saturating_sub(1));
2004    }
2005
2006    fn next_response_log_sequence(&self) -> u64 {
2007        self.response_logs
2008            .last()
2009            .map_or(1, |entry| entry.sequence.saturating_add(1))
2010    }
2011
2012    fn record_error_log(&mut self, method: &str, target: &str, error: &str) {
2013        let sequence = self.next_response_log_sequence();
2014        self.record_response_log(ResponseLogEntry::new(
2015            sequence,
2016            method,
2017            target,
2018            target,
2019            Some("text/x-index-error"),
2020            error,
2021            512,
2022        ));
2023    }
2024
2025    fn go_back(&mut self, width: usize) {
2026        if let Some(document) = self.back_stack.pop() {
2027            self.replace_document(document, width, "BACK".to_owned());
2028        } else {
2029            self.status = "BACK no previous page".to_owned();
2030        }
2031    }
2032
2033    fn submit_form_command(&mut self, input: &str) -> AppAction {
2034        let Some((target, fields)) = input.split_once(' ') else {
2035            self.status = "SUBMIT missing fields".to_owned();
2036            return AppAction::None;
2037        };
2038        let Some(form) = self.resolve_form_target(target.trim()) else {
2039            self.status = "SUBMIT form not found".to_owned();
2040            return AppAction::None;
2041        };
2042        let values = parse_form_values(fields);
2043        let borrowed = values
2044            .iter()
2045            .map(|(name, value)| (name.as_str(), value.as_str()))
2046            .collect::<Vec<_>>();
2047
2048        match form.submit(None, &borrowed) {
2049            Ok(submission) => {
2050                self.status = format!(
2051                    "SUBMIT {} {}",
2052                    submission.method.as_str(),
2053                    submission.action
2054                );
2055                AppAction::Submit(submission)
2056            }
2057            Err(error) => {
2058                self.status = format!("SUBMIT {error}");
2059                AppAction::None
2060            }
2061        }
2062    }
2063
2064    fn resolve_form_target(&self, target: &str) -> Option<&Form> {
2065        target
2066            .parse::<usize>()
2067            .ok()
2068            .and_then(|index| self.forms.iter().find(|form| form.index == index))
2069            .or_else(|| self.forms.iter().find(|form| form.name == target))
2070            .map(|form| &form.form)
2071    }
2072
2073    fn scroll_down(&mut self, amount: usize) {
2074        self.viewport.offset = self.viewport.offset.saturating_add(amount);
2075        self.clamp_viewport();
2076        self.status = "NORMAL".to_owned();
2077    }
2078
2079    fn scroll_up(&mut self, amount: usize) {
2080        self.viewport.offset = self.viewport.offset.saturating_sub(amount);
2081        self.status = "NORMAL".to_owned();
2082    }
2083
2084    fn scroll_top(&mut self) {
2085        self.viewport.offset = 0;
2086        self.status = "TOP".to_owned();
2087    }
2088
2089    fn scroll_bottom(&mut self) {
2090        self.viewport.offset = self.max_offset();
2091        self.status = "BOTTOM".to_owned();
2092    }
2093
2094    fn toggle_table_mode(&mut self) {
2095        self.table_mode = self.table_mode.toggled();
2096        self.status = format!("TABLE {}", self.table_mode.as_str());
2097        self.relayout(self.layout_width);
2098    }
2099
2100    fn scroll_table_columns_left(&mut self) {
2101        self.table_column_offset = self.table_column_offset.saturating_sub(1);
2102        self.status = format!("TABLE column {}", self.table_column_offset + 1);
2103        self.relayout(self.layout_width);
2104    }
2105
2106    fn scroll_table_columns_right(&mut self) {
2107        self.table_column_offset = self.table_column_offset.saturating_add(1);
2108        self.table_column_offset = self.table_column_offset.min(max_table_column_offset(
2109            &self.document.nodes,
2110            self.layout_width,
2111        ));
2112        self.status = format!("TABLE column {}", self.table_column_offset + 1);
2113        self.relayout(self.layout_width);
2114    }
2115
2116    fn clamp_viewport(&mut self) {
2117        self.viewport.offset = self.viewport.offset.min(self.max_offset());
2118    }
2119
2120    fn max_offset(&self) -> usize {
2121        self.lines.len().saturating_sub(self.viewport.height)
2122    }
2123
2124    fn render_document(&self, frame: &mut ratatui::Frame<'_>, area: Rect) {
2125        let visible_lines = if self.status.starts_with("OPENING ") {
2126            self.loading_page_lines()
2127        } else {
2128            self.lines
2129                .iter()
2130                .skip(self.viewport.offset)
2131                .take(usize::from(area.height))
2132                .enumerate()
2133                .map(|(visible_index, line)| {
2134                    let is_current_line = visible_index == 0;
2135                    let style = self.line_style(line);
2136                    let style = if is_current_line {
2137                        style.bg(self.theme.current_line)
2138                    } else {
2139                        style
2140                    };
2141                    self.styled_line(line, style, is_current_line)
2142                })
2143                .collect::<Vec<_>>()
2144        };
2145
2146        let paragraph = Paragraph::new(visible_lines)
2147            .block(
2148                Block::default()
2149                    .borders(Borders::ALL)
2150                    .border_set(DOTTED_BORDER)
2151                    .title(self.document.title.as_str()),
2152            )
2153            .wrap(Wrap { trim: false });
2154        frame.render_widget(paragraph, area);
2155    }
2156
2157    fn loading_page_lines(&self) -> Vec<Line<'static>> {
2158        let target = self.status.trim_start_matches("OPENING ").trim();
2159        let verse_index = (self.loading_frame / OPENING_VERSE_TICKS) % OPENING_TAO_VERSES.len();
2160        let verse = OPENING_TAO_VERSES[verse_index];
2161        let spinner =
2162            loading_spinner_frame(self.loading_frame, self.animation_mode, self.glyph_support);
2163        let title_icon = if matches!(self.glyph_support, GlyphSupport::Rich) {
2164            "󰔟"
2165        } else {
2166            "[loading]"
2167        };
2168
2169        vec![
2170            Line::from(Span::styled(
2171                format!("{title_icon} Opening"),
2172                Style::default()
2173                    .fg(self.theme.info)
2174                    .add_modifier(Modifier::BOLD),
2175            )),
2176            Line::from(Span::styled(
2177                format!("{spinner} {target}"),
2178                Style::default().fg(self.theme.status),
2179            )),
2180            Line::from(Span::raw("")),
2181            Line::from(Span::styled(
2182                verse.to_owned(),
2183                Style::default()
2184                    .fg(self.theme.quote)
2185                    .add_modifier(Modifier::ITALIC),
2186            )),
2187            Line::from(Span::raw("")),
2188            Line::from(Span::styled(
2189                "Press q or :quit to cancel.",
2190                Style::default().fg(self.theme.muted),
2191            )),
2192        ]
2193    }
2194
2195    fn line_style(&self, line: &str) -> Style {
2196        let trimmed = line.trim_start();
2197        let style = Style::default();
2198        if trimmed.starts_with("󰘬") {
2199            style
2200                .fg(self.theme.document_title)
2201                .add_modifier(Modifier::BOLD)
2202        } else if trimmed.starts_with("󰉫") {
2203            style.fg(self.theme.heading).add_modifier(Modifier::BOLD)
2204        } else if trimmed.starts_with("󰌹") {
2205            style.fg(self.theme.link)
2206        } else if trimmed.starts_with("󰄬") || trimmed.starts_with('•') {
2207            style.fg(self.theme.list)
2208        } else if trimmed.starts_with("󰅩") || trimmed.starts_with("```") {
2209            style.fg(self.theme.code)
2210        } else if trimmed.starts_with("󰓫") {
2211            style.fg(self.theme.table)
2212        } else if trimmed.starts_with("󰅂") {
2213            style.fg(self.theme.region)
2214        } else if trimmed.starts_with("󰥶") {
2215            style.fg(self.theme.image)
2216        } else if trimmed.starts_with("󰈙") {
2217            style.fg(self.theme.form)
2218        } else if trimmed.starts_with("󰂆") {
2219            style.fg(self.theme.quote).add_modifier(Modifier::ITALIC)
2220        } else if trimmed.starts_with("󰅚") {
2221            style.fg(self.theme.error).add_modifier(Modifier::BOLD)
2222        } else {
2223            style.fg(self.theme.foreground)
2224        }
2225    }
2226
2227    fn styled_line<'a>(&self, line: &'a str, base_style: Style, reveal_syntax: bool) -> Line<'a> {
2228        let display_line = if reveal_syntax {
2229            line.to_owned()
2230        } else {
2231            hide_markdown_syntax(line)
2232        };
2233        let display_line = if matches!(self.glyph_support, GlyphSupport::Plain) {
2234            plain_glyph_fallback(&display_line)
2235        } else {
2236            display_line
2237        };
2238
2239        if line.trim_start().starts_with("󰅩")
2240            || line.trim_start().starts_with("```")
2241            || line.starts_with("    ")
2242            || reveal_syntax
2243        {
2244            return Line::from(Span::styled(display_line, base_style));
2245        }
2246
2247        Line::from(markdown_inline_spans(
2248            &display_line,
2249            base_style,
2250            self.theme.bold,
2251            self.theme.italic,
2252        ))
2253    }
2254
2255    fn render_status(&mut self, frame: &mut ratatui::Frame<'_>, area: Rect) {
2256        let status_text = self.status_text();
2257        let severity = classify_status(&status_text);
2258        let icon = status_icon(severity, self.glyph_support);
2259        let status_style = status_style(severity, self.theme);
2260        let meta = format!(
2261            " | quality: {} | profile: {}{} | {}/{} | links: {}",
2262            self.document
2263                .metadata
2264                .quality
2265                .as_ref()
2266                .map_or("unknown", |quality| quality.category.as_str()),
2267            self.reader_profile,
2268            if self.reader_profile_is_auto() {
2269                " auto"
2270            } else {
2271                ""
2272            },
2273            self.viewport.offset.saturating_add(1),
2274            self.lines.len().max(1),
2275            self.links.len()
2276        );
2277        let paragraph = Paragraph::new(Line::from(vec![
2278            Span::styled(format!("{icon} {status_text}"), status_style),
2279            Span::styled(meta, Style::default().fg(self.theme.status)),
2280        ]));
2281        frame.render_widget(paragraph, area);
2282    }
2283
2284    fn status_text(&mut self) -> String {
2285        if self.status.starts_with("OPENING ") {
2286            let frame =
2287                loading_spinner_frame(self.loading_frame, self.animation_mode, GlyphSupport::Plain);
2288            let phase = self
2289                .opening_progress
2290                .as_deref()
2291                .unwrap_or_else(|| self.status.trim_start_matches("OPENING ").trim());
2292            let marker = if frame == "[ ]" || frame.trim().is_empty() {
2293                "[ ]".to_owned()
2294            } else {
2295                format!("[{frame}]")
2296            };
2297            self.loading_frame = self.loading_frame.wrapping_add(1);
2298            return format!("{marker} OPENING {phase}");
2299        }
2300
2301        self.status.clone()
2302    }
2303
2304    fn render_input(&mut self, frame: &mut ratatui::Frame<'_>, area: Rect) {
2305        let text = match &self.mode {
2306            InputMode::Normal => {
2307                if self.show_link_sidebar {
2308                    format!(
2309                        "{}: j/k select  [] mode  1-6 jump mode  enter open/jump  e edit form  space expand  esc hide",
2310                        sidebar_mode_title(self.sidebar_mode).to_ascii_lowercase()
2311                    )
2312                } else {
2313                    "j/k scroll  gg/G top/bottom  / search  f hints  l links  e edit form  t table  b back  :profile  q quit".to_owned()
2314                }
2315            }
2316            InputMode::Command(input) => {
2317                if let Some(suggestion) = self.open_command_suggestion(input) {
2318                    format!("{input}  -> {suggestion}")
2319                } else {
2320                    input.to_owned()
2321                }
2322            }
2323            InputMode::Search(input) => format!("/{input}"),
2324            InputMode::Form(edit) => format!(
2325                "form {} {} = {}  tab next  enter submit  esc cancel",
2326                edit.form_index,
2327                edit.field_label(),
2328                edit.prompt_value()
2329            ),
2330        };
2331        let colon_color =
2332            prompt_colon_color(self.prompt_blink_frame, self.theme, self.animation_mode);
2333        if matches!(self.animation_mode, AnimationMode::Normal) {
2334            self.prompt_blink_frame = self.prompt_blink_frame.wrapping_add(1);
2335        }
2336        let paragraph = Paragraph::new(Line::from(vec![
2337            Span::styled("[", Style::default().fg(self.theme.prompt)),
2338            Span::styled(":", Style::default().fg(colon_color)),
2339            Span::styled("> ", Style::default().fg(self.theme.prompt)),
2340            Span::styled(text, Style::default().fg(self.theme.muted)),
2341        ]));
2342        frame.render_widget(paragraph, area);
2343    }
2344
2345    fn render_link_hints(&self, frame: &mut ratatui::Frame<'_>, area: Rect) {
2346        let items = self
2347            .links
2348            .iter()
2349            .map(|link| {
2350                ListItem::new(Line::from(vec![
2351                    Span::styled(
2352                        format!("[{}] ", link.index),
2353                        Style::default()
2354                            .fg(self.theme.accent)
2355                            .add_modifier(Modifier::BOLD),
2356                    ),
2357                    Span::raw(format!("{} -> {}", link.text, link.href)),
2358                ]))
2359            })
2360            .collect::<Vec<_>>();
2361        let list = List::new(items).block(dotted_block("Links"));
2362        frame.render_widget(Clear, area);
2363        frame.render_widget(list, area);
2364    }
2365
2366    fn render_sidebar(&self, frame: &mut ratatui::Frame<'_>, area: Rect) {
2367        if self.sidebar_mode == SessionSidebarMode::Logs {
2368            self.render_logs_sidebar(frame, area);
2369            return;
2370        }
2371
2372        let entries = self.sidebar_entries();
2373        let lines = if entries.is_empty() {
2374            vec![Line::from(Span::styled(
2375                format!(
2376                    "No {}",
2377                    sidebar_mode_title(self.sidebar_mode).to_ascii_lowercase()
2378                ),
2379                Style::default().fg(self.theme.muted),
2380            ))]
2381        } else {
2382            entries
2383                .iter()
2384                .enumerate()
2385                .flat_map(|(position, entry)| {
2386                    let selected = position == self.selected_sidebar_item;
2387                    let marker = if selected { ">" } else { " " };
2388                    let style = if selected {
2389                        Style::default()
2390                            .fg(self.theme.accent)
2391                            .add_modifier(Modifier::BOLD)
2392                    } else {
2393                        Style::default().fg(self.theme.link)
2394                    };
2395                    [
2396                        Line::from(vec![
2397                            Span::styled(marker, style),
2398                            Span::styled(format!(" {}", truncate_display(&entry.label, 28)), style),
2399                        ]),
2400                        Line::from(Span::styled(
2401                            format!("  {}", truncate_display(&entry.detail, 28)),
2402                            Style::default().fg(self.theme.muted),
2403                        )),
2404                        Line::from(""),
2405                    ]
2406                })
2407                .collect::<Vec<_>>()
2408        };
2409
2410        let paragraph = Paragraph::new(lines)
2411            .block(dotted_block(sidebar_mode_title(self.sidebar_mode)))
2412            .wrap(Wrap { trim: false });
2413        frame.render_widget(paragraph, area);
2414    }
2415
2416    fn render_logs_sidebar(&self, frame: &mut ratatui::Frame<'_>, area: Rect) {
2417        let logs = self.response_logs.iter().rev().collect::<Vec<_>>();
2418        let mut selected_line_index = 0_usize;
2419        let lines =
2420            if logs.is_empty() {
2421                vec![Line::from(Span::styled(
2422                    "No logs",
2423                    Style::default().fg(self.theme.muted),
2424                ))]
2425            } else {
2426                let mut lines = Vec::new();
2427                for (position, log) in logs.iter().enumerate() {
2428                    let selected = position == self.selected_sidebar_item;
2429                    if selected {
2430                        selected_line_index = lines.len();
2431                    }
2432                    let severity = classify_log_entry(log);
2433                    let marker = if selected { ">" } else { " " };
2434                    let mut style = status_style(severity, self.theme);
2435                    if selected {
2436                        style = style.add_modifier(Modifier::BOLD);
2437                    }
2438
2439                    lines.push(Line::from(vec![
2440                        Span::styled(marker, style),
2441                        Span::styled(format!(" {}", truncate_display(&log.title(), 28)), style),
2442                    ]));
2443
2444                    if selected {
2445                        let label_style = Style::default()
2446                            .fg(self.theme.accent)
2447                            .add_modifier(Modifier::BOLD);
2448                        let value_style = Style::default().fg(self.theme.foreground);
2449                        lines.extend([
2450                            Line::from(Span::styled(
2451                                format!("  method: {}", log.method),
2452                                value_style,
2453                            )),
2454                            Line::from(Span::styled(
2455                                format!("  requested: {}", log.requested_url),
2456                                Style::default().fg(self.theme.muted),
2457                            )),
2458                            Line::from(Span::styled(
2459                                format!("  final: {}", log.final_url),
2460                                Style::default().fg(self.theme.muted),
2461                            )),
2462                            Line::from(Span::styled(
2463                                format!(
2464                                    "  mime: {}{}",
2465                                    log.mime_type.as_deref().unwrap_or("unknown"),
2466                                    if log.truncated { " · truncated" } else { "" }
2467                                ),
2468                                Style::default().fg(self.theme.muted),
2469                            )),
2470                            Line::from(Span::styled("  preview:", label_style)),
2471                        ]);
2472                        lines.extend(log.body_preview.lines().map(|line| {
2473                            Line::from(Span::styled(format!("    {line}"), value_style))
2474                        }));
2475                    } else {
2476                        lines.push(Line::from(Span::styled(
2477                            format!(
2478                                "  {}{}",
2479                                truncate_display(
2480                                    log.body_preview
2481                                        .lines()
2482                                        .next()
2483                                        .unwrap_or(log.mime_type.as_deref().unwrap_or("response")),
2484                                    28
2485                                ),
2486                                if log.truncated { "…" } else { "" }
2487                            ),
2488                            Style::default().fg(self.theme.muted),
2489                        )));
2490                    }
2491
2492                    lines.push(Line::from(""));
2493                }
2494                lines
2495            };
2496        let visible_lines = usize::from(area.height.saturating_sub(2));
2497        let scroll_line = selected_line_index.saturating_sub(visible_lines / 3);
2498        let scroll_y = u16::try_from(scroll_line).unwrap_or(u16::MAX);
2499
2500        let paragraph = Paragraph::new(lines)
2501            .block(dotted_block("Logs"))
2502            .scroll((scroll_y, 0))
2503            .wrap(Wrap { trim: false });
2504        frame.render_widget(paragraph, area);
2505    }
2506
2507    fn sidebar_entries(&self) -> Vec<SidebarEntry> {
2508        match self.sidebar_mode {
2509            SessionSidebarMode::Links => self
2510                .links
2511                .iter()
2512                .map(|link| SidebarEntry {
2513                    label: format!("{} {}", link.index, link.text),
2514                    detail: link.href.clone(),
2515                })
2516                .collect(),
2517            SessionSidebarMode::Outline => self.outline_entries(),
2518            SessionSidebarMode::Forms => self
2519                .forms
2520                .iter()
2521                .map(|form| SidebarEntry {
2522                    label: format!("{} {}", form.index, form.name),
2523                    detail: format!(
2524                        "{} fields · line {}",
2525                        editable_field_indices(&form.form).len(),
2526                        form.line + 1
2527                    ),
2528                })
2529                .collect(),
2530            SessionSidebarMode::Regions => self
2531                .regions
2532                .iter()
2533                .map(|region| SidebarEntry {
2534                    label: format!(
2535                        "{} {}",
2536                        if region.collapsed { "▸" } else { "▾" },
2537                        section_label(region.role, region.title.as_deref())
2538                    ),
2539                    detail: format!("{} items · line {}", region.item_count, region.line + 1),
2540                })
2541                .collect(),
2542            SessionSidebarMode::Search => self
2543                .search_result_lines()
2544                .into_iter()
2545                .map(|line| SidebarEntry {
2546                    label: format!("line {}", line + 1),
2547                    detail: self
2548                        .lines
2549                        .get(line)
2550                        .map_or_else(String::new, |value| truncate_display(value.trim(), 32)),
2551                })
2552                .collect(),
2553            SessionSidebarMode::Logs => self
2554                .response_logs
2555                .iter()
2556                .rev()
2557                .map(|log| SidebarEntry {
2558                    label: log.title(),
2559                    detail: format!(
2560                        "{}{}",
2561                        truncate_display(
2562                            log.body_preview
2563                                .lines()
2564                                .next()
2565                                .unwrap_or(log.mime_type.as_deref().unwrap_or("response")),
2566                            32
2567                        ),
2568                        if log.truncated { "…" } else { "" }
2569                    ),
2570                })
2571                .collect(),
2572        }
2573    }
2574
2575    fn outline_entries(&self) -> Vec<SidebarEntry> {
2576        let mut entries = self
2577            .headings
2578            .iter()
2579            .map(|heading| {
2580                (
2581                    heading.line,
2582                    SidebarEntry {
2583                        label: format!(
2584                            "{} {}",
2585                            "#".repeat(usize::from(heading.level)),
2586                            heading.text
2587                        ),
2588                        detail: format!("line {}", heading.line + 1),
2589                    },
2590                )
2591            })
2592            .chain(self.regions.iter().map(|region| {
2593                (
2594                    region.line,
2595                    SidebarEntry {
2596                        label: format!("§ {}", section_label(region.role, region.title.as_deref())),
2597                        detail: format!("{} items · line {}", region.item_count, region.line + 1),
2598                    },
2599                )
2600            }))
2601            .collect::<Vec<_>>();
2602        entries.sort_by_key(|(line, _)| *line);
2603        entries.into_iter().map(|(_, entry)| entry).collect()
2604    }
2605}
2606
2607#[derive(Debug, Clone, PartialEq, Eq)]
2608struct SidebarEntry {
2609    label: String,
2610    detail: String,
2611}
2612
2613impl LayoutCacheKey {
2614    fn new(document: &IndexDocument, width: usize, table_options: TableRenderOptions) -> Self {
2615        Self {
2616            document_hash: stable_layout_hash(document),
2617            width,
2618            table_options,
2619        }
2620    }
2621}
2622
2623fn stable_layout_hash(document: &IndexDocument) -> u64 {
2624    let mut hash = 0xcbf2_9ce4_8422_2325_u64;
2625    for byte in format!("{document:?}").as_bytes() {
2626        hash ^= u64::from(*byte);
2627        hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
2628    }
2629    hash
2630}
2631
2632fn dotted_block(title: &'static str) -> Block<'static> {
2633    Block::default()
2634        .borders(Borders::ALL)
2635        .border_set(DOTTED_BORDER)
2636        .title(title)
2637}
2638
2639fn sidebar_mode_title(mode: SessionSidebarMode) -> &'static str {
2640    match mode {
2641        SessionSidebarMode::Links => "Links",
2642        SessionSidebarMode::Outline => "Outline",
2643        SessionSidebarMode::Forms => "Forms",
2644        SessionSidebarMode::Regions => "Regions",
2645        SessionSidebarMode::Search => "Search",
2646        SessionSidebarMode::Logs => "Logs",
2647    }
2648}
2649
2650fn section_at_path_mut<'a>(
2651    nodes: &'a mut [IndexNode],
2652    path: &[usize],
2653) -> Option<&'a mut IndexNode> {
2654    let (first, rest) = path.split_first()?;
2655    let node = nodes.get_mut(*first)?;
2656    if rest.is_empty() {
2657        return Some(node);
2658    }
2659
2660    match node {
2661        IndexNode::Section { nodes, .. } => section_at_path_mut(nodes, rest),
2662        _ => None,
2663    }
2664}
2665
2666fn form_input_mut_by_render_index(
2667    nodes: &mut [IndexNode],
2668    target_form_index: usize,
2669    target_field_index: usize,
2670) -> Option<&mut Input> {
2671    let mut current_form_index = 0;
2672    form_input_mut_by_render_index_inner(
2673        nodes,
2674        target_form_index,
2675        target_field_index,
2676        &mut current_form_index,
2677    )
2678}
2679
2680fn form_input_mut_by_render_index_inner<'a>(
2681    nodes: &'a mut [IndexNode],
2682    target_form_index: usize,
2683    target_field_index: usize,
2684    current_form_index: &mut usize,
2685) -> Option<&'a mut Input> {
2686    for node in nodes {
2687        match node {
2688            IndexNode::Form(form) => {
2689                *current_form_index += 1;
2690                if *current_form_index == target_form_index {
2691                    return form.inputs.get_mut(target_field_index);
2692                }
2693            }
2694            IndexNode::Section {
2695                nodes, collapsed, ..
2696            } if !*collapsed => {
2697                if let Some(input) = form_input_mut_by_render_index_inner(
2698                    nodes,
2699                    target_form_index,
2700                    target_field_index,
2701                    current_form_index,
2702                ) {
2703                    return Some(input);
2704                }
2705            }
2706            _ => {}
2707        }
2708    }
2709
2710    None
2711}
2712
2713/// Runs the interactive TUI until the user quits.
2714pub fn run_tui(document: IndexDocument) -> io::Result<()> {
2715    run_tui_with_navigation(document, |_target| {
2716        Err("live navigation is not configured".to_owned())
2717    })
2718}
2719
2720/// Runs the interactive TUI with a navigation handler for `:open` actions.
2721pub fn run_tui_with_navigation<F>(document: IndexDocument, navigate: F) -> io::Result<()>
2722where
2723    F: FnMut(&str) -> Result<IndexDocument, String> + Send + 'static,
2724{
2725    run_tui_with_navigation_and_profile(document, ReaderProfile::Reader, navigate)
2726}
2727
2728/// Runs the interactive TUI with a navigation handler and initial reader profile.
2729pub fn run_tui_with_navigation_and_profile<F>(
2730    document: IndexDocument,
2731    profile: ReaderProfile,
2732    navigate: F,
2733) -> io::Result<()>
2734where
2735    F: FnMut(&str) -> Result<IndexDocument, String> + Send + 'static,
2736{
2737    run_tui_with_navigation_profile_and_forms(document, profile, navigate, |_submission| {
2738        Err("form submission is not configured".to_owned())
2739    })
2740}
2741
2742/// Runs the interactive TUI with navigation and form submission handlers.
2743pub fn run_tui_with_navigation_profile_and_forms<F, S>(
2744    document: IndexDocument,
2745    profile: ReaderProfile,
2746    mut navigate: F,
2747    mut submit_form: S,
2748) -> io::Result<()>
2749where
2750    F: FnMut(&str) -> Result<IndexDocument, String> + Send + 'static,
2751    S: FnMut(&FormSubmission) -> Result<IndexDocument, String> + Send + 'static,
2752{
2753    run_tui_with_navigation_profile_forms_and_state(
2754        document,
2755        profile,
2756        Vec::new(),
2757        Vec::new(),
2758        move |target| navigate(target).map(TuiDocumentResult::from),
2759        move |submission| submit_form(submission).map(TuiDocumentResult::from),
2760    )
2761}
2762
2763/// Runs the interactive TUI with navigation, form submission, and local session state.
2764pub fn run_tui_with_navigation_profile_forms_and_state<F, S>(
2765    document: IndexDocument,
2766    profile: ReaderProfile,
2767    url_history: Vec<String>,
2768    response_logs: Vec<ResponseLogEntry>,
2769    mut navigate: F,
2770    mut submit_form: S,
2771) -> io::Result<()>
2772where
2773    F: FnMut(&str) -> Result<TuiDocumentResult, String> + Send + 'static,
2774    S: FnMut(&FormSubmission) -> Result<TuiDocumentResult, String> + Send + 'static,
2775{
2776    run_tui_with_navigation_profile_forms_and_state_with_progress(
2777        document,
2778        profile,
2779        url_history,
2780        response_logs,
2781        move |target, _progress| navigate(target),
2782        move |submission, _progress| submit_form(submission),
2783    )
2784}
2785
2786/// Runs the interactive TUI with navigation, form submission, local session
2787/// state, and progress callbacks for long-running open/submit operations.
2788pub fn run_tui_with_navigation_profile_forms_and_state_with_progress<F, S>(
2789    document: IndexDocument,
2790    profile: ReaderProfile,
2791    url_history: Vec<String>,
2792    response_logs: Vec<ResponseLogEntry>,
2793    mut navigate: F,
2794    mut submit_form: S,
2795) -> io::Result<()>
2796where
2797    F: FnMut(&str, &mut dyn FnMut(String)) -> Result<TuiDocumentResult, String> + Send + 'static,
2798    S: FnMut(&FormSubmission, &mut dyn FnMut(String)) -> Result<TuiDocumentResult, String>
2799        + Send
2800        + 'static,
2801{
2802    enable_raw_mode()?;
2803    let mut stdout = io::stdout();
2804    execute!(stdout, EnterAlternateScreen)?;
2805    let backend = CrosstermBackend::new(stdout);
2806    let mut terminal = Terminal::new(backend)?;
2807    let mut app = TerminalApp::new(document, 88);
2808    app.set_url_history(url_history);
2809    app.set_response_logs(response_logs);
2810    if profile != ReaderProfile::Reader {
2811        app.set_reader_profile(profile);
2812    }
2813
2814    let (request_tx, request_rx) = mpsc::channel::<WorkerRequest>();
2815    let (response_tx, response_rx) = mpsc::channel::<WorkerResponse>();
2816    let _worker = thread::spawn(move || {
2817        while let Ok(request) = request_rx.recv() {
2818            match request {
2819                WorkerRequest::Open(target) => {
2820                    let mut report_progress = |message: String| {
2821                        let _ = response_tx.send(WorkerResponse::Progress { message });
2822                    };
2823                    let result = navigate(&target, &mut report_progress);
2824                    if response_tx
2825                        .send(WorkerResponse::Open { target, result })
2826                        .is_err()
2827                    {
2828                        break;
2829                    }
2830                }
2831                WorkerRequest::Submit(submission) => {
2832                    let mut report_progress = |message: String| {
2833                        let _ = response_tx.send(WorkerResponse::Progress { message });
2834                    };
2835                    let result = submit_form(&submission, &mut report_progress);
2836                    if response_tx
2837                        .send(WorkerResponse::Submit { submission, result })
2838                        .is_err()
2839                    {
2840                        break;
2841                    }
2842                }
2843                WorkerRequest::Stop => break,
2844            }
2845        }
2846    });
2847
2848    let result = run_tui_loop(&mut terminal, &mut app, &request_tx, &response_rx);
2849    let _ = request_tx.send(WorkerRequest::Stop);
2850
2851    disable_raw_mode()?;
2852    execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
2853    terminal.show_cursor()?;
2854
2855    result
2856}
2857
2858fn run_tui_loop(
2859    terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
2860    app: &mut TerminalApp,
2861    request_tx: &mpsc::Sender<WorkerRequest>,
2862    response_rx: &mpsc::Receiver<WorkerResponse>,
2863) -> io::Result<()> {
2864    while !app.should_quit() {
2865        handle_worker_response(app, response_rx);
2866        terminal.draw(|frame| app.render(frame))?;
2867        if event::poll(TUI_FRAME_DURATION)? {
2868            if let Event::Key(key) = event::read()? {
2869                let action = app.handle_key(key);
2870                dispatch_app_action(app, action, request_tx);
2871            }
2872        }
2873    }
2874
2875    Ok(())
2876}
2877
2878fn dispatch_app_action(
2879    app: &mut TerminalApp,
2880    action: AppAction,
2881    request_tx: &mpsc::Sender<WorkerRequest>,
2882) {
2883    match action {
2884        AppAction::Open(target) => {
2885            if app.status.starts_with("OPENING ") {
2886                app.status = "OPEN busy (wait or :quit)".to_owned();
2887                return;
2888            }
2889            app.start_open_loading(&target);
2890            if request_tx
2891                .send(WorkerRequest::Open(target.clone()))
2892                .is_err()
2893            {
2894                let error = "navigation worker is unavailable".to_owned();
2895                app.record_error_log("GET", &target, &error);
2896                app.open_document(
2897                    network_failure_document("Network fetch failed", &target, &error),
2898                    88,
2899                    format!("OPEN failed: {target}"),
2900                );
2901            }
2902        }
2903        AppAction::Submit(submission) => {
2904            if app.status.starts_with("OPENING ") {
2905                app.status = "SUBMIT busy (wait or :quit)".to_owned();
2906                return;
2907            }
2908            let action_target = submission.action.as_str().to_owned();
2909            app.start_open_loading(action_target.as_str());
2910            if request_tx
2911                .send(WorkerRequest::Submit(submission.clone()))
2912                .is_err()
2913            {
2914                let error = "form worker is unavailable".to_owned();
2915                app.record_error_log(submission.method.as_str(), &action_target, &error);
2916                app.open_document(
2917                    network_failure_document("Form submission failed", &action_target, &error),
2918                    88,
2919                    format!("SUBMIT failed: {action_target}"),
2920                );
2921            }
2922        }
2923        AppAction::Back => app.go_back(88),
2924        AppAction::None
2925        | AppAction::Quit
2926        | AppAction::Extract(_)
2927        | AppAction::Pipe(_)
2928        | AppAction::Ai(_) => {}
2929    }
2930}
2931
2932fn handle_worker_response(app: &mut TerminalApp, response_rx: &mpsc::Receiver<WorkerResponse>) {
2933    loop {
2934        match response_rx.try_recv() {
2935            Ok(WorkerResponse::Progress { message }) => app.set_opening_progress(message),
2936            Ok(WorkerResponse::Open { target, result }) => match result {
2937                Ok(result) => {
2938                    let visited_url = result.visited_url.clone().unwrap_or_else(|| target.clone());
2939                    if let Some(log) = result.response_log {
2940                        app.record_response_log(log);
2941                    }
2942                    app.record_visited_url(visited_url);
2943                    app.open_document(result.document, 88, format!("OPEN {target}"));
2944                }
2945                Err(error) => {
2946                    app.record_error_log("GET", &target, &error);
2947                    app.open_document(
2948                        network_failure_document("Network fetch failed", &target, &error),
2949                        88,
2950                        format!("OPEN failed: {target}"),
2951                    );
2952                }
2953            },
2954            Ok(WorkerResponse::Submit { submission, result }) => {
2955                let status = format!(
2956                    "SUBMIT {} {}",
2957                    submission.method.as_str(),
2958                    submission.action
2959                );
2960                match result {
2961                    Ok(result) => {
2962                        let visited_url = result
2963                            .visited_url
2964                            .clone()
2965                            .unwrap_or_else(|| submission.action.as_str().to_owned());
2966                        if let Some(log) = result.response_log {
2967                            app.record_response_log(log);
2968                        }
2969                        app.record_visited_url(visited_url);
2970                        app.open_document(result.document, 88, status);
2971                    }
2972                    Err(error) => {
2973                        let action = submission.action.as_str().to_owned();
2974                        app.record_error_log(submission.method.as_str(), &action, &error);
2975                        app.open_document(
2976                            network_failure_document("Form submission failed", &action, &error),
2977                            88,
2978                            format!("SUBMIT failed: {action}"),
2979                        );
2980                    }
2981                }
2982            }
2983            Err(TryRecvError::Empty) => break,
2984            Err(TryRecvError::Disconnected) => {
2985                if app.status.starts_with("OPENING ") {
2986                    let target = app.status.trim_start_matches("OPENING ").to_owned();
2987                    let error = "navigation worker disconnected".to_owned();
2988                    app.record_error_log("GET", &target, &error);
2989                    app.open_document(
2990                        network_failure_document("Network fetch failed", &target, &error),
2991                        88,
2992                        format!("OPEN failed: {target}"),
2993                    );
2994                }
2995                break;
2996            }
2997        }
2998    }
2999}
3000
3001fn prompt_colon_color(frame: usize, theme: Theme, animation_mode: AnimationMode) -> Color {
3002    if matches!(animation_mode, AnimationMode::None) {
3003        return theme.prompt;
3004    }
3005    if (frame / PROMPT_BLINK_TICKS) % 2 == 0 {
3006        theme.prompt
3007    } else {
3008        theme.muted
3009    }
3010}
3011
3012fn loading_spinner_frame(
3013    loading_frame: usize,
3014    animation_mode: AnimationMode,
3015    glyph_support: GlyphSupport,
3016) -> &'static str {
3017    if matches!(animation_mode, AnimationMode::None) {
3018        return "[ ]";
3019    }
3020    const FRAMES: [&str; 4] = ["-", "\\", "|", "/"];
3021    let frame = FRAMES[loading_frame % FRAMES.len()];
3022    let blink_slot = loading_frame % STATUS_BLINK_CYCLE_TICKS;
3023    let shown = STATUS_BLINK_ON_WINDOWS.contains(&blink_slot);
3024    if shown {
3025        return match glyph_support {
3026            GlyphSupport::Rich => match frame {
3027                "-" => "⠋",
3028                "\\" => "⠙",
3029                "|" => "⠸",
3030                "/" => "⠴",
3031                _ => "⠋",
3032            },
3033            GlyphSupport::Plain => frame,
3034        };
3035    }
3036    match glyph_support {
3037        GlyphSupport::Rich => "·",
3038        GlyphSupport::Plain => " ",
3039    }
3040}
3041
3042fn classify_status(status: &str) -> StatusSeverity {
3043    let normalized = status.to_ascii_lowercase();
3044    let has_any = |needles: &[&str]| needles.iter().any(|needle| normalized.contains(needle));
3045
3046    if has_any(&[
3047        "failed",
3048        "error",
3049        "denied",
3050        "missing",
3051        "unsupported",
3052        "not found",
3053        "disconnected",
3054        "unavailable",
3055        "no previous page",
3056    ]) {
3057        return StatusSeverity::Error;
3058    }
3059
3060    if has_any(&["busy", "confirm", "no match", "empty", "unknown command"]) {
3061        return StatusSeverity::Warning;
3062    }
3063
3064    if has_any(&[
3065        "open ", "submit ", "saved", "profile ", "repair ", "capture ",
3066    ]) {
3067        return StatusSeverity::Success;
3068    }
3069
3070    StatusSeverity::Info
3071}
3072
3073fn status_icon(severity: StatusSeverity, glyph_support: GlyphSupport) -> &'static str {
3074    match glyph_support {
3075        GlyphSupport::Rich => match severity {
3076            StatusSeverity::Error => "󰅚",
3077            StatusSeverity::Warning => "󰀪",
3078            StatusSeverity::Success => "󰄬",
3079            StatusSeverity::Info => "󰋼",
3080        },
3081        GlyphSupport::Plain => match severity {
3082            StatusSeverity::Error => "[error]",
3083            StatusSeverity::Warning => "[warn]",
3084            StatusSeverity::Success => "[ok]",
3085            StatusSeverity::Info => "[info]",
3086        },
3087    }
3088}
3089
3090fn status_style(severity: StatusSeverity, theme: Theme) -> Style {
3091    let color = match severity {
3092        StatusSeverity::Error => theme.error,
3093        StatusSeverity::Warning => theme.warning,
3094        StatusSeverity::Success => theme.success,
3095        StatusSeverity::Info => theme.info,
3096    };
3097    let mut style = Style::default().fg(color);
3098    if matches!(severity, StatusSeverity::Error | StatusSeverity::Warning) {
3099        style = style.add_modifier(Modifier::BOLD);
3100    }
3101    style
3102}
3103
3104fn classify_log_entry(log: &ResponseLogEntry) -> StatusSeverity {
3105    let mime = log
3106        .mime_type
3107        .as_deref()
3108        .unwrap_or_default()
3109        .to_ascii_lowercase();
3110    if mime.contains("x-index-error") {
3111        return StatusSeverity::Error;
3112    }
3113
3114    let body = log.body_preview.to_ascii_lowercase();
3115    if body.contains("failed")
3116        || body.contains("error")
3117        || body.contains("denied")
3118        || body.contains("unsupported")
3119    {
3120        return StatusSeverity::Error;
3121    }
3122
3123    if body.contains("busy") || body.contains("confirm") || body.contains("warning") {
3124        return StatusSeverity::Warning;
3125    }
3126
3127    StatusSeverity::Success
3128}
3129
3130#[cfg(test)]
3131fn handle_app_action<F>(app: &mut TerminalApp, action: AppAction, navigate: &mut F)
3132where
3133    F: FnMut(&str) -> Result<IndexDocument, String>,
3134{
3135    handle_app_action_with_forms(
3136        app,
3137        action,
3138        &mut |target| navigate(target).map(TuiDocumentResult::from),
3139        &mut |_submission| Err("form submission is not configured".to_owned()),
3140    );
3141}
3142
3143#[cfg(test)]
3144fn handle_app_action_with_forms<F, S>(
3145    app: &mut TerminalApp,
3146    action: AppAction,
3147    navigate: &mut F,
3148    submit_form: &mut S,
3149) where
3150    F: FnMut(&str) -> Result<TuiDocumentResult, String>,
3151    S: FnMut(&FormSubmission) -> Result<TuiDocumentResult, String>,
3152{
3153    match action {
3154        AppAction::Open(target) => {
3155            app.start_open_loading(&target);
3156            match navigate(&target) {
3157                Ok(result) => {
3158                    let visited_url = result.visited_url.clone().unwrap_or_else(|| target.clone());
3159                    if let Some(log) = result.response_log {
3160                        app.record_response_log(log);
3161                    }
3162                    app.record_visited_url(visited_url);
3163                    app.open_document(result.document, 88, format!("OPEN {target}"));
3164                }
3165                Err(error) => {
3166                    app.record_error_log("GET", &target, &error);
3167                    app.open_document(
3168                        network_failure_document("Network fetch failed", &target, &error),
3169                        88,
3170                        format!("OPEN failed: {target}"),
3171                    );
3172                }
3173            }
3174        }
3175        AppAction::Submit(submission) => {
3176            let status = format!(
3177                "SUBMIT {} {}",
3178                submission.method.as_str(),
3179                submission.action
3180            );
3181            app.start_open_loading(submission.action.as_str());
3182            match submit_form(&submission) {
3183                Ok(result) => {
3184                    let visited_url = result
3185                        .visited_url
3186                        .clone()
3187                        .unwrap_or_else(|| submission.action.as_str().to_owned());
3188                    if let Some(log) = result.response_log {
3189                        app.record_response_log(log);
3190                    }
3191                    app.record_visited_url(visited_url);
3192                    app.open_document(result.document, 88, status);
3193                }
3194                Err(error) => {
3195                    let action = submission.action.as_str().to_owned();
3196                    app.record_error_log(submission.method.as_str(), &action, &error);
3197                    app.open_document(
3198                        network_failure_document("Form submission failed", &action, &error),
3199                        88,
3200                        format!("SUBMIT failed: {action}"),
3201                    );
3202                }
3203            }
3204        }
3205        AppAction::Back => app.go_back(88),
3206        AppAction::None
3207        | AppAction::Quit
3208        | AppAction::Extract(_)
3209        | AppAction::Pipe(_)
3210        | AppAction::Ai(_) => {}
3211    }
3212}
3213
3214fn network_failure_document(title: &str, target: &str, error: &str) -> IndexDocument {
3215    FailureDiagnostic::new(
3216        title,
3217        DiagnosticSource::Network,
3218        DiagnosticConfidence::Failed,
3219        format!("could not fetch {target}: {error}"),
3220    )
3221    .with_fallback("no document was transformed")
3222    .with_tried("URL normalization")
3223    .with_tried("secure fetcher")
3224    .with_tried("bounded retry policy")
3225    .with_actions([
3226        DiagnosticAction::Retry,
3227        DiagnosticAction::Extract,
3228        DiagnosticAction::Capture,
3229    ])
3230    .with_command(format!(":open {target}"))
3231    .with_record(
3232        DiagnosticRecord::new(
3233            DiagnosticSeverity::Error,
3234            "INDEX-NETWORK-FAILED",
3235            error.to_owned(),
3236        )
3237        .with_field("target", target),
3238    )
3239    .into_document()
3240}
3241
3242fn save_current_capture(document: &IndexDocument, path: &str) -> Result<(), String> {
3243    validate_capture_path(path)?;
3244    let artifact = capture_document(document).map_err(|error| error.to_string())?;
3245    let text = artifact.to_text();
3246    validate_capture_bundle(&text).map_err(|error| error.to_string())?;
3247    fs::write(path, text).map_err(|error| error.to_string())
3248}
3249
3250fn validate_capture_path(path: &str) -> Result<(), String> {
3251    if path.is_empty() {
3252        return Err("path is required".to_owned());
3253    }
3254    if path.starts_with('-') {
3255        return Err("path must not look like an option".to_owned());
3256    }
3257    if path.contains('\0') {
3258        return Err("path contains a NUL byte".to_owned());
3259    }
3260    if Path::new(path).is_dir() {
3261        return Err("path points to a directory".to_owned());
3262    }
3263    Ok(())
3264}
3265
3266/// Renders a document as terminal-friendly plain text.
3267#[must_use]
3268pub fn render_document(document: &IndexDocument, options: RenderOptions) -> String {
3269    layout_document_with_options(document, options.width, TableRenderOptions::default())
3270        .lines
3271        .join("\n")
3272        .trim_end()
3273        .to_owned()
3274}
3275
3276fn layout_document_with_options(
3277    document: &IndexDocument,
3278    width: usize,
3279    table_options: TableRenderOptions,
3280) -> DocumentLayout {
3281    let mut layout = DocumentLayout::default();
3282
3283    if !document.title.is_empty() {
3284        layout
3285            .lines
3286            .push(format!("󰘬 # {}", sanitize_text(&document.title)));
3287        layout.lines.push(String::new());
3288    }
3289
3290    let mut link_index = 0;
3291    let mut form_index = 0;
3292    for (node_index, node) in document.nodes.iter().enumerate() {
3293        render_node(
3294            node,
3295            width,
3296            &[node_index],
3297            &mut link_index,
3298            &mut form_index,
3299            table_options,
3300            &mut layout,
3301        );
3302        if !matches!(node, IndexNode::Spacer { .. }) {
3303            layout.lines.push(String::new());
3304        }
3305    }
3306
3307    layout.lines = trim_trailing_empty_lines(layout.lines);
3308    layout
3309}
3310
3311fn render_node(
3312    node: &IndexNode,
3313    width: usize,
3314    path: &[usize],
3315    link_index: &mut usize,
3316    form_index: &mut usize,
3317    table_options: TableRenderOptions,
3318    layout: &mut DocumentLayout,
3319) {
3320    match node {
3321        IndexNode::Heading { level, text } => {
3322            let line = layout.lines.len();
3323            layout.headings.push(RenderedHeading {
3324                level: *level,
3325                text: sanitize_text(text),
3326                line,
3327            });
3328            layout.lines.push(format!(
3329                "󰉫 {} {}",
3330                "#".repeat(usize::from(*level)),
3331                sanitize_text(text)
3332            ));
3333        }
3334        IndexNode::Paragraph(text) => {
3335            layout
3336                .lines
3337                .extend(render_paragraph_lines(&sanitize_text(text), width));
3338        }
3339        IndexNode::Link(link) => {
3340            *link_index += 1;
3341            let line = format!(
3342                "󰌹 [{}] {} -> {}",
3343                link_index,
3344                sanitize_text(&link.text),
3345                sanitize_text(&link.href)
3346            );
3347            layout.links.push(RenderedLink {
3348                index: *link_index,
3349                text: sanitize_text(&link.text),
3350                href: sanitize_text(&link.href),
3351                line: layout.lines.len(),
3352            });
3353            layout.lines.push(line);
3354        }
3355        IndexNode::List { ordered, items } => {
3356            for (item_index, item) in items.iter().enumerate() {
3357                if *ordered {
3358                    layout
3359                        .lines
3360                        .push(format!("  󰄬 {}. {}", item_index + 1, sanitize_text(item)));
3361                } else {
3362                    layout.lines.push(format!("  • {}", sanitize_text(item)));
3363                }
3364            }
3365        }
3366        IndexNode::CodeBlock { language, code } => {
3367            layout.lines.push(format!(
3368                "  󰅩 ``` {}",
3369                sanitize_text(language.as_deref().unwrap_or("text"))
3370            ));
3371            layout.lines.extend(
3372                sanitize_text(code)
3373                    .lines()
3374                    .map(|line| format!("    {line}")),
3375            );
3376            layout.lines.push("  ```".to_owned());
3377        }
3378        IndexNode::Table { rows } => {
3379            layout
3380                .lines
3381                .extend(render_table_lines(rows, width, table_options));
3382        }
3383        IndexNode::Spacer {
3384            lines: spacer_lines,
3385        } => {
3386            for _ in 0..(*spacer_lines).clamp(1, 3) {
3387                layout.lines.push(String::new());
3388            }
3389        }
3390        IndexNode::Section {
3391            role,
3392            title,
3393            collapsed,
3394            nodes,
3395        } => {
3396            let line = layout.lines.len();
3397            layout.regions.push(RenderedRegion {
3398                path: path.to_vec(),
3399                role: *role,
3400                title: title.as_ref().map(|title| sanitize_text(title)),
3401                collapsed: *collapsed,
3402                line,
3403                item_count: section_item_count(nodes),
3404            });
3405            if *collapsed {
3406                layout.lines.push(format!(
3407                    "󰅂 ▸ {} ({} items)",
3408                    sanitize_text(&section_label(*role, title.as_deref())),
3409                    section_item_count(nodes)
3410                ));
3411            } else {
3412                layout.lines.push(format!(
3413                    "󰅂 ▾ {}",
3414                    sanitize_text(&section_label(*role, title.as_deref()))
3415                ));
3416                for (child_index, node) in nodes.iter().enumerate() {
3417                    let mut child_path = path.to_vec();
3418                    child_path.push(child_index);
3419                    render_node(
3420                        node,
3421                        width,
3422                        &child_path,
3423                        link_index,
3424                        form_index,
3425                        table_options,
3426                        layout,
3427                    );
3428                }
3429            }
3430        }
3431        IndexNode::Image { alt, src } => {
3432            let mut line = format!("󰥶 [image: {}", sanitize_text(alt));
3433            if let Some(src) = src {
3434                line.push_str(" -> ");
3435                line.push_str(&sanitize_text(src));
3436            }
3437            line.push(']');
3438            layout.lines.push(line);
3439        }
3440        IndexNode::Form(form) => {
3441            *form_index += 1;
3442            let line = layout.lines.len();
3443            layout.lines.push(format!(
3444                "󰈙 [form {}: {} {} {}]",
3445                form_index,
3446                sanitize_text(&form.method),
3447                sanitize_text(&form.name),
3448                sanitize_text(&form.action)
3449            ));
3450            for input in &form.inputs {
3451                let required = if input.required { " 󰐖 required" } else { "" };
3452                let value = input
3453                    .value
3454                    .as_deref()
3455                    .map(|value| form_display_value(&input.kind, value))
3456                    .filter(|value| !value.is_empty())
3457                    .map_or_else(String::new, |value| format!(" = {value}"));
3458                layout.lines.push(format!(
3459                    "  󰈙 {} {}{}{}",
3460                    sanitize_text(&input.kind),
3461                    sanitize_text(&input.name),
3462                    value,
3463                    required
3464                ));
3465            }
3466            layout.forms.push(RenderedForm {
3467                index: *form_index,
3468                name: sanitize_text(&form.name),
3469                line,
3470                form: form.clone(),
3471            });
3472        }
3473        IndexNode::Error(message) => {
3474            layout
3475                .lines
3476                .push(format!("󰅚 [error] {}", sanitize_text(message)));
3477        }
3478    }
3479}
3480
3481fn render_table_lines(
3482    rows: &[Vec<String>],
3483    width: usize,
3484    options: TableRenderOptions,
3485) -> Vec<String> {
3486    let column_count = rows.iter().map(Vec::len).max().unwrap_or(0);
3487    if rows.is_empty() || column_count == 0 {
3488        return vec!["  󰅚 [table] empty or malformed table".to_owned()];
3489    }
3490
3491    match options.mode {
3492        TableMode::Compact => {
3493            render_compact_table(rows, width, options.column_offset, column_count)
3494        }
3495        TableMode::Detail => render_detail_table(rows, width, column_count),
3496    }
3497}
3498
3499fn render_compact_table(
3500    rows: &[Vec<String>],
3501    width: usize,
3502    column_offset: usize,
3503    column_count: usize,
3504) -> Vec<String> {
3505    let offset = column_offset.min(column_count.saturating_sub(1));
3506    let visible_columns = visible_table_columns(width, column_count.saturating_sub(offset));
3507    let end = (offset + visible_columns).min(column_count);
3508    let cell_width = compact_cell_width(width, visible_columns);
3509    let mut lines = Vec::new();
3510    let hidden = offset > 0 || end < column_count;
3511
3512    if hidden {
3513        lines.push(format!("  󰓫 cols {}-{}/{}", offset + 1, end, column_count));
3514    }
3515
3516    if is_oversized_table(rows, column_count) {
3517        lines
3518            .push("  󰅚 [table] oversized table shown through a bounded column viewport".to_owned());
3519    }
3520
3521    for (row_index, row) in rows.iter().take(TABLE_MAX_COMPACT_ROWS).enumerate() {
3522        let cells = (offset..end)
3523            .map(|column| {
3524                row.get(column).map_or_else(String::new, |cell| {
3525                    truncate_display(&sanitize_text(cell), cell_width)
3526                })
3527            })
3528            .collect::<Vec<_>>();
3529        lines.push(format!("  󰓫 | {} |", cells.join(" | ")));
3530        if row_index == 0 && rows.len() > 1 {
3531            lines.push(format!(
3532                "  󰓫 | {} |",
3533                vec!["─".repeat(cell_width.min(12)); cells.len()]
3534                    .into_iter()
3535                    .collect::<Vec<_>>()
3536                    .join(" | ")
3537            ));
3538        }
3539    }
3540
3541    if rows.len() > TABLE_MAX_COMPACT_ROWS {
3542        lines.push(format!(
3543            "  󰅚 [table] {} additional rows hidden in compact mode; press t for detail",
3544            rows.len() - TABLE_MAX_COMPACT_ROWS
3545        ));
3546    }
3547
3548    lines
3549}
3550
3551fn render_detail_table(rows: &[Vec<String>], width: usize, column_count: usize) -> Vec<String> {
3552    let headers = table_headers(rows, column_count);
3553    let data_rows = rows.iter().skip(1).take(TABLE_MAX_DETAIL_ROWS);
3554    let mut lines = vec![format!(
3555        "  󰓫 table detail: {} rows, {} columns",
3556        rows.len().saturating_sub(1),
3557        column_count
3558    )];
3559
3560    if is_oversized_table(rows, column_count) {
3561        lines.push("  󰅚 [table] oversized table detail is truncated deterministically".to_owned());
3562    }
3563
3564    for (row_index, row) in data_rows.enumerate() {
3565        lines.push(format!("  󰓫 row {}", row_index + 1));
3566        for (column_index, header) in headers.iter().enumerate() {
3567            let value = row.get(column_index).map_or("", String::as_str);
3568            let prefix = format!("    {}: ", truncate_display(header, 18));
3569            lines.extend(prefix_wrapped_lines(&prefix, &sanitize_text(value), width));
3570        }
3571    }
3572
3573    let hidden_rows = rows.len().saturating_sub(1 + TABLE_MAX_DETAIL_ROWS);
3574    if hidden_rows > 0 {
3575        lines.push(format!("  󰅚 [table] {hidden_rows} additional rows hidden"));
3576    }
3577
3578    lines
3579}
3580
3581fn visible_table_columns(width: usize, available: usize) -> usize {
3582    let candidate = if width < 44 {
3583        2
3584    } else if width < 72 {
3585        3
3586    } else {
3587        4
3588    };
3589    candidate.min(available).max(1)
3590}
3591
3592fn compact_cell_width(width: usize, columns: usize) -> usize {
3593    let prefix = UnicodeWidthStr::width("  󰓫 | ");
3594    let separators = columns.saturating_sub(1) * 3 + 2;
3595    let available = width.saturating_sub(prefix + separators);
3596    (available / columns.max(1)).clamp(6, 24)
3597}
3598
3599fn is_oversized_table(rows: &[Vec<String>], column_count: usize) -> bool {
3600    rows.len().saturating_mul(column_count) > TABLE_OVERSIZED_CELL_LIMIT || column_count > 8
3601}
3602
3603fn table_headers(rows: &[Vec<String>], column_count: usize) -> Vec<String> {
3604    let first_row = rows.first();
3605    (0..column_count)
3606        .map(|index| {
3607            first_row
3608                .and_then(|row| row.get(index))
3609                .map(|header| sanitize_text(header))
3610                .filter(|header| !header.trim().is_empty())
3611                .unwrap_or_else(|| format!("column {}", index + 1))
3612        })
3613        .collect()
3614}
3615
3616fn render_paragraph_lines(text: &str, width: usize) -> Vec<String> {
3617    let trimmed = text.trim_start();
3618    if let Some(quote) = trimmed.strip_prefix('>') {
3619        return prefix_wrapped_lines("  󰂆 ", quote.trim_start(), width);
3620    }
3621
3622    prefix_wrapped_lines("│ ", text, width)
3623}
3624
3625fn section_label(role: SectionRole, title: Option<&str>) -> String {
3626    match title.map(str::trim).filter(|title| !title.is_empty()) {
3627        Some(title) => format!("{}: {title}", role.as_str()),
3628        None => role.as_str().to_owned(),
3629    }
3630}
3631
3632fn section_item_count(nodes: &[IndexNode]) -> usize {
3633    nodes
3634        .iter()
3635        .filter(|node| !matches!(node, IndexNode::Spacer { .. }))
3636        .count()
3637}
3638
3639fn max_table_column_offset(nodes: &[IndexNode], width: usize) -> usize {
3640    nodes
3641        .iter()
3642        .map(|node| match node {
3643            IndexNode::Table { rows } => {
3644                let column_count = rows.iter().map(Vec::len).max().unwrap_or(0);
3645                let visible = visible_table_columns(width, column_count);
3646                column_count.saturating_sub(visible)
3647            }
3648            IndexNode::Section { nodes, .. } => max_table_column_offset(nodes, width),
3649            _ => 0,
3650        })
3651        .max()
3652        .unwrap_or(0)
3653}
3654
3655fn markdown_inline_spans(
3656    line: &str,
3657    base_style: Style,
3658    bold_color: Color,
3659    italic_color: Color,
3660) -> Vec<Span<'static>> {
3661    let mut spans = Vec::new();
3662    let mut buffer = String::new();
3663    let mut bold = false;
3664    let mut italic = false;
3665    let mut index = 0;
3666
3667    while index < line.len() {
3668        let rest = &line[index..];
3669        if rest.starts_with("**") {
3670            push_inline_span(
3671                &mut spans,
3672                &mut buffer,
3673                base_style,
3674                bold,
3675                italic,
3676                bold_color,
3677                italic_color,
3678            );
3679            bold = !bold;
3680            index += 2;
3681        } else if rest.starts_with('*') {
3682            push_inline_span(
3683                &mut spans,
3684                &mut buffer,
3685                base_style,
3686                bold,
3687                italic,
3688                bold_color,
3689                italic_color,
3690            );
3691            italic = !italic;
3692            index += 1;
3693        } else if let Some(ch) = rest.chars().next() {
3694            buffer.push(ch);
3695            index += ch.len_utf8();
3696        } else {
3697            break;
3698        }
3699    }
3700
3701    push_inline_span(
3702        &mut spans,
3703        &mut buffer,
3704        base_style,
3705        bold,
3706        italic,
3707        bold_color,
3708        italic_color,
3709    );
3710
3711    if spans.is_empty() {
3712        spans.push(Span::styled(String::new(), base_style));
3713    }
3714    spans
3715}
3716
3717fn push_inline_span(
3718    spans: &mut Vec<Span<'static>>,
3719    buffer: &mut String,
3720    base_style: Style,
3721    bold: bool,
3722    italic: bool,
3723    bold_color: Color,
3724    italic_color: Color,
3725) {
3726    if buffer.is_empty() {
3727        return;
3728    }
3729
3730    let mut style = base_style;
3731    if bold {
3732        style = style.fg(bold_color).add_modifier(Modifier::BOLD);
3733    }
3734    if italic {
3735        style = style.fg(italic_color).add_modifier(Modifier::ITALIC);
3736    }
3737
3738    spans.push(Span::styled(std::mem::take(buffer), style));
3739}
3740
3741fn hide_markdown_syntax(line: &str) -> String {
3742    let trimmed = line.trim_start();
3743    let leading = &line[..line.len().saturating_sub(trimmed.len())];
3744
3745    if let Some(title) = trimmed.strip_prefix("󰘬 # ") {
3746        return format!("{leading}󰘬 {title}");
3747    }
3748
3749    if let Some(rest) = trimmed.strip_prefix("󰉫 ") {
3750        let heading_text = rest.trim_start_matches('#').trim_start();
3751        return format!("{leading}󰉫 {heading_text}");
3752    }
3753
3754    if let Some(rest) = trimmed.strip_prefix("󰅩 ```") {
3755        let language = rest.trim();
3756        if language.is_empty() {
3757            return format!("{leading}󰅩 code");
3758        }
3759        return format!("{leading}󰅩 {language}");
3760    }
3761
3762    if trimmed == "```" {
3763        return String::new();
3764    }
3765
3766    if let Some(rest) = trimmed.strip_prefix("󰓫 | ") {
3767        let cells = rest.trim_end_matches('|').trim();
3768        return format!("{leading}󰓫 {}", cells.replace(" | ", "  "));
3769    }
3770
3771    if let Some(rest) = trimmed.strip_prefix("󰌹 [") {
3772        if let Some((index, link)) = rest.split_once("] ") {
3773            return format!("{leading}󰌹 {index} {link}");
3774        }
3775    }
3776
3777    if let Some(rest) = trimmed.strip_prefix("󰥶 [") {
3778        return format!("{leading}󰥶 {}", rest.trim_end_matches(']'));
3779    }
3780
3781    if let Some(rest) = trimmed.strip_prefix("󰈙 [") {
3782        return format!("{leading}󰈙 {}", rest.trim_end_matches(']'));
3783    }
3784
3785    line.to_owned()
3786}
3787
3788fn plain_glyph_fallback(line: &str) -> String {
3789    line.replace("󰘬", "TITLE")
3790        .replace("󰉫", "HEAD")
3791        .replace("󰌹", "LINK")
3792        .replace("󰄬", "-")
3793        .replace("󰅩", "CODE")
3794        .replace("󰓫", "TABLE")
3795        .replace("󰅂", "REGION")
3796        .replace("󰥶", "IMG")
3797        .replace("󰈙", "FORM")
3798        .replace("󰂆", "QUOTE")
3799        .replace("󰅚", "ERROR")
3800}
3801
3802fn truncate_display(input: &str, width: usize) -> String {
3803    if UnicodeWidthStr::width(input) <= width {
3804        return input.to_owned();
3805    }
3806
3807    let mut output = String::new();
3808    let mut used = 0;
3809    for ch in input.chars() {
3810        let ch_width = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0);
3811        if used + ch_width + 1 > width {
3812            break;
3813        }
3814        output.push(ch);
3815        used += ch_width;
3816    }
3817    output.push('…');
3818    output
3819}
3820
3821fn prefix_wrapped_lines(prefix: &str, text: &str, width: usize) -> Vec<String> {
3822    let body_width = width.saturating_sub(UnicodeWidthStr::width(prefix));
3823    wrap_text(text, body_width)
3824        .into_iter()
3825        .map(|line| format!("{prefix}{line}"))
3826        .collect()
3827}
3828
3829fn parse_form_values(input: &str) -> Vec<(String, String)> {
3830    input
3831        .split_whitespace()
3832        .filter_map(|part| {
3833            let (name, value) = part.split_once('=')?;
3834            (!name.is_empty()).then_some((name.to_owned(), value.to_owned()))
3835        })
3836        .collect()
3837}
3838
3839fn editable_field_indices(form: &Form) -> Vec<usize> {
3840    form.inputs
3841        .iter()
3842        .enumerate()
3843        .filter_map(|(index, input)| is_editable_field(&input.kind).then_some(index))
3844        .collect()
3845}
3846
3847fn first_editable_field_index(form: &Form) -> Option<usize> {
3848    editable_field_indices(form).into_iter().next()
3849}
3850
3851fn is_editable_field(kind: &str) -> bool {
3852    !matches!(
3853        kind.trim().to_ascii_lowercase().as_str(),
3854        "button" | "submit" | "reset" | "image" | "hidden"
3855    )
3856}
3857
3858fn is_toggle_field(kind: &str) -> bool {
3859    matches!(
3860        kind.trim().to_ascii_lowercase().as_str(),
3861        "checkbox" | "radio"
3862    )
3863}
3864
3865fn is_secret_field(kind: &str) -> bool {
3866    kind.trim().eq_ignore_ascii_case("password")
3867}
3868
3869fn form_display_value(kind: &str, value: &str) -> String {
3870    if is_secret_field(kind) && !value.is_empty() {
3871        "•".repeat(value.chars().count().max(1))
3872    } else {
3873        sanitize_text(value)
3874    }
3875}
3876
3877fn trim_trailing_empty_lines(mut lines: Vec<String>) -> Vec<String> {
3878    while matches!(lines.last(), Some(line) if line.is_empty()) {
3879        lines.pop();
3880    }
3881    lines
3882}
3883
3884fn handle_text_mode_key(key: KeyEvent, input: &mut String) -> Option<String> {
3885    match key.code {
3886        KeyCode::Esc => {
3887            input.clear();
3888            Some(String::new())
3889        }
3890        KeyCode::Enter => Some(input.clone()),
3891        KeyCode::Backspace => {
3892            input.pop();
3893            None
3894        }
3895        KeyCode::Char(ch) => {
3896            input.push(ch);
3897            None
3898        }
3899        _ => None,
3900    }
3901}
3902
3903fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect {
3904    let vertical = Layout::default()
3905        .direction(Direction::Vertical)
3906        .constraints([
3907            Constraint::Percentage((100 - percent_y) / 2),
3908            Constraint::Percentage(percent_y),
3909            Constraint::Percentage((100 - percent_y) / 2),
3910        ])
3911        .split(area);
3912
3913    Layout::default()
3914        .direction(Direction::Horizontal)
3915        .constraints([
3916            Constraint::Percentage((100 - percent_x) / 2),
3917            Constraint::Percentage(percent_x),
3918            Constraint::Percentage((100 - percent_x) / 2),
3919        ])
3920        .split(vertical[1])[1]
3921}
3922
3923fn sanitize_text(text: &str) -> String {
3924    text.chars()
3925        .filter(|ch| *ch == '\n' || *ch == '\t' || !ch.is_control())
3926        .collect()
3927}
3928
3929fn wrap_text(text: &str, width: usize) -> Vec<String> {
3930    if width == 0 {
3931        return vec![text.to_owned()];
3932    }
3933
3934    let mut lines = Vec::new();
3935    let mut current = String::new();
3936    let mut current_width = 0;
3937
3938    for word in text.split_whitespace() {
3939        let word_width = UnicodeWidthStr::width(word);
3940        if word_width > width {
3941            if !current.is_empty() {
3942                lines.push(current);
3943                current = String::new();
3944                current_width = 0;
3945            }
3946
3947            for segment in split_display_width(word, width) {
3948                lines.push(segment);
3949            }
3950            continue;
3951        }
3952
3953        if current_width > 0 && current_width + 1 + word_width > width {
3954            lines.push(current);
3955            current = String::new();
3956            current_width = 0;
3957        }
3958
3959        if current_width > 0 {
3960            current.push(' ');
3961            current_width += 1;
3962        }
3963
3964        current.push_str(word);
3965        current_width += word_width;
3966    }
3967
3968    if !current.is_empty() {
3969        lines.push(current);
3970    }
3971
3972    if lines.is_empty() {
3973        lines.push(String::new());
3974    }
3975
3976    lines
3977}
3978
3979fn split_display_width(text: &str, width: usize) -> Vec<String> {
3980    let mut lines = Vec::new();
3981    let mut current = String::new();
3982    let mut current_width = 0;
3983
3984    for ch in text.chars() {
3985        let ch_width = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0);
3986        if current_width > 0 && current_width + ch_width > width {
3987            lines.push(current);
3988            current = String::new();
3989            current_width = 0;
3990        }
3991        current.push(ch);
3992        current_width += ch_width;
3993    }
3994
3995    if !current.is_empty() {
3996        lines.push(current);
3997    }
3998
3999    lines
4000}
4001
4002#[cfg(test)]
4003mod tests {
4004    use std::env;
4005    use std::fs;
4006    use std::sync::mpsc;
4007    use std::time::{SystemTime, UNIX_EPOCH};
4008
4009    use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
4010    use index_ai::AiAction;
4011    use index_core::{
4012        DocumentQuality, DocumentQualityCategory, Form, FormSubmission, IndexDocument, IndexNode,
4013        Input, Link, ReaderProfile, ResponseLogEntry, SectionRole, SessionSidebarMode,
4014    };
4015    use index_extract::{ExtractFormat, PipeCommand};
4016    use ratatui::Terminal;
4017    use ratatui::backend::TestBackend;
4018    use ratatui::style::{Color, Modifier};
4019    use unicode_width::UnicodeWidthStr;
4020
4021    use super::{
4022        AnimationMode, AppAction, ColorSupport, GlyphSupport, InputMode, RenderOptions,
4023        RepairAction, RepairRecipe, TableMode, TerminalApp, TerminalCapabilities, Theme,
4024        TuiDocumentResult, WorkerResponse, classify_log_entry, classify_status,
4025        dispatch_app_action, form_input_mut_by_render_index, handle_app_action,
4026        handle_app_action_with_forms, handle_worker_response, hide_markdown_syntax,
4027        render_document, save_current_capture, status_icon, status_style, suggest_reader_profile,
4028        truncate_display,
4029    };
4030
4031    fn document() -> IndexDocument {
4032        let mut document = IndexDocument::titled("Title");
4033        document.push(IndexNode::Heading {
4034            level: 2,
4035            text: "Section".to_owned(),
4036        });
4037        document.push(IndexNode::Paragraph("one two three four".to_owned()));
4038        document.push(IndexNode::Link(Link::new("One", "https://example.com/one")));
4039        document.push(IndexNode::Paragraph("Between.".to_owned()));
4040        document.push(IndexNode::Link(Link::new("Two", "https://example.com/two")));
4041        document
4042    }
4043
4044    fn key(code: KeyCode) -> KeyEvent {
4045        KeyEvent::new(code, KeyModifiers::NONE)
4046    }
4047
4048    fn modified_key(code: KeyCode, modifiers: KeyModifiers) -> KeyEvent {
4049        KeyEvent::new(code, modifiers)
4050    }
4051
4052    fn submit_command(app: &mut TerminalApp, command: &str) -> AppAction {
4053        assert_eq!(app.handle_key(key(KeyCode::Char(':'))), AppAction::None);
4054        for ch in command.chars() {
4055            assert_eq!(app.handle_key(key(KeyCode::Char(ch))), AppAction::None);
4056        }
4057        app.handle_key(key(KeyCode::Enter))
4058    }
4059
4060    fn unique_temp_file(label: &str) -> String {
4061        let nanos = SystemTime::now()
4062            .duration_since(UNIX_EPOCH)
4063            .map_or(0, |duration| duration.as_nanos());
4064        env::temp_dir()
4065            .join(format!("index-{label}-{nanos}.capture"))
4066            .display()
4067            .to_string()
4068    }
4069
4070    #[test]
4071    fn renders_plain_title_and_paragraph() {
4072        let rendered = render_document(&document(), RenderOptions::default());
4073        assert!(rendered.contains("# Title"));
4074        assert!(rendered.contains("one two three four"));
4075    }
4076
4077    #[test]
4078    fn wrapping_respects_display_width() {
4079        let mut document = IndexDocument::titled("Title");
4080        document.push(IndexNode::Paragraph("one two three four".to_owned()));
4081        let rendered = render_document(&document, RenderOptions { width: 7 });
4082        assert!(rendered.contains("│ one\n│ two\n│ three"));
4083    }
4084
4085    #[test]
4086    fn width_zero_keeps_paragraph_unwrapped() {
4087        let mut document = IndexDocument::titled("Width");
4088        document.push(IndexNode::Paragraph(
4089            "one two three four five six".to_owned(),
4090        ));
4091        let rendered = render_document(&document, RenderOptions { width: 0 });
4092        assert!(rendered.contains("one two three four five six"));
4093        assert!(!rendered.contains("one two\nthree"));
4094    }
4095
4096    #[test]
4097    fn renders_all_structural_nodes() {
4098        let mut document = IndexDocument::titled("Nodes");
4099        document.push(IndexNode::Paragraph(
4100            "> quoted public knowledge should remain readable".to_owned(),
4101        ));
4102        document.push(IndexNode::List {
4103            ordered: true,
4104            items: vec!["first".to_owned(), "second".to_owned()],
4105        });
4106        document.push(IndexNode::List {
4107            ordered: false,
4108            items: vec!["alpha".to_owned(), "beta".to_owned()],
4109        });
4110        document.push(IndexNode::CodeBlock {
4111            language: Some("rust".to_owned()),
4112            code: "fn main() {}".to_owned(),
4113        });
4114        document.push(IndexNode::CodeBlock {
4115            language: None,
4116            code: "plain text".to_owned(),
4117        });
4118        document.push(IndexNode::Table {
4119            rows: vec![
4120                vec!["col1".to_owned(), "col2".to_owned()],
4121                vec!["a".to_owned(), "b".to_owned()],
4122            ],
4123        });
4124        document.push(IndexNode::Image {
4125            alt: "diagram".to_owned(),
4126            src: Some("https://example.com/diagram.png".to_owned()),
4127        });
4128        document.push(IndexNode::Image {
4129            alt: "placeholder".to_owned(),
4130            src: None,
4131        });
4132        document.push(IndexNode::Form(Form {
4133            name: "search".to_owned(),
4134            method: "GET".to_owned(),
4135            action: "/search".to_owned(),
4136            inputs: vec![Input {
4137                name: "q".to_owned(),
4138                kind: "text".to_owned(),
4139                value: Some("index".to_owned()),
4140                required: true,
4141            }],
4142            buttons: Vec::new(),
4143        }));
4144        document.push(IndexNode::Error("recoverable message".to_owned()));
4145
4146        let rendered = render_document(&document, RenderOptions::default());
4147        assert!(rendered.contains("  󰂆 quoted public knowledge"));
4148        assert!(rendered.contains("1. first"));
4149        assert!(rendered.contains("• alpha"));
4150        assert!(rendered.contains("``` rust"));
4151        assert!(rendered.contains("``` text"));
4152        assert!(rendered.contains("    fn main() {}"));
4153        assert!(rendered.contains("| col1 | col2 |"));
4154        assert!(rendered.contains("[image: diagram -> https://example.com/diagram.png]"));
4155        assert!(rendered.contains("[image: placeholder]"));
4156        assert!(rendered.contains("[form 1: GET search /search]"));
4157        assert!(rendered.contains("text q = index 󰐖 required"));
4158        assert!(rendered.contains("[error] recoverable message"));
4159    }
4160
4161    #[test]
4162    fn render_omits_title_heading_when_document_title_is_empty() {
4163        let mut document = IndexDocument::default();
4164        document.push(IndexNode::Paragraph("Body.".to_owned()));
4165        let rendered = render_document(&document, RenderOptions::default());
4166        assert!(!rendered.starts_with("# "));
4167        assert!(rendered.contains("Body."));
4168    }
4169
4170    #[test]
4171    fn compact_table_rendering_bounds_wide_columns() {
4172        let mut document = IndexDocument::titled("Table");
4173        document.push(IndexNode::Table {
4174            rows: vec![
4175                vec![
4176                    "Name".to_owned(),
4177                    "Version".to_owned(),
4178                    "Description".to_owned(),
4179                    "License".to_owned(),
4180                ],
4181                vec![
4182                    "index-renderer".to_owned(),
4183                    "0.1.0".to_owned(),
4184                    "A terminal renderer with deliberately long descriptive text".to_owned(),
4185                    "Unlicense".to_owned(),
4186                ],
4187            ],
4188        });
4189
4190        let rendered = render_document(&document, RenderOptions { width: 42 });
4191
4192        assert!(rendered.contains("cols 1-2/4"));
4193        assert!(rendered.contains("| Name"));
4194        assert!(
4195            rendered
4196                .lines()
4197                .all(|line| UnicodeWidthStr::width(line) <= 48)
4198        );
4199    }
4200
4201    #[test]
4202    fn table_mode_toggles_detail_records() {
4203        let mut document = IndexDocument::titled("Table");
4204        document.push(IndexNode::Table {
4205            rows: vec![
4206                vec!["Name".to_owned(), "Value".to_owned()],
4207                vec!["Index".to_owned(), "Semantic browser".to_owned()],
4208            ],
4209        });
4210        let mut app = TerminalApp::new(document, 48);
4211
4212        assert_eq!(app.table_mode(), TableMode::Compact);
4213        assert_eq!(app.handle_key(key(KeyCode::Char('t'))), AppAction::None);
4214
4215        assert_eq!(app.table_mode(), TableMode::Detail);
4216        assert!(app.lines.iter().any(|line| line.contains("table detail")));
4217        assert!(app.lines.iter().any(|line| line.contains("Name: Index")));
4218    }
4219
4220    #[test]
4221    fn horizontal_table_scroll_shifts_visible_columns() {
4222        let mut document = IndexDocument::titled("Table");
4223        document.push(IndexNode::Table {
4224            rows: vec![
4225                vec![
4226                    "Name".to_owned(),
4227                    "Version".to_owned(),
4228                    "Description".to_owned(),
4229                    "License".to_owned(),
4230                ],
4231                vec![
4232                    "index".to_owned(),
4233                    "0.1".to_owned(),
4234                    "semantic browser".to_owned(),
4235                    "Unlicense".to_owned(),
4236                ],
4237            ],
4238        });
4239        let mut app = TerminalApp::new(document, 42);
4240
4241        assert!(app.lines.iter().any(|line| line.contains("Name")));
4242        assert!(!app.lines.iter().any(|line| line.contains("Description")));
4243        assert_eq!(app.handle_key(key(KeyCode::Char(']'))), AppAction::None);
4244
4245        assert_eq!(app.table_column_offset(), 1);
4246        assert!(app.lines.iter().any(|line| line.contains("Description")));
4247        assert!(app.status().contains("TABLE column 2"));
4248    }
4249
4250    #[test]
4251    fn stable_width_relayout_reuses_cached_wrapped_lines() {
4252        let mut document = IndexDocument::titled("Large");
4253        for index in 0..64 {
4254            document.push(IndexNode::Paragraph(format!(
4255                "paragraph {index} with enough words to wrap across multiple terminal lines"
4256            )));
4257        }
4258        let mut app = TerminalApp::new(document, 32);
4259        let initial_cache_len = app.layout_cache.len();
4260
4261        app.relayout(32);
4262
4263        assert_eq!(app.layout_cache.len(), initial_cache_len);
4264        assert!(app.lines.iter().any(|line| line.contains("paragraph 63")));
4265    }
4266
4267    #[test]
4268    fn ratatui_table_modes_have_stable_snapshots() -> Result<(), Box<dyn std::error::Error>> {
4269        let backend = TestBackend::new(72, 12);
4270        let mut terminal = Terminal::new(backend)?;
4271        let mut document = IndexDocument::titled("Table");
4272        document.push(IndexNode::Table {
4273            rows: vec![
4274                vec![
4275                    "Name".to_owned(),
4276                    "Version".to_owned(),
4277                    "Description".to_owned(),
4278                    "License".to_owned(),
4279                ],
4280                vec![
4281                    "index".to_owned(),
4282                    "0.1".to_owned(),
4283                    "semantic terminal browser".to_owned(),
4284                    "Unlicense".to_owned(),
4285                ],
4286            ],
4287        });
4288        let mut app = TerminalApp::new(document, 42);
4289
4290        terminal.draw(|frame| app.render(frame))?;
4291        let compact = buffer_to_string(terminal.backend().buffer());
4292        assert!(compact.contains("cols 1-2/4"));
4293        assert!(compact.contains("Name"));
4294
4295        app.handle_key(key(KeyCode::Char('t')));
4296        terminal.draw(|frame| app.render(frame))?;
4297        let detail = buffer_to_string(terminal.backend().buffer());
4298        assert!(detail.contains("table detail"));
4299        assert!(detail.contains("Description: semantic"));
4300        Ok(())
4301    }
4302
4303    #[test]
4304    fn malformed_and_oversized_tables_emit_diagnostics() {
4305        let mut document = IndexDocument::titled("Tables");
4306        document.push(IndexNode::Table { rows: Vec::new() });
4307        document.push(IndexNode::Table {
4308            rows: (0..28)
4309                .map(|row| {
4310                    (0..10)
4311                        .map(|column| format!("r{row}c{column}"))
4312                        .collect::<Vec<_>>()
4313                })
4314                .collect(),
4315        });
4316
4317        let rendered = render_document(&document, RenderOptions { width: 72 });
4318
4319        assert!(rendered.contains("empty or malformed table"));
4320        assert!(rendered.contains("oversized table"));
4321    }
4322
4323    #[test]
4324    fn renders_layout_spacers_as_extra_blank_lines() {
4325        let mut document = IndexDocument::titled("Rhythm");
4326        document.push(IndexNode::Paragraph("First.".to_owned()));
4327        document.push(IndexNode::Spacer { lines: 2 });
4328        document.push(IndexNode::Paragraph("Second.".to_owned()));
4329
4330        let rendered = render_document(&document, RenderOptions::default());
4331        assert!(rendered.contains("│ First.\n\n\n\n│ Second."));
4332    }
4333
4334    #[test]
4335    fn renders_collapsed_sections_without_link_numbering_noise() {
4336        let mut document = IndexDocument::titled("Regions");
4337        document.push(IndexNode::Paragraph("Main body.".to_owned()));
4338        document.push(IndexNode::Section {
4339            role: SectionRole::Navigation,
4340            title: Some("Site".to_owned()),
4341            collapsed: true,
4342            nodes: vec![IndexNode::Link(Link::new(
4343                "Docs",
4344                "https://example.com/docs",
4345            ))],
4346        });
4347
4348        let rendered = render_document(&document, RenderOptions::default());
4349        assert!(rendered.contains("󰅂 ▸ navigation: Site (1 items)"));
4350        assert!(!rendered.contains("Docs -> https://example.com/docs"));
4351
4352        let app = TerminalApp::new(document, 88);
4353        assert!(app.links.is_empty());
4354    }
4355
4356    #[test]
4357    fn link_hints_count_only_links() {
4358        let rendered = render_document(&document(), RenderOptions::default());
4359        assert!(rendered.contains("[1] One -> https://example.com/one"));
4360        assert!(rendered.contains("[2] Two -> https://example.com/two"));
4361    }
4362
4363    #[test]
4364    fn viewport_moves_with_navigation_keys() {
4365        let mut app = TerminalApp::new(document(), 12);
4366        app.set_viewport_height(3);
4367        assert_eq!(app.viewport().offset, 0);
4368        assert_eq!(app.handle_key(key(KeyCode::Char('j'))), AppAction::None);
4369        assert_eq!(app.viewport().offset, 1);
4370        assert_eq!(app.handle_key(key(KeyCode::Char('k'))), AppAction::None);
4371        assert_eq!(app.viewport().offset, 0);
4372        assert_eq!(app.handle_key(key(KeyCode::Char('G'))), AppAction::None);
4373        assert!(app.viewport().offset > 0);
4374        assert_eq!(app.handle_key(key(KeyCode::Char('g'))), AppAction::None);
4375        assert_eq!(app.handle_key(key(KeyCode::Char('g'))), AppAction::None);
4376        assert_eq!(app.viewport().offset, 0);
4377    }
4378
4379    #[test]
4380    fn search_mode_scrolls_to_matching_line() {
4381        let mut app = TerminalApp::new(document(), 20);
4382        app.set_viewport_height(2);
4383        assert_eq!(app.handle_key(key(KeyCode::Char('/'))), AppAction::None);
4384        assert!(matches!(app.mode(), InputMode::Search(_)));
4385        for ch in "Between".chars() {
4386            assert_eq!(app.handle_key(key(KeyCode::Char(ch))), AppAction::None);
4387        }
4388        assert_eq!(app.handle_key(key(KeyCode::Enter)), AppAction::None);
4389        assert!(matches!(app.mode(), InputMode::Normal));
4390        assert!(app.viewport().offset > 0);
4391        assert_eq!(app.status(), "SEARCH Between");
4392    }
4393
4394    #[test]
4395    fn search_mode_reports_no_match() {
4396        let mut app = TerminalApp::new(document(), 20);
4397        assert_eq!(app.handle_key(key(KeyCode::Char('/'))), AppAction::None);
4398        for ch in "missing".chars() {
4399            assert_eq!(app.handle_key(key(KeyCode::Char(ch))), AppAction::None);
4400        }
4401        assert_eq!(app.handle_key(key(KeyCode::Enter)), AppAction::None);
4402        assert_eq!(app.status(), "No match: missing");
4403    }
4404
4405    #[test]
4406    fn link_hint_overlay_toggles_with_f() {
4407        let mut app = TerminalApp::new(document(), 20);
4408        assert!(!app.show_link_hints());
4409        assert_eq!(app.handle_key(key(KeyCode::Char('f'))), AppAction::None);
4410        assert!(app.show_link_hints());
4411        assert_eq!(app.handle_key(key(KeyCode::Char('f'))), AppAction::None);
4412        assert!(!app.show_link_hints());
4413    }
4414
4415    #[test]
4416    fn link_sidebar_toggles_selects_and_opens_links() {
4417        let mut app = TerminalApp::new(document(), 20);
4418        assert!(!app.show_link_sidebar());
4419
4420        assert_eq!(app.handle_key(key(KeyCode::Char('l'))), AppAction::None);
4421        assert!(app.show_link_sidebar());
4422        assert_eq!(app.status(), "LINKS 1/2");
4423
4424        assert_eq!(app.handle_key(key(KeyCode::Char('j'))), AppAction::None);
4425        assert_eq!(app.status(), "LINKS 2/2");
4426        assert_eq!(
4427            app.handle_key(key(KeyCode::Enter)),
4428            AppAction::Open("https://example.com/two".to_owned())
4429        );
4430
4431        assert_eq!(app.handle_key(key(KeyCode::Esc)), AppAction::None);
4432        assert!(!app.show_link_sidebar());
4433    }
4434
4435    #[test]
4436    fn link_sidebar_supports_previous_open_alias_and_toggle_hide() {
4437        let mut app = TerminalApp::new(document(), 20);
4438        assert_eq!(app.handle_key(key(KeyCode::Char('l'))), AppAction::None);
4439        assert_eq!(app.handle_key(key(KeyCode::Char('j'))), AppAction::None);
4440        assert_eq!(app.handle_key(key(KeyCode::Char('k'))), AppAction::None);
4441        assert_eq!(app.status(), "LINKS 1/2");
4442        assert_eq!(
4443            app.handle_key(key(KeyCode::Char('o'))),
4444            AppAction::Open("https://example.com/one".to_owned())
4445        );
4446        assert_eq!(app.handle_key(key(KeyCode::Char('l'))), AppAction::None);
4447        assert!(!app.show_link_sidebar());
4448    }
4449
4450    #[test]
4451    fn sidebar_modes_switch_and_jump_to_outline_items() {
4452        let mut app = TerminalApp::new(document(), 20);
4453        app.set_viewport_height(2);
4454
4455        assert_eq!(app.handle_key(key(KeyCode::Char('l'))), AppAction::None);
4456        assert_eq!(app.sidebar_mode(), SessionSidebarMode::Links);
4457        assert_eq!(app.handle_key(key(KeyCode::Char(']'))), AppAction::None);
4458        assert_eq!(app.sidebar_mode(), SessionSidebarMode::Outline);
4459        assert_eq!(app.status(), "OUTLINE 1/1");
4460        assert_eq!(app.handle_key(key(KeyCode::Enter)), AppAction::None);
4461        assert!(app.viewport().offset > 0);
4462        assert!(app.status().starts_with("OUTLINE line "));
4463
4464        assert_eq!(app.handle_key(key(KeyCode::Char('['))), AppAction::None);
4465        assert_eq!(app.sidebar_mode(), SessionSidebarMode::Links);
4466        assert_eq!(app.handle_key(key(KeyCode::Char('3'))), AppAction::None);
4467        assert_eq!(app.sidebar_mode(), SessionSidebarMode::Forms);
4468    }
4469
4470    #[test]
4471    fn region_sidebar_toggles_collapsed_sections() {
4472        let mut document = IndexDocument::titled("Regions");
4473        document.push(IndexNode::Paragraph("Main.".to_owned()));
4474        document.push(IndexNode::Section {
4475            role: SectionRole::Aside,
4476            title: Some("More".to_owned()),
4477            collapsed: true,
4478            nodes: vec![IndexNode::Paragraph("Hidden detail.".to_owned())],
4479        });
4480        let mut app = TerminalApp::new(document, 40);
4481
4482        assert!(!app.lines.iter().any(|line| line.contains("Hidden detail")));
4483        assert_eq!(app.handle_key(key(KeyCode::Char('l'))), AppAction::None);
4484        assert_eq!(app.handle_key(key(KeyCode::Char('4'))), AppAction::None);
4485        assert_eq!(app.sidebar_mode(), SessionSidebarMode::Regions);
4486        assert_eq!(app.handle_key(key(KeyCode::Enter)), AppAction::None);
4487
4488        assert!(app.lines.iter().any(|line| line.contains("Hidden detail")));
4489        assert_eq!(app.status(), "REGIONS expanded");
4490        assert_eq!(app.handle_key(key(KeyCode::Char(' '))), AppAction::None);
4491        assert!(!app.lines.iter().any(|line| line.contains("Hidden detail")));
4492        assert_eq!(app.status(), "REGIONS collapsed");
4493    }
4494
4495    #[test]
4496    fn search_sidebar_uses_latest_query_results() {
4497        let mut app = TerminalApp::new(document(), 20);
4498        app.set_viewport_height(2);
4499        assert_eq!(app.handle_key(key(KeyCode::Char('/'))), AppAction::None);
4500        for ch in "Two".chars() {
4501            assert_eq!(app.handle_key(key(KeyCode::Char(ch))), AppAction::None);
4502        }
4503        assert_eq!(app.handle_key(key(KeyCode::Enter)), AppAction::None);
4504
4505        assert_eq!(app.handle_key(key(KeyCode::Char('l'))), AppAction::None);
4506        assert_eq!(app.handle_key(key(KeyCode::Char('5'))), AppAction::None);
4507        assert_eq!(app.sidebar_mode(), SessionSidebarMode::Search);
4508        assert_eq!(app.status(), "SEARCH 1/2");
4509        assert_eq!(app.handle_key(key(KeyCode::Enter)), AppAction::None);
4510        assert!(app.status().starts_with("SEARCH line "));
4511    }
4512
4513    #[test]
4514    fn back_action_restores_previous_document() {
4515        let mut app = TerminalApp::new(document(), 20);
4516        let mut navigate = |target: &str| {
4517            let mut document = IndexDocument::titled("Opened");
4518            document.push(IndexNode::Paragraph(format!("Opened {target}")));
4519            Ok(document)
4520        };
4521
4522        handle_app_action(
4523            &mut app,
4524            AppAction::Open("https://example.com/opened".to_owned()),
4525            &mut navigate,
4526        );
4527        assert_eq!(app.document.title, "Opened");
4528
4529        assert_eq!(app.handle_key(key(KeyCode::Char('b'))), AppAction::Back);
4530        handle_app_action(&mut app, AppAction::Back, &mut navigate);
4531        assert_eq!(app.document.title, "Title");
4532        assert_eq!(app.status(), "BACK");
4533
4534        handle_app_action(&mut app, AppAction::Back, &mut navigate);
4535        assert_eq!(app.status(), "BACK no previous page");
4536    }
4537
4538    #[test]
4539    fn command_mode_requests_back_action() {
4540        let mut app = TerminalApp::new(document(), 20);
4541        assert_eq!(app.handle_key(key(KeyCode::Char(':'))), AppAction::None);
4542        for ch in "back".chars() {
4543            assert_eq!(app.handle_key(key(KeyCode::Char(ch))), AppAction::None);
4544        }
4545        assert_eq!(app.handle_key(key(KeyCode::Enter)), AppAction::Back);
4546    }
4547
4548    #[test]
4549    fn link_sidebar_reports_empty_documents() {
4550        let mut app = TerminalApp::new(IndexDocument::default(), 20);
4551        assert_eq!(app.handle_key(key(KeyCode::Char('l'))), AppAction::None);
4552        assert!(app.show_link_sidebar());
4553        assert_eq!(app.status(), "LINKS empty");
4554        assert_eq!(app.handle_key(key(KeyCode::Enter)), AppAction::None);
4555        assert_eq!(app.status(), "LINKS empty");
4556    }
4557
4558    #[test]
4559    fn logs_sidebar_is_hidden_until_requested() {
4560        let mut app = TerminalApp::new(document(), 40);
4561        app.set_response_logs(vec![ResponseLogEntry::new(
4562            1,
4563            "GET",
4564            "https://example.com?token=secret",
4565            "https://example.com",
4566            Some("text/html"),
4567            "<html>server token=abc visible</html>",
4568            64,
4569        )]);
4570
4571        assert!(!app.show_link_sidebar());
4572        assert_eq!(submit_command(&mut app, "logs"), AppAction::None);
4573        assert!(app.show_link_sidebar());
4574        assert_eq!(app.sidebar_mode(), SessionSidebarMode::Logs);
4575        assert_eq!(app.status(), "LOGS 1/1");
4576        let entries = app.sidebar_entries();
4577        assert_eq!(entries.len(), 1);
4578        assert!(entries[0].label.contains("GET"));
4579        assert!(!entries[0].detail.contains("abc"));
4580    }
4581
4582    #[test]
4583    fn open_command_autocompletes_from_url_history() {
4584        let mut app = TerminalApp::new(document(), 40);
4585        app.set_url_history(vec![
4586            "https://example.com/docs".to_owned(),
4587            "https://example.org/archive".to_owned(),
4588        ]);
4589
4590        assert_eq!(app.handle_key(key(KeyCode::Char(':'))), AppAction::None);
4591        for ch in "open https://example.com/d".chars() {
4592            assert_eq!(app.handle_key(key(KeyCode::Char(ch))), AppAction::None);
4593        }
4594
4595        assert_eq!(app.handle_key(key(KeyCode::Tab)), AppAction::None);
4596        assert_eq!(
4597            app.mode(),
4598            &InputMode::Command("open https://example.com/docs".to_owned())
4599        );
4600        assert!(app.status().contains("OPEN suggestion"));
4601    }
4602
4603    #[test]
4604    fn renderer_helpers_hide_syntax_and_truncate_display_width() {
4605        assert_eq!(hide_markdown_syntax("󰘬 # Title"), "󰘬 Title");
4606        assert_eq!(hide_markdown_syntax("󰉫 ### Deep"), "󰉫 Deep");
4607        assert_eq!(hide_markdown_syntax("  󰅩 ``` rust"), "  󰅩 rust");
4608        assert_eq!(hide_markdown_syntax("```"), "");
4609        assert_eq!(hide_markdown_syntax("  󰓫 | a | b |"), "  󰓫 a  b");
4610        assert_eq!(
4611            hide_markdown_syntax("󰌹 [12] Docs -> https://example.com"),
4612            "󰌹 12 Docs -> https://example.com"
4613        );
4614        assert_eq!(
4615            hide_markdown_syntax("󰥶 [image: alt -> image.png]"),
4616            "󰥶 image: alt -> image.png"
4617        );
4618        assert_eq!(
4619            hide_markdown_syntax("󰈙 [form 1: GET search /search]"),
4620            "󰈙 form 1: GET search /search"
4621        );
4622        assert_eq!(truncate_display("short", 10), "short");
4623        assert_eq!(truncate_display("long-display-value", 8), "long-di…");
4624    }
4625
4626    #[test]
4627    fn cjk_text_wraps_by_display_width_without_overflowing_columns() {
4628        let lines = super::wrap_text(
4629            "知識ページは日本語の見出しと本文を読みやすく保つ必要があります。",
4630            12,
4631        );
4632
4633        assert!(lines.len() > 1);
4634        assert!(
4635            lines
4636                .iter()
4637                .all(|line| UnicodeWidthStr::width(line.as_str()) <= 12)
4638        );
4639        assert_eq!(
4640            lines.join(""),
4641            "知識ページは日本語の見出しと本文を読みやすく保つ必要があります。"
4642        );
4643    }
4644
4645    #[test]
4646    fn command_mode_opens_links_and_quits() {
4647        let mut app = TerminalApp::new(document(), 20);
4648        assert_eq!(app.handle_key(key(KeyCode::Char(':'))), AppAction::None);
4649        for ch in "open 2".chars() {
4650            assert_eq!(app.handle_key(key(KeyCode::Char(ch))), AppAction::None);
4651        }
4652        assert_eq!(
4653            app.handle_key(key(KeyCode::Enter)),
4654            AppAction::Open("https://example.com/two".to_owned())
4655        );
4656
4657        assert_eq!(app.handle_key(key(KeyCode::Char(':'))), AppAction::None);
4658        for ch in "quit".chars() {
4659            assert_eq!(app.handle_key(key(KeyCode::Char(ch))), AppAction::None);
4660        }
4661        assert_eq!(app.handle_key(key(KeyCode::Enter)), AppAction::Quit);
4662        assert!(app.should_quit());
4663    }
4664
4665    #[test]
4666    fn command_mode_handles_raw_targets_unknown_commands_and_escape() {
4667        let mut app = TerminalApp::new(document(), 20);
4668        assert_eq!(app.handle_key(key(KeyCode::Char(':'))), AppAction::None);
4669        for ch in "open https://example.com/raw".chars() {
4670            assert_eq!(app.handle_key(key(KeyCode::Char(ch))), AppAction::None);
4671        }
4672        assert_eq!(
4673            app.handle_key(key(KeyCode::Enter)),
4674            AppAction::Open("https://example.com/raw".to_owned())
4675        );
4676
4677        assert_eq!(app.handle_key(key(KeyCode::Char(':'))), AppAction::None);
4678        for ch in "wat".chars() {
4679            assert_eq!(app.handle_key(key(KeyCode::Char(ch))), AppAction::None);
4680        }
4681        assert_eq!(app.handle_key(key(KeyCode::Enter)), AppAction::None);
4682        assert_eq!(app.status(), "Unknown command: wat");
4683
4684        assert_eq!(app.handle_key(key(KeyCode::Char(':'))), AppAction::None);
4685        assert_eq!(app.handle_key(key(KeyCode::Char('x'))), AppAction::None);
4686        assert_eq!(app.handle_key(key(KeyCode::Backspace)), AppAction::None);
4687        assert_eq!(app.handle_key(key(KeyCode::Esc)), AppAction::None);
4688        assert!(matches!(app.mode(), InputMode::Normal));
4689    }
4690
4691    #[test]
4692    fn command_mode_submits_named_form() {
4693        let mut document = document();
4694        document.push(IndexNode::Form(Form {
4695            name: "search".to_owned(),
4696            method: "GET".to_owned(),
4697            action: "https://example.com/search".to_owned(),
4698            inputs: vec![Input {
4699                name: "q".to_owned(),
4700                kind: "search".to_owned(),
4701                value: None,
4702                required: true,
4703            }],
4704            buttons: Vec::new(),
4705        }));
4706        let mut app = TerminalApp::new(document, 20);
4707
4708        assert_eq!(app.handle_key(key(KeyCode::Char(':'))), AppAction::None);
4709        for ch in "submit search q=index".chars() {
4710            assert_eq!(app.handle_key(key(KeyCode::Char(ch))), AppAction::None);
4711        }
4712        let action = app.handle_key(key(KeyCode::Enter));
4713
4714        assert!(
4715            matches!(action, AppAction::Submit(submission) if submission.action.as_str() == "https://example.com/search?q=index")
4716        );
4717    }
4718
4719    #[test]
4720    fn form_edit_mode_submits_current_field_values() {
4721        let mut document = document();
4722        document.push(IndexNode::Form(Form {
4723            name: "search".to_owned(),
4724            method: "GET".to_owned(),
4725            action: "https://example.com/search".to_owned(),
4726            inputs: vec![Input {
4727                name: "q".to_owned(),
4728                kind: "search".to_owned(),
4729                value: None,
4730                required: true,
4731            }],
4732            buttons: Vec::new(),
4733        }));
4734        let mut app = TerminalApp::new(document, 32);
4735
4736        assert_eq!(app.handle_key(key(KeyCode::Char('e'))), AppAction::None);
4737        assert!(matches!(app.mode(), InputMode::Form(edit) if edit.field_name == "q"));
4738        for ch in "index".chars() {
4739            assert_eq!(app.handle_key(key(KeyCode::Char(ch))), AppAction::None);
4740        }
4741        assert!(app.lines.iter().any(|line| line.contains("q = index")));
4742        let action = app.handle_key(key(KeyCode::Enter));
4743
4744        assert!(
4745            matches!(action, AppAction::Submit(submission) if submission.action.as_str() == "https://example.com/search?q=index")
4746        );
4747        assert!(matches!(app.mode(), InputMode::Normal));
4748    }
4749
4750    #[test]
4751    fn form_edit_mode_tabs_between_fields_and_validates_required_fields() {
4752        let mut document = document();
4753        document.push(IndexNode::Form(Form {
4754            name: "advanced".to_owned(),
4755            method: "GET".to_owned(),
4756            action: "https://example.com/search".to_owned(),
4757            inputs: vec![
4758                Input {
4759                    name: "q".to_owned(),
4760                    kind: "search".to_owned(),
4761                    value: None,
4762                    required: true,
4763                },
4764                Input {
4765                    name: "page".to_owned(),
4766                    kind: "hidden".to_owned(),
4767                    value: Some("1".to_owned()),
4768                    required: false,
4769                },
4770                Input {
4771                    name: "tag".to_owned(),
4772                    kind: "text".to_owned(),
4773                    value: None,
4774                    required: false,
4775                },
4776            ],
4777            buttons: Vec::new(),
4778        }));
4779        let mut app = TerminalApp::new(document, 32);
4780
4781        assert_eq!(app.handle_key(key(KeyCode::Char('e'))), AppAction::None);
4782        assert_eq!(app.handle_key(key(KeyCode::Tab)), AppAction::None);
4783        assert!(matches!(app.mode(), InputMode::Form(edit) if edit.field_name == "tag"));
4784        assert_eq!(app.handle_key(key(KeyCode::Enter)), AppAction::None);
4785
4786        assert!(app.status().contains("required form field is missing: q"));
4787    }
4788
4789    #[test]
4790    fn form_sidebar_edit_opens_selected_form() {
4791        let mut document = document();
4792        document.push(IndexNode::Form(Form {
4793            name: "search".to_owned(),
4794            method: "GET".to_owned(),
4795            action: "https://example.com/search".to_owned(),
4796            inputs: vec![Input {
4797                name: "q".to_owned(),
4798                kind: "search".to_owned(),
4799                value: None,
4800                required: false,
4801            }],
4802            buttons: Vec::new(),
4803        }));
4804        let mut app = TerminalApp::new(document, 32);
4805
4806        assert_eq!(app.handle_key(key(KeyCode::Char('l'))), AppAction::None);
4807        assert_eq!(app.handle_key(key(KeyCode::Char('3'))), AppAction::None);
4808        assert_eq!(app.handle_key(key(KeyCode::Char('e'))), AppAction::None);
4809
4810        assert!(matches!(app.mode(), InputMode::Form(edit) if edit.form_index == 1));
4811        assert!(app.status().contains("FORM 1 editing q"));
4812    }
4813
4814    #[test]
4815    fn form_edit_mode_cancels_toggles_and_masks_secret_values() {
4816        let mut document = document();
4817        document.push(IndexNode::Form(Form {
4818            name: "login".to_owned(),
4819            method: "POST".to_owned(),
4820            action: "https://example.com/login".to_owned(),
4821            inputs: vec![
4822                Input {
4823                    name: "password".to_owned(),
4824                    kind: "password".to_owned(),
4825                    value: Some("secret".to_owned()),
4826                    required: true,
4827                },
4828                Input {
4829                    name: "remember".to_owned(),
4830                    kind: "checkbox".to_owned(),
4831                    value: None,
4832                    required: false,
4833                },
4834            ],
4835            buttons: Vec::new(),
4836        }));
4837        let mut app = TerminalApp::new(document, 40);
4838
4839        assert!(
4840            app.lines
4841                .iter()
4842                .any(|line| line.contains("password = ••••••"))
4843        );
4844        assert_eq!(app.handle_key(key(KeyCode::Char('e'))), AppAction::None);
4845        assert!(matches!(app.mode(), InputMode::Form(edit) if edit.prompt_value() == "••••••"));
4846        assert_eq!(app.handle_key(key(KeyCode::Tab)), AppAction::None);
4847        assert!(matches!(app.mode(), InputMode::Form(edit) if edit.field_name == "remember"));
4848        assert_eq!(app.handle_key(key(KeyCode::Char(' '))), AppAction::None);
4849        assert!(app.lines.iter().any(|line| line.contains("remember = on")));
4850        assert_eq!(app.handle_key(key(KeyCode::Esc)), AppAction::None);
4851
4852        assert!(matches!(app.mode(), InputMode::Normal));
4853        assert_eq!(app.status(), "FORM cancelled");
4854    }
4855
4856    #[test]
4857    fn form_edit_mode_reports_absent_and_non_editable_forms() {
4858        let mut app = TerminalApp::new(document(), 40);
4859        assert_eq!(app.handle_key(key(KeyCode::Char('e'))), AppAction::None);
4860        assert_eq!(app.status(), "FORM no forms on this page");
4861
4862        let mut document = document();
4863        document.push(IndexNode::Form(Form {
4864            name: "hidden".to_owned(),
4865            method: "GET".to_owned(),
4866            action: "https://example.com/hidden".to_owned(),
4867            inputs: vec![Input {
4868                name: "token".to_owned(),
4869                kind: "hidden".to_owned(),
4870                value: Some("redacted".to_owned()),
4871                required: false,
4872            }],
4873            buttons: Vec::new(),
4874        }));
4875        let mut app = TerminalApp::new(document, 40);
4876        assert_eq!(app.handle_key(key(KeyCode::Char('e'))), AppAction::None);
4877        assert_eq!(app.status(), "FORM 1 has no editable fields");
4878    }
4879
4880    #[test]
4881    fn command_mode_requests_document_extraction() {
4882        let mut app = TerminalApp::new(document(), 20);
4883        let action = submit_command(&mut app, "extract markdown");
4884        assert_eq!(action, AppAction::Extract(ExtractFormat::Markdown));
4885        assert_eq!(app.status(), "EXTRACT markdown");
4886
4887        let action = submit_command(&mut app, "extract json");
4888        assert_eq!(action, AppAction::Extract(ExtractFormat::Json));
4889    }
4890
4891    #[test]
4892    fn command_mode_requires_pipe_confirmation() {
4893        let mut app = TerminalApp::new(document(), 20);
4894        let action = submit_command(&mut app, "pipe wc -l");
4895        assert_eq!(action, AppAction::None);
4896        assert_eq!(app.status(), "PIPE confirm with :pipe --confirm wc -l");
4897    }
4898
4899    #[test]
4900    fn command_mode_denies_unsafe_pipe_commands() {
4901        let mut app = TerminalApp::new(document(), 20);
4902        let action = submit_command(&mut app, "pipe wc -l; rm -rf target");
4903        assert_eq!(action, AppAction::None);
4904        assert!(app.status().contains("PIPE denied"));
4905    }
4906
4907    #[test]
4908    fn command_mode_returns_confirmed_pipe_action() {
4909        let mut app = TerminalApp::new(document(), 20);
4910        let action = submit_command(&mut app, "pipe --confirm wc -l");
4911        assert_eq!(action, AppAction::Pipe(PipeCommand::new("wc -l")));
4912        assert_eq!(app.status(), "PIPE wc -l");
4913    }
4914
4915    #[test]
4916    fn command_mode_requests_ai_actions_explicitly() {
4917        let mut app = TerminalApp::new(document(), 20);
4918        let action = submit_command(&mut app, "ai summarize");
4919        assert_eq!(action, AppAction::Ai(AiAction::Summarize));
4920        assert_eq!(app.status(), "AI summarize");
4921    }
4922
4923    #[test]
4924    fn command_mode_rejects_unknown_ai_action() {
4925        let mut app = TerminalApp::new(document(), 20);
4926        let action = submit_command(&mut app, "ai chat");
4927        assert_eq!(action, AppAction::None);
4928        assert_eq!(app.status(), "AI unsupported action: chat");
4929    }
4930
4931    #[test]
4932    fn command_mode_reports_missing_required_form_field() {
4933        let mut document = document();
4934        document.push(IndexNode::Form(Form {
4935            name: "search".to_owned(),
4936            method: "GET".to_owned(),
4937            action: "https://example.com/search".to_owned(),
4938            inputs: vec![Input {
4939                name: "q".to_owned(),
4940                kind: "search".to_owned(),
4941                value: None,
4942                required: true,
4943            }],
4944            buttons: Vec::new(),
4945        }));
4946        let mut app = TerminalApp::new(document, 20);
4947
4948        assert_eq!(app.handle_key(key(KeyCode::Char(':'))), AppAction::None);
4949        for ch in "submit 1 x=y".chars() {
4950            assert_eq!(app.handle_key(key(KeyCode::Char(ch))), AppAction::None);
4951        }
4952        assert_eq!(app.handle_key(key(KeyCode::Enter)), AppAction::None);
4953        assert!(app.status().contains("required form field is missing"));
4954    }
4955
4956    #[test]
4957    fn submitted_get_form_navigates_through_host_callback() {
4958        let mut app = TerminalApp::new(document(), 20);
4959        let form = Form {
4960            name: "search".to_owned(),
4961            method: "GET".to_owned(),
4962            action: "https://example.com/search".to_owned(),
4963            inputs: vec![Input {
4964                name: "q".to_owned(),
4965                kind: "search".to_owned(),
4966                value: Some("index".to_owned()),
4967                required: true,
4968            }],
4969            buttons: Vec::new(),
4970        };
4971        let submission = form.submit(None, &[]).map_err(|error| error.to_string());
4972        let mut navigate = |_target: &str| Err("unexpected navigation".to_owned());
4973        let mut submit_form = |submission: &FormSubmission| {
4974            let mut document = IndexDocument::titled("Submitted");
4975            document.push(IndexNode::Paragraph(format!(
4976                "Submitted {}",
4977                submission.action
4978            )));
4979            Ok(TuiDocumentResult::new(document).with_visited_url(submission.action.as_str()))
4980        };
4981
4982        if let Ok(submission) = submission {
4983            handle_app_action_with_forms(
4984                &mut app,
4985                AppAction::Submit(submission),
4986                &mut navigate,
4987                &mut submit_form,
4988            );
4989        }
4990
4991        assert_eq!(
4992            app.status(),
4993            "SUBMIT GET https://example.com/search?q=index"
4994        );
4995        assert!(app.lines.iter().any(|line| line.contains("Submitted")));
4996    }
4997
4998    #[test]
4999    fn submitted_post_form_uses_host_submission_callback() {
5000        let mut app = TerminalApp::new(document(), 20);
5001        let form = Form {
5002            name: "login".to_owned(),
5003            method: "POST".to_owned(),
5004            action: "https://example.com/login".to_owned(),
5005            inputs: vec![Input {
5006                name: "user".to_owned(),
5007                kind: "text".to_owned(),
5008                value: Some("index".to_owned()),
5009                required: true,
5010            }],
5011            buttons: Vec::new(),
5012        };
5013        let submission = form.submit(None, &[]).map_err(|error| error.to_string());
5014        let mut navigate = |_target: &str| Err("unexpected navigation".to_owned());
5015        let mut submit_form = |submission: &FormSubmission| {
5016            let mut document = IndexDocument::titled("Posted");
5017            document.push(IndexNode::Paragraph(format!(
5018                "Posted body {}",
5019                submission.body.as_deref().unwrap_or_default()
5020            )));
5021            Ok(TuiDocumentResult::new(document))
5022        };
5023
5024        if let Ok(submission) = submission {
5025            handle_app_action_with_forms(
5026                &mut app,
5027                AppAction::Submit(submission),
5028                &mut navigate,
5029                &mut submit_form,
5030            );
5031        }
5032
5033        assert_eq!(app.status(), "SUBMIT POST https://example.com/login");
5034        assert!(app.lines.iter().any(|line| line.contains("user=index")));
5035    }
5036
5037    #[test]
5038    fn control_c_quits_from_normal_mode() {
5039        let mut app = TerminalApp::new(document(), 20);
5040        assert_eq!(
5041            app.handle_key(modified_key(KeyCode::Char('c'), KeyModifiers::CONTROL)),
5042            AppAction::Quit
5043        );
5044        assert!(app.should_quit());
5045    }
5046
5047    #[test]
5048    fn ratatui_snapshot_is_deterministic() -> Result<(), Box<dyn std::error::Error>> {
5049        let backend = TestBackend::new(48, 10);
5050        let mut terminal = Terminal::new(backend)?;
5051        let mut document = document();
5052        document.metadata.quality = Some(DocumentQuality::new(
5053            DocumentQualityCategory::StrongGeneric,
5054            82,
5055            ["generic reader emitted semantic content"],
5056        ));
5057        let mut app = TerminalApp::new(document, 20);
5058        terminal.draw(|frame| app.render(frame))?;
5059        let snapshot = buffer_to_string(terminal.backend().buffer());
5060        assert!(snapshot.contains("Title"));
5061        assert!(snapshot.contains("one two three four"));
5062        assert!(snapshot.contains("quality: strong-generic"));
5063        assert!(snapshot.contains("[:> "));
5064        assert!(snapshot.contains("j/k scroll"));
5065        Ok(())
5066    }
5067
5068    #[test]
5069    fn reader_profiles_have_distinct_deterministic_themes() {
5070        let profiles = ReaderProfile::all();
5071        let themes = profiles
5072            .iter()
5073            .map(|profile| Theme::for_profile(*profile))
5074            .collect::<Vec<_>>();
5075
5076        assert_eq!(profiles.len(), themes.len());
5077        assert_eq!(Theme::for_profile(ReaderProfile::Reader), Theme::default());
5078        assert_ne!(
5079            Theme::for_profile(ReaderProfile::Links).link,
5080            Theme::for_profile(ReaderProfile::Research).link
5081        );
5082        assert_ne!(
5083            Theme::for_profile(ReaderProfile::Docs).code,
5084            Theme::for_profile(ReaderProfile::Compact).code
5085        );
5086    }
5087
5088    #[test]
5089    fn monochrome_theme_falls_back_without_rgb_colors() {
5090        let theme = Theme::for_profile_with_capabilities(
5091            ReaderProfile::Verbose,
5092            TerminalCapabilities {
5093                color: ColorSupport::Monochrome,
5094                ..TerminalCapabilities::default()
5095            },
5096        );
5097
5098        assert_eq!(theme.current_line, Color::Black);
5099        assert_eq!(theme.link, Color::White);
5100        assert_eq!(theme.region, Color::Gray);
5101    }
5102
5103    #[test]
5104    fn no_animation_mode_disables_prompt_blink_and_loading_frames()
5105    -> Result<(), Box<dyn std::error::Error>> {
5106        let backend = TestBackend::new(64, 12);
5107        let mut terminal = Terminal::new(backend)?;
5108        let mut app = TerminalApp::with_capabilities(
5109            document(),
5110            24,
5111            TerminalCapabilities {
5112                animation: AnimationMode::None,
5113                ..TerminalCapabilities::default()
5114            },
5115        );
5116        app.start_open_loading("https://example.com");
5117
5118        terminal.draw(|frame| app.render(frame))?;
5119        let first = buffer_to_string(terminal.backend().buffer());
5120        terminal.draw(|frame| app.render(frame))?;
5121        let second = buffer_to_string(terminal.backend().buffer());
5122
5123        assert!(first.contains("[ ] OPENING queued"));
5124        assert_eq!(first, second);
5125        Ok(())
5126    }
5127
5128    #[test]
5129    fn plain_glyph_mode_keeps_navigation_surfaces_ascii() -> Result<(), Box<dyn std::error::Error>>
5130    {
5131        let backend = TestBackend::new(84, 28);
5132        let mut terminal = Terminal::new(backend)?;
5133        let mut document = document();
5134        document.push(IndexNode::List {
5135            ordered: false,
5136            items: vec!["alpha".to_owned()],
5137        });
5138        document.push(IndexNode::Table {
5139            rows: vec![
5140                vec!["Name".to_owned(), "Value".to_owned()],
5141                vec!["Index".to_owned(), "Terminal".to_owned()],
5142            ],
5143        });
5144        let mut app = TerminalApp::with_capabilities(
5145            document,
5146            42,
5147            TerminalCapabilities {
5148                glyphs: GlyphSupport::Plain,
5149                color: ColorSupport::Monochrome,
5150                animation: AnimationMode::None,
5151            },
5152        );
5153        terminal.draw(|frame| app.render(frame))?;
5154        let content_snapshot = buffer_to_string(terminal.backend().buffer());
5155
5156        let _ = app.handle_key(key(KeyCode::Char('f')));
5157        let _ = app.handle_key(key(KeyCode::Char('l')));
5158        terminal.draw(|frame| app.render(frame))?;
5159        let navigation_snapshot = buffer_to_string(terminal.backend().buffer());
5160        let snapshot = format!("{content_snapshot}\n{navigation_snapshot}");
5161
5162        assert!(snapshot.contains("TITLE"));
5163        assert!(snapshot.contains("HEAD"));
5164        assert!(snapshot.contains("LINK"));
5165        assert!(snapshot.contains("TABLE"));
5166        assert!(snapshot.contains("links"));
5167        assert!(!snapshot.contains("󰘬"));
5168        assert!(!snapshot.contains("󰉫"));
5169        assert!(!snapshot.contains("󰌹"));
5170        assert!(!snapshot.contains("󰓫"));
5171        Ok(())
5172    }
5173
5174    #[test]
5175    fn profile_command_switches_renderer_theme_without_changing_document() {
5176        let document = document();
5177        let original = document.clone();
5178        let mut app = TerminalApp::new(document, 80);
5179
5180        let action = submit_command(&mut app, "profile links");
5181
5182        assert_eq!(action, AppAction::None);
5183        assert_eq!(app.reader_profile(), ReaderProfile::Links);
5184        assert!(!app.reader_profile_is_auto());
5185        assert_eq!(app.status(), "PROFILE links");
5186        assert_eq!(app.document, original);
5187    }
5188
5189    #[test]
5190    fn auto_profile_command_reenables_intent_suggestions() {
5191        let mut document = IndexDocument::titled("API Documentation");
5192        document.push(IndexNode::CodeBlock {
5193            language: Some("rust".to_owned()),
5194            code: "fn main() {}".to_owned(),
5195        });
5196        let mut app = TerminalApp::new(document, 80);
5197        assert_eq!(app.reader_profile(), ReaderProfile::Docs);
5198
5199        assert_eq!(submit_command(&mut app, "profile links"), AppAction::None);
5200        assert_eq!(app.reader_profile(), ReaderProfile::Links);
5201        assert!(!app.reader_profile_is_auto());
5202        assert_eq!(submit_command(&mut app, "profile auto"), AppAction::None);
5203
5204        assert!(app.reader_profile_is_auto());
5205        assert_eq!(app.reader_profile(), ReaderProfile::Docs);
5206        assert!(app.status().contains("PROFILE auto profile docs"));
5207    }
5208
5209    #[test]
5210    fn intent_profile_mapping_is_deterministic() {
5211        let mut docs = IndexDocument::titled("Manual");
5212        docs.push(IndexNode::CodeBlock {
5213            language: None,
5214            code: "index --help".to_owned(),
5215        });
5216        assert_eq!(suggest_reader_profile(&docs).profile, ReaderProfile::Docs);
5217
5218        let mut links = IndexDocument::titled("Search Results");
5219        for id in 0..5 {
5220            links.push(IndexNode::Link(Link::new(
5221                format!("Result {id}"),
5222                format!("https://example.org/{id}"),
5223            )));
5224        }
5225        assert_eq!(suggest_reader_profile(&links).profile, ReaderProfile::Links);
5226
5227        let mut research = IndexDocument::titled("arXiv research paper");
5228        research.push(IndexNode::Paragraph("citation bibliography".to_owned()));
5229        assert_eq!(
5230            suggest_reader_profile(&research).profile,
5231            ReaderProfile::Research
5232        );
5233
5234        let mut essay = IndexDocument::titled("Essay");
5235        essay.push(IndexNode::Paragraph("Long quiet prose.".repeat(8)));
5236        assert_eq!(
5237            suggest_reader_profile(&essay).profile,
5238            ReaderProfile::Reader
5239        );
5240    }
5241
5242    #[test]
5243    fn unknown_profile_command_is_diagnostic_only() {
5244        let mut app = TerminalApp::new(document(), 80);
5245
5246        let action = submit_command(&mut app, "profile loud");
5247
5248        assert_eq!(action, AppAction::None);
5249        assert_eq!(app.reader_profile(), ReaderProfile::Reader);
5250        assert!(app.status().contains("unsupported profile"));
5251    }
5252
5253    #[test]
5254    fn repair_action_parser_accepts_local_repairs() {
5255        assert_eq!(
5256            RepairAction::parse("main next"),
5257            Some(RepairAction::MainNext)
5258        );
5259        assert_eq!(
5260            RepairAction::parse("main previous"),
5261            Some(RepairAction::MainPrevious)
5262        );
5263        assert_eq!(
5264            RepairAction::parse("hide region 2"),
5265            Some(RepairAction::HideRegion(2))
5266        );
5267        assert_eq!(
5268            RepairAction::parse("show region 3"),
5269            Some(RepairAction::ShowRegion(3))
5270        );
5271        assert_eq!(
5272            RepairAction::parse("promote section 4"),
5273            Some(RepairAction::PromoteSection(4))
5274        );
5275        assert_eq!(RepairAction::parse("hide region 0"), None);
5276    }
5277
5278    #[test]
5279    fn repair_recipe_serializes_stably() {
5280        let mut recipe = RepairRecipe::new();
5281        assert!(recipe.is_empty());
5282        recipe.push(RepairAction::MainNext);
5283        recipe.push(RepairAction::HideRegion(2));
5284
5285        assert_eq!(
5286            recipe.to_text(),
5287            "index-repair-v1\nmain next\nhide region 2\n"
5288        );
5289    }
5290
5291    #[test]
5292    fn repair_commands_hide_show_and_promote_regions() {
5293        let mut document = IndexDocument::titled("Regions");
5294        document.push(IndexNode::Section {
5295            role: SectionRole::Navigation,
5296            title: Some("Nav".to_owned()),
5297            collapsed: false,
5298            nodes: vec![IndexNode::Paragraph("noise".to_owned())],
5299        });
5300        document.push(IndexNode::Section {
5301            role: SectionRole::Main,
5302            title: Some("Article".to_owned()),
5303            collapsed: true,
5304            nodes: vec![IndexNode::Paragraph("body".to_owned())],
5305        });
5306        let mut app = TerminalApp::new(document, 48);
5307
5308        assert_eq!(submit_command(&mut app, "main next"), AppAction::None);
5309        assert_eq!(app.status(), "REPAIR main 2");
5310        assert_eq!(submit_command(&mut app, "show region 2"), AppAction::None);
5311        assert_eq!(app.status(), "REPAIR showed region 2");
5312        assert_eq!(submit_command(&mut app, "hide region 1"), AppAction::None);
5313        assert_eq!(app.status(), "REPAIR hid region 1");
5314        assert_eq!(
5315            submit_command(&mut app, "promote section 2"),
5316            AppAction::None
5317        );
5318        assert_eq!(app.status(), "REPAIR promoted section 2");
5319        assert!(app.repair_recipe().to_text().contains("promote section 2"));
5320    }
5321
5322    #[test]
5323    fn capture_commands_preview_and_save_current_document() -> Result<(), Box<dyn std::error::Error>>
5324    {
5325        let output_path = unique_temp_file("tui-capture");
5326        let mut document = document();
5327        document.metadata.canonical_url = Some("https://example.org/page".to_owned());
5328        let mut app = TerminalApp::new(document, 80);
5329
5330        assert_eq!(submit_command(&mut app, "capture preview"), AppAction::None);
5331        assert!(app.status().contains("CAPTURE preview"));
5332        assert_eq!(
5333            submit_command(&mut app, &format!("capture save {output_path}")),
5334            AppAction::None
5335        );
5336        assert!(app.status().contains("CAPTURE saved"));
5337
5338        let saved = fs::read_to_string(&output_path)?;
5339        assert!(saved.contains("index-capture-v1"));
5340        assert!(saved.contains("source_url: https://example.org/page"));
5341        fs::remove_file(&output_path)?;
5342        Ok(())
5343    }
5344
5345    #[test]
5346    fn capture_save_rejects_invalid_paths() {
5347        let mut app = TerminalApp::new(document(), 80);
5348
5349        assert_eq!(
5350            submit_command(&mut app, "capture save --raw"),
5351            AppAction::None
5352        );
5353        assert!(app.status().contains("path must not look like an option"));
5354        assert!(save_current_capture(&app.document, "").is_err());
5355    }
5356
5357    #[test]
5358    fn ratatui_marks_current_line_with_mild_background() -> Result<(), Box<dyn std::error::Error>> {
5359        let backend = TestBackend::new(48, 10);
5360        let mut terminal = Terminal::new(backend)?;
5361        let mut app = TerminalApp::new(document(), 20);
5362        terminal.draw(|frame| app.render(frame))?;
5363        assert_eq!(
5364            terminal.backend().buffer()[(1, 1)].style().bg,
5365            Some(Color::Rgb(24, 28, 30))
5366        );
5367        Ok(())
5368    }
5369
5370    #[test]
5371    fn ratatui_colors_semantic_lines() -> Result<(), Box<dyn std::error::Error>> {
5372        let backend = TestBackend::new(64, 12);
5373        let mut terminal = Terminal::new(backend)?;
5374        let mut app = TerminalApp::new(document(), 20);
5375        terminal.draw(|frame| app.render(frame))?;
5376
5377        let buffer = terminal.backend().buffer();
5378        assert_eq!(buffer[(1, 1)].style().fg, Some(Color::Cyan));
5379        assert_eq!(buffer[(1, 3)].style().fg, Some(Color::LightCyan));
5380        assert_eq!(buffer[(1, 7)].style().fg, Some(Color::LightBlue));
5381        Ok(())
5382    }
5383
5384    #[test]
5385    fn ratatui_styles_markdown_inline_emphasis() -> Result<(), Box<dyn std::error::Error>> {
5386        let backend = TestBackend::new(64, 8);
5387        let mut terminal = Terminal::new(backend)?;
5388        let mut document = IndexDocument::titled("Title");
5389        document.push(IndexNode::Paragraph(
5390            "plain **bold** and *italic* text".to_owned(),
5391        ));
5392        let mut app = TerminalApp::new(document, 64);
5393        terminal.draw(|frame| app.render(frame))?;
5394
5395        let buffer = terminal.backend().buffer();
5396        assert_eq!(buffer[(9, 3)].symbol(), "b");
5397        assert_eq!(buffer[(9, 3)].style().fg, Some(Color::White));
5398        assert!(buffer[(9, 3)].style().add_modifier.contains(Modifier::BOLD));
5399        assert_eq!(buffer[(18, 3)].symbol(), "i");
5400        assert_eq!(buffer[(18, 3)].style().fg, Some(Color::LightMagenta));
5401        assert!(
5402            buffer[(18, 3)]
5403                .style()
5404                .add_modifier
5405                .contains(Modifier::ITALIC)
5406        );
5407        Ok(())
5408    }
5409
5410    #[test]
5411    fn ratatui_reveals_markdown_syntax_only_on_current_line()
5412    -> Result<(), Box<dyn std::error::Error>> {
5413        let backend = TestBackend::new(72, 10);
5414        let mut terminal = Terminal::new(backend)?;
5415        let mut document = IndexDocument::titled("Title");
5416        document.push(IndexNode::Heading {
5417            level: 2,
5418            text: "Section".to_owned(),
5419        });
5420        document.push(IndexNode::Paragraph(
5421            "plain **bold** and *italic* text".to_owned(),
5422        ));
5423        for index in 0..6 {
5424            document.push(IndexNode::Paragraph(format!("tail {index}")));
5425        }
5426        let mut app = TerminalApp::new(document, 72);
5427
5428        terminal.draw(|frame| app.render(frame))?;
5429        let snapshot = buffer_to_string(terminal.backend().buffer());
5430        assert!(snapshot.contains("󰘬 # Title"));
5431        assert!(snapshot.contains("󰉫 Section"));
5432        assert!(!snapshot.contains("󰉫 ## Section"));
5433        assert!(snapshot.contains("plain bold and italic text"));
5434        assert!(!snapshot.contains("**bold**"));
5435
5436        app.viewport.offset = 4;
5437        terminal.draw(|frame| app.render(frame))?;
5438        let current_snapshot = buffer_to_string(terminal.backend().buffer());
5439        assert!(current_snapshot.contains("plain **bold** and *italic* text"));
5440        Ok(())
5441    }
5442
5443    #[test]
5444    fn ratatui_always_shows_cyan_prompt() -> Result<(), Box<dyn std::error::Error>> {
5445        let backend = TestBackend::new(60, 12);
5446        let mut terminal = Terminal::new(backend)?;
5447        let mut app = TerminalApp::new(document(), 24);
5448        terminal.draw(|frame| app.render(frame))?;
5449
5450        let buffer = terminal.backend().buffer();
5451        assert_eq!(buffer[(0, 11)].symbol(), "[");
5452        assert_eq!(buffer[(0, 11)].style().fg, Some(Color::LightCyan));
5453        assert_eq!(buffer[(1, 11)].symbol(), ":");
5454        assert_eq!(buffer[(1, 11)].style().fg, Some(Color::LightCyan));
5455        assert_eq!(buffer[(2, 11)].symbol(), ">");
5456        assert_eq!(buffer[(3, 11)].symbol(), " ");
5457        assert_eq!(buffer[(4, 11)].style().fg, Some(Color::DarkGray));
5458
5459        for _ in 0..9 {
5460            terminal.draw(|frame| app.render(frame))?;
5461        }
5462        let buffer = terminal.backend().buffer();
5463        assert_eq!(buffer[(1, 11)].symbol(), ":");
5464        assert_eq!(buffer[(1, 11)].style().fg, Some(Color::LightCyan));
5465
5466        terminal.draw(|frame| app.render(frame))?;
5467        let buffer = terminal.backend().buffer();
5468        assert_eq!(buffer[(1, 11)].symbol(), ":");
5469        assert_eq!(buffer[(1, 11)].style().fg, Some(Color::DarkGray));
5470        Ok(())
5471    }
5472
5473    #[test]
5474    fn ratatui_status_shows_ascii_loading_frame() -> Result<(), Box<dyn std::error::Error>> {
5475        let backend = TestBackend::new(64, 8);
5476        let mut terminal = Terminal::new(backend)?;
5477        let mut app = TerminalApp::new(document(), 24);
5478        app.start_open_loading("https://example.com");
5479        terminal.draw(|frame| app.render(frame))?;
5480        let first = buffer_to_string(terminal.backend().buffer());
5481        assert!(first.contains("[-] OPENING queued"));
5482        assert!(!first.contains("Tao Te Ching"));
5483        assert!(first.contains("OPENING queued"));
5484
5485        terminal.draw(|frame| app.render(frame))?;
5486        let second = buffer_to_string(terminal.backend().buffer());
5487        assert!(second.contains("[\\] OPENING queued"));
5488        Ok(())
5489    }
5490
5491    #[test]
5492    fn ratatui_status_blinks_opening_marker_three_times_per_second_pattern()
5493    -> Result<(), Box<dyn std::error::Error>> {
5494        let backend = TestBackend::new(64, 8);
5495        let mut terminal = Terminal::new(backend)?;
5496        let mut app = TerminalApp::new(document(), 24);
5497        app.start_open_loading("https://example.com");
5498        let mut visible = 0_usize;
5499        let mut hidden = 0_usize;
5500        for _ in 0..10 {
5501            terminal.draw(|frame| app.render(frame))?;
5502            let snapshot = buffer_to_string(terminal.backend().buffer());
5503            if snapshot.contains("[ ] OPENING ") {
5504                hidden = hidden.saturating_add(1);
5505            } else {
5506                visible = visible.saturating_add(1);
5507            }
5508        }
5509        assert_eq!(visible, 6);
5510        assert_eq!(hidden, 4);
5511        Ok(())
5512    }
5513
5514    #[test]
5515    fn ratatui_status_updates_opening_phase_progressively() -> Result<(), Box<dyn std::error::Error>>
5516    {
5517        let backend = TestBackend::new(72, 8);
5518        let mut terminal = Terminal::new(backend)?;
5519        let mut app = TerminalApp::new(document(), 24);
5520        app.start_open_loading("https://example.com");
5521
5522        terminal.draw(|frame| app.render(frame))?;
5523        let fetching = buffer_to_string(terminal.backend().buffer());
5524        assert!(fetching.contains("OPENING queued https://example.com"));
5525
5526        app.set_opening_progress("parsing https://example.com".to_owned());
5527        terminal.draw(|frame| app.render(frame))?;
5528        let parsing = buffer_to_string(terminal.backend().buffer());
5529        assert!(parsing.contains("OPENING parsing https://example.com"));
5530
5531        app.set_opening_progress("transforming https://example.com".to_owned());
5532        terminal.draw(|frame| app.render(frame))?;
5533        let transforming = buffer_to_string(terminal.backend().buffer());
5534        assert!(transforming.contains("OPENING transforming https://example.com"));
5535
5536        Ok(())
5537    }
5538
5539    #[test]
5540    fn status_severity_uses_requested_colors() {
5541        let theme = Theme::default();
5542        assert_eq!(
5543            status_style(classify_status("OPEN failed: https://example.com"), theme).fg,
5544            Some(theme.error)
5545        );
5546        assert_eq!(
5547            status_style(classify_status("OPEN busy (wait or :quit)"), theme).fg,
5548            Some(theme.warning)
5549        );
5550        assert_eq!(
5551            status_style(
5552                classify_status("SUBMIT POST https://example.com/login"),
5553                theme
5554            )
5555            .fg,
5556            Some(theme.success)
5557        );
5558        assert_eq!(
5559            status_style(classify_status("NORMAL"), theme).fg,
5560            Some(theme.info)
5561        );
5562    }
5563
5564    #[test]
5565    fn status_icons_fallback_in_plain_glyph_mode() {
5566        assert_eq!(
5567            status_icon(classify_status("OPEN failed"), GlyphSupport::Plain),
5568            "[error]"
5569        );
5570        assert_eq!(
5571            status_icon(classify_status("OPEN busy"), GlyphSupport::Plain),
5572            "[warn]"
5573        );
5574        assert_eq!(
5575            status_icon(
5576                classify_status("OPEN https://example.com"),
5577                GlyphSupport::Plain
5578            ),
5579            "[ok]"
5580        );
5581        assert_eq!(
5582            status_icon(classify_status("NORMAL"), GlyphSupport::Plain),
5583            "[info]"
5584        );
5585    }
5586
5587    #[test]
5588    fn opening_renders_temporary_loading_page_in_viewport() -> Result<(), Box<dyn std::error::Error>>
5589    {
5590        let backend = TestBackend::new(80, 14);
5591        let mut terminal = Terminal::new(backend)?;
5592        let mut app = TerminalApp::new(document(), 24);
5593        app.start_open_loading("https://example.com/slow");
5594        terminal.draw(|frame| app.render(frame))?;
5595        let snapshot = buffer_to_string(terminal.backend().buffer());
5596        assert!(snapshot.contains("Opening"));
5597        assert!(snapshot.contains("The highest good is like water."));
5598        assert!(!snapshot.contains("Tao Te Ching"));
5599        assert!(snapshot.contains("Press q or :quit to cancel."));
5600        Ok(())
5601    }
5602
5603    #[test]
5604    fn ratatui_status_line_uses_severity_color() -> Result<(), Box<dyn std::error::Error>> {
5605        let backend = TestBackend::new(72, 10);
5606        let mut terminal = Terminal::new(backend)?;
5607        let mut app = TerminalApp::with_capabilities(
5608            document(),
5609            24,
5610            TerminalCapabilities {
5611                glyphs: GlyphSupport::Plain,
5612                ..TerminalCapabilities::default()
5613            },
5614        );
5615        app.status = "OPEN failed: https://example.com".to_owned();
5616        terminal.draw(|frame| app.render(frame))?;
5617        let buffer = terminal.backend().buffer();
5618        assert_eq!(buffer[(0, 8)].symbol(), "[");
5619        assert_eq!(buffer[(0, 8)].style().fg, Some(app.theme.error));
5620        Ok(())
5621    }
5622
5623    #[test]
5624    fn log_entries_are_classified_by_mime_and_body() {
5625        let error_log = ResponseLogEntry::new(
5626            1,
5627            "GET",
5628            "https://example.com",
5629            "https://example.com",
5630            Some("text/x-index-error"),
5631            "network failed",
5632            128,
5633        );
5634        let warning_log = ResponseLogEntry::new(
5635            2,
5636            "GET",
5637            "https://example.com",
5638            "https://example.com",
5639            Some("text/html"),
5640            "confirm required",
5641            128,
5642        );
5643        let success_log = ResponseLogEntry::new(
5644            3,
5645            "GET",
5646            "https://example.com",
5647            "https://example.com",
5648            Some("text/html"),
5649            "<html>ok</html>",
5650            128,
5651        );
5652
5653        assert_eq!(classify_log_entry(&error_log), super::StatusSeverity::Error);
5654        assert_eq!(
5655            classify_log_entry(&warning_log),
5656            super::StatusSeverity::Warning
5657        );
5658        assert_eq!(
5659            classify_log_entry(&success_log),
5660            super::StatusSeverity::Success
5661        );
5662    }
5663
5664    #[test]
5665    fn ratatui_renders_diagnostic_documents() -> Result<(), Box<dyn std::error::Error>> {
5666        let backend = TestBackend::new(76, 16);
5667        let mut terminal = Terminal::new(backend)?;
5668        let mut document = IndexDocument::titled("Network fetch failed");
5669        document.push(IndexNode::Error(
5670            "could not fetch https://example.invalid".to_owned(),
5671        ));
5672        document.push(IndexNode::List {
5673            ordered: false,
5674            items: vec![
5675                "source: network".to_owned(),
5676                "confidence: failed".to_owned(),
5677            ],
5678        });
5679        document.push(IndexNode::Heading {
5680            level: 2,
5681            text: "Suggested actions".to_owned(),
5682        });
5683        document.push(IndexNode::List {
5684            ordered: false,
5685            items: vec!["retry the request".to_owned()],
5686        });
5687        let mut app = TerminalApp::new(document, 48);
5688
5689        terminal.draw(|frame| app.render(frame))?;
5690        let snapshot = buffer_to_string(terminal.backend().buffer());
5691        assert!(snapshot.contains("Network fetch failed"));
5692        assert!(snapshot.contains("could not fetch"));
5693        assert!(snapshot.contains("confidence: failed"));
5694        assert!(snapshot.contains("retry the request"));
5695        Ok(())
5696    }
5697
5698    #[test]
5699    fn open_action_replaces_document_and_resets_viewport() {
5700        let mut app = TerminalApp::new(document(), 20);
5701        app.handle_key(key(KeyCode::Char('j')));
5702        let mut navigate = |target: &str| {
5703            let mut document = IndexDocument::titled("Opened");
5704            document.push(IndexNode::Paragraph(format!("Opened {target}")));
5705            Ok(document)
5706        };
5707
5708        handle_app_action(
5709            &mut app,
5710            AppAction::Open("https://example.com/opened".to_owned()),
5711            &mut navigate,
5712        );
5713
5714        assert_eq!(app.viewport().offset, 0);
5715        assert_eq!(app.status(), "OPEN https://example.com/opened");
5716        assert!(app.lines.iter().any(|line| line.contains("Opened")));
5717    }
5718
5719    #[test]
5720    fn open_action_records_history_and_response_log() {
5721        let mut app = TerminalApp::new(document(), 20);
5722        let mut navigate = |target: &str| {
5723            let mut document = IndexDocument::titled("Opened");
5724            document.push(IndexNode::Paragraph(format!("Opened {target}")));
5725            Ok(TuiDocumentResult::new(document)
5726                .with_visited_url("https://example.com/final")
5727                .with_response_log(ResponseLogEntry::new(
5728                    1,
5729                    "GET",
5730                    target,
5731                    "https://example.com/final",
5732                    Some("text/html"),
5733                    "server response",
5734                    64,
5735                )))
5736        };
5737        handle_app_action_with_forms(
5738            &mut app,
5739            AppAction::Open("https://example.com/start".to_owned()),
5740            &mut navigate,
5741            &mut |_submission| Err("unexpected form".to_owned()),
5742        );
5743
5744        assert_eq!(
5745            app.url_history.first().map(String::as_str),
5746            Some("https://example.com/final")
5747        );
5748        assert_eq!(app.response_logs.len(), 1);
5749        assert!(app.lines.iter().any(|line| line.contains("Opened")));
5750    }
5751
5752    #[test]
5753    fn open_action_failure_renders_diagnostic_document_and_error_log() {
5754        let mut app = TerminalApp::new(document(), 20);
5755        let mut navigate = |_target: &str| -> Result<TuiDocumentResult, String> {
5756            Err("timeout after 30000ms".to_owned())
5757        };
5758
5759        handle_app_action_with_forms(
5760            &mut app,
5761            AppAction::Open("https://example.com/slow".to_owned()),
5762            &mut navigate,
5763            &mut |_submission| Err("unexpected form".to_owned()),
5764        );
5765
5766        assert!(app.document.title.contains("Network fetch failed"));
5767        assert!(
5768            app.lines
5769                .iter()
5770                .any(|line| line.contains("could not fetch https://example.com/slow"))
5771        );
5772        assert_eq!(app.response_logs.len(), 1);
5773        assert_eq!(app.response_logs[0].method, "GET");
5774        assert_eq!(
5775            app.response_logs[0].requested_url,
5776            "https://example.com/slow"
5777        );
5778    }
5779
5780    #[test]
5781    fn submit_action_failure_renders_diagnostic_document_and_error_log()
5782    -> Result<(), Box<dyn std::error::Error>> {
5783        let mut app = TerminalApp::new(document(), 20);
5784        let submission = FormSubmission {
5785            method: index_core::FormMethod::Post,
5786            action: index_core::IndexUrl::parse("https://example.com/login")?,
5787            body: Some("user=alice".to_owned()),
5788        };
5789        let mut submit_form = |_submission: &FormSubmission| -> Result<TuiDocumentResult, String> {
5790            Err("HTTP status 503 returned for https://example.com/login".to_owned())
5791        };
5792
5793        handle_app_action_with_forms(
5794            &mut app,
5795            AppAction::Submit(submission),
5796            &mut |_target| Err("unexpected navigation".to_owned()),
5797            &mut submit_form,
5798        );
5799
5800        assert!(app.document.title.contains("Form submission failed"));
5801        assert!(
5802            app.lines
5803                .iter()
5804                .any(|line| line.contains("could not fetch https://example.com/login"))
5805        );
5806        assert_eq!(app.response_logs.len(), 1);
5807        assert_eq!(app.response_logs[0].method, "POST");
5808        assert_eq!(
5809            app.response_logs[0].requested_url,
5810            "https://example.com/login"
5811        );
5812        Ok(())
5813    }
5814
5815    #[test]
5816    fn dispatch_action_handles_busy_and_worker_unavailable_paths()
5817    -> Result<(), Box<dyn std::error::Error>> {
5818        let mut app = TerminalApp::new(document(), 20);
5819        let (request_tx, request_rx) = mpsc::channel();
5820
5821        app.status = "OPENING https://example.com/current".to_owned();
5822        dispatch_app_action(
5823            &mut app,
5824            AppAction::Open("https://example.com/next".to_owned()),
5825            &request_tx,
5826        );
5827        assert_eq!(app.status, "OPEN busy (wait or :quit)");
5828
5829        let submission = Form {
5830            name: "login".to_owned(),
5831            method: "POST".to_owned(),
5832            action: "https://example.com/login".to_owned(),
5833            inputs: vec![Input {
5834                name: "user".to_owned(),
5835                kind: "text".to_owned(),
5836                value: Some("alice".to_owned()),
5837                required: true,
5838            }],
5839            buttons: Vec::new(),
5840        }
5841        .submit(None, &[])?;
5842        app.status = "OPENING https://example.com/current".to_owned();
5843        dispatch_app_action(&mut app, AppAction::Submit(submission.clone()), &request_tx);
5844        assert_eq!(app.status, "SUBMIT busy (wait or :quit)");
5845
5846        drop(request_rx);
5847
5848        dispatch_app_action(
5849            &mut app,
5850            AppAction::Open("https://example.com/unavailable".to_owned()),
5851            &request_tx,
5852        );
5853        assert!(app.document.title.contains("Network fetch failed"));
5854        assert_eq!(
5855            app.response_logs.last().map(|log| log.method.as_str()),
5856            Some("GET")
5857        );
5858
5859        dispatch_app_action(&mut app, AppAction::Submit(submission), &request_tx);
5860        assert!(app.document.title.contains("Form submission failed"));
5861        assert_eq!(
5862            app.response_logs.last().map(|log| log.method.as_str()),
5863            Some("POST")
5864        );
5865        Ok(())
5866    }
5867
5868    #[test]
5869    fn worker_response_updates_status_history_logs_and_disconnect_failure()
5870    -> Result<(), Box<dyn std::error::Error>> {
5871        let mut app = TerminalApp::new(document(), 20);
5872        let (response_tx, response_rx) = mpsc::channel();
5873
5874        response_tx.send(WorkerResponse::Progress {
5875            message: "fetching https://example.com/story".to_owned(),
5876        })?;
5877
5878        let mut opened = IndexDocument::titled("Opened");
5879        opened.push(IndexNode::Paragraph("Story body".to_owned()));
5880        response_tx.send(WorkerResponse::Open {
5881            target: "https://example.com/story".to_owned(),
5882            result: Ok(TuiDocumentResult::new(opened)
5883                .with_visited_url("https://example.com/final")
5884                .with_response_log(ResponseLogEntry::new(
5885                    1,
5886                    "GET",
5887                    "https://example.com/story",
5888                    "https://example.com/final",
5889                    Some("text/html"),
5890                    "<html>story</html>",
5891                    64,
5892                ))),
5893        })?;
5894
5895        let submission = Form {
5896            name: "reply".to_owned(),
5897            method: "POST".to_owned(),
5898            action: "https://example.com/reply".to_owned(),
5899            inputs: vec![Input {
5900                name: "body".to_owned(),
5901                kind: "text".to_owned(),
5902                value: Some("hello".to_owned()),
5903                required: true,
5904            }],
5905            buttons: Vec::new(),
5906        }
5907        .submit(None, &[])?;
5908        response_tx.send(WorkerResponse::Submit {
5909            submission: submission.clone(),
5910            result: Err("HTTP status 503 returned for https://example.com/reply".to_owned()),
5911        })?;
5912        drop(response_tx);
5913
5914        handle_worker_response(&mut app, &response_rx);
5915        assert_eq!(
5916            app.url_history.first().map(String::as_str),
5917            Some("https://example.com/final")
5918        );
5919        assert!(app.status.starts_with("SUBMIT failed:"));
5920        assert!(app.document.title.contains("Form submission failed"));
5921        assert!(app.response_logs.len() >= 2);
5922
5923        let mut disconnected = TerminalApp::new(document(), 20);
5924        disconnected.status = "OPENING https://example.com/stuck".to_owned();
5925        let (tx2, rx2) = mpsc::channel::<WorkerResponse>();
5926        drop(tx2);
5927        handle_worker_response(&mut disconnected, &rx2);
5928        assert!(disconnected.document.title.contains("Network fetch failed"));
5929        assert!(disconnected.status.starts_with("OPEN failed:"));
5930
5931        Ok(())
5932    }
5933
5934    #[test]
5935    fn form_input_lookup_skips_collapsed_sections_and_finds_visible_inputs() {
5936        let mut nodes = vec![
5937            IndexNode::Section {
5938                role: SectionRole::Related,
5939                title: Some("Hidden".to_owned()),
5940                collapsed: true,
5941                nodes: vec![IndexNode::Form(Form {
5942                    name: "hidden".to_owned(),
5943                    method: "GET".to_owned(),
5944                    action: "/hidden".to_owned(),
5945                    inputs: vec![Input {
5946                        name: "q".to_owned(),
5947                        kind: "text".to_owned(),
5948                        value: Some("hidden".to_owned()),
5949                        required: false,
5950                    }],
5951                    buttons: Vec::new(),
5952                })],
5953            },
5954            IndexNode::Section {
5955                role: SectionRole::Main,
5956                title: Some("Visible".to_owned()),
5957                collapsed: false,
5958                nodes: vec![IndexNode::Form(Form {
5959                    name: "visible".to_owned(),
5960                    method: "GET".to_owned(),
5961                    action: "/visible".to_owned(),
5962                    inputs: vec![Input {
5963                        name: "q".to_owned(),
5964                        kind: "text".to_owned(),
5965                        value: Some("visible".to_owned()),
5966                        required: false,
5967                    }],
5968                    buttons: Vec::new(),
5969                })],
5970            },
5971        ];
5972
5973        assert!(form_input_mut_by_render_index(&mut nodes, 1, 0).is_some());
5974        if let Some(input) = form_input_mut_by_render_index(&mut nodes, 1, 0) {
5975            input.value = Some("updated".to_owned());
5976        }
5977
5978        let updated = match &nodes[1] {
5979            IndexNode::Section { nodes, .. } => match &nodes[0] {
5980                IndexNode::Form(form) => form.inputs[0].value.clone(),
5981                _ => None,
5982            },
5983            _ => None,
5984        };
5985        assert_eq!(updated.as_deref(), Some("updated"));
5986        assert!(form_input_mut_by_render_index(&mut nodes, 2, 0).is_none());
5987    }
5988
5989    #[test]
5990    fn ratatui_link_hint_snapshot_contains_overlay() -> Result<(), Box<dyn std::error::Error>> {
5991        let backend = TestBackend::new(60, 12);
5992        let mut terminal = Terminal::new(backend)?;
5993        let mut app = TerminalApp::new(document(), 24);
5994        app.handle_key(key(KeyCode::Char('f')));
5995        terminal.draw(|frame| app.render(frame))?;
5996        let snapshot = buffer_to_string(terminal.backend().buffer());
5997        assert!(snapshot.contains("Links"));
5998        assert!(snapshot.contains("[1]"));
5999        assert!(snapshot.contains("https://example.com/one"));
6000        Ok(())
6001    }
6002
6003    #[test]
6004    fn ratatui_link_sidebar_snapshot_contains_page_links() -> Result<(), Box<dyn std::error::Error>>
6005    {
6006        let backend = TestBackend::new(84, 12);
6007        let mut terminal = Terminal::new(backend)?;
6008        let mut app = TerminalApp::new(document(), 24);
6009        app.handle_key(key(KeyCode::Char('l')));
6010        terminal.draw(|frame| app.render(frame))?;
6011        let snapshot = buffer_to_string(terminal.backend().buffer());
6012        assert!(snapshot.contains("Links"));
6013        assert!(snapshot.contains("> 1 One"));
6014        assert!(snapshot.contains("https://example.com/one"));
6015        assert!(snapshot.contains("  2 Two"));
6016        Ok(())
6017    }
6018
6019    #[test]
6020    fn ratatui_sidebar_modes_render_outline_forms_regions_search_and_logs()
6021    -> Result<(), Box<dyn std::error::Error>> {
6022        let backend = TestBackend::new(92, 18);
6023        let mut terminal = Terminal::new(backend)?;
6024        let mut document = document();
6025        document.push(IndexNode::Form(Form {
6026            name: "search".to_owned(),
6027            method: "GET".to_owned(),
6028            action: "https://example.com/search".to_owned(),
6029            inputs: Vec::new(),
6030            buttons: Vec::new(),
6031        }));
6032        document.push(IndexNode::Section {
6033            role: SectionRole::Related,
6034            title: Some("More".to_owned()),
6035            collapsed: true,
6036            nodes: vec![IndexNode::Link(Link::new(
6037                "Related",
6038                "https://example.com/related",
6039            ))],
6040        });
6041        let mut app = TerminalApp::new(document, 32);
6042        app.handle_key(key(KeyCode::Char('/')));
6043        for ch in "Section".chars() {
6044            app.handle_key(key(KeyCode::Char(ch)));
6045        }
6046        app.handle_key(key(KeyCode::Enter));
6047        app.handle_key(key(KeyCode::Char('l')));
6048
6049        app.set_sidebar_mode(SessionSidebarMode::Outline);
6050        terminal.draw(|frame| app.render(frame))?;
6051        let outline = buffer_to_string(terminal.backend().buffer());
6052        assert!(outline.contains("Outline"));
6053        assert!(outline.contains("## Section"));
6054        assert!(outline.contains("§ related: More"));
6055
6056        app.set_sidebar_mode(SessionSidebarMode::Forms);
6057        terminal.draw(|frame| app.render(frame))?;
6058        let forms = buffer_to_string(terminal.backend().buffer());
6059        assert!(forms.contains("Forms"));
6060        assert!(forms.contains("1 search"));
6061
6062        app.set_sidebar_mode(SessionSidebarMode::Regions);
6063        terminal.draw(|frame| app.render(frame))?;
6064        let regions = buffer_to_string(terminal.backend().buffer());
6065        assert!(regions.contains("Regions"));
6066        assert!(regions.contains("▸ related: More"));
6067
6068        app.set_sidebar_mode(SessionSidebarMode::Search);
6069        terminal.draw(|frame| app.render(frame))?;
6070        let search = buffer_to_string(terminal.backend().buffer());
6071        assert!(search.contains("Search"));
6072        assert!(search.contains("line 3"));
6073
6074        app.set_response_logs(vec![ResponseLogEntry::new(
6075            1,
6076            "GET",
6077            "https://example.com?token=secret",
6078            "https://example.com/final",
6079            Some("text/html"),
6080            "<title>Response</title>\n<p>server token=hidden body</p>",
6081            64,
6082        )]);
6083        app.set_sidebar_mode(SessionSidebarMode::Logs);
6084        terminal.draw(|frame| app.render(frame))?;
6085        let logs = buffer_to_string(terminal.backend().buffer());
6086        assert!(logs.contains("Logs"));
6087        assert!(logs.contains("#1 GET"));
6088        assert!(logs.contains("requested:"));
6089        assert!(logs.contains("final:"));
6090        assert!(logs.contains("example.com/final"));
6091        assert!(logs.contains("mime: text/html"));
6092        assert!(logs.contains("preview:"));
6093        assert!(logs.contains("Response"));
6094        assert!(!logs.contains("secret"));
6095        assert!(!logs.contains("hidden"));
6096        Ok(())
6097    }
6098
6099    #[test]
6100    fn ratatui_link_sidebar_renders_empty_and_hides_on_narrow_width()
6101    -> Result<(), Box<dyn std::error::Error>> {
6102        let backend = TestBackend::new(84, 8);
6103        let mut terminal = Terminal::new(backend)?;
6104        let mut app = TerminalApp::new(IndexDocument::default(), 24);
6105        app.handle_key(key(KeyCode::Char('l')));
6106        terminal.draw(|frame| app.render(frame))?;
6107        let empty_snapshot = buffer_to_string(terminal.backend().buffer());
6108        assert!(empty_snapshot.contains("No links"));
6109
6110        let backend = TestBackend::new(48, 8);
6111        let mut terminal = Terminal::new(backend)?;
6112        let mut app = TerminalApp::new(document(), 24);
6113        app.handle_key(key(KeyCode::Char('l')));
6114        terminal.draw(|frame| app.render(frame))?;
6115        let narrow_snapshot = buffer_to_string(terminal.backend().buffer());
6116        assert!(!narrow_snapshot.contains("> 1 One"));
6117        Ok(())
6118    }
6119
6120    #[test]
6121    fn ratatui_input_snapshots_include_command_and_search_bars()
6122    -> Result<(), Box<dyn std::error::Error>> {
6123        let backend = TestBackend::new(60, 12);
6124        let mut terminal = Terminal::new(backend)?;
6125        let mut app = TerminalApp::new(document(), 24);
6126
6127        app.handle_key(key(KeyCode::Char(':')));
6128        for ch in "open 1".chars() {
6129            app.handle_key(key(KeyCode::Char(ch)));
6130        }
6131        terminal.draw(|frame| app.render(frame))?;
6132        let command_snapshot = buffer_to_string(terminal.backend().buffer());
6133        assert!(command_snapshot.contains("[:> open 1"));
6134
6135        app.handle_key(key(KeyCode::Esc));
6136        app.handle_key(key(KeyCode::Char('/')));
6137        for ch in "Section".chars() {
6138            app.handle_key(key(KeyCode::Char(ch)));
6139        }
6140        terminal.draw(|frame| app.render(frame))?;
6141        let search_snapshot = buffer_to_string(terminal.backend().buffer());
6142        assert!(search_snapshot.contains("[:> /Section"));
6143        Ok(())
6144    }
6145
6146    #[test]
6147    fn strips_control_characters_from_remote_text() {
6148        let mut document = IndexDocument::titled("Title");
6149        document.push(IndexNode::Paragraph("\u{1b}[31mred\u{1b}[0m".to_owned()));
6150        let rendered = render_document(&document, RenderOptions::default());
6151        assert!(!rendered.contains('\u{1b}'));
6152        assert!(rendered.contains("[31mred[0m"));
6153    }
6154
6155    fn buffer_to_string(buffer: &ratatui::buffer::Buffer) -> String {
6156        let mut output = String::new();
6157        for y in 0..buffer.area.height {
6158            for x in 0..buffer.area.width {
6159                output.push_str(buffer[(x, y)].symbol());
6160            }
6161            output.push('\n');
6162        }
6163        output
6164    }
6165}