1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub struct RenderOptions {
68 pub width: usize,
70}
71
72#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
74pub enum ColorSupport {
75 #[default]
77 TrueColor,
78 Ansi,
80 Monochrome,
82}
83
84#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
86pub enum AnimationMode {
87 #[default]
89 Normal,
90 None,
92}
93
94#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
96pub enum GlyphSupport {
97 #[default]
99 Rich,
100 Plain,
102}
103
104#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
106pub struct TerminalCapabilities {
107 pub color: ColorSupport,
109 pub animation: AnimationMode,
111 pub glyphs: GlyphSupport,
113}
114
115impl Default for RenderOptions {
116 fn default() -> Self {
117 Self { width: 88 }
118 }
119}
120
121#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
123pub enum TableMode {
124 #[default]
126 Compact,
127 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
155pub struct Theme {
156 pub foreground: Color,
158 pub muted: Color,
160 pub document_title: Color,
162 pub heading: Color,
164 pub link: Color,
166 pub list: Color,
168 pub code: Color,
170 pub table: Color,
172 pub image: Color,
174 pub form: Color,
176 pub quote: Color,
178 pub region: Color,
180 pub bold: Color,
182 pub italic: Color,
184 pub error: Color,
186 pub warning: Color,
188 pub success: Color,
190 pub info: Color,
192 pub current_line: Color,
194 pub accent: Color,
196 pub prompt: Color,
198 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 #[must_use]
234 pub fn for_profile(profile: ReaderProfile) -> Self {
235 Self::for_profile_with_capabilities(profile, TerminalCapabilities::default())
236 }
237
238 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
346pub struct Viewport {
347 pub offset: usize,
349 pub height: usize,
351}
352
353#[derive(Debug, Clone, PartialEq, Eq, Default)]
355pub enum InputMode {
356 #[default]
358 Normal,
359 Command(String),
361 Search(String),
363 Form(FormEdit),
365}
366
367#[derive(Debug, Clone, PartialEq, Eq)]
369pub struct FormEdit {
370 pub form_index: usize,
372 pub field_index: usize,
374 pub field_name: String,
376 pub field_kind: String,
378 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#[derive(Debug, Clone, PartialEq, Eq)]
412pub enum AppAction {
413 None,
415 Quit,
417 Open(String),
419 Back,
421 Submit(FormSubmission),
423 Extract(ExtractFormat),
425 Pipe(PipeCommand),
427 Ai(AiAction),
429}
430
431#[derive(Debug, Clone, PartialEq, Eq)]
433pub struct TuiDocumentResult {
434 pub document: IndexDocument,
436 pub visited_url: Option<String>,
438 pub response_log: Option<ResponseLogEntry>,
440}
441
442impl TuiDocumentResult {
443 #[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 #[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 #[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#[derive(Debug, Clone, PartialEq, Eq)]
498pub enum RepairAction {
499 MainNext,
501 MainPrevious,
503 HideRegion(usize),
505 ShowRegion(usize),
507 PromoteSection(usize),
509}
510
511impl RepairAction {
512 #[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#[derive(Debug, Clone, PartialEq, Eq, Default)]
553pub struct RepairRecipe {
554 actions: Vec<RepairAction>,
555}
556
557impl RepairRecipe {
558 #[must_use]
560 pub fn new() -> Self {
561 Self::default()
562 }
563
564 pub fn push(&mut self, action: RepairAction) {
566 self.actions.push(action);
567 }
568
569 #[must_use]
571 pub fn is_empty(&self) -> bool {
572 self.actions.is_empty()
573 }
574
575 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
589pub enum ReaderProfileIntent {
590 Essay,
592 Documentation,
594 LinkDirectory,
596 ResearchReference,
598}
599
600impl ReaderProfileIntent {
601 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
615pub struct ReaderProfileSuggestion {
616 pub intent: ReaderProfileIntent,
618 pub profile: ReaderProfile,
620}
621
622#[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#[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 #[must_use]
856 pub fn new(document: IndexDocument, width: usize) -> Self {
857 Self::with_capabilities(document, width, TerminalCapabilities::default())
858 }
859
860 #[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 #[must_use]
915 pub const fn viewport(&self) -> Viewport {
916 self.viewport
917 }
918
919 #[must_use]
921 pub const fn mode(&self) -> &InputMode {
922 &self.mode
923 }
924
925 #[must_use]
927 pub const fn show_link_hints(&self) -> bool {
928 self.show_link_hints
929 }
930
931 #[must_use]
933 pub const fn show_link_sidebar(&self) -> bool {
934 self.show_link_sidebar
935 }
936
937 #[must_use]
939 pub const fn sidebar_mode(&self) -> SessionSidebarMode {
940 self.sidebar_mode
941 }
942
943 #[must_use]
945 pub const fn reader_profile(&self) -> ReaderProfile {
946 self.reader_profile
947 }
948
949 #[must_use]
951 pub const fn reader_profile_is_auto(&self) -> bool {
952 matches!(self.reader_profile_mode, ReaderProfileMode::Auto)
953 }
954
955 #[must_use]
957 pub const fn table_mode(&self) -> TableMode {
958 self.table_mode
959 }
960
961 #[must_use]
963 pub const fn table_column_offset(&self) -> usize {
964 self.table_column_offset
965 }
966
967 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 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 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 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 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 #[must_use]
1012 pub const fn should_quit(&self) -> bool {
1013 self.should_quit
1014 }
1015
1016 #[must_use]
1018 pub fn status(&self) -> &str {
1019 &self.status
1020 }
1021
1022 #[must_use]
1024 pub const fn repair_recipe(&self) -> &RepairRecipe {
1025 &self.repair_recipe
1026 }
1027
1028 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 pub fn set_viewport_height(&mut self, height: usize) {
1071 self.viewport.height = height.max(1);
1072 self.clamp_viewport();
1073 }
1074
1075 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 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
2713pub 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
2720pub 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
2728pub 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
2742pub 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
2763pub 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
2786pub 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#[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(§ion_label(*role, title.as_deref())),
3409 section_item_count(nodes)
3410 ));
3411 } else {
3412 layout.lines.push(format!(
3413 " ▾ {}",
3414 sanitize_text(§ion_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}