use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph, Wrap},
Frame,
};
use crate::app::{AppState, AppStatus, ChatMessage};
use crate::config::Theme;
pub struct ColorPalette {
#[allow(dead_code)]
pub bg: Color,
pub fg: Color,
pub border: Color,
pub user_msg: Color,
pub agent_msg: Color,
pub system_msg: Color,
pub accent: Color,
pub action_bar_bg: Color,
pub action_bar_fg: Color,
}
impl ColorPalette {
pub fn from_theme(theme: &Theme) -> Self {
match theme {
Theme::Dark => Self {
bg: Color::Black,
fg: Color::White,
border: Color::DarkGray,
user_msg: Color::Cyan,
agent_msg: Color::Green,
system_msg: Color::Yellow,
accent: Color::Blue,
action_bar_bg: Color::DarkGray,
action_bar_fg: Color::White,
},
Theme::Light => Self {
bg: Color::White,
fg: Color::Black,
border: Color::Gray,
user_msg: Color::Blue,
agent_msg: Color::Green,
system_msg: Color::Yellow,
accent: Color::Magenta,
action_bar_bg: Color::Gray,
action_bar_fg: Color::Black,
},
}
}
}
pub fn render(frame: &mut Frame, state: &mut AppState, theme: &Theme) {
let palette = ColorPalette::from_theme(theme);
let main_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1),
Constraint::Min(0),
Constraint::Length(3),
])
.split(frame.area());
render_action_bar(frame, main_chunks[0], &palette);
let content_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(70), Constraint::Percentage(30)])
.split(main_chunks[1]);
render_chat(frame, state, content_chunks[0], &palette);
render_dashboard(frame, state, content_chunks[1], &palette);
render_input(frame, state, main_chunks[2], &palette);
}
fn render_action_bar(frame: &mut Frame, area: Rect, palette: &ColorPalette) {
let actions = Line::from(vec![
Span::styled(
" Ctrl+B ",
Style::default()
.fg(palette.action_bar_fg)
.bg(palette.accent),
),
Span::raw("Build "),
Span::styled(
" Ctrl+T ",
Style::default().fg(palette.action_bar_fg).bg(Color::Green),
),
Span::raw("Test "),
Span::styled(
" Ctrl+D ",
Style::default().fg(palette.action_bar_fg).bg(Color::Yellow),
),
Span::raw("Deploy "),
Span::styled(
" Ctrl+S ",
Style::default().fg(palette.action_bar_fg).bg(Color::Cyan),
),
Span::raw("Save "),
Span::styled(
" /help ",
Style::default()
.fg(palette.action_bar_fg)
.bg(Color::Magenta),
),
Span::raw("Commands"),
]);
let action_bar = Paragraph::new(actions).style(Style::default().bg(palette.action_bar_bg));
frame.render_widget(action_bar, area);
}
fn wrap_text(text: &str, width: usize) -> Vec<String> {
if width == 0 {
return vec![String::new()];
}
let mut lines = Vec::new();
let mut current = String::new();
let mut current_width = 0usize;
for word in text.split(' ') {
let word_width = word.chars().count();
if word_width > width {
if current_width > 0 {
lines.push(std::mem::take(&mut current));
current_width = 0;
}
let chars: Vec<char> = word.chars().collect();
for chunk in chars.chunks(width) {
lines.push(chunk.iter().collect());
}
if let Some(last) = lines.pop() {
current_width = last.chars().count();
current = last;
}
continue;
}
let needed = if current_width == 0 {
word_width
} else {
current_width + 1 + word_width
};
if needed > width {
lines.push(std::mem::take(&mut current));
current.push_str(word);
current_width = word_width;
} else {
if current_width > 0 {
current.push(' ');
}
current.push_str(word);
current_width = needed;
}
}
lines.push(current);
lines
}
fn message_lines(msg: &ChatMessage, palette: &ColorPalette, width: usize) -> Vec<Line<'static>> {
let (prefix, style) = match msg {
ChatMessage::User(_) => (
"You",
Style::default()
.fg(palette.user_msg)
.add_modifier(Modifier::BOLD),
),
ChatMessage::Agent(_) => (
"Agent",
Style::default()
.fg(palette.agent_msg)
.add_modifier(Modifier::BOLD),
),
ChatMessage::System(_) => (
"System",
Style::default()
.fg(palette.system_msg)
.add_modifier(Modifier::BOLD),
),
};
let content = match msg {
ChatMessage::User(text) | ChatMessage::Agent(text) | ChatMessage::System(text) => {
text.as_str()
}
};
let label = format!("{}: ", prefix);
let indent = " ";
let mut out = Vec::new();
for (logical_index, logical) in content.split('\n').enumerate() {
let budget = if logical_index == 0 {
width.saturating_sub(label.chars().count())
} else {
width.saturating_sub(indent.len())
};
for (wrapped_index, piece) in wrap_text(logical, budget.max(1)).into_iter().enumerate() {
if logical_index == 0 && wrapped_index == 0 {
out.push(Line::from(vec![
Span::styled(label.clone(), style),
Span::raw(piece),
]));
} else {
out.push(Line::from(Span::raw(format!("{}{}", indent, piece))));
}
}
}
out
}
fn render_chat(frame: &mut Frame, state: &mut AppState, area: Rect, palette: &ColorPalette) {
let inner_width = area.width.saturating_sub(2) as usize;
let viewport = area.height.saturating_sub(2) as usize;
let lines: Vec<Line<'static>> = state
.messages
.iter()
.flat_map(|msg| message_lines(msg, palette, inner_width))
.collect();
let max_scroll = lines.len().saturating_sub(viewport);
let offset_from_top = state.resolve_scroll(max_scroll);
let title = if state.is_following_chat() {
format!(" Chat - {} messages ", state.messages.len())
} else {
format!(
" Chat - {} messages ({} lines below) ",
state.messages.len(),
max_scroll - offset_from_top
)
};
let chat = Paragraph::new(lines)
.scroll((offset_from_top as u16, 0))
.block(
Block::default()
.title(title)
.borders(Borders::ALL)
.border_style(Style::default().fg(palette.border)),
);
frame.render_widget(chat, area);
}
fn render_dashboard(frame: &mut Frame, state: &AppState, area: Rect, palette: &ColorPalette) {
let dashboard_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(8),
Constraint::Length(8),
Constraint::Length(8),
Constraint::Min(3),
])
.split(area);
render_status_block(frame, state, dashboard_chunks[0], palette);
render_project_block(frame, state, dashboard_chunks[1], palette);
render_account_block(frame, state, dashboard_chunks[2], palette);
render_help_block(frame, dashboard_chunks[3], palette);
}
fn render_status_block(frame: &mut Frame, state: &AppState, area: Rect, palette: &ColorPalette) {
let status_color = match state.status {
AppStatus::Connected => Color::Green,
AppStatus::Disconnected => Color::Red,
AppStatus::Processing => Color::Yellow,
};
let status_text = match state.status {
AppStatus::Connected => "Connected",
AppStatus::Disconnected => "Disconnected",
AppStatus::Processing => "Processing...",
};
let status_block = Paragraph::new(vec![
Line::from(vec![
Span::styled("Status: ", Style::default().fg(palette.fg)),
Span::styled(status_text, Style::default().fg(status_color)),
]),
Line::from(""),
Line::from(vec![
Span::styled("Network: ", Style::default().fg(palette.fg)),
Span::styled(&state.active_network, Style::default().fg(palette.accent)),
]),
Line::from(vec![
Span::styled("Explain: ", Style::default().fg(palette.fg)),
Span::styled(
if state.is_explaining() { "on" } else { "off" },
Style::default().fg(if state.is_explaining() {
palette.accent
} else {
palette.border
}),
),
]),
])
.block(
Block::default()
.title(" Status ")
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Yellow)),
);
frame.render_widget(status_block, area);
}
fn render_project_block(frame: &mut Frame, state: &AppState, area: Rect, palette: &ColorPalette) {
let project_block = Paragraph::new(vec![Line::from(vec![
Span::styled("Project: ", Style::default().fg(palette.fg)),
Span::styled(&state.project_name, Style::default().fg(palette.accent)),
])])
.block(
Block::default()
.title(" Project ")
.borders(Borders::ALL)
.border_style(Style::default().fg(palette.accent)),
);
frame.render_widget(project_block, area);
}
fn render_account_block(frame: &mut Frame, state: &AppState, area: Rect, palette: &ColorPalette) {
let account_block = Paragraph::new(vec![Line::from(vec![
Span::styled("Account: ", Style::default().fg(palette.fg)),
Span::styled(&state.active_account, Style::default().fg(Color::Cyan)),
])])
.block(
Block::default()
.title(" Account ")
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Green)),
);
frame.render_widget(account_block, area);
}
fn render_help_block(frame: &mut Frame, area: Rect, palette: &ColorPalette) {
let help_block = Paragraph::new(vec![
Line::from(Span::styled(
"Shortcuts:",
Style::default().fg(palette.fg).add_modifier(Modifier::BOLD),
)),
Line::from(""),
Line::from(" Ctrl+D Deploy"),
Line::from(" Ctrl+T Test"),
Line::from(" Ctrl+B Build"),
Line::from(" Ctrl+S Save"),
Line::from(" Up/Down Scroll"),
])
.wrap(Wrap { trim: false })
.block(
Block::default()
.title(" Help ")
.borders(Borders::ALL)
.border_style(Style::default().fg(palette.border)),
);
frame.render_widget(help_block, area);
}
fn render_input(frame: &mut Frame, state: &AppState, area: Rect, palette: &ColorPalette) {
let input_block = Paragraph::new(state.input.as_str())
.block(
Block::default()
.title(" Input (type /help for commands) ")
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Green)),
)
.style(Style::default().fg(palette.fg));
frame.render_widget(input_block, area);
let cursor_x = (state.input_cursor as u16 + 2).min(area.width.saturating_sub(3));
let cursor_y = area.y + 1;
frame.set_cursor_position((cursor_x, cursor_y));
}
#[cfg(test)]
mod tests {
use super::*;
fn palette() -> ColorPalette {
ColorPalette::from_theme(&Theme::Dark)
}
fn rendered(msg: &ChatMessage, width: usize) -> Vec<String> {
message_lines(msg, &palette(), width)
.iter()
.map(|line| {
line.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<String>()
})
.collect()
}
#[test]
fn wraps_at_the_given_width() {
let lines = wrap_text("aaa bbb ccc ddd", 7);
assert_eq!(lines, vec!["aaa bbb", "ccc ddd"]);
assert!(lines.iter().all(|l| l.chars().count() <= 7));
}
#[test]
fn short_text_stays_on_one_line() {
assert_eq!(wrap_text("hello", 40), vec!["hello"]);
}
#[test]
fn breaks_a_word_longer_than_the_line() {
let lines = wrap_text("aaaaaaaaaa", 4);
assert!(lines.iter().all(|l| l.chars().count() <= 4), "{:?}", lines);
assert_eq!(lines.concat(), "aaaaaaaaaa");
}
#[test]
fn wrapping_preserves_multibyte_content() {
let lines = wrap_text("ação corrigida direito", 10);
assert!(lines.iter().all(|l| l.chars().count() <= 10), "{:?}", lines);
assert_eq!(lines.join(" "), "ação corrigida direito");
}
#[test]
fn long_message_produces_multiple_lines_instead_of_truncating() {
let long = "palavra ".repeat(20).trim_end().to_string();
let lines = rendered(&ChatMessage::Agent(long), 20);
assert!(lines.len() > 1, "expected wrapping, got {:?}", lines);
assert!(lines.iter().all(|l| l.chars().count() <= 20), "{:?}", lines);
}
#[test]
fn first_line_is_labelled_and_continuations_are_indented() {
let lines = rendered(&ChatMessage::User("um dois tres quatro".to_string()), 12);
assert!(lines[0].starts_with("You: "), "got {:?}", lines);
assert!(lines[1].starts_with(" "), "got {:?}", lines);
}
#[test]
fn embedded_newlines_start_new_lines() {
let lines = rendered(&ChatMessage::System("um\ndois\ntres".to_string()), 40);
assert_eq!(lines, vec!["System: um", " dois", " tres"]);
}
#[test]
fn following_resolves_to_the_last_screenful() {
let mut state = AppState::new();
assert!(state.is_following_chat());
assert_eq!(state.resolve_scroll(6), 6);
}
#[test]
fn scrolling_forward_to_the_bottom_resumes_following() {
let mut state = AppState::new();
state.resolve_scroll(6);
state.scroll_back(2);
assert!(!state.is_following_chat());
assert_eq!(state.resolve_scroll(6), 4);
state.scroll_forward(2);
assert_eq!(state.resolve_scroll(6), 6);
assert!(
state.is_following_chat(),
"reaching the bottom must re-enable auto-follow"
);
}
fn visible_chat(state: &mut AppState, width: u16, height: u16) -> Vec<String> {
use ratatui::backend::TestBackend;
use ratatui::Terminal;
let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
terminal
.draw(|frame| {
let area = frame.area();
render_chat(frame, state, area, &palette());
})
.unwrap();
let buffer = terminal.backend().buffer().clone();
(0..height)
.map(|y| {
(0..width)
.map(|x| buffer[(x, y)].symbol().to_string())
.collect::<String>()
.trim_end()
.to_string()
})
.collect()
}
fn numbered_state(count: usize) -> AppState {
let mut state = AppState::new();
state.messages.clear();
for i in 0..count {
state
.messages
.push(ChatMessage::System(format!("msg{}", i)));
}
state
}
#[test]
fn at_rest_the_newest_messages_are_visible() {
let mut state = numbered_state(20);
let screen = visible_chat(&mut state, 30, 6).join("\n");
assert!(screen.contains("msg19"), "newest missing:\n{}", screen);
assert!(
!screen.contains("msg0 "),
"oldest should be off-screen:\n{}",
screen
);
}
#[test]
fn scrolling_up_reveals_older_messages() {
let mut state = numbered_state(20);
visible_chat(&mut state, 30, 6);
for _ in 0..5 {
state.scroll_back(1);
}
let screen = visible_chat(&mut state, 30, 6).join("\n");
assert!(
screen.contains("msg14"),
"expected older content:\n{}",
screen
);
assert!(
!screen.contains("msg19"),
"newest should have scrolled off:\n{}",
screen
);
}
#[test]
fn scrolling_back_down_returns_to_the_newest() {
let mut state = numbered_state(20);
visible_chat(&mut state, 30, 6);
state.scroll_back(5);
visible_chat(&mut state, 30, 6);
state.scroll_forward(5);
let screen = visible_chat(&mut state, 30, 6).join("\n");
assert!(
screen.contains("msg19"),
"should be back at the bottom:\n{}",
screen
);
}
#[test]
fn a_new_message_pins_the_view_to_the_bottom() {
let mut state = numbered_state(20);
visible_chat(&mut state, 30, 6);
state
.messages
.push(ChatMessage::Agent("recem chegada".to_string()));
let screen = visible_chat(&mut state, 30, 6).join("\n");
assert!(
screen.contains("recem chegada"),
"new message not shown:\n{}",
screen
);
}
#[test]
fn scrolling_up_then_receiving_a_message_keeps_the_reader_in_place() {
let body = |screen: Vec<String>| screen[1..screen.len() - 1].to_vec();
let mut state = numbered_state(20);
visible_chat(&mut state, 30, 6);
state.scroll_back(5);
let before = body(visible_chat(&mut state, 30, 6));
state.messages.push(ChatMessage::Agent("nova".to_string()));
let after = body(visible_chat(&mut state, 30, 6));
assert_eq!(
before, after,
"a message arriving must not yank a scrolled-back reader"
);
}
#[test]
fn scrolling_stops_at_the_oldest_message() {
let mut state = numbered_state(20);
for _ in 0..500 {
state.scroll_back(1);
visible_chat(&mut state, 30, 6);
}
let screen = visible_chat(&mut state, 30, 6).join("\n");
assert!(
screen.contains("msg0"),
"oldest should be reachable:\n{}",
screen
);
}
#[test]
fn runaway_scrolling_stops_at_the_first_line() {
let mut state = AppState::new();
state.resolve_scroll(7);
for _ in 0..500 {
state.scroll_back(1);
}
assert_eq!(
state.resolve_scroll(7),
0,
"must not scroll above the first line"
);
}
}