use ratatui::{
layout::Rect,
widgets::{Block, Borders, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState},
style::Style,
text::{Line, Span},
};
use anyhow::Result;
use crossterm::event::{KeyCode, KeyEvent};
use tui_textarea::TextArea;
use crate::{
config::ChatConfig,
renderer::Renderer,
theme::Theme,
};
pub struct ChatComponent {
theme: Box<dyn Theme + Send + Sync>,
config: ChatConfig,
messages: Vec<ChatMessage>,
input_area: TextArea<'static>,
scroll_offset: usize,
auto_scroll: bool,
}
#[derive(Debug, Clone)]
pub struct ChatMessage {
pub id: String,
pub role: MessageRole,
pub content: String,
pub timestamp: std::time::SystemTime,
pub attachments: Vec<MessageAttachment>,
}
#[derive(Debug, Clone, PartialEq)]
pub enum MessageRole {
User,
Assistant,
System,
}
#[derive(Debug, Clone)]
pub struct MessageAttachment {
pub name: String,
pub path: String,
pub mime_type: String,
pub size: u64,
}
impl ChatComponent {
pub fn new(config: &ChatConfig, theme: &dyn Theme) -> Self {
let mut input_area = TextArea::default();
input_area.set_placeholder_text("Type your message...");
Self {
theme: Box::new(crate::theme::DefaultTheme), config: config.clone(),
messages: Vec::new(),
input_area,
scroll_offset: 0,
auto_scroll: true,
}
}
pub fn add_message(&mut self, message: ChatMessage) {
self.messages.push(message);
if self.messages.len() > self.config.max_messages {
self.messages.remove(0);
}
if self.config.auto_scroll && self.auto_scroll {
self.scroll_to_bottom();
}
}
pub async fn send_message(&mut self) -> Result<()> {
let content = self.input_area.lines().join("\n");
if content.trim().is_empty() {
return Ok(());
}
let message = ChatMessage {
id: uuid::Uuid::new_v4().to_string(),
role: MessageRole::User,
content: content.clone(),
timestamp: std::time::SystemTime::now(),
attachments: Vec::new(),
};
self.add_message(message);
self.clear_input();
Ok(())
}
pub fn clear_input(&mut self) {
self.input_area = TextArea::default();
self.input_area.set_placeholder_text("Type your message...");
}
pub fn insert_newline(&mut self) {
self.input_area.insert_newline();
}
pub async fn handle_paste(&mut self, data: String) -> Result<()> {
self.input_area.insert_str(&data);
Ok(())
}
pub async fn handle_key_event(&mut self, key: KeyEvent) -> Result<()> {
match key.code {
KeyCode::Enter if key.modifiers.is_empty() => {
self.send_message().await?;
}
KeyCode::Enter if key.modifiers.contains(crossterm::event::KeyModifiers::SHIFT) => {
self.insert_newline();
}
_ => {
self.input_area.input(key);
}
}
Ok(())
}
pub fn scroll_up(&mut self) {
if self.scroll_offset > 0 {
self.scroll_offset -= 1;
self.auto_scroll = false;
}
}
pub fn scroll_down(&mut self) {
let max_offset = self.messages.len().saturating_sub(1);
if self.scroll_offset < max_offset {
self.scroll_offset += 1;
} else {
self.auto_scroll = true;
}
}
pub fn page_up(&mut self) {
let page_size = 10; self.scroll_offset = self.scroll_offset.saturating_sub(page_size);
self.auto_scroll = false;
}
pub fn page_down(&mut self) {
let page_size = 10;
let max_offset = self.messages.len().saturating_sub(1);
self.scroll_offset = (self.scroll_offset + page_size).min(max_offset);
if self.scroll_offset >= max_offset {
self.auto_scroll = true;
}
}
pub fn scroll_to_bottom(&mut self) {
self.scroll_offset = self.messages.len().saturating_sub(1);
self.auto_scroll = true;
}
pub async fn clear(&mut self) -> Result<()> {
self.messages.clear();
self.scroll_offset = 0;
self.auto_scroll = true;
Ok(())
}
pub async fn update(&mut self) -> Result<()> {
Ok(())
}
pub fn update_theme(&mut self, theme: &dyn Theme) {
}
pub fn render(&mut self, renderer: &Renderer, area: Rect) {
let block = Block::default()
.title("Chat")
.borders(Borders::ALL)
.border_style(Style::default().fg(self.theme.border()));
renderer.render_widget(block.clone(), area);
let inner_area = block.inner(area);
self.render_messages(renderer, inner_area);
}
pub fn render_input(&mut self, renderer: &Renderer, area: Rect) {
let block = Block::default()
.title("Message")
.borders(Borders::ALL)
.border_style(Style::default().fg(self.theme.border_active()));
renderer.render_widget(block.clone(), area);
let inner_area = block.inner(area);
let mut input_style = self.input_area.style();
input_style = input_style
.fg(self.theme.text())
.bg(self.theme.background_element());
self.input_area.set_style(input_style);
let widget = &self.input_area;
renderer.render_widget(widget, inner_area);
}
fn render_messages(&self, renderer: &Renderer, area: Rect) {
if self.messages.is_empty() {
let empty_msg = Paragraph::new("No messages yet. Start a conversation!")
.style(Style::default().fg(self.theme.text_muted()));
renderer.render_widget(empty_msg, area);
return;
}
let visible_height = area.height as usize;
let start_index = self.scroll_offset;
let end_index = (start_index + visible_height).min(self.messages.len());
let visible_messages = &self.messages[start_index..end_index];
let mut lines = Vec::new();
for message in visible_messages {
lines.extend(self.format_message(message));
lines.push(Line::raw("")); }
let paragraph = Paragraph::new(lines)
.style(Style::default().bg(self.theme.background()));
renderer.render_widget(paragraph, area);
if self.messages.len() > visible_height {
let scrollbar = Scrollbar::default()
.orientation(ScrollbarOrientation::VerticalRight)
.begin_symbol(Some("↑"))
.end_symbol(Some("↓"));
let mut scrollbar_state = ScrollbarState::default()
.content_length(self.messages.len())
.position(self.scroll_offset);
}
}
fn format_message<'a>(&self, message: &'a ChatMessage) -> Vec<Line<'a>> {
let mut lines = Vec::new();
let timestamp = message.timestamp
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let time_str = format_timestamp(timestamp);
let (role_text, role_style) = match message.role {
MessageRole::User => ("You", Style::default().fg(self.theme.primary())),
MessageRole::Assistant => ("Assistant", Style::default().fg(self.theme.secondary())),
MessageRole::System => ("System", Style::default().fg(self.theme.accent())),
};
let header = Line::from(vec![
Span::styled(role_text, role_style),
Span::raw(" • "),
Span::styled(time_str, Style::default().fg(self.theme.text_muted())),
]);
lines.push(header);
let content_lines: Vec<&str> = message.content.lines().collect();
for line in content_lines {
lines.push(Line::from(Span::styled(
line,
Style::default().fg(self.theme.text()),
)));
}
for attachment in &message.attachments {
lines.push(Line::from(vec![
Span::raw("📎 "),
Span::styled(
&attachment.name,
Style::default().fg(self.theme.accent()),
),
Span::styled(
format!(" ({})", format_file_size(attachment.size)),
Style::default().fg(self.theme.text_muted()),
),
]));
}
lines
}
}
fn format_timestamp(timestamp: u64) -> String {
match chrono::NaiveDateTime::from_timestamp_opt(timestamp as i64, 0) {
Some(dt) => dt.format("%H:%M:%S").to_string(),
None => "??:??:??".to_string(),
}
}
fn format_file_size(size: u64) -> String {
const UNITS: &[&str] = &["B", "KB", "MB", "GB"];
let mut size = size as f64;
let mut unit_index = 0;
while size >= 1024.0 && unit_index < UNITS.len() - 1 {
size /= 1024.0;
unit_index += 1;
}
if unit_index == 0 {
format!("{} {}", size as u64, UNITS[unit_index])
} else {
format!("{:.1} {}", size, UNITS[unit_index])
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::ChatConfig;
#[test]
fn test_format_file_size() {
assert_eq!(format_file_size(500), "500 B");
assert_eq!(format_file_size(1536), "1.5 KB");
assert_eq!(format_file_size(1048576), "1.0 MB");
}
#[test]
fn test_message_management() {
let config = ChatConfig::default();
let theme = crate::theme::DefaultTheme;
let mut chat = ChatComponent::new(&config, &theme);
let message = ChatMessage {
id: "test".to_string(),
role: MessageRole::User,
content: "Hello".to_string(),
timestamp: std::time::SystemTime::now(),
attachments: Vec::new(),
};
chat.add_message(message);
assert_eq!(chat.messages.len(), 1);
}
}