Remove App implementation: delete TUI application logic, event handling, and related structures.
This commit is contained in:
425
crates/owlen-core/src/ui.rs
Normal file
425
crates/owlen-core/src/ui.rs
Normal file
@@ -0,0 +1,425 @@
|
||||
//! 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<String> {
|
||||
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<char> = 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<char> = 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::<String>());
|
||||
}
|
||||
|
||||
// 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<char> = 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::<String>());
|
||||
}
|
||||
}
|
||||
|
||||
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<usize> {
|
||||
let chars: Vec<char> = 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<usize> {
|
||||
let chars: Vec<char> = 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
|
||||
if pos > 0 {
|
||||
pos -= 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;
|
||||
}
|
||||
if pos > 0 {
|
||||
pos -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
Some(pos)
|
||||
}
|
||||
|
||||
pub fn find_prev_word_boundary(line: &str, col: usize) -> Option<usize> {
|
||||
let chars: Vec<char> = 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
|
||||
if pos > 0 {
|
||||
pos -= 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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user