feat: implement Component trait for modular UI components and refactor TUI views to use it

This commit is contained in:
asepharyana
2026-07-21 06:08:00 +07:00
parent 6ab532018a
commit d59713d3e3
10 changed files with 326 additions and 272 deletions
+16 -109
View File
@@ -3,13 +3,15 @@
//! centered panels, and floats toast notifications over the top-right corner.
pub mod chat;
pub mod input;
pub mod markdown;
pub mod overlays;
pub mod sidebar;
pub mod status;
pub mod theme;
pub mod workflow;
pub mod overlays;
use crate::components::Component;
use crate::state::AppStateRest;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Modifier, Style};
@@ -27,7 +29,8 @@ const SIDEBAR_MIN_WIDTH: u16 = 90;
/// This separates cache mutation from rendering so `draw` can take
/// `&AppStateRest` (required by `terminal.draw` closure constraints).
pub fn pre_render(state: &mut AppStateRest) {
chat::pre_render_chat(state);
let mut chat_comp = chat::ChatComponent::default();
chat_comp.pre_render(state);
}
/// Top-level render entry point called once per TUI frame.
@@ -64,122 +67,24 @@ pub fn draw(frame: &mut Frame, state: &AppStateRest) {
let overlay = state.misc.overlay;
overlays::render_overlay(frame, chat_area, overlay, state);
} else {
render_main_panel(frame, chat_area, state);
let mut chat_comp = chat::ChatComponent::default();
chat_comp.draw(frame, chat_area, state);
}
render_input_bar(frame, input_area, state);
status::draw_status_bar(frame, status_area, state);
let mut input_comp = input::InputComponent::default();
input_comp.draw(frame, input_area, state);
let mut status_comp = status::StatusBarComponent::default();
status_comp.draw(frame, status_area, state);
if let Some(sidebar_rect) = sidebar_area {
sidebar::draw_sidebar(frame, sidebar_rect, state);
let mut sidebar_comp = sidebar::SidebarComponent::default();
sidebar_comp.draw(frame, sidebar_rect, state);
}
render_toasts(frame, state);
}
fn render_main_panel(frame: &mut Frame, area: Rect, state: &AppStateRest) {
chat::draw_chat(frame, area, state);
}
fn render_input_bar(frame: &mut Frame, area: Rect, state: &AppStateRest) {
if state.input.autocomplete_visible && !state.input.autocomplete_candidates.is_empty() {
let n = state.input.autocomplete_candidates.len().min(10) as u16;
let dropdown_height = n + 2;
let dropdown_area = Rect {
x: area.x,
y: area.y.saturating_sub(dropdown_height),
width: area.width.min(45),
height: dropdown_height,
};
let dropdown_title = match state.input.autocomplete_kind {
crate::state::AutocompleteKind::Command => " ⌘ Commands ",
crate::state::AutocompleteKind::FileMention => " 📁 Files ",
};
let dropdown_block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Theme::BORDER))
.title(Span::styled(
dropdown_title,
Style::default().fg(Theme::PRIMARY),
))
.style(Style::default().bg(Theme::SURFACE_ELEVATED));
let mut lines: Vec<Line> = Vec::new();
let selected = state.input.autocomplete_idx;
for (i, candidate) in state
.input
.autocomplete_candidates
.iter()
.enumerate()
.take(10)
{
let prefix = if i == selected { "" } else { " " };
let style = if i == selected {
Style::default()
.fg(Theme::TEXT)
.bg(Theme::HIGHLIGHT_DIM)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Theme::TEXT)
};
let label = format!("{prefix}{candidate}");
lines.push(Line::from(Span::styled(label, style)));
}
let dropdown = Paragraph::new(lines).block(dropdown_block);
frame.render_widget(dropdown, dropdown_area);
}
let block = Block::default()
.borders(Borders::TOP)
.border_style(Style::default().fg(Theme::BORDER))
.style(Style::default().bg(Theme::SURFACE));
let input_text = &state.input.buffer;
let cursor_pos = state.input.cursor;
let prompt = Span::styled(
" ",
Style::default()
.fg(Theme::PRIMARY)
.add_modifier(Modifier::BOLD),
);
let mut spans = vec![prompt];
if input_text.is_empty() {
spans.push(Span::styled(
"Type a message or /command...",
Style::default()
.fg(Theme::TEXT_DIM)
.add_modifier(Modifier::ITALIC),
));
} else {
let (before, after) = input_text.split_at(cursor_pos);
spans.push(Span::raw(before.to_string()));
let (cursor_char, after_char) = if after.is_empty() {
(" ".to_string(), "")
} else {
let c = after.chars().next().unwrap();
let char_len = c.len_utf8();
(c.to_string(), &after[char_len..])
};
spans.push(Span::styled(
cursor_char,
Style::default()
.bg(Theme::HIGHLIGHT)
.fg(Theme::BG)
.add_modifier(Modifier::BOLD),
));
if !after_char.is_empty() {
spans.push(Span::raw(after_char.to_string()));
}
}
let line = Line::from(spans);
let paragraph = Paragraph::new(line).block(block);
frame.render_widget(paragraph, area);
}
fn render_toasts(frame: &mut Frame, state: &AppStateRest) {
let now_ms = chrono::Utc::now().timestamp_millis();
let active: Vec<&zesdex_infrastructure::Toast> = state
@@ -209,6 +114,7 @@ fn render_toasts(frame: &mut Frame, state: &AppStateRest) {
break;
}
// Shadow/Dimming effect background
frame.render_widget(Clear, toast_area);
let (border_color, icon) = match toast.kind {
@@ -221,6 +127,7 @@ fn render_toasts(frame: &mut Frame, state: &AppStateRest) {
let block = Block::default()
.borders(Borders::ALL)
.border_type(ratatui::widgets::BorderType::Rounded)
.border_style(Style::default().fg(border_color))
.title(Span::styled(icon, Style::default().fg(border_color)))
.style(Style::default().bg(Theme::SURFACE_ELEVATED));