Add word wrapping and cursor mapping utilities to core library; integrate advanced text input support in TUI. Update dependencies accordingly.

This commit is contained in:
2025-09-28 01:47:50 +02:00
parent 6ddc66d864
commit ccf9349f99
11 changed files with 754 additions and 96 deletions

View File

@@ -19,6 +19,7 @@ futures-util = "0.3"
# TUI framework # TUI framework
ratatui = "0.28" ratatui = "0.28"
crossterm = "0.28" crossterm = "0.28"
tui-textarea = "0.6"
# HTTP client and JSON handling # HTTP client and JSON handling
reqwest = { version = "0.12", features = ["json", "stream"] } reqwest = { version = "0.12", features = ["json", "stream"] }
@@ -49,3 +50,5 @@ clap = { version = "4.0", features = ["derive"] }
# Dev dependencies # Dev dependencies
tempfile = "3.8" tempfile = "3.8"
tokio-test = "0.4" tokio-test = "0.4"
# For more keys and their definitions, see https://doc.rust-lang.org/cargo/reference/manifest.html

View File

@@ -5,20 +5,20 @@ edition = "2021"
description = "Core traits and types for OWLEN LLM client" description = "Core traits and types for OWLEN LLM client"
[dependencies] [dependencies]
serde = { workspace = true } anyhow = "1.0.75"
serde_json = { workspace = true } log = "0.4.20"
uuid = { workspace = true } serde = { version = "1.0.188", features = ["derive"] }
anyhow = { workspace = true } serde_json = "1.0.105"
thiserror = { workspace = true } thiserror = "1.0.48"
tokio = { workspace = true } tokio = { version = "1.32.0", features = ["full"] }
futures = { workspace = true } unicode-segmentation = "1.11"
tokio-stream = { workspace = true } unicode-width = "0.1"
async-trait = "0.1" uuid = { version = "1.4.1", features = ["v4", "serde"] }
textwrap = { workspace = true } textwrap = "0.16.0"
toml = { workspace = true } futures = "0.3.28"
shellexpand = { workspace = true } async-trait = "0.1.73"
regex = "1" toml = "0.8.0"
once_cell = "1.21.3" shellexpand = "3.1.0"
[dev-dependencies] [dev-dependencies]
tokio-test = { workspace = true } tokio-test = { workspace = true }

View File

@@ -73,8 +73,12 @@ impl MessageFormatter {
.collect(); .collect();
// 5) Belt & suspenders: remove leading/trailing blanks if any survived // 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.first().map_or(false, |s| s.trim().is_empty()) {
while lines.last().map_or(false, |s| s.trim().is_empty()) { lines.pop(); } lines.remove(0);
}
while lines.last().map_or(false, |s| s.trim().is_empty()) {
lines.pop();
}
lines lines
} }

View File

@@ -21,7 +21,7 @@ pub use model::*;
pub use provider::*; pub use provider::*;
pub use router::*; pub use router::*;
pub use session::*; pub use session::*;
pub use types::*; pub mod wrap_cursor;
/// Result type used throughout the OWLEN ecosystem /// Result type used throughout the OWLEN ecosystem
pub type Result<T> = std::result::Result<T, Error>; pub type Result<T> = std::result::Result<T, Error>;

View File

@@ -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<ScreenPos> {
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())]
}

View File

@@ -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<String> {
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
}

View File

@@ -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"
}

View File

@@ -10,6 +10,9 @@ owlen-core = { path = "../owlen-core" }
# TUI framework # TUI framework
ratatui = { workspace = true } ratatui = { workspace = true }
crossterm = { workspace = true } crossterm = { workspace = true }
tui-textarea = { workspace = true }
textwrap = { workspace = true }
unicode-width = "0.1"
# Async runtime # Async runtime
tokio = { workspace = true } tokio = { workspace = true }

View File

