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
This commit is contained in:
+358
-1
@@ -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::<Vec<_>>())
|
||||
.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<ListItem> = 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]
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user