use crate::semantic::{SemanticDocument, SemanticRef};
use crate::tui::content::ContentProjection;
use std::collections::{HashMap, HashSet};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Lifecycle {
Ready,
Loading { action: String },
Error { action: String, message: String },
}
impl Lifecycle {
pub fn is_loading(&self) -> bool {
matches!(self, Self::Loading { .. })
}
pub fn is_ready(&self) -> bool {
matches!(self, Self::Ready)
}
pub fn status_label(&self) -> &str {
match self {
Self::Ready => "Ready",
Self::Loading { .. } => "Loading",
Self::Error { .. } => "Error",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum InteractionMode {
Normal,
Input(InputKind),
Hint(HintMode),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum InputKind {
Url { buffer: String },
Search { buffer: String },
Form {
semantic_ref: SemanticRef,
buffer: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HintMode {
Follow,
NewTab,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PublishedPage {
pub document: SemanticDocument,
pub url: String,
pub title: String,
pub revision: String,
}
impl PublishedPage {
pub fn from_document(document: SemanticDocument) -> Self {
let url = document.document.url.clone();
let title = document.document.title.clone();
let revision = document.document.revision.clone();
Self {
document,
url,
title,
revision,
}
}
}
#[derive(Debug, Clone)]
pub struct ViewState {
pub scroll_y: usize,
pub scroll_x: usize,
pub viewport_height: usize,
pub viewport_width: usize,
pub wrap: bool,
pub full_width: bool,
pub projection: ContentProjection,
pub selection: Option<SemanticRef>,
pub attention: Option<SemanticRef>,
pub attention_paint: HashSet<SemanticRef>,
pub collapsed: HashSet<SemanticRef>,
pub search_matches: Vec<SemanticRef>,
pub search_index: usize,
pub search_query: String,
pub inspect_text: Option<String>,
pub inspect_title: Option<String>,
pub inspect_follow: bool,
pub status_message: Option<String>,
pub hint_buffer: String,
pub pending_form_values: HashMap<SemanticRef, String>,
}
impl Default for ViewState {
fn default() -> Self {
Self {
scroll_y: 0,
scroll_x: 0,
viewport_height: 0,
viewport_width: 0,
wrap: true,
full_width: false,
projection: ContentProjection::default(),
selection: None,
attention: None,
attention_paint: HashSet::new(),
collapsed: HashSet::new(),
search_matches: Vec::new(),
search_index: 0,
search_query: String::new(),
inspect_text: None,
inspect_title: None,
inspect_follow: false,
status_message: None,
hint_buffer: String::new(),
pending_form_values: HashMap::new(),
}
}
}
impl ViewState {
pub fn set_status(&mut self, msg: impl Into<String>) {
self.status_message = Some(msg.into());
}
pub fn clear_status(&mut self) {
self.status_message = None;
}
pub fn clear_pending_form_values(&mut self) {
self.pending_form_values.clear();
}
pub fn clear_search(&mut self) -> bool {
let had = !self.search_query.is_empty() || !self.search_matches.is_empty();
self.search_query.clear();
self.search_matches.clear();
self.search_index = 0;
if had {
if self
.status_message
.as_deref()
.is_some_and(|m| m.starts_with("search:") || m == "pattern not found")
{
self.clear_status();
}
}
had
}
}
#[derive(Debug, Clone)]
pub struct TuiState {
pub lifecycle: Lifecycle,
pub mode: InteractionMode,
pub page: Option<PublishedPage>,
pub view: ViewState,
pub can_go_back: bool,
pub can_go_forward: bool,
pub tab_position: Option<(usize, usize)>,
pub should_quit: bool,
pub clipboard_fallback: Option<String>,
}
impl Default for TuiState {
fn default() -> Self {
Self {
lifecycle: Lifecycle::Ready,
mode: InteractionMode::Normal,
page: None,
view: ViewState::default(),
can_go_back: false,
can_go_forward: false,
tab_position: None,
should_quit: false,
clipboard_fallback: None,
}
}
}
impl TuiState {
pub fn new() -> Self {
Self::default()
}
#[allow(dead_code)]
pub fn mode_label(&self) -> &'static str {
match &self.mode {
InteractionMode::Normal => "Normal",
InteractionMode::Input(InputKind::Url { .. }) => "URL",
InteractionMode::Input(InputKind::Search { .. }) => "Search",
InteractionMode::Input(InputKind::Form { .. }) => "Input",
InteractionMode::Hint(HintMode::Follow) => "Hint",
InteractionMode::Hint(HintMode::NewTab) => "Hint+",
}
}
pub fn is_input_mode(&self) -> bool {
matches!(self.mode, InteractionMode::Input(_))
}
pub fn is_hint_mode(&self) -> bool {
matches!(self.mode, InteractionMode::Hint(_))
}
pub fn allows_normal_commands(&self) -> bool {
matches!(self.mode, InteractionMode::Normal) && self.lifecycle.is_ready()
}
pub fn url(&self) -> &str {
self.page.as_ref().map(|p| p.url.as_str()).unwrap_or("")
}
pub fn title(&self) -> &str {
self.page.as_ref().map(|p| p.title.as_str()).unwrap_or("")
}
pub fn revision(&self) -> &str {
self.page
.as_ref()
.map(|p| p.revision.as_str())
.unwrap_or("")
}
pub fn document(&self) -> Option<&SemanticDocument> {
self.page.as_ref().map(|p| &p.document)
}
pub fn publish_page(&mut self, document: SemanticDocument) {
self.page = Some(PublishedPage::from_document(document));
self.lifecycle = Lifecycle::Ready;
}
pub fn enter_loading(&mut self, action: impl Into<String>) {
self.lifecycle = Lifecycle::Loading {
action: action.into(),
};
if !matches!(self.mode, InteractionMode::Normal) {
self.mode = InteractionMode::Normal;
self.view.hint_buffer.clear();
}
}
pub fn enter_error(&mut self, action: impl Into<String>, message: impl Into<String>) {
self.lifecycle = Lifecycle::Error {
action: action.into(),
message: message.into(),
};
self.mode = InteractionMode::Normal;
self.view.hint_buffer.clear();
}
pub fn clear_session(&mut self) {
self.lifecycle = Lifecycle::Ready;
self.mode = InteractionMode::Normal;
self.page = None;
self.view = ViewState {
viewport_height: self.view.viewport_height,
viewport_width: self.view.viewport_width,
wrap: self.view.wrap,
full_width: self.view.full_width,
projection: self.view.projection,
inspect_follow: false,
..ViewState::default()
};
self.can_go_back = false;
self.can_go_forward = false;
self.tab_position = None;
self.clipboard_fallback = None;
self.view
.set_status("no open tabs — press t or o to continue");
}
pub fn clear_error(&mut self) -> bool {
match std::mem::replace(&mut self.lifecycle, Lifecycle::Ready) {
Lifecycle::Error { message, .. } => {
self.mode = InteractionMode::Normal;
self.view.hint_buffer.clear();
self.view.inspect_text = None;
self.view.inspect_title = None;
self.view.inspect_follow = false;
self.view.set_status(format!("dismissed: {message}"));
true
}
other => {
self.lifecycle = other;
false
}
}
}
pub fn set_history_availability(&mut self, can_go_back: bool, can_go_forward: bool) {
self.can_go_back = can_go_back;
self.can_go_forward = can_go_forward;
}
pub fn set_tab_position(&mut self, position: Option<(usize, usize)>) {
self.tab_position = match position {
Some((index, count)) if count > 0 && index >= 1 && index <= count => {
Some((index, count))
}
_ => None,
};
}
pub fn reconcile_after_capture(
&mut self,
new_document: SemanticDocument,
previous_selection: Option<SemanticRef>,
previous_scroll_y: usize,
anchor_offset_in_viewport: usize,
content_line_of: impl Fn(&SemanticDocument, &SemanticRef) -> Option<usize>,
) {
let mut collapsed = HashSet::new();
for old in &self.view.collapsed {
if let Ok(rebound) = new_document.rebind_surviving(old) {
collapsed.insert(rebound);
}
}
let mut search_matches = Vec::new();
for old in &self.view.search_matches {
if let Ok(rebound) = new_document.rebind_surviving(old) {
search_matches.push(rebound);
}
}
let search_index = self
.view
.search_index
.min(search_matches.len().saturating_sub(1));
self.view.collapsed = collapsed;
let mut selection = None;
let mut scroll_y = previous_scroll_y;
let mut status = None;
if let Some(prev) = previous_selection {
match new_document.rebind_surviving(&prev) {
Ok(rebound) => {
selection = Some(rebound.clone());
if let Some(line) = content_line_of(&new_document, &rebound) {
scroll_y = line.saturating_sub(anchor_offset_in_viewport);
}
}
Err(_) => {
status = Some("anchor changed".to_string());
}
}
}
self.view.search_matches = search_matches;
self.view.search_index = search_index;
self.view.selection = selection;
self.view.scroll_y = scroll_y;
if let Some(msg) = status {
self.view.set_status(msg);
} else {
if self.view.status_message.as_deref() == Some("anchor changed") {
self.view.clear_status();
}
}
self.publish_page(new_document);
}
pub fn clamp_scroll(&mut self, content_len: usize) {
let max_scroll = content_len.saturating_sub(self.view.viewport_height.max(1));
if self.view.scroll_y > max_scroll {
self.view.scroll_y = max_scroll;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn normal_commands_blocked_in_input_and_loading() {
let mut s = TuiState::new();
assert!(s.allows_normal_commands());
s.mode = InteractionMode::Input(InputKind::Url {
buffer: String::new(),
});
assert!(!s.allows_normal_commands());
s.mode = InteractionMode::Normal;
s.enter_loading("reload");
assert!(!s.allows_normal_commands());
}
#[test]
fn error_retains_last_page() {
use crate::dom::DocumentMetadata;
use crate::semantic::SemanticDocument;
let doc = SemanticDocument::empty(DocumentMetadata {
document_id: "d".into(),
revision: "1".into(),
url: "https://example.com/".into(),
title: "T".into(),
ready_state: "complete".into(),
frames: vec![],
})
.expect("empty");
let mut s = TuiState::new();
s.publish_page(doc);
assert_eq!(s.url(), "https://example.com/");
s.enter_error("navigate", "boom");
assert_eq!(s.url(), "https://example.com/");
assert!(matches!(s.lifecycle, Lifecycle::Error { .. }));
}
#[test]
fn error_blocks_semantic_actions_until_dismissed() {
let mut state = TuiState::new();
state.enter_error("reload", "failed");
assert!(!state.allows_normal_commands());
assert!(state.clear_error());
assert!(state.lifecycle.is_ready());
assert!(state.allows_normal_commands());
assert_eq!(
state.view.status_message.as_deref(),
Some("dismissed: failed")
);
}
#[test]
fn clear_error_is_noop_when_ready_or_loading() {
let mut state = TuiState::new();
assert!(!state.clear_error());
state.enter_loading("navigate");
assert!(!state.clear_error());
assert!(state.lifecycle.is_loading());
}
}