Feature 3: Read-only Google Calendar panel
- CalendarEvent model with summary, start, end, location - New scope calendar.readonly in SCOPES - fetch_upcoming_events() in ApiClient (15 events, next 7 days) - Focus::Calendar variant, Tab cycle includes Calendar - Body layout: left column split into Tasks (flex) + Calendar (8) - render_calendar_panel with day headers and scroll - refresh_calendar() called on initial sync and Ctrl+R - Up/Down scroll Calendar panel when focused
This commit is contained in:
@@ -595,6 +595,77 @@ pub fn render_device_auth_popup(
|
||||
frame.render_widget(paragraph, popup_area);
|
||||
}
|
||||
|
||||
pub fn render_calendar_panel(
|
||||
frame: &mut Frame,
|
||||
area: Rect,
|
||||
events: &[CalendarEvent],
|
||||
focused: bool,
|
||||
scroll: u16,
|
||||
) {
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(if focused { FOCUS_COLOR } else { Color::DarkGray }))
|
||||
.title(" Calendar ")
|
||||
.title_alignment(Alignment::Left);
|
||||
|
||||
if events.is_empty() {
|
||||
let paragraph = Paragraph::new(Text::from(Line::from(Span::styled(
|
||||
" No upcoming events ",
|
||||
Style::default().fg(Color::DarkGray),
|
||||
))))
|
||||
.block(block)
|
||||
.alignment(Alignment::Center);
|
||||
frame.render_widget(paragraph, area);
|
||||
return;
|
||||
}
|
||||
|
||||
let mut lines: Vec<Line> = Vec::new();
|
||||
let mut current_date: Option<chrono::NaiveDate> = None;
|
||||
|
||||
for event in events {
|
||||
if let Some(start) = event.start {
|
||||
let event_date = start.date();
|
||||
if Some(event_date) != current_date {
|
||||
current_date = Some(event_date);
|
||||
let day_header = format!(
|
||||
" --- {} {} --- ",
|
||||
event_date.format("%A"),
|
||||
event_date.format("%d/%m"),
|
||||
);
|
||||
lines.push(Line::from(Span::styled(
|
||||
day_header,
|
||||
Style::default().fg(Color::Gray),
|
||||
)));
|
||||
}
|
||||
|
||||
let time_str = start.format("%H:%M").to_string();
|
||||
let summary = &event.summary;
|
||||
let line_text = if summary.len() > 30 {
|
||||
format!(" {} {:.30}…", time_str, summary)
|
||||
} else {
|
||||
format!(" {} {}", time_str, summary)
|
||||
};
|
||||
lines.push(Line::from(Span::styled(
|
||||
line_text,
|
||||
Style::default().fg(DETAIL_COLOR),
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
let inner_h = (area.height as usize).saturating_sub(2);
|
||||
let visible_lines: Vec<Line> = lines
|
||||
.iter()
|
||||
.skip(scroll as usize)
|
||||
.take(inner_h)
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
let paragraph = Paragraph::new(Text::from(visible_lines))
|
||||
.block(block)
|
||||
.scroll((0, 0));
|
||||
frame.render_widget(paragraph, 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();
|
||||
|
||||
Reference in New Issue
Block a user