Files
task_app_rust/src/ui/components.rs
T

433 lines
14 KiB
Rust
Raw Normal View History

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,
error: Option<&str>,
) {
let popup_area = centered_rect(80, 13, area);
let border_color = if error.is_some() {
Color::Red
} else if url == "starting..." || !url.is_empty() {
Color::Yellow
} else {
POPUP_BORDER
};
let block = Block::default()
.borders(Borders::ALL)
.style(Style::default().bg(POPUP_BG))
.border_style(Style::default().fg(border_color))
.title(" Google Tasks - Authorization ")
.title_alignment(Alignment::Left);
let mut lines = Vec::new();
if let Some(err) = error {
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
" Authorization Error ",
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
)));
lines.push(Line::from(""));
let wrapped = textwrap(&err, 50);
for line in wrapped {
lines.push(Line::from(Span::styled(
line,
Style::default().fg(Color::White),
)));
}
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
" Press Enter to retry | Esc to cancel ",
Style::default().fg(Color::DarkGray),
)));
} else if url == "starting..." || !url.is_empty() {
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
" Authorization in progress... ",
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD),
)));
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
" A browser tab should open automatically. ",
Style::default().fg(Color::White),
)));
lines.push(Line::from(Span::styled(
" If not, check that GOOGLE_CLIENT_ID and ",
Style::default().fg(Color::DarkGray),
)));
lines.push(Line::from(Span::styled(
" GOOGLE_CLIENT_SECRET are set correctly. ",
Style::default().fg(Color::DarkGray),
)));
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
" (Esc to cancel) ",
Style::default().fg(Color::DarkGray),
)));
} else {
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
" Google Tasks Authorization ",
Style::default().fg(Color::White).add_modifier(Modifier::BOLD),
)));
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
" This app needs access to your Google Tasks. ",
Style::default().fg(Color::White),
)));
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
" Press Enter to start ",
Style::default().fg(Color::Cyan),
)));
lines.push(Line::from(Span::styled(
" Press Esc to skip ",
Style::default().fg(Color::DarkGray),
)));
}
let paragraph = Paragraph::new(Text::from(lines))
.block(block)
.alignment(Alignment::Left);
frame.render_widget(paragraph, popup_area);
}
/// Simple word wrap: splits text at word boundaries to fit max_width chars per line
fn textwrap(text: &str, max_width: usize) -> Vec<String> {
let mut result = Vec::new();
let mut current = String::new();
for word in text.split_whitespace() {
if current.len() + word.len() + 1 > max_width && !current.is_empty() {
result.push(current.clone());
current.clear();
}
if !current.is_empty() {
current.push(' ');
}
current.push_str(word);
}
if !current.is_empty() {
result.push(current);
}
result
}
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]
}