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

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

View File

@@ -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, usize>, // 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
}
}
}

View File

@@ -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<SessionEvent>,
streaming: HashSet<Uuid>,
textarea: TextArea<'static>, // Advanced text input widget
}
impl ChatApp {
pub fn new(controller: SessionController) -> (Self, mpsc::UnboundedReceiver<SessionEvent>) {
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<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<()> {
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());
}

View File

@@ -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<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) {
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<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)
.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<Item = &'a str>,
{
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)
}