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:
@@ -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"] }
|
||||||
@@ -48,4 +49,6 @@ 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
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -49,11 +49,11 @@ impl MessageFormatter {
|
|||||||
// 2) Collapse: remove whitespace-only lines; keep exactly one '\n' between content lines
|
// 2) Collapse: remove whitespace-only lines; keep exactly one '\n' between content lines
|
||||||
let mut content = normalized
|
let mut content = normalized
|
||||||
.split('\n')
|
.split('\n')
|
||||||
.map(|l| l.trim_end()) // trim trailing spaces per line
|
.map(|l| l.trim_end()) // trim trailing spaces per line
|
||||||
.filter(|l| !l.trim().is_empty()) // drop blank/whitespace-only lines
|
.filter(|l| !l.trim().is_empty()) // drop blank/whitespace-only lines
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join("\n")
|
.join("\n")
|
||||||
.trim() // trim leading/trailing whitespace
|
.trim() // trim leading/trailing whitespace
|
||||||
.to_string();
|
.to_string();
|
||||||
|
|
||||||
if content.is_empty() && self.preserve_empty_lines {
|
if content.is_empty() && self.preserve_empty_lines {
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
92
crates/owlen-core/src/wrap_cursor.rs
Normal file
92
crates/owlen-core/src/wrap_cursor.rs
Normal 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())]
|
||||||
|
}
|
||||||
115
crates/owlen-core/tests/long_word_debug.rs
Normal file
115
crates/owlen-core/tests/long_word_debug.rs
Normal 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
|
||||||
|
}
|
||||||
96
crates/owlen-core/tests/wrap_cursor_tests.rs
Normal file
96
crates/owlen-core/tests/wrap_cursor_tests.rs
Normal 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"
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -907,4 +907,4 @@ impl App {
|
|||||||
pub fn is_session_input(&self) -> bool {
|
pub fn is_session_input(&self) -> bool {
|
||||||
self.input_mode == InputMode::SessionInput
|
self.input_mode == InputMode::SessionInput
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,20 +1,42 @@
|
|||||||
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();
|
|
||||||
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) {
|
if matches!(app.mode(), InputMode::Editing) {
|
||||||
let cursor_index = app.input_buffer().cursor();
|
let mut textarea = app.textarea().clone();
|
||||||
let (cursor_line, cursor_col) = cursor_position(&input_text, cursor_index);
|
textarea.set_block(input_block.clone());
|
||||||
let x = area.x + 1 + cursor_col as u16;
|
render_editable_textarea(frame, area, &mut textarea, true);
|
||||||
let y = area.y + 1 + cursor_line as u16;
|
} else {
|
||||||
frame.set_cursor_position((
|
// In non-editing mode, show the current input buffer content as read-only
|
||||||
x.min(area.right().saturating_sub(1)),
|
let input_text = app.input_buffer().text();
|
||||||
y.min(area.bottom().saturating_sub(1)),
|
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),
|
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)
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user