Refactor codebase: improve formatting consistency, simplify message rendering, and optimize cursor and visual selection handling logic across panels.

This commit is contained in:
2025-09-30 02:51:00 +02:00
parent 9d4633865f
commit 8ee4c5f384
6 changed files with 306 additions and 245 deletions

View File

@@ -8,8 +8,8 @@ use tui_textarea::TextArea;
use unicode_width::UnicodeWidthStr;
use crate::chat_app::ChatApp;
use owlen_core::ui::{FocusedPanel, InputMode};
use owlen_core::types::Role;
use owlen_core::ui::{FocusedPanel, InputMode};
pub fn render_chat(frame: &mut Frame<'_>, app: &mut ChatApp) {
// Update thinking content from last message
@@ -37,18 +37,15 @@ pub fn render_chat(frame: &mut Frame<'_>, app: &mut ChatApp) {
// Calculate thinking section height
let thinking_height = if let Some(thinking) = app.current_thinking() {
let content_width = available_width.saturating_sub(4);
let visual_lines = calculate_wrapped_line_count(
thinking.lines(),
content_width,
);
let visual_lines = calculate_wrapped_line_count(thinking.lines(), content_width);
(visual_lines as u16).min(6) + 2 // +2 for borders, max 6 lines
} else {
0
};
let mut constraints = vec![
Constraint::Length(4), // Header
Constraint::Min(8), // Messages
Constraint::Length(4), // Header
Constraint::Min(8), // Messages
];
if thinking_height > 0 {
@@ -56,7 +53,7 @@ pub fn render_chat(frame: &mut Frame<'_>, app: &mut ChatApp) {
}
constraints.push(Constraint::Length(input_height)); // Input
constraints.push(Constraint::Length(3)); // Status
constraints.push(Constraint::Length(3)); // Status
let layout = Layout::default()
.direction(Direction::Vertical)
@@ -437,89 +434,100 @@ fn render_header(frame: &mut Frame<'_>, area: Rect, app: &ChatApp) {
frame.render_widget(paragraph, inner_area);
}
fn apply_visual_selection(lines: Vec<Line>, selection: Option<((usize, usize), (usize, usize))>) -> Vec<Line> {
fn apply_visual_selection(
lines: Vec<Line>,
selection: Option<((usize, usize), (usize, usize))>,
) -> Vec<Line> {
if let Some(((start_row, start_col), (end_row, end_col))) = selection {
// Normalize selection (ensure start is before end)
let ((start_r, start_c), (end_r, end_c)) = if start_row < end_row || (start_row == end_row && start_col <= end_col) {
((start_row, start_col), (end_row, end_col))
} else {
((end_row, end_col), (start_row, start_col))
};
let ((start_r, start_c), (end_r, end_c)) =
if start_row < end_row || (start_row == end_row && start_col <= end_col) {
((start_row, start_col), (end_row, end_col))
} else {
((end_row, end_col), (start_row, start_col))
};
lines.into_iter().enumerate().map(|(idx, line)| {
if idx < start_r || idx > end_r {
// Line not in selection
return line;
}
// Convert line to plain text for character indexing
let line_text = line.to_string();
let char_count = line_text.chars().count();
if idx == start_r && idx == end_r {
// Selection within single line
let sel_start = start_c.min(char_count);
let sel_end = end_c.min(char_count);
if sel_start >= sel_end {
lines
.into_iter()
.enumerate()
.map(|(idx, line)| {
if idx < start_r || idx > end_r {
// Line not in selection
return line;
}
let start_byte = char_to_byte_index(&line_text, sel_start);
let end_byte = char_to_byte_index(&line_text, sel_end);
// Convert line to plain text for character indexing
let line_text = line.to_string();
let char_count = line_text.chars().count();
let mut spans = Vec::new();
if start_byte > 0 {
spans.push(Span::raw(line_text[..start_byte].to_string()));
}
spans.push(Span::styled(
line_text[start_byte..end_byte].to_string(),
Style::default().bg(Color::LightBlue).fg(Color::Black)
));
if end_byte < line_text.len() {
spans.push(Span::raw(line_text[end_byte..].to_string()));
}
Line::from(spans)
} else if idx == start_r {
// First line of multi-line selection
let sel_start = start_c.min(char_count);
let start_byte = char_to_byte_index(&line_text, sel_start);
if idx == start_r && idx == end_r {
// Selection within single line
let sel_start = start_c.min(char_count);
let sel_end = end_c.min(char_count);
let mut spans = Vec::new();
if start_byte > 0 {
spans.push(Span::raw(line_text[..start_byte].to_string()));
}
spans.push(Span::styled(
line_text[start_byte..].to_string(),
Style::default().bg(Color::LightBlue).fg(Color::Black)
));
Line::from(spans)
} else if idx == end_r {
// Last line of multi-line selection
let sel_end = end_c.min(char_count);
let end_byte = char_to_byte_index(&line_text, sel_end);
if sel_start >= sel_end {
return line;
}
let mut spans = Vec::new();
spans.push(Span::styled(
line_text[..end_byte].to_string(),
Style::default().bg(Color::LightBlue).fg(Color::Black)
));
if end_byte < line_text.len() {
spans.push(Span::raw(line_text[end_byte..].to_string()));
let start_byte = char_to_byte_index(&line_text, sel_start);
let end_byte = char_to_byte_index(&line_text, sel_end);
let mut spans = Vec::new();
if start_byte > 0 {
spans.push(Span::raw(line_text[..start_byte].to_string()));
}
spans.push(Span::styled(
line_text[start_byte..end_byte].to_string(),
Style::default().bg(Color::LightBlue).fg(Color::Black),
));
if end_byte < line_text.len() {
spans.push(Span::raw(line_text[end_byte..].to_string()));
}
Line::from(spans)
} else if idx == start_r {
// First line of multi-line selection
let sel_start = start_c.min(char_count);
let start_byte = char_to_byte_index(&line_text, sel_start);
let mut spans = Vec::new();
if start_byte > 0 {
spans.push(Span::raw(line_text[..start_byte].to_string()));
}
spans.push(Span::styled(
line_text[start_byte..].to_string(),
Style::default().bg(Color::LightBlue).fg(Color::Black),
));
Line::from(spans)
} else if idx == end_r {
// Last line of multi-line selection
let sel_end = end_c.min(char_count);
let end_byte = char_to_byte_index(&line_text, sel_end);
let mut spans = Vec::new();
spans.push(Span::styled(
line_text[..end_byte].to_string(),
Style::default().bg(Color::LightBlue).fg(Color::Black),
));
if end_byte < line_text.len() {
spans.push(Span::raw(line_text[end_byte..].to_string()));
}
Line::from(spans)
} else {
// Middle line - fully selected
let styled_spans: Vec<Span> = line
.spans
.into_iter()
.map(|span| {
Span::styled(
span.content,
span.style.bg(Color::LightBlue).fg(Color::Black),
)
})
.collect();
Line::from(styled_spans)
}
Line::from(spans)
} else {
// Middle line - fully selected
let styled_spans: Vec<Span> = line.spans.into_iter().map(|span| {
Span::styled(
span.content,
span.style.bg(Color::LightBlue).fg(Color::Black)
)
}).collect();
Line::from(styled_spans)
}
}).collect()
})
.collect()
} else {
lines
}
@@ -569,7 +577,11 @@ fn render_messages(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) {
message.content.clone()
};
let formatted: Vec<String> = content_to_display.trim().lines().map(|s| s.to_string()).collect();
let formatted: Vec<String> = content_to_display
.trim()
.lines()
.map(|s| s.to_string())
.collect();
let is_streaming = message
.metadata
.get("streaming")
@@ -586,10 +598,11 @@ fn render_messages(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) {
];
// Add loading indicator if applicable
if matches!(role, Role::Assistant) &&
app.get_loading_indicator() != "" &&
message_index == conversation.messages.len() - 1 &&
is_streaming {
if matches!(role, Role::Assistant)
&& app.get_loading_indicator() != ""
&& message_index == conversation.messages.len() - 1
&& is_streaming
{
role_line_spans.push(Span::styled(
format!(" {}", app.get_loading_indicator()),
Style::default().fg(Color::Yellow),
@@ -640,7 +653,9 @@ fn render_messages(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) {
// Add loading indicator ONLY if we're loading and there are no messages at all,
// or if the last message is from the user (no Assistant response started yet)
let last_message_is_user = conversation.messages.last()
let last_message_is_user = conversation
.messages
.last()
.map(|msg| matches!(msg.role, Role::User))
.unwrap_or(true);
@@ -649,7 +664,9 @@ fn render_messages(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) {
Span::raw("🤖 "),
Span::styled(
"Assistant:",
Style::default().fg(Color::LightMagenta).add_modifier(Modifier::BOLD),
Style::default()
.fg(Color::LightMagenta)
.add_modifier(Modifier::BOLD),
),
Span::styled(
format!(" {}", app.get_loading_indicator()),
@@ -664,7 +681,8 @@ fn render_messages(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) {
}
// Apply visual selection highlighting if in visual mode and Chat panel is focused
if matches!(app.mode(), InputMode::Visual) && matches!(app.focused_panel(), FocusedPanel::Chat) {
if matches!(app.mode(), InputMode::Visual) && matches!(app.focused_panel(), FocusedPanel::Chat)
{
if let Some(selection) = app.visual_selection() {
lines = apply_visual_selection(lines, Some(selection));
}
@@ -695,13 +713,16 @@ fn render_messages(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) {
frame.render_widget(paragraph, area);
// Render cursor if Chat panel is focused and in Normal mode
if matches!(app.focused_panel(), FocusedPanel::Chat) && matches!(app.mode(), InputMode::Normal) {
if matches!(app.focused_panel(), FocusedPanel::Chat) && matches!(app.mode(), InputMode::Normal)
{
let cursor = app.chat_cursor();
let cursor_row = cursor.0;
let cursor_col = cursor.1;
// Calculate visible cursor position (accounting for scroll)
if cursor_row >= scroll_position as usize && cursor_row < (scroll_position as usize + viewport_height) {
if cursor_row >= scroll_position as usize
&& cursor_row < (scroll_position as usize + viewport_height)
{
let visible_row = cursor_row - scroll_position as usize;
let cursor_y = area.y + 1 + visible_row as u16; // +1 for border
@@ -742,7 +763,9 @@ fn render_thinking(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) {
.collect();
// Apply visual selection highlighting if in visual mode and Thinking panel is focused
if matches!(app.mode(), InputMode::Visual) && matches!(app.focused_panel(), FocusedPanel::Thinking) {
if matches!(app.mode(), InputMode::Visual)
&& matches!(app.focused_panel(), FocusedPanel::Thinking)
{
if let Some(selection) = app.visual_selection() {
lines = apply_visual_selection(lines, Some(selection));
}
@@ -780,13 +803,17 @@ fn render_thinking(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) {
frame.render_widget(paragraph, area);
// Render cursor if Thinking panel is focused and in Normal mode
if matches!(app.focused_panel(), FocusedPanel::Thinking) && matches!(app.mode(), InputMode::Normal) {
if matches!(app.focused_panel(), FocusedPanel::Thinking)
&& matches!(app.mode(), InputMode::Normal)
{
let cursor = app.thinking_cursor();
let cursor_row = cursor.0;
let cursor_col = cursor.1;
// Calculate visible cursor position (accounting for scroll)
if cursor_row >= scroll_position as usize && cursor_row < (scroll_position as usize + viewport_height) {
if cursor_row >= scroll_position as usize
&& cursor_row < (scroll_position as usize + viewport_height)
{
let visible_row = cursor_row - scroll_position as usize;
let cursor_y = area.y + 1 + visible_row as u16; // +1 for border