use crate::tui::bridge::TuiBridge;
use crate::tui::FileAutocompleteState;
use limit_tui::components::{calculate_popup_area, ChatView, FileAutocompleteWidget};
use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph, Wrap},
Frame,
};
use std::sync::{Arc, Mutex};
pub struct UiRenderer;
impl UiRenderer {
#[allow(clippy::too_many_arguments)]
pub fn render(
frame: &mut Frame,
area: Rect,
chat_view: &Arc<Mutex<ChatView>>,
input_text: &str,
cursor_pos: usize,
status_message: &str,
status_is_error: bool,
cursor_blink_state: bool,
tui_bridge: &TuiBridge,
file_autocomplete: &Option<FileAutocompleteState>,
pending_input_preview: Option<&limit_tui::components::PendingInputPreview>,
) {
let activity_count = tui_bridge.activity_feed().lock().unwrap().len();
let activity_height = (activity_count as u16).min(3);
let constraints = if activity_height == 0 {
[
Constraint::Percentage(90),
Constraint::Length(1),
Constraint::Length(6),
Constraint::Length(0),
]
} else {
[
Constraint::Percentage(90),
Constraint::Length(activity_height),
Constraint::Length(1),
Constraint::Length(6),
]
};
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints(constraints)
.split(area);
let mut chunk_idx = 0;
Self::render_chat_view(frame, &chunks[chunk_idx], chat_view, tui_bridge);
chunk_idx += 1;
if activity_height > 0 {
Self::render_activity_feed(frame, &chunks[chunk_idx], tui_bridge);
chunk_idx += 1;
}
Self::render_status_bar(frame, &chunks[chunk_idx], status_message, status_is_error);
chunk_idx += 1;
Self::render_input_area(
frame,
&chunks[chunk_idx],
input_text,
cursor_pos,
cursor_blink_state,
);
if let Some(preview) = pending_input_preview {
if preview.has_messages() {
Self::render_pending_input_preview(frame, &chunks[chunk_idx], preview);
}
}
if let Some(ref ac) = file_autocomplete {
if ac.is_active && !ac.matches.is_empty() {
Self::render_autocomplete_popup(frame, &chunks[chunk_idx], ac);
}
}
}
fn render_chat_view(
frame: &mut Frame,
area: &Rect,
chat_view: &Arc<Mutex<ChatView>>,
tui_bridge: &TuiBridge,
) {
let chat = chat_view.lock().unwrap();
let total_input = tui_bridge.total_input_tokens();
let total_output = tui_bridge.total_output_tokens();
let title = format!(" Chat (↑{} ↓{}) ", total_input, total_output);
let chat_block = Block::default()
.borders(Borders::ALL)
.title(title)
.title_style(
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
);
frame.render_widget(&*chat, chat_block.inner(*area));
frame.render_widget(chat_block, *area);
}
fn render_activity_feed(frame: &mut Frame, area: &Rect, tui_bridge: &TuiBridge) {
let activity_feed = tui_bridge.activity_feed().lock().unwrap();
let activity_block = Block::default()
.borders(Borders::NONE)
.style(Style::default().bg(Color::Reset));
let activity_inner = activity_block.inner(*area);
frame.render_widget(activity_block, *area);
activity_feed.render(activity_inner, frame.buffer_mut());
}
fn render_status_bar(
frame: &mut Frame,
area: &Rect,
status_message: &str,
status_is_error: bool,
) {
let status_style = if status_is_error {
Style::default().fg(Color::Red).bg(Color::Reset)
} else {
Style::default().fg(Color::Yellow)
};
let status = Paragraph::new(Line::from(vec![
Span::styled(" ● ", Style::default().fg(Color::Green)),
Span::styled(status_message, status_style),
]));
frame.render_widget(status, *area);
}
fn render_input_area(
frame: &mut Frame,
area: &Rect,
input_text: &str,
cursor_pos: usize,
cursor_blink_state: bool,
) {
let input_block = Block::default()
.borders(Borders::ALL)
.title(" Input (Esc or /exit to quit) ")
.title_style(Style::default().fg(Color::Cyan));
let input_inner = input_block.inner(*area);
frame.render_widget(input_block, *area);
let input_line = if input_text.is_empty() {
Line::from(vec![Span::styled(
"Type your message here...",
Style::default().fg(Color::DarkGray),
)])
} else {
let (before_cursor, at_cursor, after_cursor) =
split_text_at_cursor(input_text, cursor_pos);
let cursor_style = if cursor_blink_state {
Style::default().bg(Color::White).fg(Color::Black)
} else {
Style::default().bg(Color::Reset).fg(Color::Reset)
};
Line::from(vec![
Span::raw(before_cursor),
Span::styled(at_cursor, cursor_style),
Span::raw(after_cursor),
])
};
frame.render_widget(
Paragraph::new(input_line).wrap(Wrap { trim: false }),
input_inner,
);
}
fn render_autocomplete_popup(
frame: &mut Frame,
input_area: &Rect,
autocomplete: &FileAutocompleteState,
) {
let popup_area = calculate_popup_area(*input_area, autocomplete.matches.len());
let widget = FileAutocompleteWidget::new(
&autocomplete.matches,
autocomplete.selected_index,
&autocomplete.query,
);
frame.render_widget(widget, popup_area);
}
fn render_pending_input_preview(
frame: &mut Frame,
input_area: &Rect,
preview: &limit_tui::components::PendingInputPreview,
) {
if !preview.has_messages() {
return;
}
let mut preview_height = 0u16;
if !preview.pending_steers.is_empty() {
preview_height += 1; for steer in &preview.pending_steers {
preview_height += steer.lines().take(3).count() as u16;
if steer.lines().count() > 3 {
preview_height += 1; }
}
}
if !preview.queued_messages.is_empty() {
preview_height += 1; for msg in &preview.queued_messages {
preview_height += msg.lines().take(3).count() as u16;
if msg.lines().count() > 3 {
preview_height += 1; }
}
preview_height += 1; }
let preview_height = preview_height
.min(8)
.min(input_area.height.saturating_sub(1));
if preview_height == 0 {
return;
}
let preview_area = Rect {
x: input_area.x,
y: input_area.y.saturating_sub(preview_height),
width: input_area.width,
height: preview_height,
};
frame.render_widget(preview.clone(), preview_area);
}
}
#[inline]
fn split_text_at_cursor(text: &str, cursor_pos: usize) -> (&str, &str, &str) {
if text.is_empty() {
return ("", " ", "");
}
let pos = cursor_pos.min(text.len());
let before_cursor = &text[..pos];
text[pos..]
.chars()
.next()
.map(|c| {
let end = c.len_utf8();
(before_cursor, &text[pos..pos + end], &text[pos + end..])
})
.unwrap_or((before_cursor, " ", ""))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_split_text_at_cursor() {
let (before, at, after) = split_text_at_cursor("", 0);
assert_eq!(before, "");
assert_eq!(at, " ");
assert_eq!(after, "");
let (before, at, after) = split_text_at_cursor("hello", 0);
assert_eq!(before, "");
assert_eq!(at, "h");
assert_eq!(after, "ello");
let (before, at, after) = split_text_at_cursor("hello", 2);
assert_eq!(before, "he");
assert_eq!(at, "l");
assert_eq!(after, "lo");
let (before, at, after) = split_text_at_cursor("hello", 5);
assert_eq!(before, "hello");
assert_eq!(at, " ");
assert_eq!(after, "");
let text = "héllo";
let pos = text.char_indices().nth(2).map(|(i, _)| i).unwrap();
let (before, at, after) = split_text_at_cursor(text, pos);
assert_eq!(before, "hé");
assert_eq!(at, "l");
assert_eq!(after, "lo");
}
}