2026-06-20 19:37:13 +01:00
|
|
|
use ratatui::style::{Color, Modifier, Style};
|
|
|
|
|
use ratatui::text::{Line, Span, Text};
|
2026-06-21 15:45:14 +01:00
|
|
|
use ratatui::widgets::{Block, Borders, Clear, List, ListItem, Paragraph, Tabs};
|
|
|
|
|
use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect};
|
2026-06-20 19:37:13 +01:00
|
|
|
use ratatui::Frame;
|
|
|
|
|
|
|
|
|
|
use crate::domain::models::*;
|
2026-06-21 14:21:14 +01:00
|
|
|
use crate::app::SyncStats;
|
2026-06-20 19:37:13 +01:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-21 16:24:54 +01:00
|
|
|
fn relative_due_str(due: chrono::NaiveDateTime) -> (String, Color) {
|
|
|
|
|
let now = chrono::Local::now().naive_local();
|
|
|
|
|
let diff = due - now;
|
|
|
|
|
|
|
|
|
|
if diff < chrono::Duration::zero() {
|
|
|
|
|
(" Overdue ".to_string(), Color::Red)
|
|
|
|
|
} else if diff < chrono::Duration::hours(24) {
|
|
|
|
|
let hours = diff.num_hours();
|
|
|
|
|
(format!(" {}h left ", hours), Color::Yellow)
|
|
|
|
|
} else {
|
|
|
|
|
let days = diff.num_days();
|
|
|
|
|
(
|
|
|
|
|
format!(" {} day{} left ", days, if days == 1 { "" } else { "s" }),
|
|
|
|
|
Color::DarkGray,
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-20 19:37:13 +01:00
|
|
|
pub fn render_task_list(
|
|
|
|
|
frame: &mut Frame,
|
|
|
|
|
area: Rect,
|
|
|
|
|
tasks: &[Task],
|
|
|
|
|
selected: usize,
|
|
|
|
|
focused: bool,
|
|
|
|
|
_scroll: u16,
|
|
|
|
|
) {
|
2026-06-21 14:21:14 +01:00
|
|
|
let total = tasks.len();
|
|
|
|
|
let done = tasks.iter().filter(|t| t.status == TaskStatus::Completed).count();
|
|
|
|
|
let todo = total - done;
|
|
|
|
|
|
2026-06-21 16:24:54 +01:00
|
|
|
let content_width = (area.width as usize).saturating_sub(5);
|
|
|
|
|
|
2026-06-20 19:37:13 +01:00
|
|
|
let items: Vec<ListItem> = tasks
|
|
|
|
|
.iter()
|
|
|
|
|
.map(|task| {
|
|
|
|
|
let checkbox = match task.status {
|
|
|
|
|
TaskStatus::Completed => "[\u{2713}]",
|
|
|
|
|
TaskStatus::NeedsAction => "[ ]",
|
|
|
|
|
};
|
|
|
|
|
|
2026-06-21 16:24:54 +01:00
|
|
|
let (due_text, due_color) = task
|
2026-06-20 19:37:13 +01:00
|
|
|
.due
|
2026-06-21 16:24:54 +01:00
|
|
|
.map(relative_due_str)
|
|
|
|
|
.unwrap_or((String::new(), Color::DarkGray));
|
2026-06-20 19:37:13 +01:00
|
|
|
|
2026-06-21 16:33:22 +01:00
|
|
|
let checkbox_str = format!("{} ", checkbox);
|
|
|
|
|
let checkbox_width = checkbox_str.chars().count();
|
|
|
|
|
let title_width = task.title.chars().count();
|
|
|
|
|
let due_width = due_text.chars().count();
|
|
|
|
|
|
|
|
|
|
let max_title = content_width.saturating_sub(checkbox_width + due_width);
|
|
|
|
|
|
|
|
|
|
let display_title: String = if title_width <= max_title {
|
|
|
|
|
task.title.to_string()
|
|
|
|
|
} else if max_title >= 2 {
|
|
|
|
|
let take = max_title - 1;
|
|
|
|
|
let mut s: String = task.title.chars().take(take).collect();
|
|
|
|
|
s.push('…');
|
|
|
|
|
s
|
|
|
|
|
} else {
|
|
|
|
|
task.title.chars().take(max_title).collect()
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let pad = if due_text.is_empty() {
|
|
|
|
|
0
|
|
|
|
|
} else {
|
|
|
|
|
let used = checkbox_width + display_title.chars().count() + due_width;
|
|
|
|
|
content_width.saturating_sub(used)
|
|
|
|
|
};
|
|
|
|
|
|
2026-06-21 16:24:54 +01:00
|
|
|
let mut spans = vec![
|
2026-06-20 19:37:13 +01:00
|
|
|
Span::styled(
|
2026-06-21 16:33:22 +01:00
|
|
|
checkbox_str,
|
2026-06-20 19:37:13 +01:00
|
|
|
Style::default().fg(if task.status == TaskStatus::Completed {
|
|
|
|
|
Color::Green
|
|
|
|
|
} else {
|
|
|
|
|
Color::DarkGray
|
|
|
|
|
}),
|
|
|
|
|
),
|
|
|
|
|
Span::styled(
|
2026-06-21 16:33:22 +01:00
|
|
|
display_title,
|
2026-06-20 19:37:13 +01:00
|
|
|
Style::default().fg(DETAIL_COLOR).add_modifier(
|
|
|
|
|
if task.status == TaskStatus::Completed {
|
|
|
|
|
Modifier::CROSSED_OUT
|
|
|
|
|
} else {
|
|
|
|
|
Modifier::empty()
|
|
|
|
|
},
|
|
|
|
|
),
|
|
|
|
|
),
|
2026-06-21 16:24:54 +01:00
|
|
|
];
|
|
|
|
|
|
|
|
|
|
if !due_text.is_empty() {
|
|
|
|
|
spans.push(Span::raw(" ".repeat(pad)));
|
|
|
|
|
spans.push(Span::styled(due_text, Style::default().fg(due_color)));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
ListItem::new(Line::from(spans))
|
2026-06-20 19:37:13 +01:00
|
|
|
})
|
|
|
|
|
.collect();
|
|
|
|
|
|
|
|
|
|
let block = Block::default()
|
|
|
|
|
.borders(Borders::ALL)
|
|
|
|
|
.border_style(Style::default().fg(if focused { FOCUS_COLOR } else { Color::DarkGray }))
|
2026-06-21 14:21:14 +01:00
|
|
|
.title(format!(" Tasks ({} todo / {} done) ", todo, done))
|
2026-06-20 19:37:13 +01:00
|
|
|
.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),
|
|
|
|
|
)));
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-21 16:11:58 +01:00
|
|
|
if let Some(created) = task.created_at {
|
|
|
|
|
lines.push(Line::from(Span::styled(
|
|
|
|
|
format!("Created: {}", created.format("%d/%m/%Y %H:%M")),
|
|
|
|
|
Style::default().fg(Color::DarkGray),
|
|
|
|
|
)));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if let Some(updated) = task.updated_at {
|
|
|
|
|
lines.push(Line::from(Span::styled(
|
|
|
|
|
format!("Updated: {}", updated.format("%d/%m/%Y %H:%M")),
|
|
|
|
|
Style::default().fg(Color::DarkGray),
|
|
|
|
|
)));
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-20 19:37:13 +01:00
|
|
|
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,
|
2026-06-21 14:21:14 +01:00
|
|
|
sync_stats: &SyncStats,
|
2026-06-20 19:37:13 +01:00
|
|
|
) {
|
2026-06-21 14:21:14 +01:00
|
|
|
let (status_text, color) = match status {
|
2026-06-20 19:37:13 +01:00
|
|
|
NetworkStatus::Online => (" ONLINE ", STATUS_ONLINE),
|
|
|
|
|
NetworkStatus::Offline => (" OFFLINE ", STATUS_OFFLINE),
|
|
|
|
|
NetworkStatus::Syncing => (" SYNCING... ", STATUS_SYNC),
|
|
|
|
|
};
|
|
|
|
|
|
2026-06-21 14:21:14 +01:00
|
|
|
let right_text = match sync_stats.last_sync_time {
|
|
|
|
|
Some(time) => {
|
|
|
|
|
let time_str = time.format("%H:%M:%S").to_string();
|
|
|
|
|
let mut parts: Vec<String> = Vec::new();
|
|
|
|
|
if sync_stats.lists_changed > 0 {
|
|
|
|
|
parts.push(format!("{} lists", sync_stats.lists_changed));
|
|
|
|
|
}
|
|
|
|
|
if sync_stats.tasks_changed > 0 {
|
|
|
|
|
parts.push(format!("{} tasks", sync_stats.tasks_changed));
|
|
|
|
|
}
|
|
|
|
|
if parts.is_empty() {
|
|
|
|
|
format!(" {} last sync ", time_str)
|
|
|
|
|
} else {
|
|
|
|
|
format!(" {} last sync: {} ", time_str, parts.join(", "))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
None => String::new(),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let full_text = format!("{}{}", status_text, right_text);
|
|
|
|
|
|
2026-06-20 19:37:13 +01:00
|
|
|
let block = Block::default()
|
|
|
|
|
.style(Style::default().bg(color).fg(Color::Black));
|
|
|
|
|
|
|
|
|
|
let paragraph = Paragraph::new(Line::from(Span::styled(
|
2026-06-21 14:21:14 +01:00
|
|
|
full_text,
|
2026-06-20 19:37:13 +01:00
|
|
|
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,
|
|
|
|
|
) {
|
2026-06-21 15:45:14 +01:00
|
|
|
let popup_area = centered_rect(75, 3, area);
|
|
|
|
|
frame.render_widget(Clear, popup_area);
|
2026-06-20 19:37:13 +01:00
|
|
|
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,
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-21 15:45:14 +01:00
|
|
|
pub fn render_edit_task_popup(
|
|
|
|
|
frame: &mut Frame,
|
|
|
|
|
area: Rect,
|
|
|
|
|
title: &str,
|
|
|
|
|
title_cursor: usize,
|
|
|
|
|
notes: &str,
|
|
|
|
|
notes_cursor: usize,
|
|
|
|
|
active_field: usize,
|
|
|
|
|
) {
|
2026-06-21 16:07:16 +01:00
|
|
|
let popup_area = centered_rect(75, 12, area);
|
2026-06-21 15:45:14 +01:00
|
|
|
|
|
|
|
|
frame.render_widget(Clear, popup_area);
|
|
|
|
|
|
|
|
|
|
let outer_block = Block::default()
|
|
|
|
|
.borders(Borders::ALL)
|
|
|
|
|
.border_style(Style::default().fg(POPUP_BORDER))
|
|
|
|
|
.title(" Edit Task ");
|
|
|
|
|
let inner_area = outer_block.inner(popup_area);
|
|
|
|
|
|
2026-06-21 15:48:11 +01:00
|
|
|
frame.render_widget(Clear, popup_area);
|
|
|
|
|
frame.render_widget(outer_block, popup_area);
|
2026-06-21 15:45:14 +01:00
|
|
|
|
|
|
|
|
let rows = Layout::default()
|
|
|
|
|
.direction(Direction::Vertical)
|
|
|
|
|
.constraints([
|
|
|
|
|
Constraint::Length(3),
|
|
|
|
|
Constraint::Length(1),
|
|
|
|
|
Constraint::Length(3),
|
|
|
|
|
Constraint::Length(1),
|
2026-06-21 16:07:16 +01:00
|
|
|
Constraint::Length(1),
|
|
|
|
|
Constraint::Length(1),
|
2026-06-21 15:45:14 +01:00
|
|
|
])
|
|
|
|
|
.split(inner_area);
|
|
|
|
|
|
|
|
|
|
// ── Title block ──
|
|
|
|
|
let title_style = if active_field == 0 {
|
2026-06-21 15:48:11 +01:00
|
|
|
Style::default().fg(FOCUS_COLOR)
|
2026-06-21 15:45:14 +01:00
|
|
|
} else {
|
2026-06-21 15:48:11 +01:00
|
|
|
Style::default().fg(Color::DarkGray)
|
2026-06-21 15:45:14 +01:00
|
|
|
};
|
|
|
|
|
let title_block = Block::default()
|
|
|
|
|
.borders(Borders::ALL)
|
|
|
|
|
.border_style(title_style)
|
|
|
|
|
.title(" Title ")
|
|
|
|
|
.title_alignment(Alignment::Left);
|
|
|
|
|
let title_para = Paragraph::new(Text::from(Line::from(Span::raw(title))))
|
|
|
|
|
.block(title_block);
|
|
|
|
|
frame.render_widget(title_para, rows[0]);
|
|
|
|
|
|
|
|
|
|
// ── Notes block ──
|
|
|
|
|
let notes_style = if active_field == 1 {
|
2026-06-21 15:48:11 +01:00
|
|
|
Style::default().fg(FOCUS_COLOR)
|
2026-06-21 15:45:14 +01:00
|
|
|
} else {
|
2026-06-21 15:48:11 +01:00
|
|
|
Style::default().fg(Color::DarkGray)
|
2026-06-21 15:45:14 +01:00
|
|
|
};
|
|
|
|
|
let notes_block = Block::default()
|
|
|
|
|
.borders(Borders::ALL)
|
|
|
|
|
.border_style(notes_style)
|
|
|
|
|
.title(" Notes ")
|
|
|
|
|
.title_alignment(Alignment::Left);
|
|
|
|
|
let notes_para = Paragraph::new(Text::from(Line::from(Span::raw(notes))))
|
|
|
|
|
.block(notes_block);
|
|
|
|
|
frame.render_widget(notes_para, rows[2]);
|
|
|
|
|
|
2026-06-21 16:07:16 +01:00
|
|
|
// ── Date buttons row ──
|
|
|
|
|
let today_style = if active_field == 2 {
|
|
|
|
|
Style::default().fg(FOCUS_COLOR).add_modifier(Modifier::BOLD)
|
|
|
|
|
} else {
|
|
|
|
|
Style::default().fg(Color::DarkGray)
|
|
|
|
|
};
|
|
|
|
|
let tomorrow_style = if active_field == 3 {
|
|
|
|
|
Style::default().fg(FOCUS_COLOR).add_modifier(Modifier::BOLD)
|
|
|
|
|
} else {
|
|
|
|
|
Style::default().fg(Color::DarkGray)
|
|
|
|
|
};
|
|
|
|
|
let next_week_style = if active_field == 4 {
|
|
|
|
|
Style::default().fg(FOCUS_COLOR).add_modifier(Modifier::BOLD)
|
|
|
|
|
} else {
|
|
|
|
|
Style::default().fg(Color::DarkGray)
|
|
|
|
|
};
|
|
|
|
|
let buttons = Paragraph::new(Line::from(vec![
|
|
|
|
|
Span::styled(" [ Today ] ", today_style),
|
|
|
|
|
Span::raw(" "),
|
|
|
|
|
Span::styled(" [ Tomorrow ] ", tomorrow_style),
|
|
|
|
|
Span::raw(" "),
|
|
|
|
|
Span::styled(" [ Next Week ] ", next_week_style),
|
|
|
|
|
]))
|
|
|
|
|
.alignment(Alignment::Center);
|
|
|
|
|
frame.render_widget(buttons, rows[4]);
|
|
|
|
|
|
2026-06-21 15:45:14 +01:00
|
|
|
// ── Hint row ──
|
|
|
|
|
let hint = Paragraph::new(Line::from(Span::styled(
|
|
|
|
|
" Tab:switch field Enter:save Esc:cancel ",
|
|
|
|
|
Style::default().fg(Color::Gray),
|
|
|
|
|
)))
|
|
|
|
|
.alignment(Alignment::Center);
|
2026-06-21 16:07:16 +01:00
|
|
|
frame.render_widget(hint, rows[5]);
|
2026-06-21 15:45:14 +01:00
|
|
|
|
|
|
|
|
// ── Cursor ──
|
2026-06-21 16:07:16 +01:00
|
|
|
let (cursor_x, cursor_y) = match active_field {
|
|
|
|
|
0 => (rows[0].x + 1 + title_cursor as u16, rows[0].y + 1),
|
|
|
|
|
1 => (rows[2].x + 1 + notes_cursor as u16, rows[2].y + 1),
|
|
|
|
|
_ => return,
|
2026-06-21 15:45:14 +01:00
|
|
|
};
|
|
|
|
|
frame.set_cursor_position(ratatui::layout::Position::new(cursor_x, cursor_y));
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-20 19:37:13 +01:00
|
|
|
pub fn render_date_picker(
|
|
|
|
|
frame: &mut Frame,
|
|
|
|
|
area: Rect,
|
|
|
|
|
date: chrono::NaiveDateTime,
|
|
|
|
|
) {
|
2026-06-21 15:45:14 +01:00
|
|
|
let popup_area = centered_rect(60, 7, area);
|
|
|
|
|
frame.render_widget(Clear, popup_area);
|
2026-06-20 19:37:13 +01:00
|
|
|
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) {
|
2026-06-21 15:45:14 +01:00
|
|
|
let popup_area = centered_rect(50, 5, area);
|
|
|
|
|
frame.render_widget(Clear, popup_area);
|
2026-06-20 19:37:13 +01:00
|
|
|
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,
|
2026-06-20 20:55:08 +01:00
|
|
|
_code: &str,
|
2026-06-20 19:56:41 +01:00
|
|
|
error: Option<&str>,
|
2026-06-20 19:37:13 +01:00
|
|
|
) {
|
2026-06-20 20:55:08 +01:00
|
|
|
let popup_area = centered_rect(80, 13, area);
|
2026-06-21 15:45:14 +01:00
|
|
|
frame.render_widget(Clear, popup_area);
|
2026-06-20 19:56:41 +01:00
|
|
|
|
|
|
|
|
let border_color = if error.is_some() {
|
|
|
|
|
Color::Red
|
2026-06-21 10:04:27 +01:00
|
|
|
} else if url == "starting..." || !url.is_empty() {
|
2026-06-20 20:55:08 +01:00
|
|
|
Color::Yellow
|
2026-06-20 19:56:41 +01:00
|
|
|
} else {
|
2026-06-21 10:04:27 +01:00
|
|
|
POPUP_BORDER
|
2026-06-20 19:56:41 +01:00
|
|
|
};
|
|
|
|
|
|
2026-06-20 19:37:13 +01:00
|
|
|
let block = Block::default()
|
|
|
|
|
.borders(Borders::ALL)
|
|
|
|
|
.style(Style::default().bg(POPUP_BG))
|
2026-06-20 19:56:41 +01:00
|
|
|
.border_style(Style::default().fg(border_color))
|
2026-06-20 20:55:08 +01:00
|
|
|
.title(" Google Tasks - Authorization ")
|
2026-06-20 19:37:13 +01:00
|
|
|
.title_alignment(Alignment::Left);
|
|
|
|
|
|
2026-06-20 19:56:41 +01:00
|
|
|
let mut lines = Vec::new();
|
|
|
|
|
|
|
|
|
|
if let Some(err) = error {
|
|
|
|
|
lines.push(Line::from(""));
|
|
|
|
|
lines.push(Line::from(Span::styled(
|
2026-06-20 20:55:08 +01:00
|
|
|
" Authorization Error ",
|
2026-06-20 19:56:41 +01:00
|
|
|
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
|
|
|
|
|
)));
|
|
|
|
|
lines.push(Line::from(""));
|
2026-06-20 20:55:08 +01:00
|
|
|
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),
|
|
|
|
|
)));
|
2026-06-21 10:04:27 +01:00
|
|
|
} else if url == "starting..." || !url.is_empty() {
|
2026-06-20 20:55:08 +01:00
|
|
|
lines.push(Line::from(""));
|
|
|
|
|
lines.push(Line::from(Span::styled(
|
2026-06-21 10:04:27 +01:00
|
|
|
" Authorization in progress... ",
|
2026-06-20 20:55:08 +01:00
|
|
|
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD),
|
|
|
|
|
)));
|
|
|
|
|
lines.push(Line::from(""));
|
2026-06-20 19:56:41 +01:00
|
|
|
lines.push(Line::from(Span::styled(
|
2026-06-21 10:04:27 +01:00
|
|
|
" A browser tab should open automatically. ",
|
2026-06-20 20:55:08 +01:00
|
|
|
Style::default().fg(Color::White),
|
|
|
|
|
)));
|
|
|
|
|
lines.push(Line::from(Span::styled(
|
2026-06-21 10:04:27 +01:00
|
|
|
" If not, check that GOOGLE_CLIENT_ID and ",
|
|
|
|
|
Style::default().fg(Color::DarkGray),
|
2026-06-20 19:56:41 +01:00
|
|
|
)));
|
|
|
|
|
lines.push(Line::from(Span::styled(
|
2026-06-21 10:04:27 +01:00
|
|
|
" GOOGLE_CLIENT_SECRET are set correctly. ",
|
|
|
|
|
Style::default().fg(Color::DarkGray),
|
2026-06-20 19:56:41 +01:00
|
|
|
)));
|
2026-06-21 10:04:27 +01:00
|
|
|
lines.push(Line::from(""));
|
2026-06-20 19:56:41 +01:00
|
|
|
lines.push(Line::from(Span::styled(
|
2026-06-21 10:04:27 +01:00
|
|
|
" (Esc to cancel) ",
|
2026-06-20 19:56:41 +01:00
|
|
|
Style::default().fg(Color::DarkGray),
|
|
|
|
|
)));
|
|
|
|
|
} else {
|
|
|
|
|
lines.push(Line::from(""));
|
|
|
|
|
lines.push(Line::from(Span::styled(
|
2026-06-21 10:04:27 +01:00
|
|
|
" Google Tasks Authorization ",
|
2026-06-20 20:55:08 +01:00
|
|
|
Style::default().fg(Color::White).add_modifier(Modifier::BOLD),
|
2026-06-20 19:56:41 +01:00
|
|
|
)));
|
|
|
|
|
lines.push(Line::from(""));
|
2026-06-21 10:04:27 +01:00
|
|
|
lines.push(Line::from(Span::styled(
|
|
|
|
|
" This app needs access to your Google Tasks. ",
|
|
|
|
|
Style::default().fg(Color::White),
|
|
|
|
|
)));
|
2026-06-20 19:56:41 +01:00
|
|
|
lines.push(Line::from(""));
|
|
|
|
|
lines.push(Line::from(Span::styled(
|
2026-06-21 10:04:27 +01:00
|
|
|
" Press Enter to start ",
|
|
|
|
|
Style::default().fg(Color::Cyan),
|
2026-06-20 19:56:41 +01:00
|
|
|
)));
|
|
|
|
|
lines.push(Line::from(Span::styled(
|
2026-06-21 10:04:27 +01:00
|
|
|
" Press Esc to skip ",
|
2026-06-20 19:37:13 +01:00
|
|
|
Style::default().fg(Color::DarkGray),
|
2026-06-20 19:56:41 +01:00
|
|
|
)));
|
|
|
|
|
}
|
2026-06-20 19:37:13 +01:00
|
|
|
|
2026-06-20 19:56:41 +01:00
|
|
|
let paragraph = Paragraph::new(Text::from(lines))
|
2026-06-20 19:37:13 +01:00
|
|
|
.block(block)
|
2026-06-20 20:55:08 +01:00
|
|
|
.alignment(Alignment::Left);
|
2026-06-20 19:37:13 +01:00
|
|
|
|
|
|
|
|
frame.render_widget(paragraph, popup_area);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-20 20:55:08 +01:00
|
|
|
/// 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
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-20 19:37:13 +01:00
|
|
|
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]
|
|
|
|
|
}
|