use std::fs;
use std::io;
use std::path::Path;
use std::sync::mpsc::{self, TryRecvError};
use std::thread;
use std::time::Duration;
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyModifiers};
use crossterm::execute;
use crossterm::terminal::{
EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
};
use index_ai::AiAction;
use index_capture::{capture_document, preview_document, validate_capture_bundle};
use index_core::{
DiagnosticAction, DiagnosticConfidence, DiagnosticRecord, DiagnosticSeverity, DiagnosticSource,
FailureDiagnostic, Form, FormSubmission, IndexDocument, IndexNode, Input, ReaderProfile,
ResponseLogEntry, SectionRole, SessionSidebarMode,
};
use index_extract::{ExtractFormat, PipeCommand, PipeDecision, classify_pipe_command};
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::symbols::border;
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Clear, List, ListItem, Paragraph, Wrap};
use unicode_width::UnicodeWidthStr;
const TUI_FRAME_DURATION: Duration = Duration::from_millis(100);
const PROMPT_BLINK_TICKS: usize = 10;
const STATUS_BLINK_CYCLE_TICKS: usize = 10;
const STATUS_BLINK_ON_WINDOWS: [usize; 6] = [0, 1, 3, 4, 6, 7];
const OPENING_VERSE_TICKS: usize = 20;
const OPENING_TAO_VERSES: [&str; 8] = [
"The highest good is like water.",
"Yield, and remain whole.",
"Knowing yourself is true wisdom.",
"The Way acts without forcing.",
"Less and less, until stillness.",
"A long journey starts underfoot.",
"The soft and weak overcome.",
"True words are not ornate.",
];
const TABLE_MAX_COMPACT_ROWS: usize = 32;
const TABLE_MAX_DETAIL_ROWS: usize = 24;
const TABLE_OVERSIZED_CELL_LIMIT: usize = 240;
const RESPONSE_LOG_LIMIT: usize = 20;
const DOTTED_BORDER: border::Set = border::Set {
top_left: " ",
top_right: " ",
bottom_left: " ",
bottom_right: " ",
vertical_left: " ",
vertical_right: " ",
horizontal_top: " ",
horizontal_bottom: " ",
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RenderOptions {
pub width: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ColorSupport {
#[default]
TrueColor,
Ansi,
Monochrome,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum AnimationMode {
#[default]
Normal,
None,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum GlyphSupport {
#[default]
Rich,
Plain,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct TerminalCapabilities {
pub color: ColorSupport,
pub animation: AnimationMode,
pub glyphs: GlyphSupport,
}
impl Default for RenderOptions {
fn default() -> Self {
Self { width: 88 }
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum TableMode {
#[default]
Compact,
Detail,
}
impl TableMode {
fn toggled(self) -> Self {
match self {
Self::Compact => Self::Detail,
Self::Detail => Self::Compact,
}
}
fn as_str(self) -> &'static str {
match self {
Self::Compact => "compact",
Self::Detail => "detail",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
struct TableRenderOptions {
mode: TableMode,
column_offset: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Theme {
pub foreground: Color,
pub muted: Color,
pub document_title: Color,
pub heading: Color,
pub link: Color,
pub list: Color,
pub code: Color,
pub table: Color,
pub image: Color,
pub form: Color,
pub quote: Color,
pub region: Color,
pub bold: Color,
pub italic: Color,
pub error: Color,
pub warning: Color,
pub success: Color,
pub info: Color,
pub current_line: Color,
pub accent: Color,
pub prompt: Color,
pub status: Color,
}
impl Default for Theme {
fn default() -> Self {
Self {
foreground: Color::Gray,
muted: Color::DarkGray,
document_title: Color::Cyan,
heading: Color::LightCyan,
link: Color::LightBlue,
list: Color::LightGreen,
code: Color::LightMagenta,
table: Color::LightYellow,
image: Color::LightYellow,
form: Color::LightGreen,
quote: Color::DarkGray,
region: Color::DarkGray,
bold: Color::White,
italic: Color::LightMagenta,
error: Color::LightRed,
warning: Color::Rgb(255, 165, 0),
success: Color::LightGreen,
info: Color::Yellow,
current_line: Color::Rgb(24, 28, 30),
accent: Color::Cyan,
prompt: Color::LightCyan,
status: Color::Yellow,
}
}
}
impl Theme {
#[must_use]
pub fn for_profile(profile: ReaderProfile) -> Self {
Self::for_profile_with_capabilities(profile, TerminalCapabilities::default())
}
#[must_use]
pub fn for_profile_with_capabilities(
profile: ReaderProfile,
capabilities: TerminalCapabilities,
) -> Self {
let theme = match profile {
ReaderProfile::Reader => Self::default(),
ReaderProfile::Docs => Self {
document_title: Color::LightCyan,
heading: Color::Cyan,
code: Color::LightGreen,
table: Color::Yellow,
region: Color::LightCyan,
accent: Color::LightCyan,
..Self::default()
},
ReaderProfile::Links => Self {
link: Color::LightCyan,
list: Color::Cyan,
accent: Color::LightBlue,
prompt: Color::LightCyan,
..Self::default()
},
ReaderProfile::Research => Self {
quote: Color::LightYellow,
italic: Color::Yellow,
error: Color::LightRed,
warning: Color::Yellow,
success: Color::LightGreen,
info: Color::Yellow,
region: Color::LightMagenta,
accent: Color::LightMagenta,
..Self::default()
},
ReaderProfile::Compact => Self {
foreground: Color::Gray,
muted: Color::DarkGray,
document_title: Color::LightBlue,
heading: Color::LightBlue,
current_line: Color::Black,
accent: Color::Blue,
status: Color::Gray,
..Self::default()
},
ReaderProfile::Verbose => Self {
document_title: Color::LightCyan,
heading: Color::LightGreen,
link: Color::LightBlue,
list: Color::LightGreen,
code: Color::LightMagenta,
table: Color::LightYellow,
form: Color::LightGreen,
quote: Color::LightYellow,
region: Color::LightCyan,
accent: Color::LightGreen,
status: Color::LightYellow,
..Self::default()
},
};
match capabilities.color {
ColorSupport::TrueColor => theme,
ColorSupport::Ansi => theme.with_ansi_fallbacks(),
ColorSupport::Monochrome => theme.with_monochrome_fallbacks(),
}
}
fn with_ansi_fallbacks(self) -> Self {
Self {
warning: Color::Yellow,
success: Color::LightGreen,
info: Color::Yellow,
current_line: Color::DarkGray,
..self
}
}
fn with_monochrome_fallbacks(self) -> Self {
Self {
foreground: Color::Gray,
muted: Color::DarkGray,
document_title: Color::White,
heading: Color::White,
link: Color::White,
list: Color::Gray,
code: Color::White,
table: Color::Gray,
image: Color::Gray,
form: Color::White,
quote: Color::Gray,
region: Color::Gray,
bold: Color::White,
italic: Color::Gray,
error: Color::White,
warning: Color::White,
success: Color::White,
info: Color::Gray,
current_line: Color::Black,
accent: Color::White,
prompt: Color::White,
status: Color::Gray,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct Viewport {
pub offset: usize,
pub height: usize,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum InputMode {
#[default]
Normal,
Command(String),
Search(String),
Form(FormEdit),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FormEdit {
pub form_index: usize,
pub field_index: usize,
pub field_name: String,
pub field_kind: String,
pub value: String,
}
impl FormEdit {
fn from_input(form_index: usize, field_index: usize, input: &Input) -> Self {
Self {
form_index,
field_index,
field_name: input.name.clone(),
field_kind: input.kind.clone(),
value: input.value.clone().unwrap_or_default(),
}
}
fn field_label(&self) -> String {
if self.field_name.is_empty() {
format!("field {}", self.field_index + 1)
} else {
self.field_name.clone()
}
}
fn prompt_value(&self) -> String {
if is_secret_field(&self.field_kind) && !self.value.is_empty() {
"•".repeat(self.value.chars().count().max(1))
} else {
self.value.clone()
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AppAction {
None,
Quit,
Open(String),
Back,
Submit(FormSubmission),
Extract(ExtractFormat),
Pipe(PipeCommand),
Ai(AiAction),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TuiDocumentResult {
pub document: IndexDocument,
pub visited_url: Option<String>,
pub response_log: Option<ResponseLogEntry>,
}
impl TuiDocumentResult {
#[must_use]
pub fn new(document: IndexDocument) -> Self {
Self {
document,
visited_url: None,
response_log: None,
}
}
#[must_use]
pub fn with_visited_url(mut self, url: impl Into<String>) -> Self {
self.visited_url = Some(url.into());
self
}
#[must_use]
pub fn with_response_log(mut self, log: ResponseLogEntry) -> Self {
self.response_log = Some(log);
self
}
}
impl From<IndexDocument> for TuiDocumentResult {
fn from(document: IndexDocument) -> Self {
Self::new(document)
}
}
#[derive(Debug)]
enum WorkerRequest {
Open(String),
Submit(FormSubmission),
Stop,
}
#[derive(Debug)]
enum WorkerResponse {
Progress {
message: String,
},
Open {
target: String,
result: Result<TuiDocumentResult, String>,
},
Submit {
submission: FormSubmission,
result: Result<TuiDocumentResult, String>,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RepairAction {
MainNext,
MainPrevious,
HideRegion(usize),
ShowRegion(usize),
PromoteSection(usize),
}
impl RepairAction {
#[must_use]
pub fn parse(input: &str) -> Option<Self> {
let input = input.trim();
match input {
"main next" => Some(Self::MainNext),
"main previous" | "main prev" => Some(Self::MainPrevious),
_ => parse_repair_region_command(input),
}
}
fn as_recipe_line(&self) -> String {
match self {
Self::MainNext => "main next".to_owned(),
Self::MainPrevious => "main previous".to_owned(),
Self::HideRegion(id) => format!("hide region {id}"),
Self::ShowRegion(id) => format!("show region {id}"),
Self::PromoteSection(id) => format!("promote section {id}"),
}
}
}
fn parse_repair_region_command(input: &str) -> Option<RepairAction> {
let (prefix, rest) = input.rsplit_once(' ')?;
let id = rest.parse::<usize>().ok().filter(|id| *id > 0)?;
match prefix {
"hide region" => Some(RepairAction::HideRegion(id)),
"show region" => Some(RepairAction::ShowRegion(id)),
"promote section" => Some(RepairAction::PromoteSection(id)),
_ => None,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum DirectionStep {
Next,
Previous,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct RepairRecipe {
actions: Vec<RepairAction>,
}
impl RepairRecipe {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn push(&mut self, action: RepairAction) {
self.actions.push(action);
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.actions.is_empty()
}
#[must_use]
pub fn to_text(&self) -> String {
let mut output = String::from("index-repair-v1\n");
for action in &self.actions {
output.push_str(&action.as_recipe_line());
output.push('\n');
}
output
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ReaderProfileIntent {
Essay,
Documentation,
LinkDirectory,
ResearchReference,
}
impl ReaderProfileIntent {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Essay => "essay",
Self::Documentation => "documentation",
Self::LinkDirectory => "link-directory",
Self::ResearchReference => "research-reference",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ReaderProfileSuggestion {
pub intent: ReaderProfileIntent,
pub profile: ReaderProfile,
}
#[must_use]
pub fn suggest_reader_profile(document: &IndexDocument) -> ReaderProfileSuggestion {
let stats = DocumentIntentStats::from_document(document);
let haystack = stats.text_haystack();
if stats.link_count >= stats.paragraph_count.saturating_mul(2).max(4)
|| contains_any(
&haystack,
&["search results", "directory", "catalog", "listing"],
)
{
return ReaderProfileSuggestion {
intent: ReaderProfileIntent::LinkDirectory,
profile: ReaderProfile::Links,
};
}
if contains_any(
&haystack,
&[
"arxiv",
"paper",
"research",
"citation",
"bibliography",
"reference",
],
) || stats.table_count >= 2
{
return ReaderProfileSuggestion {
intent: ReaderProfileIntent::ResearchReference,
profile: ReaderProfile::Research,
};
}
if stats.code_count > 0
|| contains_any(
&haystack,
&["docs", "documentation", "manual", "guide", "api", "crate"],
)
{
return ReaderProfileSuggestion {
intent: ReaderProfileIntent::Documentation,
profile: ReaderProfile::Docs,
};
}
ReaderProfileSuggestion {
intent: ReaderProfileIntent::Essay,
profile: ReaderProfile::Reader,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ReaderProfileMode {
Auto,
Manual,
}
#[derive(Debug, Default)]
struct DocumentIntentStats {
paragraph_count: usize,
link_count: usize,
code_count: usize,
table_count: usize,
text: String,
}
impl DocumentIntentStats {
fn from_document(document: &IndexDocument) -> Self {
let mut stats = Self::default();
stats.push_text(&document.title);
if let Some(canonical_url) = &document.metadata.canonical_url {
stats.push_text(canonical_url);
}
if let Some(adapter_id) = &document.metadata.adapter_id {
stats.push_text(adapter_id.as_str());
}
for node in &document.nodes {
stats.visit_node(node);
}
stats
}
fn visit_node(&mut self, node: &IndexNode) {
match node {
IndexNode::Heading { text, .. } | IndexNode::Paragraph(text) => {
self.paragraph_count += matches!(node, IndexNode::Paragraph(_)) as usize;
self.push_text(text);
}
IndexNode::Link(link) => {
self.link_count += 1;
self.push_text(&link.text);
self.push_text(&link.href);
}
IndexNode::List { items, .. } => {
for item in items {
self.push_text(item);
}
}
IndexNode::CodeBlock { .. } => self.code_count += 1,
IndexNode::Table { .. } => self.table_count += 1,
IndexNode::Spacer { .. } => {}
IndexNode::Section { title, nodes, .. } => {
if let Some(title) = title {
self.push_text(title);
}
for node in nodes {
self.visit_node(node);
}
}
IndexNode::Image { alt, src } => {
self.push_text(alt);
if let Some(src) = src {
self.push_text(src);
}
}
IndexNode::Form(form) => {
self.push_text(&form.name);
self.push_text(&form.action);
}
IndexNode::Error(error) => self.push_text(error),
}
}
fn push_text(&mut self, text: &str) {
self.text.push(' ');
self.text.push_str(text);
}
fn text_haystack(&self) -> String {
self.text.to_ascii_lowercase()
}
}
fn contains_any(haystack: &str, needles: &[&str]) -> bool {
needles.iter().any(|needle| haystack.contains(needle))
}
#[derive(Debug, Clone)]
pub struct TerminalApp {
document: IndexDocument,
layout_width: usize,
lines: Vec<String>,
links: Vec<RenderedLink>,
forms: Vec<RenderedForm>,
headings: Vec<RenderedHeading>,
regions: Vec<RenderedRegion>,
layout_cache: Vec<(LayoutCacheKey, DocumentLayout)>,
viewport: Viewport,
mode: InputMode,
show_link_hints: bool,
show_link_sidebar: bool,
sidebar_mode: SessionSidebarMode,
reader_profile: ReaderProfile,
reader_profile_mode: ReaderProfileMode,
selected_sidebar_item: usize,
table_mode: TableMode,
table_column_offset: usize,
last_search_query: Option<String>,
url_history: Vec<String>,
response_logs: Vec<ResponseLogEntry>,
back_stack: Vec<IndexDocument>,
repair_recipe: RepairRecipe,
status: String,
terminal_capabilities: TerminalCapabilities,
theme: Theme,
animation_mode: AnimationMode,
glyph_support: GlyphSupport,
loading_frame: usize,
opening_progress: Option<String>,
prompt_blink_frame: usize,
pending_g: bool,
should_quit: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct RenderedLink {
index: usize,
text: String,
href: String,
line: usize,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct RenderedForm {
index: usize,
name: String,
line: usize,
form: Form,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct RenderedHeading {
level: u8,
text: String,
line: usize,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct RenderedRegion {
path: Vec<usize>,
role: SectionRole,
title: Option<String>,
collapsed: bool,
line: usize,
item_count: usize,
}
#[derive(Debug, Clone, Default)]
struct DocumentLayout {
lines: Vec<String>,
links: Vec<RenderedLink>,
forms: Vec<RenderedForm>,
headings: Vec<RenderedHeading>,
regions: Vec<RenderedRegion>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct LayoutCacheKey {
document_hash: u64,
width: usize,
table_options: TableRenderOptions,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum StatusSeverity {
Error,
Warning,
Success,
Info,
}
impl TerminalApp {
#[must_use]
pub fn new(document: IndexDocument, width: usize) -> Self {
Self::with_capabilities(document, width, TerminalCapabilities::default())
}
#[must_use]
pub fn with_capabilities(
document: IndexDocument,
width: usize,
capabilities: TerminalCapabilities,
) -> Self {
let table_options = TableRenderOptions::default();
let key = LayoutCacheKey::new(&document, width, table_options);
let layout = layout_document_with_options(&document, width, table_options);
let cached_layout = layout.clone();
let mut app = Self {
document,
layout_width: width,
lines: layout.lines,
links: layout.links,
forms: layout.forms,
headings: layout.headings,
regions: layout.regions,
layout_cache: vec![(key, cached_layout)],
viewport: Viewport {
offset: 0,
height: 1,
},
mode: InputMode::Normal,
show_link_hints: false,
show_link_sidebar: false,
sidebar_mode: SessionSidebarMode::Links,
reader_profile: ReaderProfile::Reader,
reader_profile_mode: ReaderProfileMode::Auto,
selected_sidebar_item: 0,
table_mode: TableMode::Compact,
table_column_offset: 0,
last_search_query: None,
url_history: Vec::new(),
response_logs: Vec::new(),
back_stack: Vec::new(),
repair_recipe: RepairRecipe::new(),
status: "NORMAL".to_owned(),
terminal_capabilities: capabilities,
theme: Theme::for_profile_with_capabilities(ReaderProfile::Reader, capabilities),
animation_mode: capabilities.animation,
glyph_support: capabilities.glyphs,
loading_frame: 0,
opening_progress: None,
prompt_blink_frame: 0,
pending_g: false,
should_quit: false,
};
app.apply_auto_reader_profile(None);
app
}
#[must_use]
pub const fn viewport(&self) -> Viewport {
self.viewport
}
#[must_use]
pub const fn mode(&self) -> &InputMode {
&self.mode
}
#[must_use]
pub const fn show_link_hints(&self) -> bool {
self.show_link_hints
}
#[must_use]
pub const fn show_link_sidebar(&self) -> bool {
self.show_link_sidebar
}
#[must_use]
pub const fn sidebar_mode(&self) -> SessionSidebarMode {
self.sidebar_mode
}
#[must_use]
pub const fn reader_profile(&self) -> ReaderProfile {
self.reader_profile
}
#[must_use]
pub const fn reader_profile_is_auto(&self) -> bool {
matches!(self.reader_profile_mode, ReaderProfileMode::Auto)
}
#[must_use]
pub const fn table_mode(&self) -> TableMode {
self.table_mode
}
#[must_use]
pub const fn table_column_offset(&self) -> usize {
self.table_column_offset
}
pub fn set_sidebar_mode(&mut self, mode: SessionSidebarMode) {
self.sidebar_mode = mode;
self.selected_sidebar_item = self
.selected_sidebar_item
.min(self.active_sidebar_len().saturating_sub(1));
self.update_sidebar_status();
}
pub fn set_url_history(&mut self, history: Vec<String>) {
self.url_history.clear();
for url in history {
self.record_visited_url(url);
}
}
pub fn set_response_logs(&mut self, logs: Vec<ResponseLogEntry>) {
self.response_logs = logs;
if self.response_logs.len() > RESPONSE_LOG_LIMIT {
let drain = self.response_logs.len() - RESPONSE_LOG_LIMIT;
self.response_logs.drain(0..drain);
}
self.selected_sidebar_item = self
.selected_sidebar_item
.min(self.active_sidebar_len().saturating_sub(1));
}
pub fn set_reader_profile(&mut self, profile: ReaderProfile) {
self.reader_profile_mode = ReaderProfileMode::Manual;
self.reader_profile = profile;
self.theme = Theme::for_profile_with_capabilities(profile, self.terminal_capabilities);
self.status = format!("PROFILE {profile}");
}
pub fn set_reader_profile_auto(&mut self) {
self.reader_profile_mode = ReaderProfileMode::Auto;
self.apply_auto_reader_profile(Some("PROFILE auto"));
}
#[must_use]
pub const fn should_quit(&self) -> bool {
self.should_quit
}
#[must_use]
pub fn status(&self) -> &str {
&self.status
}
#[must_use]
pub const fn repair_recipe(&self) -> &RepairRecipe {
&self.repair_recipe
}
pub fn replace_document(&mut self, document: IndexDocument, width: usize, status: String) {
self.table_column_offset = 0;
self.layout_cache.clear();
let layout = self.cached_layout(&document, width);
self.document = document;
self.layout_width = width;
self.lines = layout.lines;
self.links = layout.links;
self.forms = layout.forms;
self.headings = layout.headings;
self.regions = layout.regions;
self.viewport.offset = 0;
self.mode = InputMode::Normal;
self.show_link_hints = false;
self.selected_sidebar_item = 0;
self.last_search_query = None;
self.status = status;
self.loading_frame = 0;
self.opening_progress = None;
self.pending_g = false;
if matches!(self.reader_profile_mode, ReaderProfileMode::Auto) {
self.apply_auto_reader_profile(None);
}
self.clamp_viewport();
}
fn apply_auto_reader_profile(&mut self, status_prefix: Option<&str>) {
let suggestion = suggest_reader_profile(&self.document);
self.reader_profile = suggestion.profile;
self.theme =
Theme::for_profile_with_capabilities(suggestion.profile, self.terminal_capabilities);
if let Some(prefix) = status_prefix {
self.status = format!(
"{prefix} profile {} for {}",
suggestion.profile,
suggestion.intent.as_str()
);
}
}
pub fn set_viewport_height(&mut self, height: usize) {
self.viewport.height = height.max(1);
self.clamp_viewport();
}
pub fn handle_key(&mut self, key: KeyEvent) -> AppAction {
match std::mem::take(&mut self.mode) {
InputMode::Normal => {
self.mode = InputMode::Normal;
self.handle_normal_key(key)
}
InputMode::Command(mut input) => {
if key.code == KeyCode::Tab {
if let Some(suggestion) = self.open_command_suggestion(&input) {
input = format!("open {suggestion}");
self.status = format!("OPEN suggestion {suggestion}");
}
self.mode = InputMode::Command(input);
AppAction::None
} else if let Some(submitted) = handle_text_mode_key(key, &mut input) {
self.submit_command(submitted)
} else {
self.mode = InputMode::Command(input);
AppAction::None
}
}
InputMode::Search(mut input) => {
if let Some(submitted) = handle_text_mode_key(key, &mut input) {
self.submit_search(submitted)
} else {
self.mode = InputMode::Search(input);
AppAction::None
}
}
InputMode::Form(edit) => self.handle_form_edit_key(key, edit),
}
}
pub fn render(&mut self, frame: &mut ratatui::Frame<'_>) {
let area = frame.area();
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Min(1),
Constraint::Length(1),
Constraint::Length(1),
])
.split(area);
let content_chunks = if self.show_link_sidebar && area.width >= 56 {
Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Min(24), Constraint::Length(34)])
.split(chunks[0])
} else {
Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(100)])
.split(chunks[0])
};
self.set_viewport_height(usize::from(content_chunks[0].height));
self.render_document(frame, content_chunks[0]);
if self.show_link_sidebar && area.width >= 56 {
self.render_sidebar(frame, content_chunks[1]);
}
self.render_status(frame, chunks[1]);
self.render_input(frame, chunks[2]);
if self.show_link_hints {
self.render_link_hints(frame, centered_rect(80, 50, area));
}
}
fn handle_normal_key(&mut self, key: KeyEvent) -> AppAction {
if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
self.should_quit = true;
return AppAction::Quit;
}
if self.show_link_sidebar {
return self.handle_sidebar_key(key);
}
match key.code {
KeyCode::Char('j') | KeyCode::Down => {
self.scroll_down(1);
self.pending_g = false;
}
KeyCode::Char('k') | KeyCode::Up => {
self.scroll_up(1);
self.pending_g = false;
}
KeyCode::Char('G') | KeyCode::End => {
self.scroll_bottom();
self.pending_g = false;
}
KeyCode::Char('g') if self.pending_g => {
self.scroll_top();
self.pending_g = false;
}
KeyCode::Char('g') => {
self.pending_g = true;
self.status = "g".to_owned();
}
KeyCode::Char('/') => {
self.mode = InputMode::Search(String::new());
self.status = "SEARCH".to_owned();
self.pending_g = false;
}
KeyCode::Char('f') => {
self.show_link_hints = !self.show_link_hints;
self.status = if self.show_link_hints {
"LINK HINTS".to_owned()
} else {
"NORMAL".to_owned()
};
self.pending_g = false;
}
KeyCode::Char('l') => {
self.toggle_link_sidebar();
self.pending_g = false;
}
KeyCode::Char('t') => {
self.toggle_table_mode();
self.pending_g = false;
}
KeyCode::Char('e') => {
self.pending_g = false;
return self.edit_current_form();
}
KeyCode::Char('[') => {
self.scroll_table_columns_left();
self.pending_g = false;
}
KeyCode::Char(']') => {
self.scroll_table_columns_right();
self.pending_g = false;
}
KeyCode::Char('b') => {
self.pending_g = false;
return AppAction::Back;
}
KeyCode::Char(':') => {
self.mode = InputMode::Command(String::new());
self.status = "COMMAND".to_owned();
self.pending_g = false;
}
KeyCode::Char('q') => {
self.should_quit = true;
return AppAction::Quit;
}
_ => {
self.pending_g = false;
}
}
AppAction::None
}
fn handle_sidebar_key(&mut self, key: KeyEvent) -> AppAction {
match key.code {
KeyCode::Char('j') | KeyCode::Down => {
self.select_next_sidebar_item();
self.pending_g = false;
}
KeyCode::Char('k') | KeyCode::Up => {
self.select_previous_sidebar_item();
self.pending_g = false;
}
KeyCode::Char(']') | KeyCode::Tab => {
self.sidebar_mode = self.sidebar_mode.next();
self.selected_sidebar_item = 0;
self.update_sidebar_status();
self.pending_g = false;
}
KeyCode::Char('[') | KeyCode::BackTab => {
self.sidebar_mode = self.sidebar_mode.previous();
self.selected_sidebar_item = 0;
self.update_sidebar_status();
self.pending_g = false;
}
KeyCode::Char('1') => self.set_sidebar_mode(SessionSidebarMode::Links),
KeyCode::Char('2') => self.set_sidebar_mode(SessionSidebarMode::Outline),
KeyCode::Char('3') => self.set_sidebar_mode(SessionSidebarMode::Forms),
KeyCode::Char('4') => self.set_sidebar_mode(SessionSidebarMode::Regions),
KeyCode::Char('5') => self.set_sidebar_mode(SessionSidebarMode::Search),
KeyCode::Char('6') => self.set_sidebar_mode(SessionSidebarMode::Logs),
KeyCode::Char(' ') | KeyCode::Char('x')
if self.sidebar_mode == SessionSidebarMode::Regions =>
{
self.toggle_selected_region();
self.pending_g = false;
}
KeyCode::Char('e') if self.sidebar_mode == SessionSidebarMode::Forms => {
self.pending_g = false;
return self.edit_selected_sidebar_form();
}
KeyCode::Enter | KeyCode::Char('o') => {
self.pending_g = false;
return self.activate_sidebar_selection();
}
KeyCode::Char('b') => {
self.pending_g = false;
return AppAction::Back;
}
KeyCode::Char('l') | KeyCode::Esc => {
self.show_link_sidebar = false;
self.status = "NORMAL".to_owned();
self.pending_g = false;
}
KeyCode::Char('q') => {
self.should_quit = true;
return AppAction::Quit;
}
_ => {
self.pending_g = false;
}
}
AppAction::None
}
fn submit_command(&mut self, input: String) -> AppAction {
self.mode = InputMode::Normal;
let command = input.trim();
if command == "quit" || command == "q" {
self.should_quit = true;
self.status = "QUIT".to_owned();
return AppAction::Quit;
}
if command == "back" || command == "b" {
self.status = "BACK".to_owned();
return AppAction::Back;
}
if command == "logs" {
self.show_link_sidebar = true;
self.set_sidebar_mode(SessionSidebarMode::Logs);
return AppAction::None;
}
if let Some(target) = command.strip_prefix("open ") {
if let Some(href) = self.resolve_open_target(target.trim()) {
self.status = format!("OPEN {href}");
return AppAction::Open(href);
}
self.status = "OPEN target not found".to_owned();
return AppAction::None;
}
if let Some(input) = command.strip_prefix("submit ") {
return self.submit_form_command(input.trim());
}
if let Some(format) = command.strip_prefix("extract ") {
return self.submit_extract_command(format.trim());
}
if let Some(input) = command.strip_prefix("pipe ") {
return self.submit_pipe_command(input.trim());
}
if let Some(input) = command.strip_prefix("ai ") {
return self.submit_ai_command(input.trim());
}
if let Some(profile) = command.strip_prefix("profile ") {
return self.submit_profile_command(profile.trim());
}
if let Some(input) = command.strip_prefix("capture ") {
return self.submit_capture_command(input.trim());
}
if let Some(action) = RepairAction::parse(command) {
return self.apply_repair_action(action);
}
self.status = if command.is_empty() {
"NORMAL".to_owned()
} else {
format!("Unknown command: {command}")
};
AppAction::None
}
fn submit_extract_command(&mut self, input: &str) -> AppAction {
let Some(format) = ExtractFormat::parse(input) else {
self.status = format!("EXTRACT unsupported format: {input}");
return AppAction::None;
};
self.status = format!("EXTRACT {format}");
AppAction::Extract(format)
}
fn submit_pipe_command(&mut self, input: &str) -> AppAction {
match classify_pipe_command(input) {
PipeDecision::Allowed(command) => {
self.status = format!("PIPE {}", command.as_str());
AppAction::Pipe(command)
}
PipeDecision::RequiresConfirmation(command) => {
self.status = format!("PIPE confirm with :pipe --confirm {}", command.as_str());
AppAction::None
}
PipeDecision::Denied(reason) => {
self.status = format!("PIPE denied: {reason}");
AppAction::None
}
}
}
fn submit_ai_command(&mut self, input: &str) -> AppAction {
let Some(action) = AiAction::parse(input) else {
self.status = format!("AI unsupported action: {input}");
return AppAction::None;
};
self.status = format!("AI {action}");
AppAction::Ai(action)
}
fn submit_profile_command(&mut self, input: &str) -> AppAction {
if input == "auto" {
self.set_reader_profile_auto();
return AppAction::None;
}
let Some(profile) = ReaderProfile::parse(input) else {
let names = ReaderProfile::all()
.iter()
.map(|profile| profile.as_str())
.chain(std::iter::once("auto"))
.collect::<Vec<_>>()
.join("|");
self.status = format!("PROFILE unsupported profile: {input} ({names})");
return AppAction::None;
};
self.set_reader_profile(profile);
AppAction::None
}
fn submit_capture_command(&mut self, input: &str) -> AppAction {
if input == "preview" {
match preview_document(&self.document) {
Ok(preview) => {
self.status = format!(
"CAPTURE preview: {} redactions; save with :capture save <path>",
preview.summary.total()
);
}
Err(error) => {
self.status = format!("CAPTURE preview failed: {error}");
}
}
return AppAction::None;
}
let Some(path) = input.strip_prefix("save ") else {
self.status = format!("CAPTURE unsupported action: {input}");
return AppAction::None;
};
let path = path.trim();
match save_current_capture(&self.document, path) {
Ok(()) => {
self.status = format!("CAPTURE saved {path}");
}
Err(error) => {
self.status = format!("CAPTURE save failed: {error}");
}
}
AppAction::None
}
fn apply_repair_action(&mut self, action: RepairAction) -> AppAction {
let status = match action {
RepairAction::MainNext => self.jump_to_main_region(DirectionStep::Next),
RepairAction::MainPrevious => self.jump_to_main_region(DirectionStep::Previous),
RepairAction::HideRegion(id) => self.set_region_collapsed_by_id(id, true),
RepairAction::ShowRegion(id) => self.set_region_collapsed_by_id(id, false),
RepairAction::PromoteSection(id) => self.promote_region_by_id(id),
};
match status {
Some(status) => {
self.repair_recipe.push(action);
self.status = format!("REPAIR {status}");
}
None => {
self.status = "REPAIR target not found".to_owned();
}
}
AppAction::None
}
fn jump_to_main_region(&mut self, step: DirectionStep) -> Option<String> {
let candidates = self.main_region_candidates();
let target = match step {
DirectionStep::Next => candidates
.iter()
.copied()
.find(|index| self.regions[*index].line > self.viewport.offset)
.or_else(|| candidates.first().copied()),
DirectionStep::Previous => candidates
.iter()
.rev()
.copied()
.find(|index| self.regions[*index].line < self.viewport.offset)
.or_else(|| candidates.last().copied()),
}?;
self.viewport.offset = self.regions[target].line;
self.clamp_viewport();
Some(format!("main {}", target + 1))
}
fn main_region_candidates(&self) -> Vec<usize> {
let main = self
.regions
.iter()
.enumerate()
.filter_map(|(index, region)| (region.role == SectionRole::Main).then_some(index))
.collect::<Vec<_>>();
if main.is_empty() {
(0..self.regions.len()).collect()
} else {
main
}
}
fn set_region_collapsed_by_id(&mut self, id: usize, collapsed: bool) -> Option<String> {
let index = id.checked_sub(1)?;
self.set_region_collapsed(index, collapsed)
}
fn set_region_collapsed(&mut self, index: usize, collapsed: bool) -> Option<String> {
let path = self.regions.get(index)?.path.clone();
let Some(IndexNode::Section {
collapsed: section_collapsed,
..
}) = section_at_path_mut(&mut self.document.nodes, &path)
else {
return None;
};
*section_collapsed = collapsed;
self.relayout(self.layout_width);
Some(format!(
"{} region {}",
if collapsed { "hid" } else { "showed" },
index + 1
))
}
fn promote_region_by_id(&mut self, id: usize) -> Option<String> {
let index = id.checked_sub(1)?;
if index >= self.regions.len() {
return None;
}
let paths = self
.regions
.iter()
.map(|region| region.path.clone())
.collect::<Vec<_>>();
for (region_index, path) in paths.iter().enumerate() {
if let Some(IndexNode::Section { collapsed, .. }) =
section_at_path_mut(&mut self.document.nodes, path)
{
*collapsed = region_index != index;
}
}
self.relayout(self.layout_width);
self.viewport.offset = self.regions.get(index).map_or(0, |region| region.line);
self.clamp_viewport();
Some(format!("promoted section {id}"))
}
fn submit_search(&mut self, input: String) -> AppAction {
self.mode = InputMode::Normal;
let query = input.trim();
if query.is_empty() {
self.status = "NORMAL".to_owned();
self.last_search_query = None;
return AppAction::None;
}
self.last_search_query = Some(query.to_owned());
if let Some(line_index) = self
.lines
.iter()
.position(|line| line.to_lowercase().contains(&query.to_lowercase()))
{
self.viewport.offset = line_index;
self.clamp_viewport();
self.status = format!("SEARCH {query}");
} else {
self.status = format!("No match: {query}");
}
AppAction::None
}
fn resolve_open_target(&self, target: &str) -> Option<String> {
target
.parse::<usize>()
.ok()
.and_then(|index| self.links.iter().find(|link| link.index == index))
.map(|link| link.href.clone())
.or_else(|| (!target.is_empty()).then_some(target.to_owned()))
}
fn toggle_link_sidebar(&mut self) {
self.show_link_sidebar = !self.show_link_sidebar;
self.selected_sidebar_item = self
.selected_sidebar_item
.min(self.active_sidebar_len().saturating_sub(1));
self.update_sidebar_status();
}
fn select_next_sidebar_item(&mut self) {
let len = self.active_sidebar_len();
if len == 0 {
self.update_sidebar_status();
return;
}
self.selected_sidebar_item = (self.selected_sidebar_item + 1).min(len - 1);
self.update_sidebar_status();
}
fn select_previous_sidebar_item(&mut self) {
if self.active_sidebar_len() == 0 {
self.update_sidebar_status();
return;
}
self.selected_sidebar_item = self.selected_sidebar_item.saturating_sub(1);
self.update_sidebar_status();
}
fn active_sidebar_len(&self) -> usize {
match self.sidebar_mode {
SessionSidebarMode::Links => self.links.len(),
SessionSidebarMode::Outline => self.headings.len() + self.regions.len(),
SessionSidebarMode::Forms => self.forms.len(),
SessionSidebarMode::Regions => self.regions.len(),
SessionSidebarMode::Search => self.search_result_lines().len(),
SessionSidebarMode::Logs => self.response_logs.len(),
}
}
fn update_sidebar_status(&mut self) {
if !self.show_link_sidebar {
self.status = "NORMAL".to_owned();
return;
}
let label = sidebar_mode_title(self.sidebar_mode).to_ascii_uppercase();
let len = self.active_sidebar_len();
if len == 0 {
self.status = format!("{label} empty");
} else {
self.selected_sidebar_item = self.selected_sidebar_item.min(len - 1);
self.status = format!("{label} {}/{}", self.selected_sidebar_item + 1, len);
}
}
fn activate_sidebar_selection(&mut self) -> AppAction {
match self.sidebar_mode {
SessionSidebarMode::Links => {
if let Some(link) = self.links.get(self.selected_sidebar_item) {
self.status = format!("OPEN {}", link.href);
return AppAction::Open(link.href.clone());
}
}
SessionSidebarMode::Outline => {
if let Some(line) = self.outline_item_line(self.selected_sidebar_item) {
self.jump_to_line(line, "OUTLINE");
return AppAction::None;
}
}
SessionSidebarMode::Forms => {
if let Some(form) = self.forms.get(self.selected_sidebar_item) {
self.jump_to_line(form.line, "FORMS");
return AppAction::None;
}
}
SessionSidebarMode::Regions => {
self.toggle_selected_region();
return AppAction::None;
}
SessionSidebarMode::Search => {
if let Some(line) = self
.search_result_lines()
.get(self.selected_sidebar_item)
.copied()
{
self.jump_to_line(line, "SEARCH");
return AppAction::None;
}
}
SessionSidebarMode::Logs => {
self.update_sidebar_status();
return AppAction::None;
}
}
self.update_sidebar_status();
AppAction::None
}
fn edit_current_form(&mut self) -> AppAction {
let Some(position) = self
.forms
.iter()
.position(|form| form.line >= self.viewport.offset)
.or_else(|| self.forms.len().checked_sub(1))
else {
self.status = "FORM no forms on this page".to_owned();
return AppAction::None;
};
self.begin_form_edit(position)
}
fn edit_selected_sidebar_form(&mut self) -> AppAction {
if self.forms.is_empty() {
self.update_sidebar_status();
return AppAction::None;
}
self.begin_form_edit(self.selected_sidebar_item)
}
fn begin_form_edit(&mut self, rendered_form_position: usize) -> AppAction {
let Some(rendered_form) = self.forms.get(rendered_form_position).cloned() else {
self.status = "FORM target not found".to_owned();
return AppAction::None;
};
let Some(field_index) = first_editable_field_index(&rendered_form.form) else {
self.status = format!("FORM {} has no editable fields", rendered_form.index);
return AppAction::None;
};
let Some(input) = rendered_form.form.inputs.get(field_index) else {
self.status = "FORM field not found".to_owned();
return AppAction::None;
};
let edit = FormEdit::from_input(rendered_form.index, field_index, input);
self.viewport.offset = rendered_form.line;
self.clamp_viewport();
self.status = format!(
"FORM {} editing {}",
rendered_form.index,
edit.field_label()
);
self.mode = InputMode::Form(edit);
AppAction::None
}
fn handle_form_edit_key(&mut self, key: KeyEvent, mut edit: FormEdit) -> AppAction {
if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
self.mode = InputMode::Normal;
self.should_quit = true;
return AppAction::Quit;
}
match key.code {
KeyCode::Esc => {
self.status = "FORM cancelled".to_owned();
self.mode = InputMode::Normal;
}
KeyCode::Enter => {
self.persist_form_edit(&edit);
self.mode = InputMode::Normal;
return self.submit_form_index(edit.form_index);
}
KeyCode::Tab => {
self.persist_form_edit(&edit);
if let Some(next) = self.next_form_edit(&edit, DirectionStep::Next) {
self.status =
format!("FORM {} editing {}", next.form_index, next.field_label());
self.mode = InputMode::Form(next);
} else {
self.status = "FORM field not found".to_owned();
self.mode = InputMode::Normal;
}
}
KeyCode::BackTab => {
self.persist_form_edit(&edit);
if let Some(previous) = self.next_form_edit(&edit, DirectionStep::Previous) {
self.status = format!(
"FORM {} editing {}",
previous.form_index,
previous.field_label()
);
self.mode = InputMode::Form(previous);
} else {
self.status = "FORM field not found".to_owned();
self.mode = InputMode::Normal;
}
}
KeyCode::Backspace => {
edit.value.pop();
self.persist_form_edit(&edit);
self.status = format!("FORM {} editing {}", edit.form_index, edit.field_label());
self.mode = InputMode::Form(edit);
}
KeyCode::Char(' ') if is_toggle_field(&edit.field_kind) => {
edit.value = if edit.value.is_empty() {
"on".to_owned()
} else {
String::new()
};
self.persist_form_edit(&edit);
self.status = format!("FORM {} editing {}", edit.form_index, edit.field_label());
self.mode = InputMode::Form(edit);
}
KeyCode::Char(ch) => {
edit.value.push(ch);
self.persist_form_edit(&edit);
self.status = format!("FORM {} editing {}", edit.form_index, edit.field_label());
self.mode = InputMode::Form(edit);
}
_ => {
self.status = format!("FORM {} editing {}", edit.form_index, edit.field_label());
self.mode = InputMode::Form(edit);
}
}
AppAction::None
}
fn persist_form_edit(&mut self, edit: &FormEdit) {
if let Some(input) = form_input_mut_by_render_index(
&mut self.document.nodes,
edit.form_index,
edit.field_index,
) {
input.value = Some(edit.value.clone());
self.relayout(self.layout_width);
}
}
fn next_form_edit(&self, edit: &FormEdit, step: DirectionStep) -> Option<FormEdit> {
let form = self
.forms
.iter()
.find(|form| form.index == edit.form_index)
.map(|form| &form.form)?;
let editable = editable_field_indices(form);
let current = editable
.iter()
.position(|field_index| *field_index == edit.field_index)
.unwrap_or(0);
let next_position = match step {
DirectionStep::Next => {
if current + 1 >= editable.len() {
0
} else {
current + 1
}
}
DirectionStep::Previous => current.checked_sub(1).unwrap_or(editable.len() - 1),
};
let field_index = *editable.get(next_position)?;
let input = form.inputs.get(field_index)?;
Some(FormEdit::from_input(edit.form_index, field_index, input))
}
fn submit_form_index(&mut self, form_index: usize) -> AppAction {
let Some(form) = self
.forms
.iter()
.find(|form| form.index == form_index)
.map(|form| form.form.clone())
else {
self.status = "SUBMIT form not found".to_owned();
return AppAction::None;
};
match form.submit(None, &[]) {
Ok(submission) => {
self.status = format!(
"SUBMIT {} {}",
submission.method.as_str(),
submission.action
);
AppAction::Submit(submission)
}
Err(error) => {
self.status = format!("SUBMIT {error}");
AppAction::None
}
}
}
fn jump_to_line(&mut self, line: usize, label: &str) {
self.viewport.offset = line;
self.clamp_viewport();
self.status = format!("{label} line {}", line + 1);
}
fn outline_item_line(&self, index: usize) -> Option<usize> {
let mut items = self
.headings
.iter()
.map(|heading| heading.line)
.chain(self.regions.iter().map(|region| region.line))
.collect::<Vec<_>>();
items.sort_unstable();
items.get(index).copied()
}
fn toggle_selected_region(&mut self) {
let Some(region) = self.regions.get(self.selected_sidebar_item) else {
self.update_sidebar_status();
return;
};
let path = region.path.clone();
let Some(IndexNode::Section { collapsed, .. }) =
section_at_path_mut(&mut self.document.nodes, &path)
else {
self.update_sidebar_status();
return;
};
*collapsed = !*collapsed;
let status = if *collapsed {
"REGIONS collapsed".to_owned()
} else {
"REGIONS expanded".to_owned()
};
self.relayout(self.layout_width);
self.selected_sidebar_item = self
.selected_sidebar_item
.min(self.regions.len().saturating_sub(1));
self.status = status;
}
fn relayout(&mut self, width: usize) {
let document = self.document.clone();
let layout = self.cached_layout(&document, width);
self.lines = layout.lines;
self.links = layout.links;
self.forms = layout.forms;
self.headings = layout.headings;
self.regions = layout.regions;
self.clamp_viewport();
}
fn cached_layout(&mut self, document: &IndexDocument, width: usize) -> DocumentLayout {
let key = LayoutCacheKey::new(document, width, self.table_render_options());
if let Some((_, layout)) = self
.layout_cache
.iter()
.find(|(candidate, _)| *candidate == key)
{
return layout.clone();
}
let layout = layout_document_with_options(document, width, self.table_render_options());
self.layout_cache.push((key, layout.clone()));
if self.layout_cache.len() > 4 {
self.layout_cache.remove(0);
}
layout
}
fn table_render_options(&self) -> TableRenderOptions {
TableRenderOptions {
mode: self.table_mode,
column_offset: self.table_column_offset,
}
}
fn search_result_lines(&self) -> Vec<usize> {
let Some(query) = self.last_search_query.as_deref() else {
return Vec::new();
};
let query = query.to_lowercase();
self.lines
.iter()
.enumerate()
.filter_map(|(index, line)| line.to_lowercase().contains(&query).then_some(index))
.collect()
}
fn open_command_suggestion(&self, input: &str) -> Option<String> {
let partial = input.strip_prefix("open ")?.trim_start();
if partial.is_empty() {
return self.url_history.first().cloned();
}
self.url_history
.iter()
.find(|url| url.starts_with(partial) && url.as_str() != partial)
.cloned()
}
fn start_open_loading(&mut self, target: &str) {
self.status = format!("OPENING {target}");
self.loading_frame = 0;
self.opening_progress = Some(format!("queued {target}"));
}
fn set_opening_progress(&mut self, progress: String) {
if self.status.starts_with("OPENING ") {
self.opening_progress = Some(progress);
}
}
fn open_document(&mut self, document: IndexDocument, width: usize, status: String) {
self.back_stack.push(self.document.clone());
self.repair_recipe = RepairRecipe::new();
self.replace_document(document, width, status);
}
fn record_visited_url(&mut self, url: impl Into<String>) {
let url = url.into();
if url.trim().is_empty() {
return;
}
self.url_history.retain(|candidate| candidate != &url);
self.url_history.insert(0, url);
self.url_history.truncate(50);
}
fn record_response_log(&mut self, log: ResponseLogEntry) {
self.response_logs.push(log);
if self.response_logs.len() > RESPONSE_LOG_LIMIT {
let drain = self.response_logs.len() - RESPONSE_LOG_LIMIT;
self.response_logs.drain(0..drain);
}
self.selected_sidebar_item = self
.selected_sidebar_item
.min(self.active_sidebar_len().saturating_sub(1));
}
fn next_response_log_sequence(&self) -> u64 {
self.response_logs
.last()
.map_or(1, |entry| entry.sequence.saturating_add(1))
}
fn record_error_log(&mut self, method: &str, target: &str, error: &str) {
let sequence = self.next_response_log_sequence();
self.record_response_log(ResponseLogEntry::new(
sequence,
method,
target,
target,
Some("text/x-index-error"),
error,
512,
));
}
fn go_back(&mut self, width: usize) {
if let Some(document) = self.back_stack.pop() {
self.replace_document(document, width, "BACK".to_owned());
} else {
self.status = "BACK no previous page".to_owned();
}
}
fn submit_form_command(&mut self, input: &str) -> AppAction {
let Some((target, fields)) = input.split_once(' ') else {
self.status = "SUBMIT missing fields".to_owned();
return AppAction::None;
};
let Some(form) = self.resolve_form_target(target.trim()) else {
self.status = "SUBMIT form not found".to_owned();
return AppAction::None;
};
let values = parse_form_values(fields);
let borrowed = values
.iter()
.map(|(name, value)| (name.as_str(), value.as_str()))
.collect::<Vec<_>>();
match form.submit(None, &borrowed) {
Ok(submission) => {
self.status = format!(
"SUBMIT {} {}",
submission.method.as_str(),
submission.action
);
AppAction::Submit(submission)
}
Err(error) => {
self.status = format!("SUBMIT {error}");
AppAction::None
}
}
}
fn resolve_form_target(&self, target: &str) -> Option<&Form> {
target
.parse::<usize>()
.ok()
.and_then(|index| self.forms.iter().find(|form| form.index == index))
.or_else(|| self.forms.iter().find(|form| form.name == target))
.map(|form| &form.form)
}
fn scroll_down(&mut self, amount: usize) {
self.viewport.offset = self.viewport.offset.saturating_add(amount);
self.clamp_viewport();
self.status = "NORMAL".to_owned();
}
fn scroll_up(&mut self, amount: usize) {
self.viewport.offset = self.viewport.offset.saturating_sub(amount);
self.status = "NORMAL".to_owned();
}
fn scroll_top(&mut self) {
self.viewport.offset = 0;
self.status = "TOP".to_owned();
}
fn scroll_bottom(&mut self) {
self.viewport.offset = self.max_offset();
self.status = "BOTTOM".to_owned();
}
fn toggle_table_mode(&mut self) {
self.table_mode = self.table_mode.toggled();
self.status = format!("TABLE {}", self.table_mode.as_str());
self.relayout(self.layout_width);
}
fn scroll_table_columns_left(&mut self) {
self.table_column_offset = self.table_column_offset.saturating_sub(1);
self.status = format!("TABLE column {}", self.table_column_offset + 1);
self.relayout(self.layout_width);
}
fn scroll_table_columns_right(&mut self) {
self.table_column_offset = self.table_column_offset.saturating_add(1);
self.table_column_offset = self.table_column_offset.min(max_table_column_offset(
&self.document.nodes,
self.layout_width,
));
self.status = format!("TABLE column {}", self.table_column_offset + 1);
self.relayout(self.layout_width);
}
fn clamp_viewport(&mut self) {
self.viewport.offset = self.viewport.offset.min(self.max_offset());
}
fn max_offset(&self) -> usize {
self.lines.len().saturating_sub(self.viewport.height)
}
fn render_document(&self, frame: &mut ratatui::Frame<'_>, area: Rect) {
let visible_lines = if self.status.starts_with("OPENING ") {
self.loading_page_lines()
} else {
self.lines
.iter()
.skip(self.viewport.offset)
.take(usize::from(area.height))
.enumerate()
.map(|(visible_index, line)| {
let is_current_line = visible_index == 0;
let style = self.line_style(line);
let style = if is_current_line {
style.bg(self.theme.current_line)
} else {
style
};
self.styled_line(line, style, is_current_line)
})
.collect::<Vec<_>>()
};
let paragraph = Paragraph::new(visible_lines)
.block(
Block::default()
.borders(Borders::ALL)
.border_set(DOTTED_BORDER)
.title(self.document.title.as_str()),
)
.wrap(Wrap { trim: false });
frame.render_widget(paragraph, area);
}
fn loading_page_lines(&self) -> Vec<Line<'static>> {
let target = self.status.trim_start_matches("OPENING ").trim();
let verse_index = (self.loading_frame / OPENING_VERSE_TICKS) % OPENING_TAO_VERSES.len();
let verse = OPENING_TAO_VERSES[verse_index];
let spinner =
loading_spinner_frame(self.loading_frame, self.animation_mode, self.glyph_support);
let title_icon = if matches!(self.glyph_support, GlyphSupport::Rich) {
""
} else {
"[loading]"
};
vec![
Line::from(Span::styled(
format!("{title_icon} Opening"),
Style::default()
.fg(self.theme.info)
.add_modifier(Modifier::BOLD),
)),
Line::from(Span::styled(
format!("{spinner} {target}"),
Style::default().fg(self.theme.status),
)),
Line::from(Span::raw("")),
Line::from(Span::styled(
verse.to_owned(),
Style::default()
.fg(self.theme.quote)
.add_modifier(Modifier::ITALIC),
)),
Line::from(Span::raw("")),
Line::from(Span::styled(
"Press q or :quit to cancel.",
Style::default().fg(self.theme.muted),
)),
]
}
fn line_style(&self, line: &str) -> Style {
let trimmed = line.trim_start();
let style = Style::default();
if trimmed.starts_with("") {
style
.fg(self.theme.document_title)
.add_modifier(Modifier::BOLD)
} else if trimmed.starts_with("") {
style.fg(self.theme.heading).add_modifier(Modifier::BOLD)
} else if trimmed.starts_with("") {
style.fg(self.theme.link)
} else if trimmed.starts_with("") || trimmed.starts_with('•') {
style.fg(self.theme.list)
} else if trimmed.starts_with("") || trimmed.starts_with("```") {
style.fg(self.theme.code)
} else if trimmed.starts_with("") {
style.fg(self.theme.table)
} else if trimmed.starts_with("") {
style.fg(self.theme.region)
} else if trimmed.starts_with("") {
style.fg(self.theme.image)
} else if trimmed.starts_with("") {
style.fg(self.theme.form)
} else if trimmed.starts_with("") {
style.fg(self.theme.quote).add_modifier(Modifier::ITALIC)
} else if trimmed.starts_with("") {
style.fg(self.theme.error).add_modifier(Modifier::BOLD)
} else {
style.fg(self.theme.foreground)
}
}
fn styled_line<'a>(&self, line: &'a str, base_style: Style, reveal_syntax: bool) -> Line<'a> {
let display_line = if reveal_syntax {
line.to_owned()
} else {
hide_markdown_syntax(line)
};
let display_line = if matches!(self.glyph_support, GlyphSupport::Plain) {
plain_glyph_fallback(&display_line)
} else {
display_line
};
if line.trim_start().starts_with("")
|| line.trim_start().starts_with("```")
|| line.starts_with(" ")
|| reveal_syntax
{
return Line::from(Span::styled(display_line, base_style));
}
Line::from(markdown_inline_spans(
&display_line,
base_style,
self.theme.bold,
self.theme.italic,
))
}
fn render_status(&mut self, frame: &mut ratatui::Frame<'_>, area: Rect) {
let status_text = self.status_text();
let severity = classify_status(&status_text);
let icon = status_icon(severity, self.glyph_support);
let status_style = status_style(severity, self.theme);
let meta = format!(
" | quality: {} | profile: {}{} | {}/{} | links: {}",
self.document
.metadata
.quality
.as_ref()
.map_or("unknown", |quality| quality.category.as_str()),
self.reader_profile,
if self.reader_profile_is_auto() {
" auto"
} else {
""
},
self.viewport.offset.saturating_add(1),
self.lines.len().max(1),
self.links.len()
);
let paragraph = Paragraph::new(Line::from(vec![
Span::styled(format!("{icon} {status_text}"), status_style),
Span::styled(meta, Style::default().fg(self.theme.status)),
]));
frame.render_widget(paragraph, area);
}
fn status_text(&mut self) -> String {
if self.status.starts_with("OPENING ") {
let frame =
loading_spinner_frame(self.loading_frame, self.animation_mode, GlyphSupport::Plain);
let phase = self
.opening_progress
.as_deref()
.unwrap_or_else(|| self.status.trim_start_matches("OPENING ").trim());
let marker = if frame == "[ ]" || frame.trim().is_empty() {
"[ ]".to_owned()
} else {
format!("[{frame}]")
};
self.loading_frame = self.loading_frame.wrapping_add(1);
return format!("{marker} OPENING {phase}");
}
self.status.clone()
}
fn render_input(&mut self, frame: &mut ratatui::Frame<'_>, area: Rect) {
let text = match &self.mode {
InputMode::Normal => {
if self.show_link_sidebar {
format!(
"{}: j/k select [] mode 1-6 jump mode enter open/jump e edit form space expand esc hide",
sidebar_mode_title(self.sidebar_mode).to_ascii_lowercase()
)
} else {
"j/k scroll gg/G top/bottom / search f hints l links e edit form t table b back :profile q quit".to_owned()
}
}
InputMode::Command(input) => {
if let Some(suggestion) = self.open_command_suggestion(input) {
format!("{input} -> {suggestion}")
} else {
input.to_owned()
}
}
InputMode::Search(input) => format!("/{input}"),
InputMode::Form(edit) => format!(
"form {} {} = {} tab next enter submit esc cancel",
edit.form_index,
edit.field_label(),
edit.prompt_value()
),
};
let colon_color =
prompt_colon_color(self.prompt_blink_frame, self.theme, self.animation_mode);
if matches!(self.animation_mode, AnimationMode::Normal) {
self.prompt_blink_frame = self.prompt_blink_frame.wrapping_add(1);
}
let paragraph = Paragraph::new(Line::from(vec![
Span::styled("[", Style::default().fg(self.theme.prompt)),
Span::styled(":", Style::default().fg(colon_color)),
Span::styled("> ", Style::default().fg(self.theme.prompt)),
Span::styled(text, Style::default().fg(self.theme.muted)),
]));
frame.render_widget(paragraph, area);
}
fn render_link_hints(&self, frame: &mut ratatui::Frame<'_>, area: Rect) {
let items = self
.links
.iter()
.map(|link| {
ListItem::new(Line::from(vec![
Span::styled(
format!("[{}] ", link.index),
Style::default()
.fg(self.theme.accent)
.add_modifier(Modifier::BOLD),
),
Span::raw(format!("{} -> {}", link.text, link.href)),
]))
})
.collect::<Vec<_>>();
let list = List::new(items).block(dotted_block("Links"));
frame.render_widget(Clear, area);
frame.render_widget(list, area);
}
fn render_sidebar(&self, frame: &mut ratatui::Frame<'_>, area: Rect) {
if self.sidebar_mode == SessionSidebarMode::Logs {
self.render_logs_sidebar(frame, area);
return;
}
let entries = self.sidebar_entries();
let lines = if entries.is_empty() {
vec![Line::from(Span::styled(
format!(
"No {}",
sidebar_mode_title(self.sidebar_mode).to_ascii_lowercase()
),
Style::default().fg(self.theme.muted),
))]
} else {
entries
.iter()
.enumerate()
.flat_map(|(position, entry)| {
let selected = position == self.selected_sidebar_item;
let marker = if selected { ">" } else { " " };
let style = if selected {
Style::default()
.fg(self.theme.accent)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(self.theme.link)
};
[
Line::from(vec![
Span::styled(marker, style),
Span::styled(format!(" {}", truncate_display(&entry.label, 28)), style),
]),
Line::from(Span::styled(
format!(" {}", truncate_display(&entry.detail, 28)),
Style::default().fg(self.theme.muted),
)),
Line::from(""),
]
})
.collect::<Vec<_>>()
};
let paragraph = Paragraph::new(lines)
.block(dotted_block(sidebar_mode_title(self.sidebar_mode)))
.wrap(Wrap { trim: false });
frame.render_widget(paragraph, area);
}
fn render_logs_sidebar(&self, frame: &mut ratatui::Frame<'_>, area: Rect) {
let logs = self.response_logs.iter().rev().collect::<Vec<_>>();
let mut selected_line_index = 0_usize;
let lines =
if logs.is_empty() {
vec![Line::from(Span::styled(
"No logs",
Style::default().fg(self.theme.muted),
))]
} else {
let mut lines = Vec::new();
for (position, log) in logs.iter().enumerate() {
let selected = position == self.selected_sidebar_item;
if selected {
selected_line_index = lines.len();
}
let severity = classify_log_entry(log);
let marker = if selected { ">" } else { " " };
let mut style = status_style(severity, self.theme);
if selected {
style = style.add_modifier(Modifier::BOLD);
}
lines.push(Line::from(vec![
Span::styled(marker, style),
Span::styled(format!(" {}", truncate_display(&log.title(), 28)), style),
]));
if selected {
let label_style = Style::default()
.fg(self.theme.accent)
.add_modifier(Modifier::BOLD);
let value_style = Style::default().fg(self.theme.foreground);
lines.extend([
Line::from(Span::styled(
format!(" method: {}", log.method),
value_style,
)),
Line::from(Span::styled(
format!(" requested: {}", log.requested_url),
Style::default().fg(self.theme.muted),
)),
Line::from(Span::styled(
format!(" final: {}", log.final_url),
Style::default().fg(self.theme.muted),
)),
Line::from(Span::styled(
format!(
" mime: {}{}",
log.mime_type.as_deref().unwrap_or("unknown"),
if log.truncated { " · truncated" } else { "" }
),
Style::default().fg(self.theme.muted),
)),
Line::from(Span::styled(" preview:", label_style)),
]);
lines.extend(log.body_preview.lines().map(|line| {
Line::from(Span::styled(format!(" {line}"), value_style))
}));
} else {
lines.push(Line::from(Span::styled(
format!(
" {}{}",
truncate_display(
log.body_preview
.lines()
.next()
.unwrap_or(log.mime_type.as_deref().unwrap_or("response")),
28
),
if log.truncated { "…" } else { "" }
),
Style::default().fg(self.theme.muted),
)));
}
lines.push(Line::from(""));
}
lines
};
let visible_lines = usize::from(area.height.saturating_sub(2));
let scroll_line = selected_line_index.saturating_sub(visible_lines / 3);
let scroll_y = u16::try_from(scroll_line).unwrap_or(u16::MAX);
let paragraph = Paragraph::new(lines)
.block(dotted_block("Logs"))
.scroll((scroll_y, 0))
.wrap(Wrap { trim: false });
frame.render_widget(paragraph, area);
}
fn sidebar_entries(&self) -> Vec<SidebarEntry> {
match self.sidebar_mode {
SessionSidebarMode::Links => self
.links
.iter()
.map(|link| SidebarEntry {
label: format!("{} {}", link.index, link.text),
detail: link.href.clone(),
})
.collect(),
SessionSidebarMode::Outline => self.outline_entries(),
SessionSidebarMode::Forms => self
.forms
.iter()
.map(|form| SidebarEntry {
label: format!("{} {}", form.index, form.name),
detail: format!(
"{} fields · line {}",
editable_field_indices(&form.form).len(),
form.line + 1
),
})
.collect(),
SessionSidebarMode::Regions => self
.regions
.iter()
.map(|region| SidebarEntry {
label: format!(
"{} {}",
if region.collapsed { "▸" } else { "▾" },
section_label(region.role, region.title.as_deref())
),
detail: format!("{} items · line {}", region.item_count, region.line + 1),
})
.collect(),
SessionSidebarMode::Search => self
.search_result_lines()
.into_iter()
.map(|line| SidebarEntry {
label: format!("line {}", line + 1),
detail: self
.lines
.get(line)
.map_or_else(String::new, |value| truncate_display(value.trim(), 32)),
})
.collect(),
SessionSidebarMode::Logs => self
.response_logs
.iter()
.rev()
.map(|log| SidebarEntry {
label: log.title(),
detail: format!(
"{}{}",
truncate_display(
log.body_preview
.lines()
.next()
.unwrap_or(log.mime_type.as_deref().unwrap_or("response")),
32
),
if log.truncated { "…" } else { "" }
),
})
.collect(),
}
}
fn outline_entries(&self) -> Vec<SidebarEntry> {
let mut entries = self
.headings
.iter()
.map(|heading| {
(
heading.line,
SidebarEntry {
label: format!(
"{} {}",
"#".repeat(usize::from(heading.level)),
heading.text
),
detail: format!("line {}", heading.line + 1),
},
)
})
.chain(self.regions.iter().map(|region| {
(
region.line,
SidebarEntry {
label: format!("§ {}", section_label(region.role, region.title.as_deref())),
detail: format!("{} items · line {}", region.item_count, region.line + 1),
},
)
}))
.collect::<Vec<_>>();
entries.sort_by_key(|(line, _)| *line);
entries.into_iter().map(|(_, entry)| entry).collect()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct SidebarEntry {
label: String,
detail: String,
}
impl LayoutCacheKey {
fn new(document: &IndexDocument, width: usize, table_options: TableRenderOptions) -> Self {
Self {
document_hash: stable_layout_hash(document),
width,
table_options,
}
}
}
fn stable_layout_hash(document: &IndexDocument) -> u64 {
let mut hash = 0xcbf2_9ce4_8422_2325_u64;
for byte in format!("{document:?}").as_bytes() {
hash ^= u64::from(*byte);
hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
}
hash
}
fn dotted_block(title: &'static str) -> Block<'static> {
Block::default()
.borders(Borders::ALL)
.border_set(DOTTED_BORDER)
.title(title)
}
fn sidebar_mode_title(mode: SessionSidebarMode) -> &'static str {
match mode {
SessionSidebarMode::Links => "Links",
SessionSidebarMode::Outline => "Outline",
SessionSidebarMode::Forms => "Forms",
SessionSidebarMode::Regions => "Regions",
SessionSidebarMode::Search => "Search",
SessionSidebarMode::Logs => "Logs",
}
}
fn section_at_path_mut<'a>(
nodes: &'a mut [IndexNode],
path: &[usize],
) -> Option<&'a mut IndexNode> {
let (first, rest) = path.split_first()?;
let node = nodes.get_mut(*first)?;
if rest.is_empty() {
return Some(node);
}
match node {
IndexNode::Section { nodes, .. } => section_at_path_mut(nodes, rest),
_ => None,
}
}
fn form_input_mut_by_render_index(
nodes: &mut [IndexNode],
target_form_index: usize,
target_field_index: usize,
) -> Option<&mut Input> {
let mut current_form_index = 0;
form_input_mut_by_render_index_inner(
nodes,
target_form_index,
target_field_index,
&mut current_form_index,
)
}
fn form_input_mut_by_render_index_inner<'a>(
nodes: &'a mut [IndexNode],
target_form_index: usize,
target_field_index: usize,
current_form_index: &mut usize,
) -> Option<&'a mut Input> {
for node in nodes {
match node {
IndexNode::Form(form) => {
*current_form_index += 1;
if *current_form_index == target_form_index {
return form.inputs.get_mut(target_field_index);
}
}
IndexNode::Section {
nodes, collapsed, ..
} if !*collapsed => {
if let Some(input) = form_input_mut_by_render_index_inner(
nodes,
target_form_index,
target_field_index,
current_form_index,
) {
return Some(input);
}
}
_ => {}
}
}
None
}
pub fn run_tui(document: IndexDocument) -> io::Result<()> {
run_tui_with_navigation(document, |_target| {
Err("live navigation is not configured".to_owned())
})
}
pub fn run_tui_with_navigation<F>(document: IndexDocument, navigate: F) -> io::Result<()>
where
F: FnMut(&str) -> Result<IndexDocument, String> + Send + 'static,
{
run_tui_with_navigation_and_profile(document, ReaderProfile::Reader, navigate)
}
pub fn run_tui_with_navigation_and_profile<F>(
document: IndexDocument,
profile: ReaderProfile,
navigate: F,
) -> io::Result<()>
where
F: FnMut(&str) -> Result<IndexDocument, String> + Send + 'static,
{
run_tui_with_navigation_profile_and_forms(document, profile, navigate, |_submission| {
Err("form submission is not configured".to_owned())
})
}
pub fn run_tui_with_navigation_profile_and_forms<F, S>(
document: IndexDocument,
profile: ReaderProfile,
mut navigate: F,
mut submit_form: S,
) -> io::Result<()>
where
F: FnMut(&str) -> Result<IndexDocument, String> + Send + 'static,
S: FnMut(&FormSubmission) -> Result<IndexDocument, String> + Send + 'static,
{
run_tui_with_navigation_profile_forms_and_state(
document,
profile,
Vec::new(),
Vec::new(),
move |target| navigate(target).map(TuiDocumentResult::from),
move |submission| submit_form(submission).map(TuiDocumentResult::from),
)
}
pub fn run_tui_with_navigation_profile_forms_and_state<F, S>(
document: IndexDocument,
profile: ReaderProfile,
url_history: Vec<String>,
response_logs: Vec<ResponseLogEntry>,
mut navigate: F,
mut submit_form: S,
) -> io::Result<()>
where
F: FnMut(&str) -> Result<TuiDocumentResult, String> + Send + 'static,
S: FnMut(&FormSubmission) -> Result<TuiDocumentResult, String> + Send + 'static,
{
run_tui_with_navigation_profile_forms_and_state_with_progress(
document,
profile,
url_history,
response_logs,
move |target, _progress| navigate(target),
move |submission, _progress| submit_form(submission),
)
}
pub fn run_tui_with_navigation_profile_forms_and_state_with_progress<F, S>(
document: IndexDocument,
profile: ReaderProfile,
url_history: Vec<String>,
response_logs: Vec<ResponseLogEntry>,
mut navigate: F,
mut submit_form: S,
) -> io::Result<()>
where
F: FnMut(&str, &mut dyn FnMut(String)) -> Result<TuiDocumentResult, String> + Send + 'static,
S: FnMut(&FormSubmission, &mut dyn FnMut(String)) -> Result<TuiDocumentResult, String>
+ Send
+ 'static,
{
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let mut app = TerminalApp::new(document, 88);
app.set_url_history(url_history);
app.set_response_logs(response_logs);
if profile != ReaderProfile::Reader {
app.set_reader_profile(profile);
}
let (request_tx, request_rx) = mpsc::channel::<WorkerRequest>();
let (response_tx, response_rx) = mpsc::channel::<WorkerResponse>();
let _worker = thread::spawn(move || {
while let Ok(request) = request_rx.recv() {
match request {
WorkerRequest::Open(target) => {
let mut report_progress = |message: String| {
let _ = response_tx.send(WorkerResponse::Progress { message });
};
let result = navigate(&target, &mut report_progress);
if response_tx
.send(WorkerResponse::Open { target, result })
.is_err()
{
break;
}
}
WorkerRequest::Submit(submission) => {
let mut report_progress = |message: String| {
let _ = response_tx.send(WorkerResponse::Progress { message });
};
let result = submit_form(&submission, &mut report_progress);
if response_tx
.send(WorkerResponse::Submit { submission, result })
.is_err()
{
break;
}
}
WorkerRequest::Stop => break,
}
}
});
let result = run_tui_loop(&mut terminal, &mut app, &request_tx, &response_rx);
let _ = request_tx.send(WorkerRequest::Stop);
disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
terminal.show_cursor()?;
result
}
fn run_tui_loop(
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
app: &mut TerminalApp,
request_tx: &mpsc::Sender<WorkerRequest>,
response_rx: &mpsc::Receiver<WorkerResponse>,
) -> io::Result<()> {
while !app.should_quit() {
handle_worker_response(app, response_rx);
terminal.draw(|frame| app.render(frame))?;
if event::poll(TUI_FRAME_DURATION)? {
if let Event::Key(key) = event::read()? {
let action = app.handle_key(key);
dispatch_app_action(app, action, request_tx);
}
}
}
Ok(())
}
fn dispatch_app_action(
app: &mut TerminalApp,
action: AppAction,
request_tx: &mpsc::Sender<WorkerRequest>,
) {
match action {
AppAction::Open(target) => {
if app.status.starts_with("OPENING ") {
app.status = "OPEN busy (wait or :quit)".to_owned();
return;
}
app.start_open_loading(&target);
if request_tx
.send(WorkerRequest::Open(target.clone()))
.is_err()
{
let error = "navigation worker is unavailable".to_owned();
app.record_error_log("GET", &target, &error);
app.open_document(
network_failure_document("Network fetch failed", &target, &error),
88,
format!("OPEN failed: {target}"),
);
}
}
AppAction::Submit(submission) => {
if app.status.starts_with("OPENING ") {
app.status = "SUBMIT busy (wait or :quit)".to_owned();
return;
}
let action_target = submission.action.as_str().to_owned();
app.start_open_loading(action_target.as_str());
if request_tx
.send(WorkerRequest::Submit(submission.clone()))
.is_err()
{
let error = "form worker is unavailable".to_owned();
app.record_error_log(submission.method.as_str(), &action_target, &error);
app.open_document(
network_failure_document("Form submission failed", &action_target, &error),
88,
format!("SUBMIT failed: {action_target}"),
);
}
}
AppAction::Back => app.go_back(88),
AppAction::None
| AppAction::Quit
| AppAction::Extract(_)
| AppAction::Pipe(_)
| AppAction::Ai(_) => {}
}
}
fn handle_worker_response(app: &mut TerminalApp, response_rx: &mpsc::Receiver<WorkerResponse>) {
loop {
match response_rx.try_recv() {
Ok(WorkerResponse::Progress { message }) => app.set_opening_progress(message),
Ok(WorkerResponse::Open { target, result }) => match result {
Ok(result) => {
let visited_url = result.visited_url.clone().unwrap_or_else(|| target.clone());
if let Some(log) = result.response_log {
app.record_response_log(log);
}
app.record_visited_url(visited_url);
app.open_document(result.document, 88, format!("OPEN {target}"));
}
Err(error) => {
app.record_error_log("GET", &target, &error);
app.open_document(
network_failure_document("Network fetch failed", &target, &error),
88,
format!("OPEN failed: {target}"),
);
}
},
Ok(WorkerResponse::Submit { submission, result }) => {
let status = format!(
"SUBMIT {} {}",
submission.method.as_str(),
submission.action
);
match result {
Ok(result) => {
let visited_url = result
.visited_url
.clone()
.unwrap_or_else(|| submission.action.as_str().to_owned());
if let Some(log) = result.response_log {
app.record_response_log(log);
}
app.record_visited_url(visited_url);
app.open_document(result.document, 88, status);
}
Err(error) => {
let action = submission.action.as_str().to_owned();
app.record_error_log(submission.method.as_str(), &action, &error);
app.open_document(
network_failure_document("Form submission failed", &action, &error),
88,
format!("SUBMIT failed: {action}"),
);
}
}
}
Err(TryRecvError::Empty) => break,
Err(TryRecvError::Disconnected) => {
if app.status.starts_with("OPENING ") {
let target = app.status.trim_start_matches("OPENING ").to_owned();
let error = "navigation worker disconnected".to_owned();
app.record_error_log("GET", &target, &error);
app.open_document(
network_failure_document("Network fetch failed", &target, &error),
88,
format!("OPEN failed: {target}"),
);
}
break;
}
}
}
}
fn prompt_colon_color(frame: usize, theme: Theme, animation_mode: AnimationMode) -> Color {
if matches!(animation_mode, AnimationMode::None) {
return theme.prompt;
}
if (frame / PROMPT_BLINK_TICKS) % 2 == 0 {
theme.prompt
} else {
theme.muted
}
}
fn loading_spinner_frame(
loading_frame: usize,
animation_mode: AnimationMode,
glyph_support: GlyphSupport,
) -> &'static str {
if matches!(animation_mode, AnimationMode::None) {
return "[ ]";
}
const FRAMES: [&str; 4] = ["-", "\\", "|", "/"];
let frame = FRAMES[loading_frame % FRAMES.len()];
let blink_slot = loading_frame % STATUS_BLINK_CYCLE_TICKS;
let shown = STATUS_BLINK_ON_WINDOWS.contains(&blink_slot);
if shown {
return match glyph_support {
GlyphSupport::Rich => match frame {
"-" => "⠋",
"\\" => "⠙",
"|" => "⠸",
"/" => "⠴",
_ => "⠋",
},
GlyphSupport::Plain => frame,
};
}
match glyph_support {
GlyphSupport::Rich => "·",
GlyphSupport::Plain => " ",
}
}
fn classify_status(status: &str) -> StatusSeverity {
let normalized = status.to_ascii_lowercase();
let has_any = |needles: &[&str]| needles.iter().any(|needle| normalized.contains(needle));
if has_any(&[
"failed",
"error",
"denied",
"missing",
"unsupported",
"not found",
"disconnected",
"unavailable",
"no previous page",
]) {
return StatusSeverity::Error;
}
if has_any(&["busy", "confirm", "no match", "empty", "unknown command"]) {
return StatusSeverity::Warning;
}
if has_any(&[
"open ", "submit ", "saved", "profile ", "repair ", "capture ",
]) {
return StatusSeverity::Success;
}
StatusSeverity::Info
}
fn status_icon(severity: StatusSeverity, glyph_support: GlyphSupport) -> &'static str {
match glyph_support {
GlyphSupport::Rich => match severity {
StatusSeverity::Error => "",
StatusSeverity::Warning => "",
StatusSeverity::Success => "",
StatusSeverity::Info => "",
},
GlyphSupport::Plain => match severity {
StatusSeverity::Error => "[error]",
StatusSeverity::Warning => "[warn]",
StatusSeverity::Success => "[ok]",
StatusSeverity::Info => "[info]",
},
}
}
fn status_style(severity: StatusSeverity, theme: Theme) -> Style {
let color = match severity {
StatusSeverity::Error => theme.error,
StatusSeverity::Warning => theme.warning,
StatusSeverity::Success => theme.success,
StatusSeverity::Info => theme.info,
};
let mut style = Style::default().fg(color);
if matches!(severity, StatusSeverity::Error | StatusSeverity::Warning) {
style = style.add_modifier(Modifier::BOLD);
}
style
}
fn classify_log_entry(log: &ResponseLogEntry) -> StatusSeverity {
let mime = log
.mime_type
.as_deref()
.unwrap_or_default()
.to_ascii_lowercase();
if mime.contains("x-index-error") {
return StatusSeverity::Error;
}
let body = log.body_preview.to_ascii_lowercase();
if body.contains("failed")
|| body.contains("error")
|| body.contains("denied")
|| body.contains("unsupported")
{
return StatusSeverity::Error;
}
if body.contains("busy") || body.contains("confirm") || body.contains("warning") {
return StatusSeverity::Warning;
}
StatusSeverity::Success
}
#[cfg(test)]
fn handle_app_action<F>(app: &mut TerminalApp, action: AppAction, navigate: &mut F)
where
F: FnMut(&str) -> Result<IndexDocument, String>,
{
handle_app_action_with_forms(
app,
action,
&mut |target| navigate(target).map(TuiDocumentResult::from),
&mut |_submission| Err("form submission is not configured".to_owned()),
);
}
#[cfg(test)]
fn handle_app_action_with_forms<F, S>(
app: &mut TerminalApp,
action: AppAction,
navigate: &mut F,
submit_form: &mut S,
) where
F: FnMut(&str) -> Result<TuiDocumentResult, String>,
S: FnMut(&FormSubmission) -> Result<TuiDocumentResult, String>,
{
match action {
AppAction::Open(target) => {
app.start_open_loading(&target);
match navigate(&target) {
Ok(result) => {
let visited_url = result.visited_url.clone().unwrap_or_else(|| target.clone());
if let Some(log) = result.response_log {
app.record_response_log(log);
}
app.record_visited_url(visited_url);
app.open_document(result.document, 88, format!("OPEN {target}"));
}
Err(error) => {
app.record_error_log("GET", &target, &error);
app.open_document(
network_failure_document("Network fetch failed", &target, &error),
88,
format!("OPEN failed: {target}"),
);
}
}
}
AppAction::Submit(submission) => {
let status = format!(
"SUBMIT {} {}",
submission.method.as_str(),
submission.action
);
app.start_open_loading(submission.action.as_str());
match submit_form(&submission) {
Ok(result) => {
let visited_url = result
.visited_url
.clone()
.unwrap_or_else(|| submission.action.as_str().to_owned());
if let Some(log) = result.response_log {
app.record_response_log(log);
}
app.record_visited_url(visited_url);
app.open_document(result.document, 88, status);
}
Err(error) => {
let action = submission.action.as_str().to_owned();
app.record_error_log(submission.method.as_str(), &action, &error);
app.open_document(
network_failure_document("Form submission failed", &action, &error),
88,
format!("SUBMIT failed: {action}"),
);
}
}
}
AppAction::Back => app.go_back(88),
AppAction::None
| AppAction::Quit
| AppAction::Extract(_)
| AppAction::Pipe(_)
| AppAction::Ai(_) => {}
}
}
fn network_failure_document(title: &str, target: &str, error: &str) -> IndexDocument {
FailureDiagnostic::new(
title,
DiagnosticSource::Network,
DiagnosticConfidence::Failed,
format!("could not fetch {target}: {error}"),
)
.with_fallback("no document was transformed")
.with_tried("URL normalization")
.with_tried("secure fetcher")
.with_tried("bounded retry policy")
.with_actions([
DiagnosticAction::Retry,
DiagnosticAction::Extract,
DiagnosticAction::Capture,
])
.with_command(format!(":open {target}"))
.with_record(
DiagnosticRecord::new(
DiagnosticSeverity::Error,
"INDEX-NETWORK-FAILED",
error.to_owned(),
)
.with_field("target", target),
)
.into_document()
}
fn save_current_capture(document: &IndexDocument, path: &str) -> Result<(), String> {
validate_capture_path(path)?;
let artifact = capture_document(document).map_err(|error| error.to_string())?;
let text = artifact.to_text();
validate_capture_bundle(&text).map_err(|error| error.to_string())?;
fs::write(path, text).map_err(|error| error.to_string())
}
fn validate_capture_path(path: &str) -> Result<(), String> {
if path.is_empty() {
return Err("path is required".to_owned());
}
if path.starts_with('-') {
return Err("path must not look like an option".to_owned());
}
if path.contains('\0') {
return Err("path contains a NUL byte".to_owned());
}
if Path::new(path).is_dir() {
return Err("path points to a directory".to_owned());
}
Ok(())
}
#[must_use]
pub fn render_document(document: &IndexDocument, options: RenderOptions) -> String {
layout_document_with_options(document, options.width, TableRenderOptions::default())
.lines
.join("\n")
.trim_end()
.to_owned()
}
fn layout_document_with_options(
document: &IndexDocument,
width: usize,
table_options: TableRenderOptions,
) -> DocumentLayout {
let mut layout = DocumentLayout::default();
if !document.title.is_empty() {
layout
.lines
.push(format!(" # {}", sanitize_text(&document.title)));
layout.lines.push(String::new());
}
let mut link_index = 0;
let mut form_index = 0;
for (node_index, node) in document.nodes.iter().enumerate() {
render_node(
node,
width,
&[node_index],
&mut link_index,
&mut form_index,
table_options,
&mut layout,
);
if !matches!(node, IndexNode::Spacer { .. }) {
layout.lines.push(String::new());
}
}
layout.lines = trim_trailing_empty_lines(layout.lines);
layout
}
fn render_node(
node: &IndexNode,
width: usize,
path: &[usize],
link_index: &mut usize,
form_index: &mut usize,
table_options: TableRenderOptions,
layout: &mut DocumentLayout,
) {
match node {
IndexNode::Heading { level, text } => {
let line = layout.lines.len();
layout.headings.push(RenderedHeading {
level: *level,
text: sanitize_text(text),
line,
});
layout.lines.push(format!(
" {} {}",
"#".repeat(usize::from(*level)),
sanitize_text(text)
));
}
IndexNode::Paragraph(text) => {
layout
.lines
.extend(render_paragraph_lines(&sanitize_text(text), width));
}
IndexNode::Link(link) => {
*link_index += 1;
let line = format!(
" [{}] {} -> {}",
link_index,
sanitize_text(&link.text),
sanitize_text(&link.href)
);
layout.links.push(RenderedLink {
index: *link_index,
text: sanitize_text(&link.text),
href: sanitize_text(&link.href),
line: layout.lines.len(),
});
layout.lines.push(line);
}
IndexNode::List { ordered, items } => {
for (item_index, item) in items.iter().enumerate() {
if *ordered {
layout
.lines
.push(format!(" {}. {}", item_index + 1, sanitize_text(item)));
} else {
layout.lines.push(format!(" • {}", sanitize_text(item)));
}
}
}
IndexNode::CodeBlock { language, code } => {
layout.lines.push(format!(
" ``` {}",
sanitize_text(language.as_deref().unwrap_or("text"))
));
layout.lines.extend(
sanitize_text(code)
.lines()
.map(|line| format!(" {line}")),
);
layout.lines.push(" ```".to_owned());
}
IndexNode::Table { rows } => {
layout
.lines
.extend(render_table_lines(rows, width, table_options));
}
IndexNode::Spacer {
lines: spacer_lines,
} => {
for _ in 0..(*spacer_lines).clamp(1, 3) {
layout.lines.push(String::new());
}
}
IndexNode::Section {
role,
title,
collapsed,
nodes,
} => {
let line = layout.lines.len();
layout.regions.push(RenderedRegion {
path: path.to_vec(),
role: *role,
title: title.as_ref().map(|title| sanitize_text(title)),
collapsed: *collapsed,
line,
item_count: section_item_count(nodes),
});
if *collapsed {
layout.lines.push(format!(
" ▸ {} ({} items)",
sanitize_text(§ion_label(*role, title.as_deref())),
section_item_count(nodes)
));
} else {
layout.lines.push(format!(
" ▾ {}",
sanitize_text(§ion_label(*role, title.as_deref()))
));
for (child_index, node) in nodes.iter().enumerate() {
let mut child_path = path.to_vec();
child_path.push(child_index);
render_node(
node,
width,
&child_path,
link_index,
form_index,
table_options,
layout,
);
}
}
}
IndexNode::Image { alt, src } => {
let mut line = format!(" [image: {}", sanitize_text(alt));
if let Some(src) = src {
line.push_str(" -> ");
line.push_str(&sanitize_text(src));
}
line.push(']');
layout.lines.push(line);
}
IndexNode::Form(form) => {
*form_index += 1;
let line = layout.lines.len();
layout.lines.push(format!(
" [form {}: {} {} {}]",
form_index,
sanitize_text(&form.method),
sanitize_text(&form.name),
sanitize_text(&form.action)
));
for input in &form.inputs {
let required = if input.required { " required" } else { "" };
let value = input
.value
.as_deref()
.map(|value| form_display_value(&input.kind, value))
.filter(|value| !value.is_empty())
.map_or_else(String::new, |value| format!(" = {value}"));
layout.lines.push(format!(
" {} {}{}{}",
sanitize_text(&input.kind),
sanitize_text(&input.name),
value,
required
));
}
layout.forms.push(RenderedForm {
index: *form_index,
name: sanitize_text(&form.name),
line,
form: form.clone(),
});
}
IndexNode::Error(message) => {
layout
.lines
.push(format!(" [error] {}", sanitize_text(message)));
}
}
}
fn render_table_lines(
rows: &[Vec<String>],
width: usize,
options: TableRenderOptions,
) -> Vec<String> {
let column_count = rows.iter().map(Vec::len).max().unwrap_or(0);
if rows.is_empty() || column_count == 0 {
return vec![" [table] empty or malformed table".to_owned()];
}
match options.mode {
TableMode::Compact => {
render_compact_table(rows, width, options.column_offset, column_count)
}
TableMode::Detail => render_detail_table(rows, width, column_count),
}
}
fn render_compact_table(
rows: &[Vec<String>],
width: usize,
column_offset: usize,
column_count: usize,
) -> Vec<String> {
let offset = column_offset.min(column_count.saturating_sub(1));
let visible_columns = visible_table_columns(width, column_count.saturating_sub(offset));
let end = (offset + visible_columns).min(column_count);
let cell_width = compact_cell_width(width, visible_columns);
let mut lines = Vec::new();
let hidden = offset > 0 || end < column_count;
if hidden {
lines.push(format!(" cols {}-{}/{}", offset + 1, end, column_count));
}
if is_oversized_table(rows, column_count) {
lines
.push(" [table] oversized table shown through a bounded column viewport".to_owned());
}
for (row_index, row) in rows.iter().take(TABLE_MAX_COMPACT_ROWS).enumerate() {
let cells = (offset..end)
.map(|column| {
row.get(column).map_or_else(String::new, |cell| {
truncate_display(&sanitize_text(cell), cell_width)
})
})
.collect::<Vec<_>>();
lines.push(format!(" | {} |", cells.join(" | ")));
if row_index == 0 && rows.len() > 1 {
lines.push(format!(
" | {} |",
vec!["─".repeat(cell_width.min(12)); cells.len()]
.into_iter()
.collect::<Vec<_>>()
.join(" | ")
));
}
}
if rows.len() > TABLE_MAX_COMPACT_ROWS {
lines.push(format!(
" [table] {} additional rows hidden in compact mode; press t for detail",
rows.len() - TABLE_MAX_COMPACT_ROWS
));
}
lines
}
fn render_detail_table(rows: &[Vec<String>], width: usize, column_count: usize) -> Vec<String> {
let headers = table_headers(rows, column_count);
let data_rows = rows.iter().skip(1).take(TABLE_MAX_DETAIL_ROWS);
let mut lines = vec![format!(
" table detail: {} rows, {} columns",
rows.len().saturating_sub(1),
column_count
)];
if is_oversized_table(rows, column_count) {
lines.push(" [table] oversized table detail is truncated deterministically".to_owned());
}
for (row_index, row) in data_rows.enumerate() {
lines.push(format!(" row {}", row_index + 1));
for (column_index, header) in headers.iter().enumerate() {
let value = row.get(column_index).map_or("", String::as_str);
let prefix = format!(" {}: ", truncate_display(header, 18));
lines.extend(prefix_wrapped_lines(&prefix, &sanitize_text(value), width));
}
}
let hidden_rows = rows.len().saturating_sub(1 + TABLE_MAX_DETAIL_ROWS);
if hidden_rows > 0 {
lines.push(format!(" [table] {hidden_rows} additional rows hidden"));
}
lines
}
fn visible_table_columns(width: usize, available: usize) -> usize {
let candidate = if width < 44 {
2
} else if width < 72 {
3
} else {
4
};
candidate.min(available).max(1)
}
fn compact_cell_width(width: usize, columns: usize) -> usize {
let prefix = UnicodeWidthStr::width(" | ");
let separators = columns.saturating_sub(1) * 3 + 2;
let available = width.saturating_sub(prefix + separators);
(available / columns.max(1)).clamp(6, 24)
}
fn is_oversized_table(rows: &[Vec<String>], column_count: usize) -> bool {
rows.len().saturating_mul(column_count) > TABLE_OVERSIZED_CELL_LIMIT || column_count > 8
}
fn table_headers(rows: &[Vec<String>], column_count: usize) -> Vec<String> {
let first_row = rows.first();
(0..column_count)
.map(|index| {
first_row
.and_then(|row| row.get(index))
.map(|header| sanitize_text(header))
.filter(|header| !header.trim().is_empty())
.unwrap_or_else(|| format!("column {}", index + 1))
})
.collect()
}
fn render_paragraph_lines(text: &str, width: usize) -> Vec<String> {
let trimmed = text.trim_start();
if let Some(quote) = trimmed.strip_prefix('>') {
return prefix_wrapped_lines(" ", quote.trim_start(), width);
}
prefix_wrapped_lines("│ ", text, width)
}
fn section_label(role: SectionRole, title: Option<&str>) -> String {
match title.map(str::trim).filter(|title| !title.is_empty()) {
Some(title) => format!("{}: {title}", role.as_str()),
None => role.as_str().to_owned(),
}
}
fn section_item_count(nodes: &[IndexNode]) -> usize {
nodes
.iter()
.filter(|node| !matches!(node, IndexNode::Spacer { .. }))
.count()
}
fn max_table_column_offset(nodes: &[IndexNode], width: usize) -> usize {
nodes
.iter()
.map(|node| match node {
IndexNode::Table { rows } => {
let column_count = rows.iter().map(Vec::len).max().unwrap_or(0);
let visible = visible_table_columns(width, column_count);
column_count.saturating_sub(visible)
}
IndexNode::Section { nodes, .. } => max_table_column_offset(nodes, width),
_ => 0,
})
.max()
.unwrap_or(0)
}
fn markdown_inline_spans(
line: &str,
base_style: Style,
bold_color: Color,
italic_color: Color,
) -> Vec<Span<'static>> {
let mut spans = Vec::new();
let mut buffer = String::new();
let mut bold = false;
let mut italic = false;
let mut index = 0;
while index < line.len() {
let rest = &line[index..];
if rest.starts_with("**") {
push_inline_span(
&mut spans,
&mut buffer,
base_style,
bold,
italic,
bold_color,
italic_color,
);
bold = !bold;
index += 2;
} else if rest.starts_with('*') {
push_inline_span(
&mut spans,
&mut buffer,
base_style,
bold,
italic,
bold_color,
italic_color,
);
italic = !italic;
index += 1;
} else if let Some(ch) = rest.chars().next() {
buffer.push(ch);
index += ch.len_utf8();
} else {
break;
}
}
push_inline_span(
&mut spans,
&mut buffer,
base_style,
bold,
italic,
bold_color,
italic_color,
);
if spans.is_empty() {
spans.push(Span::styled(String::new(), base_style));
}
spans
}
fn push_inline_span(
spans: &mut Vec<Span<'static>>,
buffer: &mut String,
base_style: Style,
bold: bool,
italic: bool,
bold_color: Color,
italic_color: Color,
) {
if buffer.is_empty() {
return;
}
let mut style = base_style;
if bold {
style = style.fg(bold_color).add_modifier(Modifier::BOLD);
}
if italic {
style = style.fg(italic_color).add_modifier(Modifier::ITALIC);
}
spans.push(Span::styled(std::mem::take(buffer), style));
}
fn hide_markdown_syntax(line: &str) -> String {
let trimmed = line.trim_start();
let leading = &line[..line.len().saturating_sub(trimmed.len())];
if let Some(title) = trimmed.strip_prefix(" # ") {
return format!("{leading} {title}");
}
if let Some(rest) = trimmed.strip_prefix(" ") {
let heading_text = rest.trim_start_matches('#').trim_start();
return format!("{leading} {heading_text}");
}
if let Some(rest) = trimmed.strip_prefix(" ```") {
let language = rest.trim();
if language.is_empty() {
return format!("{leading} code");
}
return format!("{leading} {language}");
}
if trimmed == "```" {
return String::new();
}
if let Some(rest) = trimmed.strip_prefix(" | ") {
let cells = rest.trim_end_matches('|').trim();
return format!("{leading} {}", cells.replace(" | ", " "));
}
if let Some(rest) = trimmed.strip_prefix(" [") {
if let Some((index, link)) = rest.split_once("] ") {
return format!("{leading} {index} {link}");
}
}
if let Some(rest) = trimmed.strip_prefix(" [") {
return format!("{leading} {}", rest.trim_end_matches(']'));
}
if let Some(rest) = trimmed.strip_prefix(" [") {
return format!("{leading} {}", rest.trim_end_matches(']'));
}
line.to_owned()
}
fn plain_glyph_fallback(line: &str) -> String {
line.replace("", "TITLE")
.replace("", "HEAD")
.replace("", "LINK")
.replace("", "-")
.replace("", "CODE")
.replace("", "TABLE")
.replace("", "REGION")
.replace("", "IMG")
.replace("", "FORM")
.replace("", "QUOTE")
.replace("", "ERROR")
}
fn truncate_display(input: &str, width: usize) -> String {
if UnicodeWidthStr::width(input) <= width {
return input.to_owned();
}
let mut output = String::new();
let mut used = 0;
for ch in input.chars() {
let ch_width = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0);
if used + ch_width + 1 > width {
break;
}
output.push(ch);
used += ch_width;
}
output.push('…');
output
}
fn prefix_wrapped_lines(prefix: &str, text: &str, width: usize) -> Vec<String> {
let body_width = width.saturating_sub(UnicodeWidthStr::width(prefix));
wrap_text(text, body_width)
.into_iter()
.map(|line| format!("{prefix}{line}"))
.collect()
}
fn parse_form_values(input: &str) -> Vec<(String, String)> {
input
.split_whitespace()
.filter_map(|part| {
let (name, value) = part.split_once('=')?;
(!name.is_empty()).then_some((name.to_owned(), value.to_owned()))
})
.collect()
}
fn editable_field_indices(form: &Form) -> Vec<usize> {
form.inputs
.iter()
.enumerate()
.filter_map(|(index, input)| is_editable_field(&input.kind).then_some(index))
.collect()
}
fn first_editable_field_index(form: &Form) -> Option<usize> {
editable_field_indices(form).into_iter().next()
}
fn is_editable_field(kind: &str) -> bool {
!matches!(
kind.trim().to_ascii_lowercase().as_str(),
"button" | "submit" | "reset" | "image" | "hidden"
)
}
fn is_toggle_field(kind: &str) -> bool {
matches!(
kind.trim().to_ascii_lowercase().as_str(),
"checkbox" | "radio"
)
}
fn is_secret_field(kind: &str) -> bool {
kind.trim().eq_ignore_ascii_case("password")
}
fn form_display_value(kind: &str, value: &str) -> String {
if is_secret_field(kind) && !value.is_empty() {
"•".repeat(value.chars().count().max(1))
} else {
sanitize_text(value)
}
}
fn trim_trailing_empty_lines(mut lines: Vec<String>) -> Vec<String> {
while matches!(lines.last(), Some(line) if line.is_empty()) {
lines.pop();
}
lines
}
fn handle_text_mode_key(key: KeyEvent, input: &mut String) -> Option<String> {
match key.code {
KeyCode::Esc => {
input.clear();
Some(String::new())
}
KeyCode::Enter => Some(input.clone()),
KeyCode::Backspace => {
input.pop();
None
}
KeyCode::Char(ch) => {
input.push(ch);
None
}
_ => None,
}
}
fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect {
let vertical = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage((100 - percent_y) / 2),
Constraint::Percentage(percent_y),
Constraint::Percentage((100 - percent_y) / 2),
])
.split(area);
Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage((100 - percent_x) / 2),
Constraint::Percentage(percent_x),
Constraint::Percentage((100 - percent_x) / 2),
])
.split(vertical[1])[1]
}
fn sanitize_text(text: &str) -> String {
text.chars()
.filter(|ch| *ch == '\n' || *ch == '\t' || !ch.is_control())
.collect()
}
fn wrap_text(text: &str, width: usize) -> Vec<String> {
if width == 0 {
return vec![text.to_owned()];
}
let mut lines = Vec::new();
let mut current = String::new();
let mut current_width = 0;
for word in text.split_whitespace() {
let word_width = UnicodeWidthStr::width(word);
if word_width > width {
if !current.is_empty() {
lines.push(current);
current = String::new();
current_width = 0;
}
for segment in split_display_width(word, width) {
lines.push(segment);
}
continue;
}
if current_width > 0 && current_width + 1 + word_width > width {
lines.push(current);
current = String::new();
current_width = 0;
}
if current_width > 0 {
current.push(' ');
current_width += 1;
}
current.push_str(word);
current_width += word_width;
}
if !current.is_empty() {
lines.push(current);
}
if lines.is_empty() {
lines.push(String::new());
}
lines
}
fn split_display_width(text: &str, width: usize) -> Vec<String> {
let mut lines = Vec::new();
let mut current = String::new();
let mut current_width = 0;
for ch in text.chars() {
let ch_width = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0);
if current_width > 0 && current_width + ch_width > width {
lines.push(current);
current = String::new();
current_width = 0;
}
current.push(ch);
current_width += ch_width;
}
if !current.is_empty() {
lines.push(current);
}
lines
}
#[cfg(test)]
mod tests {
use std::env;
use std::fs;
use std::sync::mpsc;
use std::time::{SystemTime, UNIX_EPOCH};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use index_ai::AiAction;
use index_core::{
DocumentQuality, DocumentQualityCategory, Form, FormSubmission, IndexDocument, IndexNode,
Input, Link, ReaderProfile, ResponseLogEntry, SectionRole, SessionSidebarMode,
};
use index_extract::{ExtractFormat, PipeCommand};
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use ratatui::style::{Color, Modifier};
use unicode_width::UnicodeWidthStr;
use super::{
AnimationMode, AppAction, ColorSupport, GlyphSupport, InputMode, RenderOptions,
RepairAction, RepairRecipe, TableMode, TerminalApp, TerminalCapabilities, Theme,
TuiDocumentResult, WorkerResponse, classify_log_entry, classify_status,
dispatch_app_action, form_input_mut_by_render_index, handle_app_action,
handle_app_action_with_forms, handle_worker_response, hide_markdown_syntax,
render_document, save_current_capture, status_icon, status_style, suggest_reader_profile,
truncate_display,
};
fn document() -> IndexDocument {
let mut document = IndexDocument::titled("Title");
document.push(IndexNode::Heading {
level: 2,
text: "Section".to_owned(),
});
document.push(IndexNode::Paragraph("one two three four".to_owned()));
document.push(IndexNode::Link(Link::new("One", "https://example.com/one")));
document.push(IndexNode::Paragraph("Between.".to_owned()));
document.push(IndexNode::Link(Link::new("Two", "https://example.com/two")));
document
}
fn key(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
fn modified_key(code: KeyCode, modifiers: KeyModifiers) -> KeyEvent {
KeyEvent::new(code, modifiers)
}
fn submit_command(app: &mut TerminalApp, command: &str) -> AppAction {
assert_eq!(app.handle_key(key(KeyCode::Char(':'))), AppAction::None);
for ch in command.chars() {
assert_eq!(app.handle_key(key(KeyCode::Char(ch))), AppAction::None);
}
app.handle_key(key(KeyCode::Enter))
}
fn unique_temp_file(label: &str) -> String {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |duration| duration.as_nanos());
env::temp_dir()
.join(format!("index-{label}-{nanos}.capture"))
.display()
.to_string()
}
#[test]
fn renders_plain_title_and_paragraph() {
let rendered = render_document(&document(), RenderOptions::default());
assert!(rendered.contains("# Title"));
assert!(rendered.contains("one two three four"));
}
#[test]
fn wrapping_respects_display_width() {
let mut document = IndexDocument::titled("Title");
document.push(IndexNode::Paragraph("one two three four".to_owned()));
let rendered = render_document(&document, RenderOptions { width: 7 });
assert!(rendered.contains("│ one\n│ two\n│ three"));
}
#[test]
fn width_zero_keeps_paragraph_unwrapped() {
let mut document = IndexDocument::titled("Width");
document.push(IndexNode::Paragraph(
"one two three four five six".to_owned(),
));
let rendered = render_document(&document, RenderOptions { width: 0 });
assert!(rendered.contains("one two three four five six"));
assert!(!rendered.contains("one two\nthree"));
}
#[test]
fn renders_all_structural_nodes() {
let mut document = IndexDocument::titled("Nodes");
document.push(IndexNode::Paragraph(
"> quoted public knowledge should remain readable".to_owned(),
));
document.push(IndexNode::List {
ordered: true,
items: vec!["first".to_owned(), "second".to_owned()],
});
document.push(IndexNode::List {
ordered: false,
items: vec!["alpha".to_owned(), "beta".to_owned()],
});
document.push(IndexNode::CodeBlock {
language: Some("rust".to_owned()),
code: "fn main() {}".to_owned(),
});
document.push(IndexNode::CodeBlock {
language: None,
code: "plain text".to_owned(),
});
document.push(IndexNode::Table {
rows: vec![
vec!["col1".to_owned(), "col2".to_owned()],
vec!["a".to_owned(), "b".to_owned()],
],
});
document.push(IndexNode::Image {
alt: "diagram".to_owned(),
src: Some("https://example.com/diagram.png".to_owned()),
});
document.push(IndexNode::Image {
alt: "placeholder".to_owned(),
src: None,
});
document.push(IndexNode::Form(Form {
name: "search".to_owned(),
method: "GET".to_owned(),
action: "/search".to_owned(),
inputs: vec![Input {
name: "q".to_owned(),
kind: "text".to_owned(),
value: Some("index".to_owned()),
required: true,
}],
buttons: Vec::new(),
}));
document.push(IndexNode::Error("recoverable message".to_owned()));
let rendered = render_document(&document, RenderOptions::default());
assert!(rendered.contains(" quoted public knowledge"));
assert!(rendered.contains("1. first"));
assert!(rendered.contains("• alpha"));
assert!(rendered.contains("``` rust"));
assert!(rendered.contains("``` text"));
assert!(rendered.contains(" fn main() {}"));
assert!(rendered.contains("| col1 | col2 |"));
assert!(rendered.contains("[image: diagram -> https://example.com/diagram.png]"));
assert!(rendered.contains("[image: placeholder]"));
assert!(rendered.contains("[form 1: GET search /search]"));
assert!(rendered.contains("text q = index required"));
assert!(rendered.contains("[error] recoverable message"));
}
#[test]
fn render_omits_title_heading_when_document_title_is_empty() {
let mut document = IndexDocument::default();
document.push(IndexNode::Paragraph("Body.".to_owned()));
let rendered = render_document(&document, RenderOptions::default());
assert!(!rendered.starts_with("# "));
assert!(rendered.contains("Body."));
}
#[test]
fn compact_table_rendering_bounds_wide_columns() {
let mut document = IndexDocument::titled("Table");
document.push(IndexNode::Table {
rows: vec![
vec![
"Name".to_owned(),
"Version".to_owned(),
"Description".to_owned(),
"License".to_owned(),
],
vec![
"index-renderer".to_owned(),
"0.1.0".to_owned(),
"A terminal renderer with deliberately long descriptive text".to_owned(),
"Unlicense".to_owned(),
],
],
});
let rendered = render_document(&document, RenderOptions { width: 42 });
assert!(rendered.contains("cols 1-2/4"));
assert!(rendered.contains("| Name"));
assert!(
rendered
.lines()
.all(|line| UnicodeWidthStr::width(line) <= 48)
);
}
#[test]
fn table_mode_toggles_detail_records() {
let mut document = IndexDocument::titled("Table");
document.push(IndexNode::Table {
rows: vec![
vec!["Name".to_owned(), "Value".to_owned()],
vec!["Index".to_owned(), "Semantic browser".to_owned()],
],
});
let mut app = TerminalApp::new(document, 48);
assert_eq!(app.table_mode(), TableMode::Compact);
assert_eq!(app.handle_key(key(KeyCode::Char('t'))), AppAction::None);
assert_eq!(app.table_mode(), TableMode::Detail);
assert!(app.lines.iter().any(|line| line.contains("table detail")));
assert!(app.lines.iter().any(|line| line.contains("Name: Index")));
}
#[test]
fn horizontal_table_scroll_shifts_visible_columns() {
let mut document = IndexDocument::titled("Table");
document.push(IndexNode::Table {
rows: vec![
vec![
"Name".to_owned(),
"Version".to_owned(),
"Description".to_owned(),
"License".to_owned(),
],
vec![
"index".to_owned(),
"0.1".to_owned(),
"semantic browser".to_owned(),
"Unlicense".to_owned(),
],
],
});
let mut app = TerminalApp::new(document, 42);
assert!(app.lines.iter().any(|line| line.contains("Name")));
assert!(!app.lines.iter().any(|line| line.contains("Description")));
assert_eq!(app.handle_key(key(KeyCode::Char(']'))), AppAction::None);
assert_eq!(app.table_column_offset(), 1);
assert!(app.lines.iter().any(|line| line.contains("Description")));
assert!(app.status().contains("TABLE column 2"));
}
#[test]
fn stable_width_relayout_reuses_cached_wrapped_lines() {
let mut document = IndexDocument::titled("Large");
for index in 0..64 {
document.push(IndexNode::Paragraph(format!(
"paragraph {index} with enough words to wrap across multiple terminal lines"
)));
}
let mut app = TerminalApp::new(document, 32);
let initial_cache_len = app.layout_cache.len();
app.relayout(32);
assert_eq!(app.layout_cache.len(), initial_cache_len);
assert!(app.lines.iter().any(|line| line.contains("paragraph 63")));
}
#[test]
fn ratatui_table_modes_have_stable_snapshots() -> Result<(), Box<dyn std::error::Error>> {
let backend = TestBackend::new(72, 12);
let mut terminal = Terminal::new(backend)?;
let mut document = IndexDocument::titled("Table");
document.push(IndexNode::Table {
rows: vec![
vec![
"Name".to_owned(),
"Version".to_owned(),
"Description".to_owned(),
"License".to_owned(),
],
vec![
"index".to_owned(),
"0.1".to_owned(),
"semantic terminal browser".to_owned(),
"Unlicense".to_owned(),
],
],
});
let mut app = TerminalApp::new(document, 42);
terminal.draw(|frame| app.render(frame))?;
let compact = buffer_to_string(terminal.backend().buffer());
assert!(compact.contains("cols 1-2/4"));
assert!(compact.contains("Name"));
app.handle_key(key(KeyCode::Char('t')));
terminal.draw(|frame| app.render(frame))?;
let detail = buffer_to_string(terminal.backend().buffer());
assert!(detail.contains("table detail"));
assert!(detail.contains("Description: semantic"));
Ok(())
}
#[test]
fn malformed_and_oversized_tables_emit_diagnostics() {
let mut document = IndexDocument::titled("Tables");
document.push(IndexNode::Table { rows: Vec::new() });
document.push(IndexNode::Table {
rows: (0..28)
.map(|row| {
(0..10)
.map(|column| format!("r{row}c{column}"))
.collect::<Vec<_>>()
})
.collect(),
});
let rendered = render_document(&document, RenderOptions { width: 72 });
assert!(rendered.contains("empty or malformed table"));
assert!(rendered.contains("oversized table"));
}
#[test]
fn renders_layout_spacers_as_extra_blank_lines() {
let mut document = IndexDocument::titled("Rhythm");
document.push(IndexNode::Paragraph("First.".to_owned()));
document.push(IndexNode::Spacer { lines: 2 });
document.push(IndexNode::Paragraph("Second.".to_owned()));
let rendered = render_document(&document, RenderOptions::default());
assert!(rendered.contains("│ First.\n\n\n\n│ Second."));
}
#[test]
fn renders_collapsed_sections_without_link_numbering_noise() {
let mut document = IndexDocument::titled("Regions");
document.push(IndexNode::Paragraph("Main body.".to_owned()));
document.push(IndexNode::Section {
role: SectionRole::Navigation,
title: Some("Site".to_owned()),
collapsed: true,
nodes: vec![IndexNode::Link(Link::new(
"Docs",
"https://example.com/docs",
))],
});
let rendered = render_document(&document, RenderOptions::default());
assert!(rendered.contains(" ▸ navigation: Site (1 items)"));
assert!(!rendered.contains("Docs -> https://example.com/docs"));
let app = TerminalApp::new(document, 88);
assert!(app.links.is_empty());
}
#[test]
fn link_hints_count_only_links() {
let rendered = render_document(&document(), RenderOptions::default());
assert!(rendered.contains("[1] One -> https://example.com/one"));
assert!(rendered.contains("[2] Two -> https://example.com/two"));
}
#[test]
fn viewport_moves_with_navigation_keys() {
let mut app = TerminalApp::new(document(), 12);
app.set_viewport_height(3);
assert_eq!(app.viewport().offset, 0);
assert_eq!(app.handle_key(key(KeyCode::Char('j'))), AppAction::None);
assert_eq!(app.viewport().offset, 1);
assert_eq!(app.handle_key(key(KeyCode::Char('k'))), AppAction::None);
assert_eq!(app.viewport().offset, 0);
assert_eq!(app.handle_key(key(KeyCode::Char('G'))), AppAction::None);
assert!(app.viewport().offset > 0);
assert_eq!(app.handle_key(key(KeyCode::Char('g'))), AppAction::None);
assert_eq!(app.handle_key(key(KeyCode::Char('g'))), AppAction::None);
assert_eq!(app.viewport().offset, 0);
}
#[test]
fn search_mode_scrolls_to_matching_line() {
let mut app = TerminalApp::new(document(), 20);
app.set_viewport_height(2);
assert_eq!(app.handle_key(key(KeyCode::Char('/'))), AppAction::None);
assert!(matches!(app.mode(), InputMode::Search(_)));
for ch in "Between".chars() {
assert_eq!(app.handle_key(key(KeyCode::Char(ch))), AppAction::None);
}
assert_eq!(app.handle_key(key(KeyCode::Enter)), AppAction::None);
assert!(matches!(app.mode(), InputMode::Normal));
assert!(app.viewport().offset > 0);
assert_eq!(app.status(), "SEARCH Between");
}
#[test]
fn search_mode_reports_no_match() {
let mut app = TerminalApp::new(document(), 20);
assert_eq!(app.handle_key(key(KeyCode::Char('/'))), AppAction::None);
for ch in "missing".chars() {
assert_eq!(app.handle_key(key(KeyCode::Char(ch))), AppAction::None);
}
assert_eq!(app.handle_key(key(KeyCode::Enter)), AppAction::None);
assert_eq!(app.status(), "No match: missing");
}
#[test]
fn link_hint_overlay_toggles_with_f() {
let mut app = TerminalApp::new(document(), 20);
assert!(!app.show_link_hints());
assert_eq!(app.handle_key(key(KeyCode::Char('f'))), AppAction::None);
assert!(app.show_link_hints());
assert_eq!(app.handle_key(key(KeyCode::Char('f'))), AppAction::None);
assert!(!app.show_link_hints());
}
#[test]
fn link_sidebar_toggles_selects_and_opens_links() {
let mut app = TerminalApp::new(document(), 20);
assert!(!app.show_link_sidebar());
assert_eq!(app.handle_key(key(KeyCode::Char('l'))), AppAction::None);
assert!(app.show_link_sidebar());
assert_eq!(app.status(), "LINKS 1/2");
assert_eq!(app.handle_key(key(KeyCode::Char('j'))), AppAction::None);
assert_eq!(app.status(), "LINKS 2/2");
assert_eq!(
app.handle_key(key(KeyCode::Enter)),
AppAction::Open("https://example.com/two".to_owned())
);
assert_eq!(app.handle_key(key(KeyCode::Esc)), AppAction::None);
assert!(!app.show_link_sidebar());
}
#[test]
fn link_sidebar_supports_previous_open_alias_and_toggle_hide() {
let mut app = TerminalApp::new(document(), 20);
assert_eq!(app.handle_key(key(KeyCode::Char('l'))), AppAction::None);
assert_eq!(app.handle_key(key(KeyCode::Char('j'))), AppAction::None);
assert_eq!(app.handle_key(key(KeyCode::Char('k'))), AppAction::None);
assert_eq!(app.status(), "LINKS 1/2");
assert_eq!(
app.handle_key(key(KeyCode::Char('o'))),
AppAction::Open("https://example.com/one".to_owned())
);
assert_eq!(app.handle_key(key(KeyCode::Char('l'))), AppAction::None);
assert!(!app.show_link_sidebar());
}
#[test]
fn sidebar_modes_switch_and_jump_to_outline_items() {
let mut app = TerminalApp::new(document(), 20);
app.set_viewport_height(2);
assert_eq!(app.handle_key(key(KeyCode::Char('l'))), AppAction::None);
assert_eq!(app.sidebar_mode(), SessionSidebarMode::Links);
assert_eq!(app.handle_key(key(KeyCode::Char(']'))), AppAction::None);
assert_eq!(app.sidebar_mode(), SessionSidebarMode::Outline);
assert_eq!(app.status(), "OUTLINE 1/1");
assert_eq!(app.handle_key(key(KeyCode::Enter)), AppAction::None);
assert!(app.viewport().offset > 0);
assert!(app.status().starts_with("OUTLINE line "));
assert_eq!(app.handle_key(key(KeyCode::Char('['))), AppAction::None);
assert_eq!(app.sidebar_mode(), SessionSidebarMode::Links);
assert_eq!(app.handle_key(key(KeyCode::Char('3'))), AppAction::None);
assert_eq!(app.sidebar_mode(), SessionSidebarMode::Forms);
}
#[test]
fn region_sidebar_toggles_collapsed_sections() {
let mut document = IndexDocument::titled("Regions");
document.push(IndexNode::Paragraph("Main.".to_owned()));
document.push(IndexNode::Section {
role: SectionRole::Aside,
title: Some("More".to_owned()),
collapsed: true,
nodes: vec![IndexNode::Paragraph("Hidden detail.".to_owned())],
});
let mut app = TerminalApp::new(document, 40);
assert!(!app.lines.iter().any(|line| line.contains("Hidden detail")));
assert_eq!(app.handle_key(key(KeyCode::Char('l'))), AppAction::None);
assert_eq!(app.handle_key(key(KeyCode::Char('4'))), AppAction::None);
assert_eq!(app.sidebar_mode(), SessionSidebarMode::Regions);
assert_eq!(app.handle_key(key(KeyCode::Enter)), AppAction::None);
assert!(app.lines.iter().any(|line| line.contains("Hidden detail")));
assert_eq!(app.status(), "REGIONS expanded");
assert_eq!(app.handle_key(key(KeyCode::Char(' '))), AppAction::None);
assert!(!app.lines.iter().any(|line| line.contains("Hidden detail")));
assert_eq!(app.status(), "REGIONS collapsed");
}
#[test]
fn search_sidebar_uses_latest_query_results() {
let mut app = TerminalApp::new(document(), 20);
app.set_viewport_height(2);
assert_eq!(app.handle_key(key(KeyCode::Char('/'))), AppAction::None);
for ch in "Two".chars() {
assert_eq!(app.handle_key(key(KeyCode::Char(ch))), AppAction::None);
}
assert_eq!(app.handle_key(key(KeyCode::Enter)), AppAction::None);
assert_eq!(app.handle_key(key(KeyCode::Char('l'))), AppAction::None);
assert_eq!(app.handle_key(key(KeyCode::Char('5'))), AppAction::None);
assert_eq!(app.sidebar_mode(), SessionSidebarMode::Search);
assert_eq!(app.status(), "SEARCH 1/2");
assert_eq!(app.handle_key(key(KeyCode::Enter)), AppAction::None);
assert!(app.status().starts_with("SEARCH line "));
}
#[test]
fn back_action_restores_previous_document() {
let mut app = TerminalApp::new(document(), 20);
let mut navigate = |target: &str| {
let mut document = IndexDocument::titled("Opened");
document.push(IndexNode::Paragraph(format!("Opened {target}")));
Ok(document)
};
handle_app_action(
&mut app,
AppAction::Open("https://example.com/opened".to_owned()),
&mut navigate,
);
assert_eq!(app.document.title, "Opened");
assert_eq!(app.handle_key(key(KeyCode::Char('b'))), AppAction::Back);
handle_app_action(&mut app, AppAction::Back, &mut navigate);
assert_eq!(app.document.title, "Title");
assert_eq!(app.status(), "BACK");
handle_app_action(&mut app, AppAction::Back, &mut navigate);
assert_eq!(app.status(), "BACK no previous page");
}
#[test]
fn command_mode_requests_back_action() {
let mut app = TerminalApp::new(document(), 20);
assert_eq!(app.handle_key(key(KeyCode::Char(':'))), AppAction::None);
for ch in "back".chars() {
assert_eq!(app.handle_key(key(KeyCode::Char(ch))), AppAction::None);
}
assert_eq!(app.handle_key(key(KeyCode::Enter)), AppAction::Back);
}
#[test]
fn link_sidebar_reports_empty_documents() {
let mut app = TerminalApp::new(IndexDocument::default(), 20);
assert_eq!(app.handle_key(key(KeyCode::Char('l'))), AppAction::None);
assert!(app.show_link_sidebar());
assert_eq!(app.status(), "LINKS empty");
assert_eq!(app.handle_key(key(KeyCode::Enter)), AppAction::None);
assert_eq!(app.status(), "LINKS empty");
}
#[test]
fn logs_sidebar_is_hidden_until_requested() {
let mut app = TerminalApp::new(document(), 40);
app.set_response_logs(vec![ResponseLogEntry::new(
1,
"GET",
"https://example.com?token=secret",
"https://example.com",
Some("text/html"),
"<html>server token=abc visible</html>",
64,
)]);
assert!(!app.show_link_sidebar());
assert_eq!(submit_command(&mut app, "logs"), AppAction::None);
assert!(app.show_link_sidebar());
assert_eq!(app.sidebar_mode(), SessionSidebarMode::Logs);
assert_eq!(app.status(), "LOGS 1/1");
let entries = app.sidebar_entries();
assert_eq!(entries.len(), 1);
assert!(entries[0].label.contains("GET"));
assert!(!entries[0].detail.contains("abc"));
}
#[test]
fn open_command_autocompletes_from_url_history() {
let mut app = TerminalApp::new(document(), 40);
app.set_url_history(vec![
"https://example.com/docs".to_owned(),
"https://example.org/archive".to_owned(),
]);
assert_eq!(app.handle_key(key(KeyCode::Char(':'))), AppAction::None);
for ch in "open https://example.com/d".chars() {
assert_eq!(app.handle_key(key(KeyCode::Char(ch))), AppAction::None);
}
assert_eq!(app.handle_key(key(KeyCode::Tab)), AppAction::None);
assert_eq!(
app.mode(),
&InputMode::Command("open https://example.com/docs".to_owned())
);
assert!(app.status().contains("OPEN suggestion"));
}
#[test]
fn renderer_helpers_hide_syntax_and_truncate_display_width() {
assert_eq!(hide_markdown_syntax(" # Title"), " Title");
assert_eq!(hide_markdown_syntax(" ### Deep"), " Deep");
assert_eq!(hide_markdown_syntax(" ``` rust"), " rust");
assert_eq!(hide_markdown_syntax("```"), "");
assert_eq!(hide_markdown_syntax(" | a | b |"), " a b");
assert_eq!(
hide_markdown_syntax(" [12] Docs -> https://example.com"),
" 12 Docs -> https://example.com"
);
assert_eq!(
hide_markdown_syntax(" [image: alt -> image.png]"),
" image: alt -> image.png"
);
assert_eq!(
hide_markdown_syntax(" [form 1: GET search /search]"),
" form 1: GET search /search"
);
assert_eq!(truncate_display("short", 10), "short");
assert_eq!(truncate_display("long-display-value", 8), "long-di…");
}
#[test]
fn cjk_text_wraps_by_display_width_without_overflowing_columns() {
let lines = super::wrap_text(
"知識ページは日本語の見出しと本文を読みやすく保つ必要があります。",
12,
);
assert!(lines.len() > 1);
assert!(
lines
.iter()
.all(|line| UnicodeWidthStr::width(line.as_str()) <= 12)
);
assert_eq!(
lines.join(""),
"知識ページは日本語の見出しと本文を読みやすく保つ必要があります。"
);
}
#[test]
fn command_mode_opens_links_and_quits() {
let mut app = TerminalApp::new(document(), 20);
assert_eq!(app.handle_key(key(KeyCode::Char(':'))), AppAction::None);
for ch in "open 2".chars() {
assert_eq!(app.handle_key(key(KeyCode::Char(ch))), AppAction::None);
}
assert_eq!(
app.handle_key(key(KeyCode::Enter)),
AppAction::Open("https://example.com/two".to_owned())
);
assert_eq!(app.handle_key(key(KeyCode::Char(':'))), AppAction::None);
for ch in "quit".chars() {
assert_eq!(app.handle_key(key(KeyCode::Char(ch))), AppAction::None);
}
assert_eq!(app.handle_key(key(KeyCode::Enter)), AppAction::Quit);
assert!(app.should_quit());
}
#[test]
fn command_mode_handles_raw_targets_unknown_commands_and_escape() {
let mut app = TerminalApp::new(document(), 20);
assert_eq!(app.handle_key(key(KeyCode::Char(':'))), AppAction::None);
for ch in "open https://example.com/raw".chars() {
assert_eq!(app.handle_key(key(KeyCode::Char(ch))), AppAction::None);
}
assert_eq!(
app.handle_key(key(KeyCode::Enter)),
AppAction::Open("https://example.com/raw".to_owned())
);
assert_eq!(app.handle_key(key(KeyCode::Char(':'))), AppAction::None);
for ch in "wat".chars() {
assert_eq!(app.handle_key(key(KeyCode::Char(ch))), AppAction::None);
}
assert_eq!(app.handle_key(key(KeyCode::Enter)), AppAction::None);
assert_eq!(app.status(), "Unknown command: wat");
assert_eq!(app.handle_key(key(KeyCode::Char(':'))), AppAction::None);
assert_eq!(app.handle_key(key(KeyCode::Char('x'))), AppAction::None);
assert_eq!(app.handle_key(key(KeyCode::Backspace)), AppAction::None);
assert_eq!(app.handle_key(key(KeyCode::Esc)), AppAction::None);
assert!(matches!(app.mode(), InputMode::Normal));
}
#[test]
fn command_mode_submits_named_form() {
let mut document = document();
document.push(IndexNode::Form(Form {
name: "search".to_owned(),
method: "GET".to_owned(),
action: "https://example.com/search".to_owned(),
inputs: vec![Input {
name: "q".to_owned(),
kind: "search".to_owned(),
value: None,
required: true,
}],
buttons: Vec::new(),
}));
let mut app = TerminalApp::new(document, 20);
assert_eq!(app.handle_key(key(KeyCode::Char(':'))), AppAction::None);
for ch in "submit search q=index".chars() {
assert_eq!(app.handle_key(key(KeyCode::Char(ch))), AppAction::None);
}
let action = app.handle_key(key(KeyCode::Enter));
assert!(
matches!(action, AppAction::Submit(submission) if submission.action.as_str() == "https://example.com/search?q=index")
);
}
#[test]
fn form_edit_mode_submits_current_field_values() {
let mut document = document();
document.push(IndexNode::Form(Form {
name: "search".to_owned(),
method: "GET".to_owned(),
action: "https://example.com/search".to_owned(),
inputs: vec![Input {
name: "q".to_owned(),
kind: "search".to_owned(),
value: None,
required: true,
}],
buttons: Vec::new(),
}));
let mut app = TerminalApp::new(document, 32);
assert_eq!(app.handle_key(key(KeyCode::Char('e'))), AppAction::None);
assert!(matches!(app.mode(), InputMode::Form(edit) if edit.field_name == "q"));
for ch in "index".chars() {
assert_eq!(app.handle_key(key(KeyCode::Char(ch))), AppAction::None);
}
assert!(app.lines.iter().any(|line| line.contains("q = index")));
let action = app.handle_key(key(KeyCode::Enter));
assert!(
matches!(action, AppAction::Submit(submission) if submission.action.as_str() == "https://example.com/search?q=index")
);
assert!(matches!(app.mode(), InputMode::Normal));
}
#[test]
fn form_edit_mode_tabs_between_fields_and_validates_required_fields() {
let mut document = document();
document.push(IndexNode::Form(Form {
name: "advanced".to_owned(),
method: "GET".to_owned(),
action: "https://example.com/search".to_owned(),
inputs: vec![
Input {
name: "q".to_owned(),
kind: "search".to_owned(),
value: None,
required: true,
},
Input {
name: "page".to_owned(),
kind: "hidden".to_owned(),
value: Some("1".to_owned()),
required: false,
},
Input {
name: "tag".to_owned(),
kind: "text".to_owned(),
value: None,
required: false,
},
],
buttons: Vec::new(),
}));
let mut app = TerminalApp::new(document, 32);
assert_eq!(app.handle_key(key(KeyCode::Char('e'))), AppAction::None);
assert_eq!(app.handle_key(key(KeyCode::Tab)), AppAction::None);
assert!(matches!(app.mode(), InputMode::Form(edit) if edit.field_name == "tag"));
assert_eq!(app.handle_key(key(KeyCode::Enter)), AppAction::None);
assert!(app.status().contains("required form field is missing: q"));
}
#[test]
fn form_sidebar_edit_opens_selected_form() {
let mut document = document();
document.push(IndexNode::Form(Form {
name: "search".to_owned(),
method: "GET".to_owned(),
action: "https://example.com/search".to_owned(),
inputs: vec![Input {
name: "q".to_owned(),
kind: "search".to_owned(),
value: None,
required: false,
}],
buttons: Vec::new(),
}));
let mut app = TerminalApp::new(document, 32);
assert_eq!(app.handle_key(key(KeyCode::Char('l'))), AppAction::None);
assert_eq!(app.handle_key(key(KeyCode::Char('3'))), AppAction::None);
assert_eq!(app.handle_key(key(KeyCode::Char('e'))), AppAction::None);
assert!(matches!(app.mode(), InputMode::Form(edit) if edit.form_index == 1));
assert!(app.status().contains("FORM 1 editing q"));
}
#[test]
fn form_edit_mode_cancels_toggles_and_masks_secret_values() {
let mut document = document();
document.push(IndexNode::Form(Form {
name: "login".to_owned(),
method: "POST".to_owned(),
action: "https://example.com/login".to_owned(),
inputs: vec![
Input {
name: "password".to_owned(),
kind: "password".to_owned(),
value: Some("secret".to_owned()),
required: true,
},
Input {
name: "remember".to_owned(),
kind: "checkbox".to_owned(),
value: None,
required: false,
},
],
buttons: Vec::new(),
}));
let mut app = TerminalApp::new(document, 40);
assert!(
app.lines
.iter()
.any(|line| line.contains("password = ••••••"))
);
assert_eq!(app.handle_key(key(KeyCode::Char('e'))), AppAction::None);
assert!(matches!(app.mode(), InputMode::Form(edit) if edit.prompt_value() == "••••••"));
assert_eq!(app.handle_key(key(KeyCode::Tab)), AppAction::None);
assert!(matches!(app.mode(), InputMode::Form(edit) if edit.field_name == "remember"));
assert_eq!(app.handle_key(key(KeyCode::Char(' '))), AppAction::None);
assert!(app.lines.iter().any(|line| line.contains("remember = on")));
assert_eq!(app.handle_key(key(KeyCode::Esc)), AppAction::None);
assert!(matches!(app.mode(), InputMode::Normal));
assert_eq!(app.status(), "FORM cancelled");
}
#[test]
fn form_edit_mode_reports_absent_and_non_editable_forms() {
let mut app = TerminalApp::new(document(), 40);
assert_eq!(app.handle_key(key(KeyCode::Char('e'))), AppAction::None);
assert_eq!(app.status(), "FORM no forms on this page");
let mut document = document();
document.push(IndexNode::Form(Form {
name: "hidden".to_owned(),
method: "GET".to_owned(),
action: "https://example.com/hidden".to_owned(),
inputs: vec![Input {
name: "token".to_owned(),
kind: "hidden".to_owned(),
value: Some("redacted".to_owned()),
required: false,
}],
buttons: Vec::new(),
}));
let mut app = TerminalApp::new(document, 40);
assert_eq!(app.handle_key(key(KeyCode::Char('e'))), AppAction::None);
assert_eq!(app.status(), "FORM 1 has no editable fields");
}
#[test]
fn command_mode_requests_document_extraction() {
let mut app = TerminalApp::new(document(), 20);
let action = submit_command(&mut app, "extract markdown");
assert_eq!(action, AppAction::Extract(ExtractFormat::Markdown));
assert_eq!(app.status(), "EXTRACT markdown");
let action = submit_command(&mut app, "extract json");
assert_eq!(action, AppAction::Extract(ExtractFormat::Json));
}
#[test]
fn command_mode_requires_pipe_confirmation() {
let mut app = TerminalApp::new(document(), 20);
let action = submit_command(&mut app, "pipe wc -l");
assert_eq!(action, AppAction::None);
assert_eq!(app.status(), "PIPE confirm with :pipe --confirm wc -l");
}
#[test]
fn command_mode_denies_unsafe_pipe_commands() {
let mut app = TerminalApp::new(document(), 20);
let action = submit_command(&mut app, "pipe wc -l; rm -rf target");
assert_eq!(action, AppAction::None);
assert!(app.status().contains("PIPE denied"));
}
#[test]
fn command_mode_returns_confirmed_pipe_action() {
let mut app = TerminalApp::new(document(), 20);
let action = submit_command(&mut app, "pipe --confirm wc -l");
assert_eq!(action, AppAction::Pipe(PipeCommand::new("wc -l")));
assert_eq!(app.status(), "PIPE wc -l");
}
#[test]
fn command_mode_requests_ai_actions_explicitly() {
let mut app = TerminalApp::new(document(), 20);
let action = submit_command(&mut app, "ai summarize");
assert_eq!(action, AppAction::Ai(AiAction::Summarize));
assert_eq!(app.status(), "AI summarize");
}
#[test]
fn command_mode_rejects_unknown_ai_action() {
let mut app = TerminalApp::new(document(), 20);
let action = submit_command(&mut app, "ai chat");
assert_eq!(action, AppAction::None);
assert_eq!(app.status(), "AI unsupported action: chat");
}
#[test]
fn command_mode_reports_missing_required_form_field() {
let mut document = document();
document.push(IndexNode::Form(Form {
name: "search".to_owned(),
method: "GET".to_owned(),
action: "https://example.com/search".to_owned(),
inputs: vec![Input {
name: "q".to_owned(),
kind: "search".to_owned(),
value: None,
required: true,
}],
buttons: Vec::new(),
}));
let mut app = TerminalApp::new(document, 20);
assert_eq!(app.handle_key(key(KeyCode::Char(':'))), AppAction::None);
for ch in "submit 1 x=y".chars() {
assert_eq!(app.handle_key(key(KeyCode::Char(ch))), AppAction::None);
}
assert_eq!(app.handle_key(key(KeyCode::Enter)), AppAction::None);
assert!(app.status().contains("required form field is missing"));
}
#[test]
fn submitted_get_form_navigates_through_host_callback() {
let mut app = TerminalApp::new(document(), 20);
let form = Form {
name: "search".to_owned(),
method: "GET".to_owned(),
action: "https://example.com/search".to_owned(),
inputs: vec![Input {
name: "q".to_owned(),
kind: "search".to_owned(),
value: Some("index".to_owned()),
required: true,
}],
buttons: Vec::new(),
};
let submission = form.submit(None, &[]).map_err(|error| error.to_string());
let mut navigate = |_target: &str| Err("unexpected navigation".to_owned());
let mut submit_form = |submission: &FormSubmission| {
let mut document = IndexDocument::titled("Submitted");
document.push(IndexNode::Paragraph(format!(
"Submitted {}",
submission.action
)));
Ok(TuiDocumentResult::new(document).with_visited_url(submission.action.as_str()))
};
if let Ok(submission) = submission {
handle_app_action_with_forms(
&mut app,
AppAction::Submit(submission),
&mut navigate,
&mut submit_form,
);
}
assert_eq!(
app.status(),
"SUBMIT GET https://example.com/search?q=index"
);
assert!(app.lines.iter().any(|line| line.contains("Submitted")));
}
#[test]
fn submitted_post_form_uses_host_submission_callback() {
let mut app = TerminalApp::new(document(), 20);
let form = Form {
name: "login".to_owned(),
method: "POST".to_owned(),
action: "https://example.com/login".to_owned(),
inputs: vec![Input {
name: "user".to_owned(),
kind: "text".to_owned(),
value: Some("index".to_owned()),
required: true,
}],
buttons: Vec::new(),
};
let submission = form.submit(None, &[]).map_err(|error| error.to_string());
let mut navigate = |_target: &str| Err("unexpected navigation".to_owned());
let mut submit_form = |submission: &FormSubmission| {
let mut document = IndexDocument::titled("Posted");
document.push(IndexNode::Paragraph(format!(
"Posted body {}",
submission.body.as_deref().unwrap_or_default()
)));
Ok(TuiDocumentResult::new(document))
};
if let Ok(submission) = submission {
handle_app_action_with_forms(
&mut app,
AppAction::Submit(submission),
&mut navigate,
&mut submit_form,
);
}
assert_eq!(app.status(), "SUBMIT POST https://example.com/login");
assert!(app.lines.iter().any(|line| line.contains("user=index")));
}
#[test]
fn control_c_quits_from_normal_mode() {
let mut app = TerminalApp::new(document(), 20);
assert_eq!(
app.handle_key(modified_key(KeyCode::Char('c'), KeyModifiers::CONTROL)),
AppAction::Quit
);
assert!(app.should_quit());
}
#[test]
fn ratatui_snapshot_is_deterministic() -> Result<(), Box<dyn std::error::Error>> {
let backend = TestBackend::new(48, 10);
let mut terminal = Terminal::new(backend)?;
let mut document = document();
document.metadata.quality = Some(DocumentQuality::new(
DocumentQualityCategory::StrongGeneric,
82,
["generic reader emitted semantic content"],
));
let mut app = TerminalApp::new(document, 20);
terminal.draw(|frame| app.render(frame))?;
let snapshot = buffer_to_string(terminal.backend().buffer());
assert!(snapshot.contains("Title"));
assert!(snapshot.contains("one two three four"));
assert!(snapshot.contains("quality: strong-generic"));
assert!(snapshot.contains("[:> "));
assert!(snapshot.contains("j/k scroll"));
Ok(())
}
#[test]
fn reader_profiles_have_distinct_deterministic_themes() {
let profiles = ReaderProfile::all();
let themes = profiles
.iter()
.map(|profile| Theme::for_profile(*profile))
.collect::<Vec<_>>();
assert_eq!(profiles.len(), themes.len());
assert_eq!(Theme::for_profile(ReaderProfile::Reader), Theme::default());
assert_ne!(
Theme::for_profile(ReaderProfile::Links).link,
Theme::for_profile(ReaderProfile::Research).link
);
assert_ne!(
Theme::for_profile(ReaderProfile::Docs).code,
Theme::for_profile(ReaderProfile::Compact).code
);
}
#[test]
fn monochrome_theme_falls_back_without_rgb_colors() {
let theme = Theme::for_profile_with_capabilities(
ReaderProfile::Verbose,
TerminalCapabilities {
color: ColorSupport::Monochrome,
..TerminalCapabilities::default()
},
);
assert_eq!(theme.current_line, Color::Black);
assert_eq!(theme.link, Color::White);
assert_eq!(theme.region, Color::Gray);
}
#[test]
fn no_animation_mode_disables_prompt_blink_and_loading_frames()
-> Result<(), Box<dyn std::error::Error>> {
let backend = TestBackend::new(64, 12);
let mut terminal = Terminal::new(backend)?;
let mut app = TerminalApp::with_capabilities(
document(),
24,
TerminalCapabilities {
animation: AnimationMode::None,
..TerminalCapabilities::default()
},
);
app.start_open_loading("https://example.com");
terminal.draw(|frame| app.render(frame))?;
let first = buffer_to_string(terminal.backend().buffer());
terminal.draw(|frame| app.render(frame))?;
let second = buffer_to_string(terminal.backend().buffer());
assert!(first.contains("[ ] OPENING queued"));
assert_eq!(first, second);
Ok(())
}
#[test]
fn plain_glyph_mode_keeps_navigation_surfaces_ascii() -> Result<(), Box<dyn std::error::Error>>
{
let backend = TestBackend::new(84, 28);
let mut terminal = Terminal::new(backend)?;
let mut document = document();
document.push(IndexNode::List {
ordered: false,
items: vec!["alpha".to_owned()],
});
document.push(IndexNode::Table {
rows: vec![
vec!["Name".to_owned(), "Value".to_owned()],
vec!["Index".to_owned(), "Terminal".to_owned()],
],
});
let mut app = TerminalApp::with_capabilities(
document,
42,
TerminalCapabilities {
glyphs: GlyphSupport::Plain,
color: ColorSupport::Monochrome,
animation: AnimationMode::None,
},
);
terminal.draw(|frame| app.render(frame))?;
let content_snapshot = buffer_to_string(terminal.backend().buffer());
let _ = app.handle_key(key(KeyCode::Char('f')));
let _ = app.handle_key(key(KeyCode::Char('l')));
terminal.draw(|frame| app.render(frame))?;
let navigation_snapshot = buffer_to_string(terminal.backend().buffer());
let snapshot = format!("{content_snapshot}\n{navigation_snapshot}");
assert!(snapshot.contains("TITLE"));
assert!(snapshot.contains("HEAD"));
assert!(snapshot.contains("LINK"));
assert!(snapshot.contains("TABLE"));
assert!(snapshot.contains("links"));
assert!(!snapshot.contains(""));
assert!(!snapshot.contains(""));
assert!(!snapshot.contains(""));
assert!(!snapshot.contains(""));
Ok(())
}
#[test]
fn profile_command_switches_renderer_theme_without_changing_document() {
let document = document();
let original = document.clone();
let mut app = TerminalApp::new(document, 80);
let action = submit_command(&mut app, "profile links");
assert_eq!(action, AppAction::None);
assert_eq!(app.reader_profile(), ReaderProfile::Links);
assert!(!app.reader_profile_is_auto());
assert_eq!(app.status(), "PROFILE links");
assert_eq!(app.document, original);
}
#[test]
fn auto_profile_command_reenables_intent_suggestions() {
let mut document = IndexDocument::titled("API Documentation");
document.push(IndexNode::CodeBlock {
language: Some("rust".to_owned()),
code: "fn main() {}".to_owned(),
});
let mut app = TerminalApp::new(document, 80);
assert_eq!(app.reader_profile(), ReaderProfile::Docs);
assert_eq!(submit_command(&mut app, "profile links"), AppAction::None);
assert_eq!(app.reader_profile(), ReaderProfile::Links);
assert!(!app.reader_profile_is_auto());
assert_eq!(submit_command(&mut app, "profile auto"), AppAction::None);
assert!(app.reader_profile_is_auto());
assert_eq!(app.reader_profile(), ReaderProfile::Docs);
assert!(app.status().contains("PROFILE auto profile docs"));
}
#[test]
fn intent_profile_mapping_is_deterministic() {
let mut docs = IndexDocument::titled("Manual");
docs.push(IndexNode::CodeBlock {
language: None,
code: "index --help".to_owned(),
});
assert_eq!(suggest_reader_profile(&docs).profile, ReaderProfile::Docs);
let mut links = IndexDocument::titled("Search Results");
for id in 0..5 {
links.push(IndexNode::Link(Link::new(
format!("Result {id}"),
format!("https://example.org/{id}"),
)));
}
assert_eq!(suggest_reader_profile(&links).profile, ReaderProfile::Links);
let mut research = IndexDocument::titled("arXiv research paper");
research.push(IndexNode::Paragraph("citation bibliography".to_owned()));
assert_eq!(
suggest_reader_profile(&research).profile,
ReaderProfile::Research
);
let mut essay = IndexDocument::titled("Essay");
essay.push(IndexNode::Paragraph("Long quiet prose.".repeat(8)));
assert_eq!(
suggest_reader_profile(&essay).profile,
ReaderProfile::Reader
);
}
#[test]
fn unknown_profile_command_is_diagnostic_only() {
let mut app = TerminalApp::new(document(), 80);
let action = submit_command(&mut app, "profile loud");
assert_eq!(action, AppAction::None);
assert_eq!(app.reader_profile(), ReaderProfile::Reader);
assert!(app.status().contains("unsupported profile"));
}
#[test]
fn repair_action_parser_accepts_local_repairs() {
assert_eq!(
RepairAction::parse("main next"),
Some(RepairAction::MainNext)
);
assert_eq!(
RepairAction::parse("main previous"),
Some(RepairAction::MainPrevious)
);
assert_eq!(
RepairAction::parse("hide region 2"),
Some(RepairAction::HideRegion(2))
);
assert_eq!(
RepairAction::parse("show region 3"),
Some(RepairAction::ShowRegion(3))
);
assert_eq!(
RepairAction::parse("promote section 4"),
Some(RepairAction::PromoteSection(4))
);
assert_eq!(RepairAction::parse("hide region 0"), None);
}
#[test]
fn repair_recipe_serializes_stably() {
let mut recipe = RepairRecipe::new();
assert!(recipe.is_empty());
recipe.push(RepairAction::MainNext);
recipe.push(RepairAction::HideRegion(2));
assert_eq!(
recipe.to_text(),
"index-repair-v1\nmain next\nhide region 2\n"
);
}
#[test]
fn repair_commands_hide_show_and_promote_regions() {
let mut document = IndexDocument::titled("Regions");
document.push(IndexNode::Section {
role: SectionRole::Navigation,
title: Some("Nav".to_owned()),
collapsed: false,
nodes: vec![IndexNode::Paragraph("noise".to_owned())],
});
document.push(IndexNode::Section {
role: SectionRole::Main,
title: Some("Article".to_owned()),
collapsed: true,
nodes: vec![IndexNode::Paragraph("body".to_owned())],
});
let mut app = TerminalApp::new(document, 48);
assert_eq!(submit_command(&mut app, "main next"), AppAction::None);
assert_eq!(app.status(), "REPAIR main 2");
assert_eq!(submit_command(&mut app, "show region 2"), AppAction::None);
assert_eq!(app.status(), "REPAIR showed region 2");
assert_eq!(submit_command(&mut app, "hide region 1"), AppAction::None);
assert_eq!(app.status(), "REPAIR hid region 1");
assert_eq!(
submit_command(&mut app, "promote section 2"),
AppAction::None
);
assert_eq!(app.status(), "REPAIR promoted section 2");
assert!(app.repair_recipe().to_text().contains("promote section 2"));
}
#[test]
fn capture_commands_preview_and_save_current_document() -> Result<(), Box<dyn std::error::Error>>
{
let output_path = unique_temp_file("tui-capture");
let mut document = document();
document.metadata.canonical_url = Some("https://example.org/page".to_owned());
let mut app = TerminalApp::new(document, 80);
assert_eq!(submit_command(&mut app, "capture preview"), AppAction::None);
assert!(app.status().contains("CAPTURE preview"));
assert_eq!(
submit_command(&mut app, &format!("capture save {output_path}")),
AppAction::None
);
assert!(app.status().contains("CAPTURE saved"));
let saved = fs::read_to_string(&output_path)?;
assert!(saved.contains("index-capture-v1"));
assert!(saved.contains("source_url: https://example.org/page"));
fs::remove_file(&output_path)?;
Ok(())
}
#[test]
fn capture_save_rejects_invalid_paths() {
let mut app = TerminalApp::new(document(), 80);
assert_eq!(
submit_command(&mut app, "capture save --raw"),
AppAction::None
);
assert!(app.status().contains("path must not look like an option"));
assert!(save_current_capture(&app.document, "").is_err());
}
#[test]
fn ratatui_marks_current_line_with_mild_background() -> Result<(), Box<dyn std::error::Error>> {
let backend = TestBackend::new(48, 10);
let mut terminal = Terminal::new(backend)?;
let mut app = TerminalApp::new(document(), 20);
terminal.draw(|frame| app.render(frame))?;
assert_eq!(
terminal.backend().buffer()[(1, 1)].style().bg,
Some(Color::Rgb(24, 28, 30))
);
Ok(())
}
#[test]
fn ratatui_colors_semantic_lines() -> Result<(), Box<dyn std::error::Error>> {
let backend = TestBackend::new(64, 12);
let mut terminal = Terminal::new(backend)?;
let mut app = TerminalApp::new(document(), 20);
terminal.draw(|frame| app.render(frame))?;
let buffer = terminal.backend().buffer();
assert_eq!(buffer[(1, 1)].style().fg, Some(Color::Cyan));
assert_eq!(buffer[(1, 3)].style().fg, Some(Color::LightCyan));
assert_eq!(buffer[(1, 7)].style().fg, Some(Color::LightBlue));
Ok(())
}
#[test]
fn ratatui_styles_markdown_inline_emphasis() -> Result<(), Box<dyn std::error::Error>> {
let backend = TestBackend::new(64, 8);
let mut terminal = Terminal::new(backend)?;
let mut document = IndexDocument::titled("Title");
document.push(IndexNode::Paragraph(
"plain **bold** and *italic* text".to_owned(),
));
let mut app = TerminalApp::new(document, 64);
terminal.draw(|frame| app.render(frame))?;
let buffer = terminal.backend().buffer();
assert_eq!(buffer[(9, 3)].symbol(), "b");
assert_eq!(buffer[(9, 3)].style().fg, Some(Color::White));
assert!(buffer[(9, 3)].style().add_modifier.contains(Modifier::BOLD));
assert_eq!(buffer[(18, 3)].symbol(), "i");
assert_eq!(buffer[(18, 3)].style().fg, Some(Color::LightMagenta));
assert!(
buffer[(18, 3)]
.style()
.add_modifier
.contains(Modifier::ITALIC)
);
Ok(())
}
#[test]
fn ratatui_reveals_markdown_syntax_only_on_current_line()
-> Result<(), Box<dyn std::error::Error>> {
let backend = TestBackend::new(72, 10);
let mut terminal = Terminal::new(backend)?;
let mut document = IndexDocument::titled("Title");
document.push(IndexNode::Heading {
level: 2,
text: "Section".to_owned(),
});
document.push(IndexNode::Paragraph(
"plain **bold** and *italic* text".to_owned(),
));
for index in 0..6 {
document.push(IndexNode::Paragraph(format!("tail {index}")));
}
let mut app = TerminalApp::new(document, 72);
terminal.draw(|frame| app.render(frame))?;
let snapshot = buffer_to_string(terminal.backend().buffer());
assert!(snapshot.contains(" # Title"));
assert!(snapshot.contains(" Section"));
assert!(!snapshot.contains(" ## Section"));
assert!(snapshot.contains("plain bold and italic text"));
assert!(!snapshot.contains("**bold**"));
app.viewport.offset = 4;
terminal.draw(|frame| app.render(frame))?;
let current_snapshot = buffer_to_string(terminal.backend().buffer());
assert!(current_snapshot.contains("plain **bold** and *italic* text"));
Ok(())
}
#[test]
fn ratatui_always_shows_cyan_prompt() -> Result<(), Box<dyn std::error::Error>> {
let backend = TestBackend::new(60, 12);
let mut terminal = Terminal::new(backend)?;
let mut app = TerminalApp::new(document(), 24);
terminal.draw(|frame| app.render(frame))?;
let buffer = terminal.backend().buffer();
assert_eq!(buffer[(0, 11)].symbol(), "[");
assert_eq!(buffer[(0, 11)].style().fg, Some(Color::LightCyan));
assert_eq!(buffer[(1, 11)].symbol(), ":");
assert_eq!(buffer[(1, 11)].style().fg, Some(Color::LightCyan));
assert_eq!(buffer[(2, 11)].symbol(), ">");
assert_eq!(buffer[(3, 11)].symbol(), " ");
assert_eq!(buffer[(4, 11)].style().fg, Some(Color::DarkGray));
for _ in 0..9 {
terminal.draw(|frame| app.render(frame))?;
}
let buffer = terminal.backend().buffer();
assert_eq!(buffer[(1, 11)].symbol(), ":");
assert_eq!(buffer[(1, 11)].style().fg, Some(Color::LightCyan));
terminal.draw(|frame| app.render(frame))?;
let buffer = terminal.backend().buffer();
assert_eq!(buffer[(1, 11)].symbol(), ":");
assert_eq!(buffer[(1, 11)].style().fg, Some(Color::DarkGray));
Ok(())
}
#[test]
fn ratatui_status_shows_ascii_loading_frame() -> Result<(), Box<dyn std::error::Error>> {
let backend = TestBackend::new(64, 8);
let mut terminal = Terminal::new(backend)?;
let mut app = TerminalApp::new(document(), 24);
app.start_open_loading("https://example.com");
terminal.draw(|frame| app.render(frame))?;
let first = buffer_to_string(terminal.backend().buffer());
assert!(first.contains("[-] OPENING queued"));
assert!(!first.contains("Tao Te Ching"));
assert!(first.contains("OPENING queued"));
terminal.draw(|frame| app.render(frame))?;
let second = buffer_to_string(terminal.backend().buffer());
assert!(second.contains("[\\] OPENING queued"));
Ok(())
}
#[test]
fn ratatui_status_blinks_opening_marker_three_times_per_second_pattern()
-> Result<(), Box<dyn std::error::Error>> {
let backend = TestBackend::new(64, 8);
let mut terminal = Terminal::new(backend)?;
let mut app = TerminalApp::new(document(), 24);
app.start_open_loading("https://example.com");
let mut visible = 0_usize;
let mut hidden = 0_usize;
for _ in 0..10 {
terminal.draw(|frame| app.render(frame))?;
let snapshot = buffer_to_string(terminal.backend().buffer());
if snapshot.contains("[ ] OPENING ") {
hidden = hidden.saturating_add(1);
} else {
visible = visible.saturating_add(1);
}
}
assert_eq!(visible, 6);
assert_eq!(hidden, 4);
Ok(())
}
#[test]
fn ratatui_status_updates_opening_phase_progressively() -> Result<(), Box<dyn std::error::Error>>
{
let backend = TestBackend::new(72, 8);
let mut terminal = Terminal::new(backend)?;
let mut app = TerminalApp::new(document(), 24);
app.start_open_loading("https://example.com");
terminal.draw(|frame| app.render(frame))?;
let fetching = buffer_to_string(terminal.backend().buffer());
assert!(fetching.contains("OPENING queued https://example.com"));
app.set_opening_progress("parsing https://example.com".to_owned());
terminal.draw(|frame| app.render(frame))?;
let parsing = buffer_to_string(terminal.backend().buffer());
assert!(parsing.contains("OPENING parsing https://example.com"));
app.set_opening_progress("transforming https://example.com".to_owned());
terminal.draw(|frame| app.render(frame))?;
let transforming = buffer_to_string(terminal.backend().buffer());
assert!(transforming.contains("OPENING transforming https://example.com"));
Ok(())
}
#[test]
fn status_severity_uses_requested_colors() {
let theme = Theme::default();
assert_eq!(
status_style(classify_status("OPEN failed: https://example.com"), theme).fg,
Some(theme.error)
);
assert_eq!(
status_style(classify_status("OPEN busy (wait or :quit)"), theme).fg,
Some(theme.warning)
);
assert_eq!(
status_style(
classify_status("SUBMIT POST https://example.com/login"),
theme
)
.fg,
Some(theme.success)
);
assert_eq!(
status_style(classify_status("NORMAL"), theme).fg,
Some(theme.info)
);
}
#[test]
fn status_icons_fallback_in_plain_glyph_mode() {
assert_eq!(
status_icon(classify_status("OPEN failed"), GlyphSupport::Plain),
"[error]"
);
assert_eq!(
status_icon(classify_status("OPEN busy"), GlyphSupport::Plain),
"[warn]"
);
assert_eq!(
status_icon(
classify_status("OPEN https://example.com"),
GlyphSupport::Plain
),
"[ok]"
);
assert_eq!(
status_icon(classify_status("NORMAL"), GlyphSupport::Plain),
"[info]"
);
}
#[test]
fn opening_renders_temporary_loading_page_in_viewport() -> Result<(), Box<dyn std::error::Error>>
{
let backend = TestBackend::new(80, 14);
let mut terminal = Terminal::new(backend)?;
let mut app = TerminalApp::new(document(), 24);
app.start_open_loading("https://example.com/slow");
terminal.draw(|frame| app.render(frame))?;
let snapshot = buffer_to_string(terminal.backend().buffer());
assert!(snapshot.contains("Opening"));
assert!(snapshot.contains("The highest good is like water."));
assert!(!snapshot.contains("Tao Te Ching"));
assert!(snapshot.contains("Press q or :quit to cancel."));
Ok(())
}
#[test]
fn ratatui_status_line_uses_severity_color() -> Result<(), Box<dyn std::error::Error>> {
let backend = TestBackend::new(72, 10);
let mut terminal = Terminal::new(backend)?;
let mut app = TerminalApp::with_capabilities(
document(),
24,
TerminalCapabilities {
glyphs: GlyphSupport::Plain,
..TerminalCapabilities::default()
},
);
app.status = "OPEN failed: https://example.com".to_owned();
terminal.draw(|frame| app.render(frame))?;
let buffer = terminal.backend().buffer();
assert_eq!(buffer[(0, 8)].symbol(), "[");
assert_eq!(buffer[(0, 8)].style().fg, Some(app.theme.error));
Ok(())
}
#[test]
fn log_entries_are_classified_by_mime_and_body() {
let error_log = ResponseLogEntry::new(
1,
"GET",
"https://example.com",
"https://example.com",
Some("text/x-index-error"),
"network failed",
128,
);
let warning_log = ResponseLogEntry::new(
2,
"GET",
"https://example.com",
"https://example.com",
Some("text/html"),
"confirm required",
128,
);
let success_log = ResponseLogEntry::new(
3,
"GET",
"https://example.com",
"https://example.com",
Some("text/html"),
"<html>ok</html>",
128,
);
assert_eq!(classify_log_entry(&error_log), super::StatusSeverity::Error);
assert_eq!(
classify_log_entry(&warning_log),
super::StatusSeverity::Warning
);
assert_eq!(
classify_log_entry(&success_log),
super::StatusSeverity::Success
);
}
#[test]
fn ratatui_renders_diagnostic_documents() -> Result<(), Box<dyn std::error::Error>> {
let backend = TestBackend::new(76, 16);
let mut terminal = Terminal::new(backend)?;
let mut document = IndexDocument::titled("Network fetch failed");
document.push(IndexNode::Error(
"could not fetch https://example.invalid".to_owned(),
));
document.push(IndexNode::List {
ordered: false,
items: vec![
"source: network".to_owned(),
"confidence: failed".to_owned(),
],
});
document.push(IndexNode::Heading {
level: 2,
text: "Suggested actions".to_owned(),
});
document.push(IndexNode::List {
ordered: false,
items: vec!["retry the request".to_owned()],
});
let mut app = TerminalApp::new(document, 48);
terminal.draw(|frame| app.render(frame))?;
let snapshot = buffer_to_string(terminal.backend().buffer());
assert!(snapshot.contains("Network fetch failed"));
assert!(snapshot.contains("could not fetch"));
assert!(snapshot.contains("confidence: failed"));
assert!(snapshot.contains("retry the request"));
Ok(())
}
#[test]
fn open_action_replaces_document_and_resets_viewport() {
let mut app = TerminalApp::new(document(), 20);
app.handle_key(key(KeyCode::Char('j')));
let mut navigate = |target: &str| {
let mut document = IndexDocument::titled("Opened");
document.push(IndexNode::Paragraph(format!("Opened {target}")));
Ok(document)
};
handle_app_action(
&mut app,
AppAction::Open("https://example.com/opened".to_owned()),
&mut navigate,
);
assert_eq!(app.viewport().offset, 0);
assert_eq!(app.status(), "OPEN https://example.com/opened");
assert!(app.lines.iter().any(|line| line.contains("Opened")));
}
#[test]
fn open_action_records_history_and_response_log() {
let mut app = TerminalApp::new(document(), 20);
let mut navigate = |target: &str| {
let mut document = IndexDocument::titled("Opened");
document.push(IndexNode::Paragraph(format!("Opened {target}")));
Ok(TuiDocumentResult::new(document)
.with_visited_url("https://example.com/final")
.with_response_log(ResponseLogEntry::new(
1,
"GET",
target,
"https://example.com/final",
Some("text/html"),
"server response",
64,
)))
};
handle_app_action_with_forms(
&mut app,
AppAction::Open("https://example.com/start".to_owned()),
&mut navigate,
&mut |_submission| Err("unexpected form".to_owned()),
);
assert_eq!(
app.url_history.first().map(String::as_str),
Some("https://example.com/final")
);
assert_eq!(app.response_logs.len(), 1);
assert!(app.lines.iter().any(|line| line.contains("Opened")));
}
#[test]
fn open_action_failure_renders_diagnostic_document_and_error_log() {
let mut app = TerminalApp::new(document(), 20);
let mut navigate = |_target: &str| -> Result<TuiDocumentResult, String> {
Err("timeout after 30000ms".to_owned())
};
handle_app_action_with_forms(
&mut app,
AppAction::Open("https://example.com/slow".to_owned()),
&mut navigate,
&mut |_submission| Err("unexpected form".to_owned()),
);
assert!(app.document.title.contains("Network fetch failed"));
assert!(
app.lines
.iter()
.any(|line| line.contains("could not fetch https://example.com/slow"))
);
assert_eq!(app.response_logs.len(), 1);
assert_eq!(app.response_logs[0].method, "GET");
assert_eq!(
app.response_logs[0].requested_url,
"https://example.com/slow"
);
}
#[test]
fn submit_action_failure_renders_diagnostic_document_and_error_log()
-> Result<(), Box<dyn std::error::Error>> {
let mut app = TerminalApp::new(document(), 20);
let submission = FormSubmission {
method: index_core::FormMethod::Post,
action: index_core::IndexUrl::parse("https://example.com/login")?,
body: Some("user=alice".to_owned()),
};
let mut submit_form = |_submission: &FormSubmission| -> Result<TuiDocumentResult, String> {
Err("HTTP status 503 returned for https://example.com/login".to_owned())
};
handle_app_action_with_forms(
&mut app,
AppAction::Submit(submission),
&mut |_target| Err("unexpected navigation".to_owned()),
&mut submit_form,
);
assert!(app.document.title.contains("Form submission failed"));
assert!(
app.lines
.iter()
.any(|line| line.contains("could not fetch https://example.com/login"))
);
assert_eq!(app.response_logs.len(), 1);
assert_eq!(app.response_logs[0].method, "POST");
assert_eq!(
app.response_logs[0].requested_url,
"https://example.com/login"
);
Ok(())
}
#[test]
fn dispatch_action_handles_busy_and_worker_unavailable_paths()
-> Result<(), Box<dyn std::error::Error>> {
let mut app = TerminalApp::new(document(), 20);
let (request_tx, request_rx) = mpsc::channel();
app.status = "OPENING https://example.com/current".to_owned();
dispatch_app_action(
&mut app,
AppAction::Open("https://example.com/next".to_owned()),
&request_tx,
);
assert_eq!(app.status, "OPEN busy (wait or :quit)");
let submission = Form {
name: "login".to_owned(),
method: "POST".to_owned(),
action: "https://example.com/login".to_owned(),
inputs: vec![Input {
name: "user".to_owned(),
kind: "text".to_owned(),
value: Some("alice".to_owned()),
required: true,
}],
buttons: Vec::new(),
}
.submit(None, &[])?;
app.status = "OPENING https://example.com/current".to_owned();
dispatch_app_action(&mut app, AppAction::Submit(submission.clone()), &request_tx);
assert_eq!(app.status, "SUBMIT busy (wait or :quit)");
drop(request_rx);
dispatch_app_action(
&mut app,
AppAction::Open("https://example.com/unavailable".to_owned()),
&request_tx,
);
assert!(app.document.title.contains("Network fetch failed"));
assert_eq!(
app.response_logs.last().map(|log| log.method.as_str()),
Some("GET")
);
dispatch_app_action(&mut app, AppAction::Submit(submission), &request_tx);
assert!(app.document.title.contains("Form submission failed"));
assert_eq!(
app.response_logs.last().map(|log| log.method.as_str()),
Some("POST")
);
Ok(())
}
#[test]
fn worker_response_updates_status_history_logs_and_disconnect_failure()
-> Result<(), Box<dyn std::error::Error>> {
let mut app = TerminalApp::new(document(), 20);
let (response_tx, response_rx) = mpsc::channel();
response_tx.send(WorkerResponse::Progress {
message: "fetching https://example.com/story".to_owned(),
})?;
let mut opened = IndexDocument::titled("Opened");
opened.push(IndexNode::Paragraph("Story body".to_owned()));
response_tx.send(WorkerResponse::Open {
target: "https://example.com/story".to_owned(),
result: Ok(TuiDocumentResult::new(opened)
.with_visited_url("https://example.com/final")
.with_response_log(ResponseLogEntry::new(
1,
"GET",
"https://example.com/story",
"https://example.com/final",
Some("text/html"),
"<html>story</html>",
64,
))),
})?;
let submission = Form {
name: "reply".to_owned(),
method: "POST".to_owned(),
action: "https://example.com/reply".to_owned(),
inputs: vec![Input {
name: "body".to_owned(),
kind: "text".to_owned(),
value: Some("hello".to_owned()),
required: true,
}],
buttons: Vec::new(),
}
.submit(None, &[])?;
response_tx.send(WorkerResponse::Submit {
submission: submission.clone(),
result: Err("HTTP status 503 returned for https://example.com/reply".to_owned()),
})?;
drop(response_tx);
handle_worker_response(&mut app, &response_rx);
assert_eq!(
app.url_history.first().map(String::as_str),
Some("https://example.com/final")
);
assert!(app.status.starts_with("SUBMIT failed:"));
assert!(app.document.title.contains("Form submission failed"));
assert!(app.response_logs.len() >= 2);
let mut disconnected = TerminalApp::new(document(), 20);
disconnected.status = "OPENING https://example.com/stuck".to_owned();
let (tx2, rx2) = mpsc::channel::<WorkerResponse>();
drop(tx2);
handle_worker_response(&mut disconnected, &rx2);
assert!(disconnected.document.title.contains("Network fetch failed"));
assert!(disconnected.status.starts_with("OPEN failed:"));
Ok(())
}
#[test]
fn form_input_lookup_skips_collapsed_sections_and_finds_visible_inputs() {
let mut nodes = vec![
IndexNode::Section {
role: SectionRole::Related,
title: Some("Hidden".to_owned()),
collapsed: true,
nodes: vec![IndexNode::Form(Form {
name: "hidden".to_owned(),
method: "GET".to_owned(),
action: "/hidden".to_owned(),
inputs: vec![Input {
name: "q".to_owned(),
kind: "text".to_owned(),
value: Some("hidden".to_owned()),
required: false,
}],
buttons: Vec::new(),
})],
},
IndexNode::Section {
role: SectionRole::Main,
title: Some("Visible".to_owned()),
collapsed: false,
nodes: vec![IndexNode::Form(Form {
name: "visible".to_owned(),
method: "GET".to_owned(),
action: "/visible".to_owned(),
inputs: vec![Input {
name: "q".to_owned(),
kind: "text".to_owned(),
value: Some("visible".to_owned()),
required: false,
}],
buttons: Vec::new(),
})],
},
];
assert!(form_input_mut_by_render_index(&mut nodes, 1, 0).is_some());
if let Some(input) = form_input_mut_by_render_index(&mut nodes, 1, 0) {
input.value = Some("updated".to_owned());
}
let updated = match &nodes[1] {
IndexNode::Section { nodes, .. } => match &nodes[0] {
IndexNode::Form(form) => form.inputs[0].value.clone(),
_ => None,
},
_ => None,
};
assert_eq!(updated.as_deref(), Some("updated"));
assert!(form_input_mut_by_render_index(&mut nodes, 2, 0).is_none());
}
#[test]
fn ratatui_link_hint_snapshot_contains_overlay() -> Result<(), Box<dyn std::error::Error>> {
let backend = TestBackend::new(60, 12);
let mut terminal = Terminal::new(backend)?;
let mut app = TerminalApp::new(document(), 24);
app.handle_key(key(KeyCode::Char('f')));
terminal.draw(|frame| app.render(frame))?;
let snapshot = buffer_to_string(terminal.backend().buffer());
assert!(snapshot.contains("Links"));
assert!(snapshot.contains("[1]"));
assert!(snapshot.contains("https://example.com/one"));
Ok(())
}
#[test]
fn ratatui_link_sidebar_snapshot_contains_page_links() -> Result<(), Box<dyn std::error::Error>>
{
let backend = TestBackend::new(84, 12);
let mut terminal = Terminal::new(backend)?;
let mut app = TerminalApp::new(document(), 24);
app.handle_key(key(KeyCode::Char('l')));
terminal.draw(|frame| app.render(frame))?;
let snapshot = buffer_to_string(terminal.backend().buffer());
assert!(snapshot.contains("Links"));
assert!(snapshot.contains("> 1 One"));
assert!(snapshot.contains("https://example.com/one"));
assert!(snapshot.contains(" 2 Two"));
Ok(())
}
#[test]
fn ratatui_sidebar_modes_render_outline_forms_regions_search_and_logs()
-> Result<(), Box<dyn std::error::Error>> {
let backend = TestBackend::new(92, 18);
let mut terminal = Terminal::new(backend)?;
let mut document = document();
document.push(IndexNode::Form(Form {
name: "search".to_owned(),
method: "GET".to_owned(),
action: "https://example.com/search".to_owned(),
inputs: Vec::new(),
buttons: Vec::new(),
}));
document.push(IndexNode::Section {
role: SectionRole::Related,
title: Some("More".to_owned()),
collapsed: true,
nodes: vec![IndexNode::Link(Link::new(
"Related",
"https://example.com/related",
))],
});
let mut app = TerminalApp::new(document, 32);
app.handle_key(key(KeyCode::Char('/')));
for ch in "Section".chars() {
app.handle_key(key(KeyCode::Char(ch)));
}
app.handle_key(key(KeyCode::Enter));
app.handle_key(key(KeyCode::Char('l')));
app.set_sidebar_mode(SessionSidebarMode::Outline);
terminal.draw(|frame| app.render(frame))?;
let outline = buffer_to_string(terminal.backend().buffer());
assert!(outline.contains("Outline"));
assert!(outline.contains("## Section"));
assert!(outline.contains("§ related: More"));
app.set_sidebar_mode(SessionSidebarMode::Forms);
terminal.draw(|frame| app.render(frame))?;
let forms = buffer_to_string(terminal.backend().buffer());
assert!(forms.contains("Forms"));
assert!(forms.contains("1 search"));
app.set_sidebar_mode(SessionSidebarMode::Regions);
terminal.draw(|frame| app.render(frame))?;
let regions = buffer_to_string(terminal.backend().buffer());
assert!(regions.contains("Regions"));
assert!(regions.contains("▸ related: More"));
app.set_sidebar_mode(SessionSidebarMode::Search);
terminal.draw(|frame| app.render(frame))?;
let search = buffer_to_string(terminal.backend().buffer());
assert!(search.contains("Search"));
assert!(search.contains("line 3"));
app.set_response_logs(vec![ResponseLogEntry::new(
1,
"GET",
"https://example.com?token=secret",
"https://example.com/final",
Some("text/html"),
"<title>Response</title>\n<p>server token=hidden body</p>",
64,
)]);
app.set_sidebar_mode(SessionSidebarMode::Logs);
terminal.draw(|frame| app.render(frame))?;
let logs = buffer_to_string(terminal.backend().buffer());
assert!(logs.contains("Logs"));
assert!(logs.contains("#1 GET"));
assert!(logs.contains("requested:"));
assert!(logs.contains("final:"));
assert!(logs.contains("example.com/final"));
assert!(logs.contains("mime: text/html"));
assert!(logs.contains("preview:"));
assert!(logs.contains("Response"));
assert!(!logs.contains("secret"));
assert!(!logs.contains("hidden"));
Ok(())
}
#[test]
fn ratatui_link_sidebar_renders_empty_and_hides_on_narrow_width()
-> Result<(), Box<dyn std::error::Error>> {
let backend = TestBackend::new(84, 8);
let mut terminal = Terminal::new(backend)?;
let mut app = TerminalApp::new(IndexDocument::default(), 24);
app.handle_key(key(KeyCode::Char('l')));
terminal.draw(|frame| app.render(frame))?;
let empty_snapshot = buffer_to_string(terminal.backend().buffer());
assert!(empty_snapshot.contains("No links"));
let backend = TestBackend::new(48, 8);
let mut terminal = Terminal::new(backend)?;
let mut app = TerminalApp::new(document(), 24);
app.handle_key(key(KeyCode::Char('l')));
terminal.draw(|frame| app.render(frame))?;
let narrow_snapshot = buffer_to_string(terminal.backend().buffer());
assert!(!narrow_snapshot.contains("> 1 One"));
Ok(())
}
#[test]
fn ratatui_input_snapshots_include_command_and_search_bars()
-> Result<(), Box<dyn std::error::Error>> {
let backend = TestBackend::new(60, 12);
let mut terminal = Terminal::new(backend)?;
let mut app = TerminalApp::new(document(), 24);
app.handle_key(key(KeyCode::Char(':')));
for ch in "open 1".chars() {
app.handle_key(key(KeyCode::Char(ch)));
}
terminal.draw(|frame| app.render(frame))?;
let command_snapshot = buffer_to_string(terminal.backend().buffer());
assert!(command_snapshot.contains("[:> open 1"));
app.handle_key(key(KeyCode::Esc));
app.handle_key(key(KeyCode::Char('/')));
for ch in "Section".chars() {
app.handle_key(key(KeyCode::Char(ch)));
}
terminal.draw(|frame| app.render(frame))?;
let search_snapshot = buffer_to_string(terminal.backend().buffer());
assert!(search_snapshot.contains("[:> /Section"));
Ok(())
}
#[test]
fn strips_control_characters_from_remote_text() {
let mut document = IndexDocument::titled("Title");
document.push(IndexNode::Paragraph("\u{1b}[31mred\u{1b}[0m".to_owned()));
let rendered = render_document(&document, RenderOptions::default());
assert!(!rendered.contains('\u{1b}'));
assert!(rendered.contains("[31mred[0m"));
}
fn buffer_to_string(buffer: &ratatui::buffer::Buffer) -> String {
let mut output = String::new();
for y in 0..buffer.area.height {
for x in 0..buffer.area.width {
output.push_str(buffer[(x, y)].symbol());
}
output.push('\n');
}
output
}
}