- Introduce `RoleLabelDisplay` enum (inline, above, none) and integrate it into UI rendering and message formatting. - Replace `show_role_labels` boolean with `role_label_mode` across config, formatter, session, and TUI components. - Add `syntax_highlighting` boolean to UI settings with default `false` and support in message rendering. - Update configuration schema version to 1.3.0 and provide deserialization handling for legacy boolean values. - Extend theme definitions with code block styling fields (background, border, text, keyword, string, comment) and default values in `Theme`. - Adjust related modules (`formatting.rs`, `ui.rs`, `session.rs`, `chat_app.rs`) to use the new settings and theme fields.
292 lines
8.0 KiB
Rust
292 lines
8.0 KiB
Rust
//! Shared UI components and state management for TUI applications
|
|
//!
|
|
//! This module contains reusable UI components that can be shared between
|
|
//! different TUI applications (chat, code, etc.)
|
|
|
|
/// Application state
|
|
pub use crate::state::AppState;
|
|
|
|
/// Input modes for TUI applications
|
|
pub use crate::state::InputMode;
|
|
|
|
/// Represents which panel is currently focused
|
|
pub use crate::state::FocusedPanel;
|
|
|
|
/// Auto-scroll state manager for scrollable panels
|
|
pub use crate::state::AutoScroll;
|
|
|
|
/// Visual selection state for text selection
|
|
pub use crate::state::VisualSelection;
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
/// How role labels should be rendered alongside chat messages.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "lowercase")]
|
|
pub enum RoleLabelDisplay {
|
|
Inline,
|
|
Above,
|
|
None,
|
|
}
|
|
|
|
/// Extract text from a selection range in a list of lines
|
|
pub fn extract_text_from_selection(
|
|
lines: &[String],
|
|
start: (usize, usize),
|
|
end: (usize, usize),
|
|
) -> Option<String> {
|
|
if lines.is_empty() || start.0 >= lines.len() {
|
|
return None;
|
|
}
|
|
|
|
let start_row = start.0;
|
|
let start_col = start.1;
|
|
let end_row = end.0.min(lines.len() - 1);
|
|
let end_col = end.1;
|
|
|
|
if start_row == end_row {
|
|
// Single line selection
|
|
let line = &lines[start_row];
|
|
let chars: Vec<char> = line.chars().collect();
|
|
let start_c = start_col.min(chars.len());
|
|
let end_c = end_col.min(chars.len());
|
|
|
|
if start_c >= end_c {
|
|
return None;
|
|
}
|
|
|
|
let selected: String = chars[start_c..end_c].iter().collect();
|
|
Some(selected)
|
|
} else {
|
|
// Multi-line selection
|
|
let mut result = Vec::new();
|
|
|
|
// First line: from start_col to end
|
|
let first_line = &lines[start_row];
|
|
let first_chars: Vec<char> = first_line.chars().collect();
|
|
let start_c = start_col.min(first_chars.len());
|
|
if start_c < first_chars.len() {
|
|
result.push(first_chars[start_c..].iter().collect::<String>());
|
|
}
|
|
|
|
// Middle lines: entire lines
|
|
for row in (start_row + 1)..end_row {
|
|
if row < lines.len() {
|
|
result.push(lines[row].clone());
|
|
}
|
|
}
|
|
|
|
// Last line: from start to end_col
|
|
if end_row < lines.len() && end_row > start_row {
|
|
let last_line = &lines[end_row];
|
|
let last_chars: Vec<char> = last_line.chars().collect();
|
|
let end_c = end_col.min(last_chars.len());
|
|
if end_c > 0 {
|
|
result.push(last_chars[..end_c].iter().collect::<String>());
|
|
}
|
|
}
|
|
|
|
if result.is_empty() {
|
|
None
|
|
} else {
|
|
Some(result.join("\n"))
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Cursor position for navigating scrollable content
|
|
pub use crate::state::CursorPosition;
|
|
|
|
/// Word boundary detection for navigation
|
|
pub fn find_next_word_boundary(line: &str, col: usize) -> Option<usize> {
|
|
let chars: Vec<char> = line.chars().collect();
|
|
|
|
if col >= chars.len() {
|
|
return Some(chars.len());
|
|
}
|
|
|
|
let mut pos = col;
|
|
let is_word_char = |c: char| c.is_alphanumeric() || c == '_';
|
|
|
|
// Skip current word
|
|
if is_word_char(chars[pos]) {
|
|
while pos < chars.len() && is_word_char(chars[pos]) {
|
|
pos += 1;
|
|
}
|
|
} else {
|
|
// Skip non-word characters
|
|
while pos < chars.len() && !is_word_char(chars[pos]) {
|
|
pos += 1;
|
|
}
|
|
}
|
|
|
|
Some(pos)
|
|
}
|
|
|
|
pub fn find_word_end(line: &str, col: usize) -> Option<usize> {
|
|
let chars: Vec<char> = line.chars().collect();
|
|
|
|
if col >= chars.len() {
|
|
return Some(chars.len());
|
|
}
|
|
|
|
let mut pos = col;
|
|
let is_word_char = |c: char| c.is_alphanumeric() || c == '_';
|
|
|
|
// If on a word character, move to end of current word
|
|
if is_word_char(chars[pos]) {
|
|
while pos < chars.len() && is_word_char(chars[pos]) {
|
|
pos += 1;
|
|
}
|
|
// Move back one to be ON the last character
|
|
pos = pos.saturating_sub(1);
|
|
} else {
|
|
// Skip non-word characters
|
|
while pos < chars.len() && !is_word_char(chars[pos]) {
|
|
pos += 1;
|
|
}
|
|
// Now on first char of next word, move to its end
|
|
while pos < chars.len() && is_word_char(chars[pos]) {
|
|
pos += 1;
|
|
}
|
|
pos = pos.saturating_sub(1);
|
|
}
|
|
|
|
Some(pos)
|
|
}
|
|
|
|
pub fn find_prev_word_boundary(line: &str, col: usize) -> Option<usize> {
|
|
let chars: Vec<char> = line.chars().collect();
|
|
|
|
if col == 0 || chars.is_empty() {
|
|
return Some(0);
|
|
}
|
|
|
|
let mut pos = col.min(chars.len());
|
|
let is_word_char = |c: char| c.is_alphanumeric() || c == '_';
|
|
|
|
// Move back one position first
|
|
pos = pos.saturating_sub(1);
|
|
|
|
// Skip non-word characters
|
|
while pos > 0 && !is_word_char(chars[pos]) {
|
|
pos -= 1;
|
|
}
|
|
|
|
// Skip word characters to find start of word
|
|
while pos > 0 && is_word_char(chars[pos - 1]) {
|
|
pos -= 1;
|
|
}
|
|
|
|
Some(pos)
|
|
}
|
|
|
|
use crate::theme::Theme;
|
|
use async_trait::async_trait;
|
|
use std::io::stdout;
|
|
|
|
pub fn show_mouse_cursor() {
|
|
let mut stdout = stdout();
|
|
crossterm::execute!(stdout, crossterm::cursor::Show).ok();
|
|
}
|
|
|
|
pub fn hide_mouse_cursor() {
|
|
let mut stdout = stdout();
|
|
crossterm::execute!(stdout, crossterm::cursor::Hide).ok();
|
|
}
|
|
|
|
pub fn apply_theme_to_string(s: &str, _theme: &Theme) -> String {
|
|
// This is a placeholder. In a real implementation, you'd parse the string
|
|
// and apply colors based on syntax or other rules.
|
|
s.to_string()
|
|
}
|
|
|
|
/// A trait for abstracting UI interactions like confirmations.
|
|
#[async_trait]
|
|
pub trait UiController: Send + Sync {
|
|
async fn confirm(&self, prompt: &str) -> bool;
|
|
}
|
|
|
|
/// A no-op UI controller for non-interactive contexts.
|
|
pub struct NoOpUiController;
|
|
|
|
#[async_trait]
|
|
impl UiController for NoOpUiController {
|
|
async fn confirm(&self, _prompt: &str) -> bool {
|
|
false // Always decline in non-interactive mode
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_auto_scroll() {
|
|
let mut scroll = AutoScroll {
|
|
content_len: 100,
|
|
..Default::default()
|
|
};
|
|
|
|
// Test on_viewport with stick_to_bottom
|
|
scroll.on_viewport(10);
|
|
assert_eq!(scroll.scroll, 90);
|
|
|
|
// Test user scroll up
|
|
scroll.on_user_scroll(-10, 10);
|
|
assert_eq!(scroll.scroll, 80);
|
|
assert!(!scroll.stick_to_bottom);
|
|
|
|
// Test jump to bottom
|
|
scroll.jump_to_bottom(10);
|
|
assert!(scroll.stick_to_bottom);
|
|
assert_eq!(scroll.scroll, 90);
|
|
}
|
|
|
|
#[test]
|
|
fn test_visual_selection() {
|
|
let mut selection = VisualSelection::new();
|
|
assert!(!selection.is_active());
|
|
|
|
selection.start_at((0, 0));
|
|
assert!(selection.is_active());
|
|
|
|
selection.extend_to((2, 5));
|
|
let normalized = selection.get_normalized();
|
|
assert_eq!(normalized, Some(((0, 0), (2, 5))));
|
|
|
|
selection.clear();
|
|
assert!(!selection.is_active());
|
|
}
|
|
|
|
#[test]
|
|
fn test_extract_text_single_line() {
|
|
let lines = vec!["Hello World".to_string()];
|
|
let result = extract_text_from_selection(&lines, (0, 0), (0, 5));
|
|
assert_eq!(result, Some("Hello".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn test_extract_text_multi_line() {
|
|
let lines = vec![
|
|
"First line".to_string(),
|
|
"Second line".to_string(),
|
|
"Third line".to_string(),
|
|
];
|
|
let result = extract_text_from_selection(&lines, (0, 6), (2, 5));
|
|
assert_eq!(result, Some("line\nSecond line\nThird".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn test_word_boundaries() {
|
|
let line = "hello world test";
|
|
assert_eq!(find_next_word_boundary(line, 0), Some(5));
|
|
assert_eq!(find_next_word_boundary(line, 5), Some(6));
|
|
assert_eq!(find_next_word_boundary(line, 6), Some(11));
|
|
|
|
assert_eq!(find_prev_word_boundary(line, 16), Some(12));
|
|
assert_eq!(find_prev_word_boundary(line, 11), Some(6));
|
|
assert_eq!(find_prev_word_boundary(line, 6), Some(0));
|
|
}
|
|
}
|