@@ -105,7 +105,7 @@ pub struct App {
pub model_selection_index: usize, pub model_selection_index: usize,
/// Ollama client for making API requests /// Ollama client for making API requests
ollama_client: OllamaClient, ollama_client: OllamaClient,
/// Currently active requests (for tracking streaming responses) /// Currently active requests ( for tracking streaming responses)
active_requests: HashMap<Uuid, usize>, // UUID -> message index active_requests: HashMap<Uuid, usize>, // UUID -> message index
/// Status message to show at the bottom /// Status message to show at the bottom
pub status_message: String, pub status_message: String,

View File

@@ -3,7 +3,9 @@ use owlen_core::{
session::{SessionController, SessionOutcome}, session::{SessionController, SessionOutcome},
types::{ChatParameters, ChatResponse, Conversation, ModelInfo}, types::{ChatParameters, ChatResponse, Conversation, ModelInfo},
}; };
use ratatui::style::{Modifier, Style};
use tokio::sync::mpsc; use tokio::sync::mpsc;
use tui_textarea::{Input, TextArea};
use uuid::Uuid; use uuid::Uuid;
use crate::config; use crate::config;
@@ -64,11 +66,15 @@ pub struct ChatApp {
scroll: usize, scroll: usize,
session_tx: mpsc::UnboundedSender<SessionEvent>, session_tx: mpsc::UnboundedSender<SessionEvent>,
streaming: HashSet<Uuid>, streaming: HashSet<Uuid>,
textarea: TextArea<'static>, // Advanced text input widget
} }
impl ChatApp { impl ChatApp {
pub fn new(controller: SessionController) -> (Self, mpsc::UnboundedReceiver<SessionEvent>) { pub fn new(controller: SessionController) -> (Self, mpsc::UnboundedReceiver<SessionEvent>) {
let (session_tx, session_rx) = mpsc::unbounded_channel(); let (session_tx, session_rx) = mpsc::unbounded_channel();
let mut textarea = TextArea::default();
configure_textarea_defaults(&mut textarea);
let app = Self { let app = Self {
controller, controller,
mode: InputMode::Normal, mode: InputMode::Normal,
@@ -82,6 +88,7 @@ impl ChatApp {
scroll: 0, scroll: 0,
session_tx, session_tx,
streaming: std::collections::HashSet::new(), streaming: std::collections::HashSet::new(),
textarea,
}; };
(app, session_rx) (app, session_rx)
@@ -146,6 +153,28 @@ impl ChatApp {
self.controller.input_buffer_mut() 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<String> = 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<()> { pub async fn initialize_models(&mut self) -> Result<()> {
let config_model_name = self.controller.config().general.default_model.clone(); let config_model_name = self.controller.config().general.default_model.clone();
let config_model_provider = self.controller.config().general.default_provider.clone(); let config_model_provider = self.controller.config().general.default_provider.clone();
@@ -227,6 +256,7 @@ impl ChatApp {
(KeyCode::Enter, KeyModifiers::NONE) (KeyCode::Enter, KeyModifiers::NONE)
| (KeyCode::Char('i'), KeyModifiers::NONE) => { | (KeyCode::Char('i'), KeyModifiers::NONE) => {
self.mode = InputMode::Editing; self.mode = InputMode::Editing;
self.sync_buffer_to_textarea();
} }
(KeyCode::Up, KeyModifiers::NONE) => { (KeyCode::Up, KeyModifiers::NONE) => {
self.scroll = self.scroll.saturating_add(1); self.scroll = self.scroll.saturating_add(1);
@@ -241,56 +271,41 @@ impl ChatApp {
}, },
InputMode::Editing => match key.code { InputMode::Editing => match key.code {
KeyCode::Esc if key.modifiers.is_empty() => { 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.mode = InputMode::Normal;
self.reset_status(); self.reset_status();
} }
KeyCode::Enter if key.modifiers.contains(KeyModifiers::SHIFT) => { KeyCode::Char('j' | 'J') if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.input_buffer_mut().insert_char('\n'); self.textarea.insert_newline();
} }
KeyCode::Enter if key.modifiers.is_empty() => { KeyCode::Enter if key.modifiers.is_empty() => {
// Send message and return to normal mode
self.sync_textarea_to_buffer();
self.try_send_message().await?; 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; self.mode = InputMode::Normal;
} }
KeyCode::Enter => { 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) => { KeyCode::Up if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.input_buffer_mut().insert_char('\n'); // Navigate through input history
}
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 => {
self.input_buffer_mut().history_previous(); 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.input_buffer_mut().history_next();
self.sync_buffer_to_textarea();
} }
KeyCode::Char(c) _ => {
if key.modifiers.is_empty() // Let tui-textarea handle all other input
|| key.modifiers.contains(KeyModifiers::SHIFT) => self.textarea.input(Input::from(key));
{
self.input_buffer_mut().insert_char(c);
} }
KeyCode::Tab => {
self.input_buffer_mut().insert_tab();
}
_ => {}
}, },
InputMode::ProviderSelection => match key.code { InputMode::ProviderSelection => match key.code {
KeyCode::Esc => { KeyCode::Esc => {
@@ -381,7 +396,11 @@ impl ChatApp {
message_id, message_id,
response, response,
} => { } => {
let at_bottom = self.scroll == 0;
self.controller.apply_stream_chunk(message_id, &response)?; self.controller.apply_stream_chunk(message_id, &response)?;
if at_bottom {
self.scroll_to_bottom();
}
if response.is_final { if response.is_final {
self.streaming.remove(&message_id); self.streaming.remove(&message_id);
self.status = "Response complete".to_string(); self.status = "Response complete".to_string();
@@ -458,6 +477,8 @@ impl ChatApp {
return Ok(()); return Ok(());
} }
self.scroll_to_bottom();
let message = self.controller.input_buffer_mut().commit_to_history(); let message = self.controller.input_buffer_mut().commit_to_history();
let mut parameters = ChatParameters::default(); let mut parameters = ChatParameters::default();
parameters.stream = self.controller.config().general.enable_streaming; 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) { fn spawn_stream(&mut self, message_id: Uuid, mut stream: owlen_core::provider::ChatStream) {
let sender = self.session_tx.clone(); let sender = self.session_tx.clone();
self.streaming.insert(message_id); 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());
}

View File

@@ -1,19 +1,41 @@
use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect}; use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Modifier, Style}; use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span}; 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 ratatui::Frame;
use textwrap::{wrap, Options};
use tui_textarea::TextArea;
use unicode_width::UnicodeWidthStr;
use crate::chat_app::{ChatApp, InputMode}; use crate::chat_app::{ChatApp, InputMode};
use owlen_core::types::Role; use owlen_core::types::Role;
pub fn render_chat(frame: &mut Frame<'_>, app: &ChatApp) { 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() let layout = Layout::default()
.direction(Direction::Vertical) .direction(Direction::Vertical)
.constraints([ .constraints([
Constraint::Length(4), // Header Constraint::Length(4), // Header
Constraint::Min(8), // Messages Constraint::Min(8), // Messages
Constraint::Length(3), // Input Constraint::Length(input_height), // Input
Constraint::Length(3), // Status Constraint::Length(3), // Status
]) ])
.split(frame.area()); .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<Line> = 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<Span<'static>> {
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<char>,
inner: Rect,
wrap_lines: bool,
) -> Option<CursorMetrics> {
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<String> {
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) { fn render_header(frame: &mut Frame<'_>, area: Rect, app: &ChatApp) {
let title_span = Span::styled( let title_span = Span::styled(
" 🦉 OWLEN - AI Assistant ", " 🦉 OWLEN - AI Assistant ",
@@ -116,7 +403,7 @@ fn render_messages(frame: &mut Frame<'_>, area: Rect, app: &ChatApp) {
.borders(Borders::ALL) .borders(Borders::ALL)
.border_style(Style::default().fg(Color::Rgb(95, 20, 135))), .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; let scroll = app.scroll().min(u16::MAX as usize) as u16;
paragraph = paragraph.scroll((scroll, 0)); paragraph = paragraph.scroll((scroll, 0));
@@ -140,22 +427,58 @@ fn render_input(frame: &mut Frame<'_>, area: Rect, app: &ChatApp) {
.borders(Borders::ALL) .borders(Borders::ALL)
.border_style(Style::default().fg(Color::Rgb(95, 20, 135))); .border_style(Style::default().fg(Color::Rgb(95, 20, 135)));
let input_text = app.input_buffer().text().to_string(); if matches!(app.mode(), InputMode::Editing) {
let paragraph = Paragraph::new(input_text.clone()) 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<Line> = 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) .block(input_block)
.wrap(ratatui::widgets::Wrap { trim: false }); .wrap(Wrap { trim: false });
frame.render_widget(paragraph, area); frame.render_widget(paragraph, area);
}
}
if matches!(app.mode(), InputMode::Editing) { fn calculate_wrapped_line_count<'a, I>(lines: I, available_width: u16) -> usize
let cursor_index = app.input_buffer().cursor(); where
let (cursor_line, cursor_col) = cursor_position(&input_text, cursor_index); I: IntoIterator<Item = &'a str>,
let x = area.x + 1 + cursor_col as u16; {
let y = area.y + 1 + cursor_line as u16; let content_width = available_width.saturating_sub(2); // subtract block borders
frame.set_cursor_position(( if content_width == 0 {
x.min(area.right().saturating_sub(1)), let mut count = 0;
y.min(area.bottom().saturating_sub(1)), 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), 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)
}