use ratatui::{
layout::{Constraint, Direction, Flex, Layout, Margin, Rect},
Frame,
};
use std::sync::{LazyLock, Mutex};
use super::app::App;
use super::state::GenerationStatus;
use crate::tui::widgets::{AttachmentWidget, ChatWidget, InputState, InputWidget, StatusLineWidget, StatusWidget};
use crate::utils::MutexExt;
#[derive(Clone)]
struct LayoutCache {
main_layout: Option<(u16, u16, u16, u16, Vec<Rect>)>, }
impl LayoutCache {
fn new() -> Self {
Self {
main_layout: None,
}
}
fn get_main_layout(&mut self, area: Rect, input_height: u16, status_line_height: u16, attachment_height: u16) -> Vec<Rect> {
if let Some((w, ih, sh, ah, ref rects)) = self.main_layout {
if w == area.width && ih == input_height && sh == status_line_height && ah == attachment_height {
return rects.clone();
}
}
let layout = Layout::default()
.direction(Direction::Vertical)
.margin(0)
.spacing(0)
.flex(Flex::Start)
.constraints([
Constraint::Min(10), Constraint::Length(status_line_height), Constraint::Length(attachment_height), Constraint::Length(input_height), Constraint::Length(2), ])
.split(area);
let layout_vec = layout.to_vec();
self.main_layout = Some((area.width, input_height, status_line_height, attachment_height, layout_vec.clone()));
layout_vec
}
}
static LAYOUT_CACHE: LazyLock<Mutex<LayoutCache>> = LazyLock::new(|| Mutex::new(LayoutCache::new()));
pub fn render_ui(frame: &mut Frame, app: &mut App) {
if let Some(ref title) = app.session_state.conversation_title {
app.set_terminal_title(title);
} else {
app.set_terminal_title(&format!("mermaid - {}", app.working_dir));
}
let terminal_width = frame.area().width.saturating_sub(4) as usize; let input_lines = if app.input.is_empty() {
1
} else {
let mut lines = 1;
let mut current_line_length = 0;
for ch in app.input.get().chars() {
if ch == '\n' || current_line_length >= terminal_width {
lines += 1;
current_line_length = if ch == '\n' { 0 } else { 1 };
} else {
current_line_length += 1;
}
}
lines.min(5) };
let input_height = (input_lines + 2) as u16;
let queued_count = app.operation_state.queued_message_count();
let status_line_height = (1 + queued_count).min(6) as u16;
let attachment_height = if app.attachment_state.is_empty() { 0 } else { 1 };
let chunks = {
let mut cache = LAYOUT_CACHE.lock_mut_safe();
cache.get_main_layout(frame.area(), input_height, status_line_height, attachment_height)
};
let chat_area = chunks[0].inner(Margin {
horizontal: 1,
vertical: 0,
});
let chat_widget = ChatWidget {
messages: &app.session_state.messages,
is_generating: app.app_state.is_generating(),
pending_file_read: app.operation_state.pending_file_read,
reading_file_status: app.operation_state.reading_file_status.as_deref(),
theme: &app.ui_state.theme,
markdown_cache: &mut app.session_state.markdown_cache,
};
frame.render_stateful_widget(chat_widget, chat_area, &mut app.ui_state.chat_state);
if app.app_state.is_generating() {
let elapsed_secs = app
.app_state
.generation_start_time()
.map(|start| start.elapsed().as_secs())
.unwrap_or(0);
let actual_tokens = app.app_state.tokens_received().unwrap_or(0);
let (tokens_display, tokens_estimated) = if actual_tokens == 0 && !app.current_response.is_empty() {
(app.current_response.len() / 4, true)
} else {
(actual_tokens, false)
};
let status_line_widget = StatusLineWidget {
status: app.app_state.generation_status().unwrap_or(GenerationStatus::Idle),
custom_status: app.status_state.custom_status.as_ref(),
elapsed_secs,
tokens_received: tokens_display,
tokens_estimated,
theme: &app.ui_state.theme,
queued_messages: app.operation_state.get_queued_messages(),
};
frame.render_widget(status_line_widget, chunks[1]);
}
if !app.attachment_state.is_empty() {
app.ui_state.attachment_area_y = Some(chunks[2].y);
let attachment_widget = AttachmentWidget {
attachments: &app.attachment_state.attachments,
theme: &app.ui_state.theme,
focused: app.ui_state.attachment_focused,
selected: app.ui_state.selected_attachment,
};
frame.render_widget(attachment_widget, chunks[2]);
} else {
app.ui_state.attachment_area_y = None;
}
let input_widget = InputWidget {
input: app.input.get(),
showing_command_hints: app.input.get().starts_with(':'),
theme: &app.ui_state.theme,
thinking_enabled: app.model_state.thinking_enabled,
};
frame.render_stateful_widget(input_widget, chunks[3], &mut app.ui_state.input_state);
if app.ui_state.attachment_focused {
} else {
let input_area = chunks[3];
let content_width = input_area.width.saturating_sub(2) as usize;
let (cursor_row, cursor_col) = InputState::calculate_cursor_position(
app.input.get(),
app.input.cursor_position,
content_width,
);
frame.set_cursor_position((
input_area.x + cursor_col + 2,
input_area.y + 1 + cursor_row,
));
}
let status_widget = StatusWidget {
theme: &app.ui_state.theme,
working_dir: &app.working_dir,
cumulative_tokens: app.session_state.cumulative_tokens,
model_name: &app.model_state.model_name,
thinking_enabled: app.model_state.thinking_enabled,
};
frame.render_widget(status_widget, chunks[4]);
}