//! Shared UI components and state management for TUI applications //! //! This module contains reusable UI components that can be shared between //! different TUI applications (chat, code, etc.) use std::fmt; /// Application state #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AppState { Running, Quit, } /// Input modes for TUI applications #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum InputMode { Normal, Editing, ProviderSelection, ModelSelection, Help, Visual, Command, } impl fmt::Display for InputMode { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { let label = match self { InputMode::Normal => "Normal", InputMode::Editing => "Editing", InputMode::ModelSelection => "Model", InputMode::ProviderSelection => "Provider", InputMode::Help => "Help", InputMode::Visual => "Visual", InputMode::Command => "Command", }; f.write_str(label) } } /// Represents which panel is currently focused #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum FocusedPanel { Chat, Thinking, Input, } /// Auto-scroll state manager for scrollable panels #[derive(Debug, Clone)] pub struct AutoScroll { pub scroll: usize, pub content_len: usize, pub stick_to_bottom: bool, } impl Default for AutoScroll { fn default() -> Self { Self { scroll: 0, content_len: 0, stick_to_bottom: true, } } } impl AutoScroll { /// Update scroll position based on viewport height pub fn on_viewport(&mut self, viewport_h: usize) { let max = self.content_len.saturating_sub(viewport_h); if self.stick_to_bottom { self.scroll = max; } else { self.scroll = self.scroll.min(max); } } /// Handle user scroll input pub fn on_user_scroll(&mut self, delta: isize, viewport_h: usize) { let max = self.content_len.saturating_sub(viewport_h) as isize; let s = (self.scroll as isize + delta).clamp(0, max) as usize; self.scroll = s; self.stick_to_bottom = s as isize == max; } /// Scroll down half page pub fn scroll_half_page_down(&mut self, viewport_h: usize) { let delta = (viewport_h / 2) as isize; self.on_user_scroll(delta, viewport_h); } /// Scroll up half page pub fn scroll_half_page_up(&mut self, viewport_h: usize) { let delta = -((viewport_h / 2) as isize); self.on_user_scroll(delta, viewport_h); } /// Scroll down full page pub fn scroll_full_page_down(&mut self, viewport_h: usize) { let delta = viewport_h as isize; self.on_user_scroll(delta, viewport_h); } /// Scroll up full page pub fn scroll_full_page_up(&mut self, viewport_h: usize) { let delta = -(viewport_h as isize); self.on_user_scroll(delta, viewport_h); } /// Jump to top pub fn jump_to_top(&mut self) { self.scroll = 0; self.stick_to_bottom = false; } /// Jump to bottom pub fn jump_to_bottom(&mut self, viewport_h: usize) { self.stick_to_bottom = true; self.on_viewport(viewport_h); } } /// Visual selection state for text selection #[derive(Debug, Clone, Default)] pub struct VisualSelection { pub start: Option<(usize, usize)>, // (row, col) pub end: Option<(usize, usize)>, // (row, col) } impl VisualSelection { pub fn new() -> Self { Self::default() } pub fn start_at(&mut self, pos: (usize, usize)) { self.start = Some(pos); self.end = Some(pos); } pub fn extend_to(&mut self, pos: (usize, usize)) { self.end = Some(pos); } pub fn clear(&mut self) { self.start = None; self.end = None; } pub fn is_active(&self) -> bool { self.start.is_some() && self.end.is_some() } pub fn get_normalized(&self) -> Option<((usize, usize), (usize, usize))> { if let (Some(s), Some(e)) = (self.start, self.end) { // Normalize selection so start is always before end if s.0 < e.0 || (s.0 == e.0 && s.1 <= e.1) { Some((s, e)) } else { Some((e, s)) } } else { None } } } /// Extract text from a selection range in a list of lines pub fn extract_text_from_selection( lines: &[String], start: (usize, usize), end: (usize, usize), ) -> Option { if lines.is_empty() || start.0 >= lines.len() { return None; } let start_row = start.0; let start_col = start.1; let end_row = end.0.min(lines.len() - 1); let end_col = end.1; if start_row == end_row { // Single line selection let line = &lines[start_row]; let chars: Vec = line.chars().collect(); let start_c = start_col.min(chars.len()); let end_c = end_col.min(chars.len()); if start_c >= end_c { return None; } let selected: String = chars[start_c..end_c].iter().collect(); Some(selected) } else { // Multi-line selection let mut result = Vec::new(); // First line: from start_col to end let first_line = &lines[start_row]; let first_chars: Vec = first_line.chars().collect(); let start_c = start_col.min(first_chars.len()); if start_c < first_chars.len() { result.push(first_chars[start_c..].iter().collect::()); } // Middle lines: entire lines for row in (start_row + 1)..end_row { if row < lines.len() { result.push(lines[row].clone()); } } // Last line: from start to end_col if end_row < lines.len() && end_row > start_row { let last_line = &lines[end_row]; let last_chars: Vec = last_line.chars().collect(); let end_c = end_col.min(last_chars.len()); if end_c > 0 { result.push(last_chars[..end_c].iter().collect::()); } } if result.is_empty() { None } else { Some(result.join("\n")) } } } /// Cursor position for navigating scrollable content #[derive(Debug, Clone, Copy, Default)] pub struct CursorPosition { pub row: usize, pub col: usize, } impl CursorPosition { pub fn new(row: usize, col: usize) -> Self { Self { row, col } } pub fn move_up(&mut self, amount: usize) { self.row = self.row.saturating_sub(amount); } pub fn move_down(&mut self, amount: usize, max: usize) { self.row = (self.row + amount).min(max); } pub fn move_left(&mut self, amount: usize) { self.col = self.col.saturating_sub(amount); } pub fn move_right(&mut self, amount: usize, max: usize) { self.col = (self.col + amount).min(max); } pub fn as_tuple(&self) -> (usize, usize) { (self.row, self.col) } } /// Word boundary detection for navigation pub fn find_next_word_boundary(line: &str, col: usize) -> Option { let chars: Vec = line.chars().collect(); if col >= chars.len() { return Some(chars.len()); } let mut pos = col; let is_word_char = |c: char| c.is_alphanumeric() || c == '_'; // Skip current word if is_word_char(chars[pos]) { while pos < chars.len() && is_word_char(chars[pos]) { pos += 1; } } else { // Skip non-word characters while pos < chars.len() && !is_word_char(chars[pos]) { pos += 1; } } Some(pos) } pub fn find_word_end(line: &str, col: usize) -> Option { let chars: Vec = line.chars().collect(); if col >= chars.len() { return Some(chars.len()); } let mut pos = col; let is_word_char = |c: char| c.is_alphanumeric() || c == '_'; // If on a word character, move to end of current word if is_word_char(chars[pos]) { while pos < chars.len() && is_word_char(chars[pos]) { pos += 1; } // Move back one to be ON the last character pos = pos.saturating_sub(1); } else { // Skip non-word characters while pos < chars.len() && !is_word_char(chars[pos]) { pos += 1; } // Now on first char of next word, move to its end while pos < chars.len() && is_word_char(chars[pos]) { pos += 1; } pos = pos.saturating_sub(1); } Some(pos) } pub fn find_prev_word_boundary(line: &str, col: usize) -> Option { let chars: Vec = line.chars().collect(); if col == 0 || chars.is_empty() { return Some(0); } let mut pos = col.min(chars.len()); let is_word_char = |c: char| c.is_alphanumeric() || c == '_'; // Move back one position first pos = pos.saturating_sub(1); // Skip non-word characters while pos > 0 && !is_word_char(chars[pos]) { pos -= 1; } // Skip word characters to find start of word while pos > 0 && is_word_char(chars[pos - 1]) { pos -= 1; } Some(pos) } #[cfg(test)] mod tests { use super::*; #[test] fn test_auto_scroll() { let mut scroll = AutoScroll::default(); scroll.content_len = 100; // Test on_viewport with stick_to_bottom scroll.on_viewport(10); assert_eq!(scroll.scroll, 90); // Test user scroll up scroll.on_user_scroll(-10, 10); assert_eq!(scroll.scroll, 80); assert!(!scroll.stick_to_bottom); // Test jump to bottom scroll.jump_to_bottom(10); assert!(scroll.stick_to_bottom); assert_eq!(scroll.scroll, 90); } #[test] fn test_visual_selection() { let mut selection = VisualSelection::new(); assert!(!selection.is_active()); selection.start_at((0, 0)); assert!(selection.is_active()); selection.extend_to((2, 5)); let normalized = selection.get_normalized(); assert_eq!(normalized, Some(((0, 0), (2, 5)))); selection.clear(); assert!(!selection.is_active()); } #[test] fn test_extract_text_single_line() { let lines = vec!["Hello World".to_string()]; let result = extract_text_from_selection(&lines, (0, 0), (0, 5)); assert_eq!(result, Some("Hello".to_string())); } #[test] fn test_extract_text_multi_line() { let lines = vec![ "First line".to_string(), "Second line".to_string(), "Third line".to_string(), ]; let result = extract_text_from_selection(&lines, (0, 6), (2, 5)); assert_eq!(result, Some("line\nSecond line\nThird".to_string())); } #[test] fn test_word_boundaries() { let line = "hello world test"; assert_eq!(find_next_word_boundary(line, 0), Some(5)); assert_eq!(find_next_word_boundary(line, 5), Some(6)); assert_eq!(find_next_word_boundary(line, 6), Some(11)); assert_eq!(find_prev_word_boundary(line, 16), Some(12)); assert_eq!(find_prev_word_boundary(line, 11), Some(6)); assert_eq!(find_prev_word_boundary(line, 6), Some(0)); } }