From ccf9349f990bc468e413726fa56ee8eb37f5c82a Mon Sep 17 00:00:00 2001 From: vikingowl Date: Sun, 28 Sep 2025 01:47:50 +0200 Subject: [PATCH] Add word wrapping and cursor mapping utilities to core library; integrate advanced text input support in TUI. Update dependencies accordingly. --- Cargo.toml | 5 +- crates/owlen-core/Cargo.toml | 28 +- crates/owlen-core/src/formatting.rs | 14 +- crates/owlen-core/src/lib.rs | 2 +- crates/owlen-core/src/wrap_cursor.rs | 92 +++++ crates/owlen-core/tests/long_word_debug.rs | 115 ++++++ crates/owlen-core/tests/wrap_cursor_tests.rs | 96 +++++ crates/owlen-tui/Cargo.toml | 3 + crates/owlen-tui/src/app.rs | 4 +- crates/owlen-tui/src/chat_app.rs | 109 ++++-- crates/owlen-tui/src/ui.rs | 382 +++++++++++++++++-- 11 files changed, 754 insertions(+), 96 deletions(-) create mode 100644 crates/owlen-core/src/wrap_cursor.rs create mode 100644 crates/owlen-core/tests/long_word_debug.rs create mode 100644 crates/owlen-core/tests/wrap_cursor_tests.rs diff --git a/Cargo.toml b/Cargo.toml index 20cff15..5035d2c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,7 @@ futures-util = "0.3" # TUI framework ratatui = "0.28" crossterm = "0.28" +tui-textarea = "0.6" # HTTP client and JSON handling reqwest = { version = "0.12", features = ["json", "stream"] } @@ -48,4 +49,6 @@ clap = { version = "4.0", features = ["derive"] } # Dev dependencies tempfile = "3.8" -tokio-test = "0.4" \ No newline at end of file +tokio-test = "0.4" + +# For more keys and their definitions, see https://doc.rust-lang.org/cargo/reference/manifest.html diff --git a/crates/owlen-core/Cargo.toml b/crates/owlen-core/Cargo.toml index a645122..03c90fc 100644 --- a/crates/owlen-core/Cargo.toml +++ b/crates/owlen-core/Cargo.toml @@ -5,20 +5,20 @@ edition = "2021" description = "Core traits and types for OWLEN LLM client" [dependencies] -serde = { workspace = true } -serde_json = { workspace = true } -uuid = { workspace = true } -anyhow = { workspace = true } -thiserror = { workspace = true } -tokio = { workspace = true } -futures = { workspace = true } -tokio-stream = { workspace = true } -async-trait = "0.1" -textwrap = { workspace = true } -toml = { workspace = true } -shellexpand = { workspace = true } -regex = "1" -once_cell = "1.21.3" +anyhow = "1.0.75" +log = "0.4.20" +serde = { version = "1.0.188", features = ["derive"] } +serde_json = "1.0.105" +thiserror = "1.0.48" +tokio = { version = "1.32.0", features = ["full"] } +unicode-segmentation = "1.11" +unicode-width = "0.1" +uuid = { version = "1.4.1", features = ["v4", "serde"] } +textwrap = "0.16.0" +futures = "0.3.28" +async-trait = "0.1.73" +toml = "0.8.0" +shellexpand = "3.1.0" [dev-dependencies] tokio-test = { workspace = true } diff --git a/crates/owlen-core/src/formatting.rs b/crates/owlen-core/src/formatting.rs index 6971cd5..910dd40 100644 --- a/crates/owlen-core/src/formatting.rs +++ b/crates/owlen-core/src/formatting.rs @@ -49,11 +49,11 @@ impl MessageFormatter { // 2) Collapse: remove whitespace-only lines; keep exactly one '\n' between content lines let mut content = normalized .split('\n') - .map(|l| l.trim_end()) // trim trailing spaces per line - .filter(|l| !l.trim().is_empty()) // drop blank/whitespace-only lines + .map(|l| l.trim_end()) // trim trailing spaces per line + .filter(|l| !l.trim().is_empty()) // drop blank/whitespace-only lines .collect::>() .join("\n") - .trim() // trim leading/trailing whitespace + .trim() // trim leading/trailing whitespace .to_string(); if content.is_empty() && self.preserve_empty_lines { @@ -73,8 +73,12 @@ impl MessageFormatter { .collect(); // 5) Belt & suspenders: remove leading/trailing blanks if any survived - while lines.first().map_or(false, |s| s.trim().is_empty()) { lines.remove(0); } - while lines.last().map_or(false, |s| s.trim().is_empty()) { lines.pop(); } + while lines.first().map_or(false, |s| s.trim().is_empty()) { + lines.remove(0); + } + while lines.last().map_or(false, |s| s.trim().is_empty()) { + lines.pop(); + } lines } diff --git a/crates/owlen-core/src/lib.rs b/crates/owlen-core/src/lib.rs index 309ac61..cc8a96d 100644 --- a/crates/owlen-core/src/lib.rs +++ b/crates/owlen-core/src/lib.rs @@ -21,7 +21,7 @@ pub use model::*; pub use provider::*; pub use router::*; pub use session::*; -pub use types::*; +pub mod wrap_cursor; /// Result type used throughout the OWLEN ecosystem pub type Result = std::result::Result; diff --git a/crates/owlen-core/src/wrap_cursor.rs b/crates/owlen-core/src/wrap_cursor.rs new file mode 100644 index 0000000..5d15463 --- /dev/null +++ b/crates/owlen-core/src/wrap_cursor.rs @@ -0,0 +1,92 @@ +#![allow(clippy::cast_possible_truncation)] + +use unicode_segmentation::UnicodeSegmentation; +use unicode_width::UnicodeWidthStr; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ScreenPos { + pub row: u16, + pub col: u16, +} + +pub fn build_cursor_map(text: &str, width: u16) -> Vec { + assert!(width > 0); + let width = width as usize; + let mut pos_map = vec![ScreenPos { row: 0, col: 0 }; text.len() + 1]; + let mut row = 0; + let mut col = 0; + + let mut word_start_idx = 0; + let mut word_start_col = 0; + + for (byte_offset, grapheme) in text.grapheme_indices(true) { + let grapheme_width = UnicodeWidthStr::width(grapheme); + + if grapheme == "\n" { + row += 1; + col = 0; + word_start_col = 0; + word_start_idx = byte_offset + grapheme.len(); + // Set position for the end of this grapheme and any intermediate bytes + let end_pos = ScreenPos { + row: row as u16, + col: col as u16, + }; + for i in 1..=grapheme.len() { + if byte_offset + i < pos_map.len() { + pos_map[byte_offset + i] = end_pos; + } + } + continue; + } + + if grapheme.chars().all(char::is_whitespace) { + if col + grapheme_width > width { + // Whitespace causes wrap + row += 1; + col = 1; // Position after wrapping space + word_start_col = 1; + word_start_idx = byte_offset + grapheme.len(); + } else { + col += grapheme_width; + word_start_col = col; + word_start_idx = byte_offset + grapheme.len(); + } + } else { + if col + grapheme_width > width { + if word_start_col > 0 && byte_offset == word_start_idx { + // This is the first character of a new word that won't fit, wrap it + row += 1; + col = grapheme_width; + } else if word_start_col == 0 { + // No previous word boundary, hard break + row += 1; + col = grapheme_width; + } else { + // This is part of a word already on the line, let it extend beyond width + col += grapheme_width; + } + } else { + col += grapheme_width; + } + } + + // Set position for the end of this grapheme and any intermediate bytes + let end_pos = ScreenPos { + row: row as u16, + col: col as u16, + }; + for i in 1..=grapheme.len() { + if byte_offset + i < pos_map.len() { + pos_map[byte_offset + i] = end_pos; + } + } + } + + pos_map +} + +pub fn byte_to_screen_pos(text: &str, byte_idx: usize, width: u16) -> ScreenPos { + let pos_map = build_cursor_map(text, width); + pos_map[byte_idx.min(text.len())] +} diff --git a/crates/owlen-core/tests/long_word_debug.rs b/crates/owlen-core/tests/long_word_debug.rs new file mode 100644 index 0000000..e814263 --- /dev/null +++ b/crates/owlen-core/tests/long_word_debug.rs @@ -0,0 +1,115 @@ +use owlen_core::wrap_cursor::build_cursor_map; + +#[test] +fn debug_long_word_wrapping() { + // Test the exact scenario from the user's issue + let text = "asdnklasdnaklsdnkalsdnaskldaskldnaskldnaskldnaskldnaskldnaskldnaskld asdnklska dnskadl dasnksdl asdn"; + let width = 50; // Approximate width from the user's example + + println!("Testing long word text with width {}", width); + println!("Text: '{}'", text); + + // Check what the cursor map shows + let cursor_map = build_cursor_map(text, width); + + println!("\nCursor map for key positions:"); + let long_word_end = text.find(' ').unwrap_or(text.len()); + for i in [ + 0, + 10, + 20, + 30, + 40, + 50, + 60, + 70, + long_word_end, + long_word_end + 1, + text.len(), + ] { + if i <= text.len() { + let pos = cursor_map[i]; + let char_at = if i < text.len() { + format!("'{}'", text.chars().nth(i).unwrap_or('?')) + } else { + "END".to_string() + }; + println!( + " Byte {}: {} -> row {}, col {}", + i, char_at, pos.row, pos.col + ); + } + } + + // Test what my formatting function produces + let lines = format_text_with_word_wrap_debug(text, width); + + println!("\nFormatted lines:"); + for (i, line) in lines.iter().enumerate() { + println!(" Line {}: '{}' (length: {})", i, line, line.len()); + } + + // The long word should be broken up, not kept on one line + assert!( + lines[0].len() <= width as usize + 5, + "First line is too long: {} chars", + lines[0].len() + ); +} + +fn format_text_with_word_wrap_debug(text: &str, width: u16) -> Vec { + if text.is_empty() { + return vec!["".to_string()]; + } + + // Use the cursor map to determine where line breaks should occur + let cursor_map = build_cursor_map(text, width); + + let mut lines = Vec::new(); + let mut current_line = String::new(); + let mut current_row = 0; + + for (byte_idx, ch) in text.char_indices() { + let pos_before = if byte_idx > 0 { + cursor_map[byte_idx] + } else { + cursor_map[0] + }; + let pos_after = cursor_map[byte_idx + ch.len_utf8()]; + + println!( + "Processing '{}' at byte {}: before=({},{}) after=({},{})", + ch, byte_idx, pos_before.row, pos_before.col, pos_after.row, pos_after.col + ); + + // If the row changed, we need to start a new line + if pos_after.row > current_row { + println!( + " Row changed from {} to {}! Finishing line: '{}'", + current_row, pos_after.row, current_line + ); + if !current_line.is_empty() { + lines.push(current_line.clone()); + current_line.clear(); + } + current_row = pos_after.row; + + // If this character is a space that caused the wrap, don't include it + if ch.is_whitespace() && pos_before.row < pos_after.row { + println!(" Skipping wrapping space"); + continue; // Skip the wrapping space + } + } + + current_line.push(ch); + } + + // Add the final line + if !current_line.is_empty() { + lines.push(current_line); + } else if lines.is_empty() { + lines.push("".to_string()); + } + + lines +} diff --git a/crates/owlen-core/tests/wrap_cursor_tests.rs b/crates/owlen-core/tests/wrap_cursor_tests.rs new file mode 100644 index 0000000..08c20bf --- /dev/null +++ b/crates/owlen-core/tests/wrap_cursor_tests.rs @@ -0,0 +1,96 @@ +#![allow(non_snake_case)] + +use owlen_core::wrap_cursor::{build_cursor_map, ScreenPos}; + +fn assert_cursor_pos(map: &[ScreenPos], byte_idx: usize, expected: ScreenPos) { + assert_eq!(map[byte_idx], expected, "Mismatch at byte {}", byte_idx); +} + +#[test] +fn test_basic_wrap_at_spaces() { + let text = "hello world"; + let width = 5; + let map = build_cursor_map(text, width); + + assert_cursor_pos(&map, 0, ScreenPos { row: 0, col: 0 }); + assert_cursor_pos(&map, 5, ScreenPos { row: 0, col: 5 }); // after "hello" + assert_cursor_pos(&map, 6, ScreenPos { row: 1, col: 1 }); // after "hello " + assert_cursor_pos(&map, 11, ScreenPos { row: 1, col: 6 }); // after "world" +} + +#[test] +fn test_hard_line_break() { + let text = "a\nb"; + let width = 10; + let map = build_cursor_map(text, width); + + assert_cursor_pos(&map, 0, ScreenPos { row: 0, col: 0 }); + assert_cursor_pos(&map, 1, ScreenPos { row: 0, col: 1 }); // after "a" + assert_cursor_pos(&map, 2, ScreenPos { row: 1, col: 0 }); // after "\n" + assert_cursor_pos(&map, 3, ScreenPos { row: 1, col: 1 }); // after "b" +} + +#[test] +fn test_long_word_split() { + let text = "abcdefgh"; + let width = 3; + let map = build_cursor_map(text, width); + + assert_cursor_pos(&map, 0, ScreenPos { row: 0, col: 0 }); + assert_cursor_pos(&map, 1, ScreenPos { row: 0, col: 1 }); + assert_cursor_pos(&map, 2, ScreenPos { row: 0, col: 2 }); + assert_cursor_pos(&map, 3, ScreenPos { row: 0, col: 3 }); + assert_cursor_pos(&map, 4, ScreenPos { row: 1, col: 1 }); + assert_cursor_pos(&map, 5, ScreenPos { row: 1, col: 2 }); + assert_cursor_pos(&map, 6, ScreenPos { row: 1, col: 3 }); + assert_cursor_pos(&map, 7, ScreenPos { row: 2, col: 1 }); + assert_cursor_pos(&map, 8, ScreenPos { row: 2, col: 2 }); +} + +#[test] +fn test_trailing_spaces_preserved() { + let text = "x y"; + let width = 2; + let map = build_cursor_map(text, width); + + assert_cursor_pos(&map, 0, ScreenPos { row: 0, col: 0 }); + assert_cursor_pos(&map, 1, ScreenPos { row: 0, col: 1 }); // after "x" + assert_cursor_pos(&map, 2, ScreenPos { row: 0, col: 2 }); // after "x " + assert_cursor_pos(&map, 3, ScreenPos { row: 1, col: 1 }); // after "x " + assert_cursor_pos(&map, 4, ScreenPos { row: 1, col: 2 }); // after "y" +} + +#[test] +fn test_graphemes_emoji() { + let text = "🙂🙂a"; + let width = 3; + let map = build_cursor_map(text, width); + + assert_cursor_pos(&map, 0, ScreenPos { row: 0, col: 0 }); + assert_cursor_pos(&map, 4, ScreenPos { row: 0, col: 2 }); // after first emoji + assert_cursor_pos(&map, 8, ScreenPos { row: 1, col: 2 }); // after second emoji + assert_cursor_pos(&map, 9, ScreenPos { row: 1, col: 3 }); // after "a" +} + +#[test] +fn test_graphemes_combining() { + let text = "e\u{0301}"; + let width = 10; + let map = build_cursor_map(text, width); + + assert_cursor_pos(&map, 0, ScreenPos { row: 0, col: 0 }); + assert_cursor_pos(&map, 1, ScreenPos { row: 0, col: 1 }); // after "e" + assert_cursor_pos(&map, 3, ScreenPos { row: 0, col: 1 }); // after combining mark +} + +#[test] +fn test_exact_edge() { + let text = "abc def"; + let width = 3; + let map = build_cursor_map(text, width); + + assert_cursor_pos(&map, 0, ScreenPos { row: 0, col: 0 }); + assert_cursor_pos(&map, 3, ScreenPos { row: 0, col: 3 }); // after "abc" + assert_cursor_pos(&map, 4, ScreenPos { row: 1, col: 1 }); // after " " + assert_cursor_pos(&map, 7, ScreenPos { row: 1, col: 4 }); // after "def" +} diff --git a/crates/owlen-tui/Cargo.toml b/crates/owlen-tui/Cargo.toml index 23ca0dc..a8ac8ae 100644 --- a/crates/owlen-tui/Cargo.toml +++ b/crates/owlen-tui/Cargo.toml @@ -10,6 +10,9 @@ owlen-core = { path = "../owlen-core" } # TUI framework ratatui = { workspace = true } crossterm = { workspace = true } +tui-textarea = { workspace = true } +textwrap = { workspace = true } +unicode-width = "0.1" # Async runtime tokio = { workspace = true } diff --git a/crates/owlen-tui/src/app.rs b/crates/owlen-tui/src/app.rs index 517df4c..0e708a7 100644 --- a/crates/owlen-tui/src/app.rs +++ b/crates/owlen-tui/src/app.rs @@ -105,7 +105,7 @@ pub struct App { pub model_selection_index: usize, /// Ollama client for making API requests ollama_client: OllamaClient, - /// Currently active requests (for tracking streaming responses) + /// Currently active requests ( for tracking streaming responses) active_requests: HashMap, // UUID -> message index /// Status message to show at the bottom pub status_message: String, @@ -907,4 +907,4 @@ impl App { pub fn is_session_input(&self) -> bool { self.input_mode == InputMode::SessionInput } -} \ No newline at end of file +} diff --git a/crates/owlen-tui/src/chat_app.rs b/crates/owlen-tui/src/chat_app.rs index c6f174a..4ed1138 100644 --- a/crates/owlen-tui/src/chat_app.rs +++ b/crates/owlen-tui/src/chat_app.rs @@ -3,7 +3,9 @@ use owlen_core::{ session::{SessionController, SessionOutcome}, types::{ChatParameters, ChatResponse, Conversation, ModelInfo}, }; +use ratatui::style::{Modifier, Style}; use tokio::sync::mpsc; +use tui_textarea::{Input, TextArea}; use uuid::Uuid; use crate::config; @@ -64,11 +66,15 @@ pub struct ChatApp { scroll: usize, session_tx: mpsc::UnboundedSender, streaming: HashSet, + textarea: TextArea<'static>, // Advanced text input widget } impl ChatApp { pub fn new(controller: SessionController) -> (Self, mpsc::UnboundedReceiver) { let (session_tx, session_rx) = mpsc::unbounded_channel(); + let mut textarea = TextArea::default(); + configure_textarea_defaults(&mut textarea); + let app = Self { controller, mode: InputMode::Normal, @@ -82,6 +88,7 @@ impl ChatApp { scroll: 0, session_tx, streaming: std::collections::HashSet::new(), + textarea, }; (app, session_rx) @@ -146,6 +153,28 @@ impl ChatApp { self.controller.input_buffer_mut() } + pub fn textarea(&self) -> &TextArea<'static> { + &self.textarea + } + + pub fn textarea_mut(&mut self) -> &mut TextArea<'static> { + &mut self.textarea + } + + /// Sync textarea content to input buffer + fn sync_textarea_to_buffer(&mut self) { + let text = self.textarea.lines().join("\n"); + self.input_buffer_mut().set_text(text); + } + + /// Sync input buffer content to textarea + fn sync_buffer_to_textarea(&mut self) { + let text = self.input_buffer().text().to_string(); + let lines: Vec = text.lines().map(|s| s.to_string()).collect(); + self.textarea = TextArea::new(lines); + configure_textarea_defaults(&mut self.textarea); + } + pub async fn initialize_models(&mut self) -> Result<()> { let config_model_name = self.controller.config().general.default_model.clone(); let config_model_provider = self.controller.config().general.default_provider.clone(); @@ -227,6 +256,7 @@ impl ChatApp { (KeyCode::Enter, KeyModifiers::NONE) | (KeyCode::Char('i'), KeyModifiers::NONE) => { self.mode = InputMode::Editing; + self.sync_buffer_to_textarea(); } (KeyCode::Up, KeyModifiers::NONE) => { self.scroll = self.scroll.saturating_add(1); @@ -241,56 +271,41 @@ impl ChatApp { }, InputMode::Editing => match key.code { KeyCode::Esc if key.modifiers.is_empty() => { + // Sync textarea content to input buffer before leaving edit mode + self.sync_textarea_to_buffer(); self.mode = InputMode::Normal; self.reset_status(); } - KeyCode::Enter if key.modifiers.contains(KeyModifiers::SHIFT) => { - self.input_buffer_mut().insert_char('\n'); + KeyCode::Char('j' | 'J') if key.modifiers.contains(KeyModifiers::CONTROL) => { + self.textarea.insert_newline(); } KeyCode::Enter if key.modifiers.is_empty() => { + // Send message and return to normal mode + self.sync_textarea_to_buffer(); self.try_send_message().await?; + // Clear the textarea by setting it to empty + self.textarea = TextArea::default(); + configure_textarea_defaults(&mut self.textarea); self.mode = InputMode::Normal; } KeyCode::Enter => { - self.input_buffer_mut().insert_char('\n'); + // Any Enter with modifiers keeps editing and inserts a newline via tui-textarea + self.textarea.input(Input::from(key)); } - KeyCode::Char('j') if key.modifiers.contains(KeyModifiers::CONTROL) => { - self.input_buffer_mut().insert_char('\n'); - } - KeyCode::Backspace => { - self.input_buffer_mut().backspace(); - } - KeyCode::Delete => { - self.input_buffer_mut().delete(); - } - KeyCode::Left => { - self.input_buffer_mut().move_left(); - } - KeyCode::Right => { - self.input_buffer_mut().move_right(); - } - KeyCode::Home => { - self.input_buffer_mut().move_home(); - } - KeyCode::End => { - self.input_buffer_mut().move_end(); - } - KeyCode::Up => { + KeyCode::Up if key.modifiers.contains(KeyModifiers::CONTROL) => { + // Navigate through input history self.input_buffer_mut().history_previous(); + self.sync_buffer_to_textarea(); } - KeyCode::Down => { + KeyCode::Down if key.modifiers.contains(KeyModifiers::CONTROL) => { + // Navigate through input history self.input_buffer_mut().history_next(); + self.sync_buffer_to_textarea(); } - KeyCode::Char(c) - if key.modifiers.is_empty() - || key.modifiers.contains(KeyModifiers::SHIFT) => - { - self.input_buffer_mut().insert_char(c); + _ => { + // Let tui-textarea handle all other input + self.textarea.input(Input::from(key)); } - KeyCode::Tab => { - self.input_buffer_mut().insert_tab(); - } - _ => {} }, InputMode::ProviderSelection => match key.code { KeyCode::Esc => { @@ -381,7 +396,11 @@ impl ChatApp { message_id, response, } => { + let at_bottom = self.scroll == 0; self.controller.apply_stream_chunk(message_id, &response)?; + if at_bottom { + self.scroll_to_bottom(); + } if response.is_final { self.streaming.remove(&message_id); self.status = "Response complete".to_string(); @@ -458,6 +477,8 @@ impl ChatApp { return Ok(()); } + self.scroll_to_bottom(); + let message = self.controller.input_buffer_mut().commit_to_history(); let mut parameters = ChatParameters::default(); parameters.stream = self.controller.config().general.enable_streaming; @@ -538,6 +559,10 @@ impl ChatApp { } } + fn scroll_to_bottom(&mut self) { + self.scroll = 0; + } + fn spawn_stream(&mut self, message_id: Uuid, mut stream: owlen_core::provider::ChatStream) { let sender = self.session_tx.clone(); self.streaming.insert(message_id); @@ -569,3 +594,17 @@ impl ChatApp { }); } } + +fn configure_textarea_defaults(textarea: &mut TextArea<'static>) { + textarea.set_placeholder_text("Type your message here..."); + textarea.set_tab_length(4); + + textarea.set_style( + Style::default() + .remove_modifier(Modifier::UNDERLINED) + .remove_modifier(Modifier::ITALIC) + .remove_modifier(Modifier::BOLD), + ); + textarea.set_cursor_style(Style::default()); + textarea.set_cursor_line_style(Style::default()); +} diff --git a/crates/owlen-tui/src/ui.rs b/crates/owlen-tui/src/ui.rs index 944a6a8..b0f26e1 100644 --- a/crates/owlen-tui/src/ui.rs +++ b/crates/owlen-tui/src/ui.rs @@ -1,20 +1,42 @@ use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect}; use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; -use ratatui::widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph}; +use ratatui::widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph, Wrap}; use ratatui::Frame; +use textwrap::{wrap, Options}; +use tui_textarea::TextArea; +use unicode_width::UnicodeWidthStr; use crate::chat_app::{ChatApp, InputMode}; use owlen_core::types::Role; pub fn render_chat(frame: &mut Frame<'_>, app: &ChatApp) { + // Calculate dynamic input height based on textarea content + let available_width = frame.area().width; + let input_height = if matches!(app.mode(), InputMode::Editing) { + let visual_lines = calculate_wrapped_line_count( + app.textarea().lines().iter().map(|s| s.as_str()), + available_width, + ); + (visual_lines as u16).min(10) + 2 // +2 for borders + } else { + let buffer_text = app.input_buffer().text(); + let lines: Vec<&str> = if buffer_text.is_empty() { + vec![""] + } else { + buffer_text.lines().collect() + }; + let visual_lines = calculate_wrapped_line_count(lines.into_iter(), available_width); + (visual_lines as u16).min(10) + 2 // +2 for borders + }; + let layout = Layout::default() .direction(Direction::Vertical) .constraints([ - Constraint::Length(4), // Header - Constraint::Min(8), // Messages - Constraint::Length(3), // Input - Constraint::Length(3), // Status + Constraint::Length(4), // Header + Constraint::Min(8), // Messages + Constraint::Length(input_height), // Input + Constraint::Length(3), // Status ]) .split(frame.area()); @@ -31,6 +53,271 @@ pub fn render_chat(frame: &mut Frame<'_>, app: &ChatApp) { } } +fn render_editable_textarea( + frame: &mut Frame<'_>, + area: Rect, + textarea: &mut TextArea<'static>, + wrap_lines: bool, +) { + let block = textarea.block().cloned(); + let inner = block.as_ref().map(|b| b.inner(area)).unwrap_or(area); + let base_style = textarea.style(); + let cursor_line_style = textarea.cursor_line_style(); + let selection_style = textarea.selection_style(); + let selection_range = textarea.selection_range(); + let cursor = textarea.cursor(); + let mask_char = textarea.mask_char(); + let is_empty = textarea.is_empty(); + let placeholder_text = textarea.placeholder_text().to_string(); + let placeholder_style = textarea.placeholder_style(); + let lines_slice = textarea.lines(); + + let mut render_lines: Vec = Vec::new(); + + if is_empty { + if !placeholder_text.is_empty() { + let style = placeholder_style.unwrap_or_else(|| Style::default().fg(Color::DarkGray)); + render_lines.push(Line::from(vec![Span::styled(placeholder_text, style)])); + } else { + render_lines.push(Line::default()); + } + } else { + for (row_idx, raw_line) in lines_slice.iter().enumerate() { + let display_line = mask_char + .map(|mask| mask_line(raw_line, mask)) + .unwrap_or_else(|| raw_line.clone()); + + let spans = build_line_spans(&display_line, row_idx, selection_range, selection_style); + + let mut line = Line::from(spans); + if row_idx == cursor.0 { + line = line.patch_style(cursor_line_style); + } + render_lines.push(line); + } + } + + if render_lines.is_empty() { + render_lines.push(Line::default()); + } + + let mut paragraph = Paragraph::new(render_lines).style(base_style); + + if wrap_lines { + paragraph = paragraph.wrap(Wrap { trim: false }); + } + + let metrics = compute_cursor_metrics(lines_slice, cursor, mask_char, inner, wrap_lines); + + if let Some(ref metrics) = metrics { + if metrics.scroll_top > 0 { + paragraph = paragraph.scroll((metrics.scroll_top, 0)); + } + } + + if let Some(block) = block { + paragraph = paragraph.block(block); + } + + frame.render_widget(paragraph, area); + + if let Some(metrics) = metrics { + frame.set_cursor_position((metrics.cursor_x, metrics.cursor_y)); + } +} + +fn mask_line(line: &str, mask: char) -> String { + line.chars().map(|_| mask).collect() +} + +fn build_line_spans( + display_line: &str, + row_idx: usize, + selection: Option<((usize, usize), (usize, usize))>, + selection_style: Style, +) -> Vec> { + if let Some(((start_row, start_col), (end_row, end_col))) = selection { + if row_idx < start_row || row_idx > end_row { + return vec![Span::raw(display_line.to_string())]; + } + + let char_count = display_line.chars().count(); + let start = if row_idx == start_row { + start_col.min(char_count) + } else { + 0 + }; + let end = if row_idx == end_row { + end_col.min(char_count) + } else { + char_count + }; + + if start >= end { + return vec![Span::raw(display_line.to_string())]; + } + + let start_byte = char_to_byte_idx(display_line, start); + let end_byte = char_to_byte_idx(display_line, end); + + let mut spans = Vec::new(); + if start_byte > 0 { + spans.push(Span::raw(display_line[..start_byte].to_string())); + } + spans.push(Span::styled( + display_line[start_byte..end_byte].to_string(), + selection_style, + )); + if end_byte < display_line.len() { + spans.push(Span::raw(display_line[end_byte..].to_string())); + } + if spans.is_empty() { + spans.push(Span::raw(String::new())); + } + spans + } else { + vec![Span::raw(display_line.to_string())] + } +} + +fn char_to_byte_idx(s: &str, char_idx: usize) -> usize { + if char_idx == 0 { + return 0; + } + + let mut iter = s.char_indices(); + for (i, (byte_idx, _)) in iter.by_ref().enumerate() { + if i == char_idx { + return byte_idx; + } + } + s.len() +} + +struct CursorMetrics { + cursor_x: u16, + cursor_y: u16, + scroll_top: u16, +} + +fn compute_cursor_metrics( + lines: &[String], + cursor: (usize, usize), + mask_char: Option, + inner: Rect, + wrap_lines: bool, +) -> Option { + if inner.width == 0 || inner.height == 0 { + return None; + } + + let content_width = inner.width as usize; + let visible_height = inner.height as usize; + if content_width == 0 || visible_height == 0 { + return None; + } + + let cursor_row = cursor.0.min(lines.len().saturating_sub(1)); + let cursor_col = cursor.1; + + let mut total_visual_rows = 0usize; + let mut cursor_visual_row = 0usize; + let mut cursor_col_width = 0usize; + let mut cursor_found = false; + + for (row_idx, line) in lines.iter().enumerate() { + let display_owned = mask_char.map(|mask| mask_line(line, mask)); + let display_line = display_owned.as_deref().unwrap_or_else(|| line.as_str()); + + let mut segments = if wrap_lines { + wrap_line_segments(display_line, content_width) + } else { + vec![display_line.to_string()] + }; + + if segments.is_empty() { + segments.push(String::new()); + } + + if row_idx == cursor_row && !cursor_found { + let mut remaining = cursor_col; + for (segment_idx, segment) in segments.iter().enumerate() { + let segment_len = segment.chars().count(); + let is_last_segment = segment_idx + 1 == segments.len(); + + if remaining > segment_len { + remaining -= segment_len; + continue; + } + + if remaining == segment_len && !is_last_segment { + cursor_visual_row = total_visual_rows + segment_idx + 1; + cursor_col_width = 0; + cursor_found = true; + break; + } + + let prefix: String = segment.chars().take(remaining).collect(); + cursor_visual_row = total_visual_rows + segment_idx; + cursor_col_width = UnicodeWidthStr::width(prefix.as_str()); + cursor_found = true; + break; + } + + if !cursor_found { + if let Some(last_segment) = segments.last() { + cursor_visual_row = total_visual_rows + segments.len().saturating_sub(1); + cursor_col_width = UnicodeWidthStr::width(last_segment.as_str()); + cursor_found = true; + } + } + } + + total_visual_rows += segments.len(); + } + + if !cursor_found { + cursor_visual_row = total_visual_rows.saturating_sub(1); + cursor_col_width = 0; + } + + let mut scroll_top = 0usize; + if cursor_visual_row + 1 > visible_height { + scroll_top = cursor_visual_row + 1 - visible_height; + } + + let max_scroll = total_visual_rows.saturating_sub(visible_height); + if scroll_top > max_scroll { + scroll_top = max_scroll; + } + + let cursor_visible_row = cursor_visual_row.saturating_sub(scroll_top); + let cursor_y = inner.y + cursor_visible_row.min(visible_height.saturating_sub(1)) as u16; + let cursor_x = inner.x + cursor_col_width.min(content_width.saturating_sub(1)) as u16; + + Some(CursorMetrics { + cursor_x, + cursor_y, + scroll_top: scroll_top as u16, + }) +} + +fn wrap_line_segments(line: &str, width: usize) -> Vec { + if width == 0 { + return vec![String::new()]; + } + + let wrapped = wrap(line, Options::new(width).break_words(false)); + if wrapped.is_empty() { + vec![String::new()] + } else { + wrapped + .into_iter() + .map(|segment| segment.into_owned()) + .collect() + } +} + fn render_header(frame: &mut Frame<'_>, area: Rect, app: &ChatApp) { let title_span = Span::styled( " 🦉 OWLEN - AI Assistant ", @@ -116,7 +403,7 @@ fn render_messages(frame: &mut Frame<'_>, area: Rect, app: &ChatApp) { .borders(Borders::ALL) .border_style(Style::default().fg(Color::Rgb(95, 20, 135))), ) - .wrap(ratatui::widgets::Wrap { trim: false }); + .wrap(Wrap { trim: false }); let scroll = app.scroll().min(u16::MAX as usize) as u16; paragraph = paragraph.scroll((scroll, 0)); @@ -140,22 +427,58 @@ fn render_input(frame: &mut Frame<'_>, area: Rect, app: &ChatApp) { .borders(Borders::ALL) .border_style(Style::default().fg(Color::Rgb(95, 20, 135))); - let input_text = app.input_buffer().text().to_string(); - let paragraph = Paragraph::new(input_text.clone()) - .block(input_block) - .wrap(ratatui::widgets::Wrap { trim: false }); - - frame.render_widget(paragraph, area); - if matches!(app.mode(), InputMode::Editing) { - let cursor_index = app.input_buffer().cursor(); - let (cursor_line, cursor_col) = cursor_position(&input_text, cursor_index); - let x = area.x + 1 + cursor_col as u16; - let y = area.y + 1 + cursor_line as u16; - frame.set_cursor_position(( - x.min(area.right().saturating_sub(1)), - y.min(area.bottom().saturating_sub(1)), - )); + let mut textarea = app.textarea().clone(); + textarea.set_block(input_block.clone()); + render_editable_textarea(frame, area, &mut textarea, true); + } else { + // In non-editing mode, show the current input buffer content as read-only + let input_text = app.input_buffer().text(); + let lines: Vec = if input_text.is_empty() { + vec![Line::from("Press 'i' to start typing")] + } else { + input_text.lines().map(|line| Line::from(line)).collect() + }; + + let paragraph = Paragraph::new(lines) + .block(input_block) + .wrap(Wrap { trim: false }); + + frame.render_widget(paragraph, area); + } +} + +fn calculate_wrapped_line_count<'a, I>(lines: I, available_width: u16) -> usize +where + I: IntoIterator, +{ + let content_width = available_width.saturating_sub(2); // subtract block borders + if content_width == 0 { + let mut count = 0; + for _ in lines.into_iter() { + count += 1; + } + return count.max(1); + } + + let options = Options::new(content_width as usize).break_words(false); + + let mut total = 0usize; + let mut seen = false; + for line in lines.into_iter() { + seen = true; + if line.is_empty() { + total += 1; + continue; + } + let wrapped = wrap(line, &options); + total += wrapped.len().max(1); + } + + if !seen { + 1 + } else { + total.max(1) } } @@ -372,20 +695,3 @@ fn role_color(role: &Role) -> Style { Role::System => Style::default().fg(Color::Cyan), } } - -fn cursor_position(text: &str, cursor: usize) -> (usize, usize) { - let mut line = 0; - let mut col = 0; - for (idx, ch) in text.char_indices() { - if idx >= cursor { - break; - } - if ch == '\n' { - line += 1; - col = 0; - } else { - col += 1; - } - } - (line, col) -}