Files
task_app_rust/src/ui/components.rs
T

832 lines
27 KiB
Rust
Raw Normal View History

use std::collections::BTreeSet;
use chrono::Datelike;
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span, Text};
2026-06-21 16:57:28 +01:00
use ratatui::widgets::{Block, Borders, Clear, List, ListItem, Paragraph, Tabs, Wrap};
2026-06-21 15:45:14 +01:00
use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect};
use ratatui::Frame;
use crate::domain::models::*;
2026-06-21 14:21:14 +01:00
use crate::app::SyncStats;
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);
}
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,
)
}
}
pub fn render_task_list(
frame: &mut Frame,
area: Rect,
tasks: &[Task],
selected: usize,
focused: bool,
_scroll: u16,
selected_tasks: &BTreeSet<usize>,
) {
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;
let content_width = (area.width as usize).saturating_sub(5);
let items: Vec<ListItem> = tasks
.iter()
.enumerate()
.map(|(idx, task)| {
let is_selected = selected_tasks.contains(&idx);
let checkbox = match task.status {
TaskStatus::Completed => "[\u{2713}]",
TaskStatus::NeedsAction => "[ ]",
};
let (due_text, due_color) = task
.due
.map(relative_due_str)
.unwrap_or((String::new(), Color::DarkGray));
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)
};
let checkbox_style = if is_selected {
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD).bg(Color::DarkGray)
} else if task.status == TaskStatus::Completed {
Style::default().fg(Color::Green)
} else {
Style::default().fg(Color::DarkGray)
};
let title_style = if is_selected {
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD).bg(Color::DarkGray)
} else {
Style::default().fg(DETAIL_COLOR).add_modifier(
if task.status == TaskStatus::Completed {
Modifier::CROSSED_OUT
} else {
Modifier::empty()
},
)
};
let mut spans = vec![
Span::styled(checkbox_str, checkbox_style),
Span::styled(display_title, title_style),
];
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))
})
.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))
.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(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),
)));
}
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)
2026-06-21 16:57:28 +01:00
.wrap(Wrap { trim: false })
.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-21 14:21:14 +01:00
let (status_text, color) = match status {
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);
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,
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);
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,
notes_scroll: u16,
2026-06-21 15:45:14 +01:00
active_field: usize,
) {
let popup_area = centered_rect(75, 14, 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);
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(5),
2026-06-21 15:45:14 +01:00
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 {
Style::default().fg(FOCUS_COLOR)
2026-06-21 15:45:14 +01:00
} else {
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 {
Style::default().fg(FOCUS_COLOR)
2026-06-21 15:45:14 +01:00
} else {
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_lines: Vec<Line> = notes.lines().map(|l| Line::from(Span::raw(l.to_string()))).collect();
let notes_para = Paragraph::new(Text::from(notes_lines))
.block(notes_block)
.wrap(Wrap { trim: false })
.scroll((notes_scroll, 0));
2026-06-21 15:45:14 +01:00
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));
}
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);
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, context: &str) {
let popup_area = centered_rect(60, 6, area);
2026-06-21 15:45:14 +01:00
frame.render_widget(Clear, popup_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(
format!(" {} ", context),
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_bulk_action_popup(frame: &mut Frame, area: Rect, count: usize, selected: usize) {
let popup_area = centered_rect(55, 12, area);
frame.render_widget(Clear, popup_area);
let block = Block::default()
.borders(Borders::ALL)
.style(Style::default().bg(POPUP_BG))
.border_style(Style::default().fg(POPUP_BORDER))
.title(format!(" Bulk Actions ({} selected) ", count))
.title_alignment(Alignment::Left);
let options = [
"1. Mark as completed",
"2. Mark as uncomplete",
"3. Set due date to Today",
"4. Set due date to Tomorrow",
"5. Set due date to Next Week",
"6. Move to new list...",
"7. Move to existing list...",
];
let mut lines = vec![Line::from("")];
for (i, opt) in options.iter().enumerate() {
let style = if i == selected {
Style::default().fg(FOCUS_COLOR).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::Cyan)
};
lines.push(Line::from(Span::styled(
format!(" {}", opt),
style,
)));
}
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
" ↑/↓: navigate Enter:ok 1-7:shortcut Esc:cancel",
Style::default().fg(Color::DarkGray),
)));
let paragraph = Paragraph::new(Text::from(lines))
.block(block)
.alignment(Alignment::Left);
frame.render_widget(paragraph, popup_area);
}
pub fn render_pick_list_popup(
frame: &mut Frame,
area: Rect,
lists: &[(String, String)],
selected: usize,
) {
let popup_area = centered_rect(60, 10, area);
frame.render_widget(Clear, popup_area);
let block = Block::default()
.borders(Borders::ALL)
.style(Style::default().bg(POPUP_BG))
.border_style(Style::default().fg(POPUP_BORDER))
.title(" Select List ")
.title_alignment(Alignment::Left);
let mut lines = vec![Line::from("")];
for (i, (title, _)) in lists.iter().enumerate() {
let style = if i == selected {
Style::default().fg(FOCUS_COLOR).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::Cyan)
};
lines.push(Line::from(Span::styled(
format!(" {}", title),
style,
)));
}
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
" ↑/↓: navigate Enter:ok Esc:cancel",
Style::default().fg(Color::DarkGray),
)));
let paragraph = Paragraph::new(Text::from(lines))
.block(block)
.alignment(Alignment::Left);
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);
2026-06-21 15:45:14 +01:00
frame.render_widget(Clear, popup_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);
}
2026-06-21 17:18:22 +01:00
pub fn render_calendar_panel(
frame: &mut Frame,
area: Rect,
events: &[CalendarEvent],
focused: bool,
scrolls: &[u16; 4],
active_week: usize,
2026-06-21 17:18:22 +01:00
) {
if area.width < 20 || area.height < 3 {
2026-06-21 17:18:22 +01:00
return;
}
let today = chrono::Local::now().naive_local().date();
let weekday = today.weekday().num_days_from_monday();
let this_monday = today - chrono::Duration::days(weekday as i64);
let cols = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Ratio(1, 4); 4])
.split(area);
2026-06-21 17:18:22 +01:00
let has_events = events.iter().any(|e| {
e.start.map_or(false, |s| {
let d = s.date();
d >= this_monday && d < this_monday + chrono::Duration::days(28)
})
});
for week_idx in 0..4 {
let week_start = this_monday + chrono::Duration::weeks(week_idx as i64);
let week_title = format!(" W/C {} ", week_start.format("%d/%m"));
let col_area = cols[week_idx];
let border = if focused && week_idx == active_week { FOCUS_COLOR } else { Color::DarkGray };
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(border))
.title(week_title)
.title_alignment(Alignment::Left);
if !has_events {
let msg = if week_idx == 0 {
" No upcoming events "
2026-06-21 17:18:22 +01:00
} else {
""
2026-06-21 17:18:22 +01:00
};
let paragraph = Paragraph::new(Text::from(Line::from(Span::styled(
msg,
Style::default().fg(Color::DarkGray),
))))
.block(block);
frame.render_widget(paragraph, col_area);
continue;
2026-06-21 17:18:22 +01:00
}
let mut lines: Vec<Line> = Vec::new();
for day_offset in 0..7 {
let day = week_start + chrono::Duration::days(day_offset);
let day_style = if day == today {
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)
} else if matches!(day.weekday(), chrono::Weekday::Sat | chrono::Weekday::Sun) {
Style::default().fg(Color::Magenta).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)
};
let day_label = format!(
" {} {}",
match day.weekday() {
chrono::Weekday::Mon => "Mon",
chrono::Weekday::Tue => "Tue",
chrono::Weekday::Wed => "Wed",
chrono::Weekday::Thu => "Thu",
chrono::Weekday::Fri => "Fri",
chrono::Weekday::Sat => "Sat",
chrono::Weekday::Sun => "Sun",
},
day.format("%d/%m")
);
lines.push(Line::from(Span::styled(day_label, day_style)));
for event in events.iter().filter(|e| e.start.map_or(false, |s| s.date() == day)) {
let time_str = event.start.map(|s| s.format("%H:%M").to_string()).unwrap_or_default();
let line_text = format!(" {} {}", time_str, event.summary);
lines.push(Line::from(Span::styled(
line_text,
Style::default().fg(DETAIL_COLOR),
)));
}
}
let paragraph = Paragraph::new(Text::from(lines))
.block(block)
.scroll((scrolls[week_idx], 0));
frame.render_widget(paragraph, col_area);
}
2026-06-21 17:18:22 +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
}
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]
}