1use std::cell::Cell;
4use std::rc::Rc;
5
6use ravenrs_core::{Border, Color, CursorIcon, Insets, Role};
7use ravenrs_layout::{Align, Axis, Justify, LayoutStyle};
8use ravenrs_reactive::Binding;
9use ravenrs_style::{Semantic, TextStyle, Weight};
10
11use crate::calendar::GridKeys;
12use crate::context::{BuildCx, NodeId, Paint, QuadPaint, Semantics};
13use crate::date::Date;
14use crate::display::Tone;
15use crate::recurrence::{Frequency, RecurrenceEnd, RecurrenceRule};
16use crate::{
17 Button, ButtonSize, ButtonStyle, DatePicker, FormField, HStack, IconButton, IconName, SegmentedControl,
18 Stepper, Text, ToggleButton, VStack, View, util,
19};
20
21#[derive(Clone, Debug, PartialEq, Eq)]
23#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
24pub struct CalendarEvent {
25 pub id: u64,
27 pub date: Date,
29 pub title: String,
31 pub tone: Tone,
33 pub done: bool,
35}
36
37#[cfg_attr(feature = "live", ravenrs_live::expose)]
38impl CalendarEvent {
39 pub fn new(id: u64, date: Date, title: impl Into<String>) -> Self {
41 Self { id, date, title: title.into(), tone: Tone::Accent, done: false }
42 }
43
44 pub fn tone(mut self, tone: Tone) -> Self {
46 self.tone = tone;
47 self
48 }
49
50 pub fn done(mut self, done: bool) -> Self {
52 self.done = done;
53 self
54 }
55}
56
57#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
77pub struct EventCalendar {
78 month: Binding<Date>,
79 selected: Binding<Option<Date>>,
80 events: Vec<CalendarEvent>,
81 today: Option<Date>,
82 per_day: usize,
83 on_event: Option<Rc<dyn Fn(u64)>>,
84}
85
86impl std::fmt::Debug for EventCalendar {
87 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
88 f.debug_struct("EventCalendar").field("events", &self.events.len()).finish_non_exhaustive()
89 }
90}
91
92const DAY_HEIGHT: f32 = 104.0;
94
95#[cfg_attr(feature = "live", ravenrs_live::expose)]
96impl EventCalendar {
97 pub fn new(month: Binding<Date>, selected: Binding<Option<Date>>) -> Self {
100 Self { month, selected, events: Vec::new(), today: None, per_day: 3, on_event: None }
101 }
102
103 pub fn events(mut self, events: impl IntoIterator<Item = CalendarEvent>) -> Self {
105 self.events = events.into_iter().collect();
106 self
107 }
108
109 pub fn today(mut self, today: Date) -> Self {
111 self.today = Some(today);
112 self
113 }
114
115 pub fn events_per_day(mut self, count: usize) -> Self {
117 self.per_day = count.max(1);
118 self
119 }
120
121 pub fn on_event(mut self, handler: impl Fn(u64) + 'static) -> Self {
123 self.on_event = Some(Rc::new(handler));
124 self
125 }
126}
127
128struct EventChip<'a> {
129 event: &'a CalendarEvent,
130 selected: &'a Binding<Option<Date>>,
131 on_event: Option<Rc<dyn Fn(u64)>>,
132}
133
134impl View for EventChip<'_> {
135 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
136 let theme = *cx.theme();
137 let event = self.event;
138 let hovered = cx.is_hovered();
139 let color = if event.done { theme.color(Semantic::InkMuted) } else { event.tone.color(&theme) };
140 let fill = color.with_alpha(match (event.done, hovered) {
141 (true, false) => 0.08,
142 (true, true) | (false, false) => 0.16,
143 (false, true) => 0.26,
144 });
145 let mut children = Vec::new();
146 if event.done {
147 children.push(util::icon(cx, IconName::Check, 12.0, color));
148 }
149 let ink = if event.done { color } else { theme.color(Semantic::Ink) };
150 let title =
151 cx.child(&Text::new(event.title.clone()).style(TextStyle::Caption).foreground(ink).truncate());
152 children.push(cx.container(
153 LayoutStyle {
154 grow: 1.0,
155 min_width: Some(0.0),
156 overflow_hidden: true,
157 ..LayoutStyle::stack(Axis::Horizontal)
158 },
159 &[title],
160 Paint::None,
161 ));
162 let node = cx.container(
163 LayoutStyle {
164 padding: Insets::symmetric(2.0, 6.0),
165 spacing: 4.0,
166 align: Align::Center,
167 min_width: Some(0.0),
168 overflow_hidden: true,
169 ..LayoutStyle::stack(Axis::Horizontal)
170 },
171 &children,
172 Paint::Quad(QuadPaint { corner_radius: theme.radii.sm, ..QuadPaint::new(fill) }),
173 );
174 cx.semantics(node, Semantics::new(Role::Button).name(event.title.clone()));
175 let (selected, date, id, on_event) =
176 (self.selected.clone(), event.date, event.id, self.on_event.clone());
177 cx.on_click(
178 node,
179 Rc::new(move || {
180 selected.set(Some(date));
181 if let Some(handler) = &on_event {
182 handler(id);
183 }
184 }),
185 );
186 cx.cursor(node, CursorIcon::Pointer);
187 node
188 }
189}
190
191struct CalendarDay<'a> {
192 date: Date,
193 in_month: bool,
194 active: bool,
195 events: Vec<&'a CalendarEvent>,
196 scheduler: &'a EventCalendar,
197 keys: &'a GridKeys,
198}
199
200impl View for CalendarDay<'_> {
201 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
202 let theme = *cx.theme();
203 let s = self.scheduler;
204 let labels = cx.labels();
205 let (selected, is_today) = (s.selected.get() == Some(self.date), s.today == Some(self.date));
206 let hovered = cx.is_hovered() || cx.is_focus_visible();
207 let ink = theme.color(Semantic::Ink);
208 let number_color = if is_today {
209 theme.color(Semantic::OnAccent)
210 } else if self.in_month {
211 ink
212 } else {
213 theme.color(Semantic::InkMuted)
214 };
215 let weight = is_today.then_some(Weight::SEMIBOLD);
216 let number = util::label(cx, &self.date.day().to_string(), TextStyle::Caption, weight, number_color);
217 cx.semantics(number, Semantics::hidden());
218 let number = cx.container(
219 LayoutStyle {
220 width: Some(22.0),
221 height: Some(22.0),
222 align: Align::Center,
223 justify: Justify::Center,
224 align_self: Some(Align::Fit),
225 ..LayoutStyle::stack(Axis::Horizontal)
226 },
227 &[number],
228 if is_today {
229 Paint::Quad(QuadPaint {
230 corner_radius: 11.0,
231 ..QuadPaint::new(theme.color(Semantic::Accent))
232 })
233 } else {
234 Paint::None
235 },
236 );
237 let mut children = vec![number];
238 for event in self.events.iter().take(s.per_day) {
239 let chip = EventChip { event, selected: &s.selected, on_event: s.on_event.clone() };
240 children.push(cx.keyed_child(event.id, &chip));
241 }
242 let hidden = self.events.len().saturating_sub(s.per_day);
243 if hidden > 0 {
244 let more = format!("+{hidden} {}", labels.calendar_more);
245 let line = util::label(cx, &more, TextStyle::Caption, None, theme.color(Semantic::InkMuted));
246 cx.semantics(line, Semantics::hidden());
247 children.push(line);
248 }
249 let fill = if hovered {
250 ink.with_alpha(0.05)
251 } else if self.in_month {
252 Color::TRANSPARENT
253 } else {
254 ink.with_alpha(0.02)
255 };
256 let border = if selected {
257 Border { width: 1.5, color: theme.color(Semantic::Accent) }
258 } else {
259 Border { width: 1.0, color: theme.color(Semantic::Border) }
260 };
261 let node = cx.container(
262 LayoutStyle {
263 min_height: Some(DAY_HEIGHT),
264 min_width: Some(0.0),
265 padding: Insets::uniform(4.0),
266 spacing: 3.0,
267 align: Align::Stretch,
268 overflow_hidden: true,
269 ..LayoutStyle::stack(Axis::Vertical)
270 },
271 &children,
272 Paint::Quad(QuadPaint {
273 corner_radius: theme.radii.sm,
274 border: Some(border),
275 ..QuadPaint::new(fill)
276 }),
277 );
278 let mut semantics = Semantics::new(Role::Button).name(labels.long_date(self.date)).selected(selected);
279 let mut notes: Vec<String> = Vec::new();
280 if is_today {
281 notes.push(labels.today.clone());
282 }
283 notes.extend(self.events.iter().map(|e| e.title.clone()));
284 if !notes.is_empty() {
285 semantics = semantics.description(notes.join(", "));
286 }
287 cx.semantics(node, semantics);
288 if self.keys.focus_to.get() == Some(self.date) {
289 self.keys.focus_to.set(None);
290 cx.request_focus();
291 }
292 if self.active {
293 cx.focusable(node, theme.radii.sm);
294 cx.on_key(node, self.keys.handler(self.date));
295 }
296 let (pick, date) = (s.selected.clone(), self.date);
297 cx.on_click(node, Rc::new(move || pick.set(Some(date))));
298 cx.cursor(node, CursorIcon::Pointer);
299 node
300 }
301}
302
303impl View for EventCalendar {
304 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
305 let theme = *cx.theme();
306 let labels = cx.labels();
307 let shown = self.month.get().first_of_month();
308 let (prev, next) = (self.month.clone(), self.month.clone());
309 let mut header = vec![
310 cx.child(
311 &IconButton::new(IconName::ChevronLeft)
312 .label(labels.previous_month.clone())
313 .size(ButtonSize::Small)
314 .on_click(move || prev.set(shown.add_months(-1))),
315 ),
316 cx.child(
317 &IconButton::new(IconName::ChevronRight)
318 .label(labels.next_month.clone())
319 .size(ButtonSize::Small)
320 .on_click(move || next.set(shown.add_months(1))),
321 ),
322 util::label(
323 cx,
324 &labels.month_title(shown),
325 TextStyle::Headline,
326 None,
327 theme.color(Semantic::Ink),
328 ),
329 ];
330 if let Some(today) = self.today {
331 let (month, selected) = (self.month.clone(), self.selected.clone());
332 header.push(
333 cx.child(
334 &Button::new(labels.today.clone())
335 .style(ButtonStyle::Ghost)
336 .size(ButtonSize::Small)
337 .on_click(move || {
338 month.set(today.first_of_month());
339 selected.set(Some(today));
340 }),
341 ),
342 );
343 }
344 let header = cx.container(
345 LayoutStyle { spacing: 6.0, align: Align::Center, ..LayoutStyle::stack(Axis::Horizontal) },
346 &header,
347 Paint::None,
348 );
349 let mut cells: Vec<NodeId> = labels
350 .weekdays
351 .iter()
352 .map(|d| {
353 let name = util::label(cx, d, TextStyle::Caption, None, theme.color(Semantic::InkMuted));
354 cx.semantics(name, Semantics::hidden());
356 name
357 })
358 .collect();
359 let keys = GridKeys {
360 month: self.month.clone(),
361 cursor: cx.use_state(|| None),
362 focus_to: cx.use_state(|| Rc::new(Cell::new(None))).get(),
363 };
364 let in_shown = |d: &Date| d.first_of_month() == shown;
365 let active = [keys.cursor.get(), self.selected.get(), self.today]
366 .into_iter()
367 .flatten()
368 .find(in_shown)
369 .unwrap_or(shown);
370 let start = shown.add_days(-i32::from(shown.weekday()));
372 let last = shown.add_months(1).add_days(-1);
373 let days = i32::try_from(last.to_days() - start.to_days()).unwrap_or(34) / 7 * 7 + 7;
374 for i in 0..days {
375 let date = start.add_days(i);
376 let events = self.events.iter().filter(|e| e.date == date).collect();
377 let day = CalendarDay {
378 date,
379 in_month: in_shown(&date),
380 active: date == active,
381 events,
382 scheduler: self,
383 keys: &keys,
384 };
385 cells.push(cx.keyed_child(date.to_days() as u64, &day));
386 }
387 let grid = cx.container(
388 LayoutStyle {
389 grid_columns: Some(7),
390 spacing: 4.0,
391 align_self: Some(Align::Stretch),
392 ..LayoutStyle::stack(Axis::Vertical)
393 },
394 &cells,
395 Paint::None,
396 );
397 cx.semantics(grid, Semantics::new(Role::Calendar).name(labels.month_title(shown)));
398 cx.container(
399 LayoutStyle { spacing: 10.0, align: Align::Stretch, ..LayoutStyle::stack(Axis::Vertical) },
400 &[header, grid],
401 Paint::None,
402 )
403 }
404}
405
406#[derive(Debug)]
420#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
421pub struct RecurrenceEditor {
422 rule: Binding<RecurrenceRule>,
423 start: Date,
424}
425
426#[cfg_attr(feature = "live", ravenrs_live::expose)]
427impl RecurrenceEditor {
428 pub fn new(rule: Binding<RecurrenceRule>, start: Date) -> Self {
430 Self { rule, start }
431 }
432}
433
434fn part<T: 'static>(
436 rule: &Binding<RecurrenceRule>,
437 read: impl Fn(&RecurrenceRule) -> T + 'static,
438 write: impl Fn(&mut RecurrenceRule, T) + 'static,
439) -> Binding<T> {
440 let (from, to) = (rule.clone(), rule.clone());
441 Binding::new(move || read(&from.get()), move |value| to.update(|r| write(r, value)))
442}
443
444impl View for RecurrenceEditor {
445 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
446 let labels = cx.labels();
447 let rule = self.rule.get();
448 let repeats = rule.frequency != Frequency::Once;
449 let frequency = part(
450 &self.rule,
451 |r| Frequency::ALL.iter().position(|f| *f == r.frequency).unwrap_or(0),
452 |r, i: usize| r.frequency = Frequency::ALL[i.min(Frequency::ALL.len() - 1)],
453 );
454 let mut rows: Vec<crate::AnyView> = vec![Box::new(
455 SegmentedControl::new(frequency, labels.recurrence_frequencies.clone())
456 .label(labels.recurrence_name.clone()),
457 )];
458 if repeats {
459 let interval = part(
460 &self.rule,
461 |r| i32::try_from(r.interval.max(1)).unwrap_or(1),
462 |r, n: i32| r.interval = u32::try_from(n.max(1)).unwrap_or(1),
463 );
464 rows.push(Box::new(
465 HStack::new((
466 Text::new(labels.recurrence_every.clone()).style(TextStyle::Callout),
467 Stepper::new(interval).range(1, 99).label(labels.recurrence_every.clone()),
468 Text::new(labels.recurrence_unit(rule.frequency, rule.interval).to_owned())
469 .style(TextStyle::Callout),
470 ))
471 .spacing(8.0)
472 .align(Align::Center),
473 ));
474 }
475 if rule.frequency == Frequency::Weekly {
476 let own = usize::from(self.start.weekday());
477 let days: Vec<crate::AnyView> = (0..7)
478 .map(|day| {
479 let chosen = part(
480 &self.rule,
481 move |r| if r.weekdays.contains(&true) { r.weekdays[day] } else { day == own },
483 move |r, on: bool| {
484 if !r.weekdays.contains(&true) {
485 r.weekdays[own] = true;
486 }
487 r.weekdays[day] = on;
488 },
489 );
490 Box::new(ToggleButton::new(chosen, labels.weekdays[day].clone())) as crate::AnyView
491 })
492 .collect();
493 rows.push(Box::new(HStack::new(days).spacing(4.0)));
494 }
495 if repeats {
496 let start = self.start;
497 let kind = part(
498 &self.rule,
499 |r| match r.end {
500 RecurrenceEnd::Until(_) => 1,
501 RecurrenceEnd::After(_) => 2,
502 _ => 0,
503 },
504 move |r, i: usize| {
505 r.end = match i {
506 1 => RecurrenceEnd::Until(start.add_months(12)),
507 2 => RecurrenceEnd::After(12),
508 _ => RecurrenceEnd::Never,
509 };
510 },
511 );
512 let ends = SegmentedControl::new(kind, labels.recurrence_ends.clone())
513 .label(labels.recurrence_ends_name.clone());
514 let extra: Option<crate::AnyView> = match rule.end {
515 RecurrenceEnd::Until(day) => {
516 let last = part(
517 &self.rule,
518 |r| if let RecurrenceEnd::Until(day) = r.end { Some(day) } else { None },
519 |r, day: Option<Date>| {
520 if let Some(day) = day {
521 r.end = RecurrenceEnd::Until(day);
522 }
523 },
524 );
525 Some(Box::new(
526 DatePicker::new(last, day).label(labels.recurrence_ends_name.clone()).width(190.0),
527 ))
528 }
529 RecurrenceEnd::After(_) => {
530 let count = part(
531 &self.rule,
532 |r| {
533 if let RecurrenceEnd::After(n) = r.end {
534 i32::try_from(n).unwrap_or(1)
535 } else {
536 1
537 }
538 },
539 |r, n: i32| r.end = RecurrenceEnd::After(u32::try_from(n.max(1)).unwrap_or(1)),
540 );
541 Some(Box::new(
542 HStack::new((
543 Stepper::new(count).range(1, 999).label(labels.recurrence_times.clone()),
544 Text::new(labels.recurrence_times.clone()).style(TextStyle::Callout),
545 ))
546 .spacing(8.0)
547 .align(Align::Center),
548 ))
549 }
550 _ => None,
551 };
552 rows.push(Box::new(FormField::new(
553 labels.recurrence_ends_name.clone(),
554 VStack::new((ends, extra)).spacing(8.0),
555 )));
556 }
557 let summary = labels.recurrence_summary(&rule, self.start);
558 rows.push(Box::new(Text::new(summary).style(TextStyle::Callout).foreground(Semantic::InkMuted)));
559 cx.child(&VStack::new(rows).spacing(10.0).align(Align::Stretch))
560 }
561}