From efc3c1c84c990815dd206e56fa2755d839ca410f Mon Sep 17 00:00:00 2001 From: Ruben Rosario Date: Sat, 20 Jun 2026 19:37:13 +0100 Subject: [PATCH] feat(ui): render layout, tabs, panes, popups, and status bar - ui/mod.rs: AppView struct, Focus/Popup/NetworkStatus enums, draw() layout function - Top-Tabs + Bottom-Split layout (50/50 left/right) - TabsBar: list selector with highlight on active - TaskListPane: checkbox + title + due date per task - DetailPane: title, status, due, notes of selected task - InputPopup: centered modal with cursor - DatePickerPopup: date/time edit modal with instructions - ConfirmDeletePopup: confirmation dialog - DeviceAuthPopup: OAuth URL + code display - StatusBar: ONLINE/OFFLINE/SYNCING with color coding --- src/ui/components.rs | 359 ++++++++++++++++++++++++++++++++++++++++++- src/ui/mod.rs | 98 ++++++++++++ 2 files changed, 456 insertions(+), 1 deletion(-) diff --git a/src/ui/components.rs b/src/ui/components.rs index f88397e..31feb39 100644 --- a/src/ui/components.rs +++ b/src/ui/components.rs @@ -1 +1,358 @@ -// TODO: Fase 3 - Ratatui widgets +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span, Text}; +use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph, Tabs}; +use ratatui::layout::{Alignment, Rect}; +use ratatui::Frame; + +use crate::domain::models::*; +use super::NetworkStatus; + +const TAB_COLOR: Color = Color::Cyan; +const FOCUS_COLOR: Color = Color::Yellow; +const SELECTED_COLOR: Color = Color::Green; +const DETAIL_COLOR: Color = Color::White; +const STATUS_ONLINE: Color = Color::Green; +const STATUS_OFFLINE: Color = Color::Red; +const STATUS_SYNC: Color = Color::Yellow; +const POPUP_BG: Color = Color::Black; +const POPUP_BORDER: Color = Color::Cyan; + +pub fn render_tabs_bar( + frame: &mut Frame, + area: Rect, + lists: &[TaskList], + selected: usize, + focused: bool, +) { + let tab_titles: Vec<&str> = lists.iter().map(|l| l.title.as_str()).collect(); + + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(if focused { FOCUS_COLOR } else { TAB_COLOR })) + .title(" Lists ") + .title_alignment(Alignment::Left); + + let tabs = if tab_titles.is_empty() { + Tabs::new(vec![Line::from(" No lists ")]) + .block(block) + .highlight_style(Style::default().add_modifier(Modifier::BOLD)) + .select(selected) + } else { + Tabs::new(tab_titles.into_iter().map(|t| Line::from(Span::raw(t))).collect::>()) + .block(block) + .divider(Span::raw(" | ")) + .highlight_style(Style::default().fg(FOCUS_COLOR).add_modifier(Modifier::BOLD)) + .select(selected) + }; + + frame.render_widget(tabs, area); +} + +pub fn render_task_list( + frame: &mut Frame, + area: Rect, + tasks: &[Task], + selected: usize, + focused: bool, + _scroll: u16, +) { + let items: Vec = tasks + .iter() + .map(|task| { + let checkbox = match task.status { + TaskStatus::Completed => "[\u{2713}]", + TaskStatus::NeedsAction => "[ ]", + }; + + let due_str = task + .due + .map(|d| d.format(" %d/%m/%Y %H:%M").to_string()) + .unwrap_or_default(); + + let content = Line::from(vec![ + Span::styled( + format!("{} ", checkbox), + Style::default().fg(if task.status == TaskStatus::Completed { + Color::Green + } else { + Color::DarkGray + }), + ), + Span::styled( + &task.title, + Style::default().fg(DETAIL_COLOR).add_modifier( + if task.status == TaskStatus::Completed { + Modifier::CROSSED_OUT + } else { + Modifier::empty() + }, + ), + ), + Span::styled( + due_str, + Style::default().fg(Color::DarkGray), + ), + ]); + ListItem::new(content) + }) + .collect(); + + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(if focused { FOCUS_COLOR } else { Color::DarkGray })) + .title(" Tasks ") + .title_alignment(Alignment::Left); + + let list = List::new(items) + .block(block) + .highlight_style( + Style::default() + .bg(if focused { Color::DarkGray } else { Color::Black }) + .fg(SELECTED_COLOR) + .add_modifier(Modifier::BOLD), + ) + .highlight_symbol(">> "); + + frame.render_stateful_widget(list, area, &mut ratatui::widgets::ListState::default().with_selected(Some(selected))); +} + +pub fn render_detail( + frame: &mut Frame, + area: Rect, + task: Option<&Task>, + focused: bool, + scroll: u16, +) { + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(if focused { FOCUS_COLOR } else { Color::DarkGray })) + .title(" Details ") + .title_alignment(Alignment::Left); + + if let Some(task) = task { + let mut lines = Vec::new(); + + let status_text = match task.status { + TaskStatus::Completed => "Completed", + TaskStatus::NeedsAction => "Needs Action", + }; + + lines.push(Line::from(Span::styled( + format!("Title: {}", task.title), + Style::default().fg(DETAIL_COLOR).add_modifier(Modifier::BOLD), + ))); + lines.push(Line::from(Span::styled( + format!("Status: {}", status_text), + Style::default().fg(Color::Cyan), + ))); + + if let Some(due) = task.due { + lines.push(Line::from(Span::styled( + format!("Due: {}", due.format("%d/%m/%Y %H:%M")), + Style::default().fg(Color::Yellow), + ))); + } + + if let Some(notes) = &task.notes { + if !notes.is_empty() { + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + "Notes:", + Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD), + ))); + for note_line in notes.lines() { + lines.push(Line::from(Span::raw(note_line.to_string()))); + } + } + } + + let paragraph = Paragraph::new(Text::from(lines)) + .block(block) + .scroll((scroll, 0)); + frame.render_widget(paragraph, area); + } else { + let paragraph = Paragraph::new(Text::from(Line::from(Span::styled( + "Select a task to view details", + Style::default().fg(Color::DarkGray), + )))) + .block(block) + .alignment(Alignment::Center); + frame.render_widget(paragraph, area); + } +} + +pub fn render_status_bar( + frame: &mut Frame, + area: Rect, + status: &NetworkStatus, +) { + let (text, color) = match status { + NetworkStatus::Online => (" ONLINE ", STATUS_ONLINE), + NetworkStatus::Offline => (" OFFLINE ", STATUS_OFFLINE), + NetworkStatus::Syncing => (" SYNCING... ", STATUS_SYNC), + }; + + let block = Block::default() + .style(Style::default().bg(color).fg(Color::Black)); + + let paragraph = Paragraph::new(Line::from(Span::styled( + text, + Style::default().fg(Color::Black).add_modifier(Modifier::BOLD), + ))) + .block(block) + .alignment(Alignment::Left); + + frame.render_widget(paragraph, area); +} + +pub fn render_input_popup( + frame: &mut Frame, + area: Rect, + input: &str, + cursor: usize, +) { + let popup_area = centered_rect(60, 3, area); + let block = Block::default() + .borders(Borders::ALL) + .style(Style::default().bg(POPUP_BG)) + .border_style(Style::default().fg(POPUP_BORDER)) + .title(" Input ") + .title_alignment(Alignment::Left); + + let paragraph = Paragraph::new(Text::from(Line::from(Span::raw(input)))) + .block(block); + + frame.render_widget(paragraph, popup_area); + frame.set_cursor_position(ratatui::layout::Position::new( + popup_area.x + 1 + cursor as u16, + popup_area.y + 1, + )); +} + +pub fn render_date_picker( + frame: &mut Frame, + area: Rect, + date: chrono::NaiveDateTime, +) { + let popup_area = centered_rect(50, 7, area); + let block = Block::default() + .borders(Borders::ALL) + .style(Style::default().bg(POPUP_BG)) + .border_style(Style::default().fg(POPUP_BORDER)) + .title(" Date Picker ") + .title_alignment(Alignment::Left); + + let date_str = date.format("%Y-%m-%d %H:%M").to_string(); + let text = Text::from(vec![ + Line::from(Span::raw("Edit date/time:")), + Line::from(""), + Line::from(Span::styled( + format!(" {} ", date_str), + Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD), + )), + Line::from(""), + Line::from(Span::styled( + " Tab:next field Up/Down:change Enter:ok Esc:cancel", + Style::default().fg(Color::DarkGray), + )), + ]); + + let paragraph = Paragraph::new(text) + .block(block) + .alignment(Alignment::Center); + + frame.render_widget(paragraph, popup_area); +} + +pub fn render_confirm_popup(frame: &mut Frame, area: Rect) { + let popup_area = centered_rect(40, 5, area); + let block = Block::default() + .borders(Borders::ALL) + .style(Style::default().bg(POPUP_BG)) + .border_style(Style::default().fg(Color::Red)) + .title(" Confirm ") + .title_alignment(Alignment::Left); + + let text = Text::from(vec![ + Line::from(""), + Line::from(Span::styled( + " Delete this item? ", + Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), + )), + Line::from(""), + Line::from(Span::styled( + " Enter:confirm Esc:cancel ", + Style::default().fg(Color::DarkGray), + )), + ]); + + let paragraph = Paragraph::new(text) + .block(block) + .alignment(Alignment::Center); + + frame.render_widget(paragraph, popup_area); +} + +pub fn render_device_auth_popup( + frame: &mut Frame, + area: Rect, + url: &str, + code: &str, +) { + let popup_area = centered_rect(70, 9, area); + let block = Block::default() + .borders(Borders::ALL) + .style(Style::default().bg(POPUP_BG)) + .border_style(Style::default().fg(POPUP_BORDER)) + .title(" Google OAuth ") + .title_alignment(Alignment::Left); + + let text = Text::from(vec![ + Line::from(""), + Line::from(Span::styled( + " Visit the following URL to authorize: ", + Style::default().fg(Color::White), + )), + Line::from(""), + Line::from(Span::styled( + url, + Style::default().fg(Color::Cyan).add_modifier(Modifier::UNDERLINED), + )), + Line::from(" "), + Line::from(Span::styled( + format!(" Enter code: {} ", code), + Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD), + )), + Line::from(""), + Line::from(Span::styled( + " After authorizing, press Enter to continue... ", + Style::default().fg(Color::DarkGray), + )), + ]); + + let paragraph = Paragraph::new(text) + .block(block) + .alignment(Alignment::Center); + + frame.render_widget(paragraph, popup_area); +} + +fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect { + let popup_layout = ratatui::layout::Layout::default() + .direction(ratatui::layout::Direction::Vertical) + .constraints([ + ratatui::layout::Constraint::Length((area.height.saturating_sub(percent_y)) / 2), + ratatui::layout::Constraint::Length(percent_y), + ratatui::layout::Constraint::Min(0), + ]) + .split(area); + + ratatui::layout::Layout::default() + .direction(ratatui::layout::Direction::Horizontal) + .constraints([ + ratatui::layout::Constraint::Length((area.width.saturating_sub(percent_x)) / 2), + ratatui::layout::Constraint::Length(percent_x), + ratatui::layout::Constraint::Min(0), + ]) + .split(popup_layout[1])[1] +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs index f188f2c..3c9d736 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -1 +1,99 @@ pub mod components; + +use ratatui::layout::{Constraint, Direction, Layout}; +use ratatui::Frame; + +use crate::domain::models::*; +use components::*; + +#[derive(Debug, Clone, PartialEq)] +pub enum Focus { + Tabs, + TaskList, + Detail, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum Popup { + Input, + DatePicker, + ConfirmDelete, + DeviceAuth { url: String, code: String }, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum NetworkStatus { + Online, + Offline, + Syncing, +} + +pub struct AppView<'a> { + pub lists: &'a [TaskList], + pub tasks: &'a [Task], + pub selected_list: usize, + pub selected_task: usize, + pub focus: Focus, + pub show_popup: Option<&'a Popup>, + pub popup_input: &'a str, + pub popup_cursor: usize, + pub draft_date: chrono::NaiveDateTime, + pub network_status: &'a NetworkStatus, + pub task_list_scroll: u16, + pub detail_scroll: u16, +} + +pub fn draw(frame: &mut Frame, view: AppView) { + let area = frame.area(); + + let main_layout = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), + Constraint::Min(0), + Constraint::Length(1), + ]) + .split(area); + + let tabs_area = main_layout[0]; + let body_area = main_layout[1]; + let status_area = main_layout[2]; + + let is_tabs_focused = view.focus == Focus::Tabs; + render_tabs_bar(frame, tabs_area, view.lists, view.selected_list, is_tabs_focused); + + let body_layout = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) + .split(body_area); + + let is_task_list_focused = view.focus == Focus::TaskList; + render_task_list( + frame, + body_layout[0], + view.tasks, + view.selected_task, + is_task_list_focused, + view.task_list_scroll, + ); + + let is_detail_focused = view.focus == Focus::Detail; + render_detail( + frame, + body_layout[1], + view.tasks.get(view.selected_task), + is_detail_focused, + view.detail_scroll, + ); + + render_status_bar(frame, status_area, view.network_status); + + if let Some(popup) = view.show_popup { + match popup { + Popup::Input => render_input_popup(frame, area, view.popup_input, view.popup_cursor), + Popup::DatePicker => render_date_picker(frame, area, view.draft_date), + Popup::ConfirmDelete => render_confirm_popup(frame, area), + Popup::DeviceAuth { url, code } => render_device_auth_popup(frame, area, url, code), + } + } +}