1use super::InteractionPreview;
21use super::button::ButtonFocusRing;
22use super::text_field::type_style;
23use crate::{
24 color::ColorRole,
25 icons::{Icon, Symbol},
26 motion::{self, MotionToken},
27 theme::{blend, color},
28 tokens::{
29 state,
30 typography::{TypeRole, rem},
31 },
32};
33use chrono::{Datelike, Days, Months, NaiveDate, Weekday};
34use gpui::{prelude::*, *};
35use std::rc::Rc;
36
37pub const DAY_CELL: f32 = 48.;
39const DAY_CONTAINER: f32 = 40.;
40pub const YEAR_CELL_HEIGHT: f32 = 52.;
42const YEAR_PILL: (f32, f32) = (72., 36.);
43pub const LIST_ITEM_HEIGHT: f32 = 48.;
45pub const GRID_WEEKS: usize = 6;
47
48pub(crate) type Activate = Rc<dyn Fn(&mut Window, &mut App)>;
49
50pub fn month_weeks(year: i32, month: u32, first_day: Weekday) -> Vec<[NaiveDate; 7]> {
53 let first = first_of_month(year, month);
54 let offset =
55 (first.weekday().num_days_from_sunday() + 7 - first_day.num_days_from_sunday()) % 7;
56 let start = first - Days::new(offset as u64);
57 (0..GRID_WEEKS)
58 .map(|week| std::array::from_fn(|day| start + Days::new((week * 7 + day) as u64)))
59 .collect()
60}
61
62pub fn first_of_month(year: i32, month: u32) -> NaiveDate {
65 let total = year as i64 * 12 + month as i64 - 1;
66 NaiveDate::from_ymd_opt(
67 total.div_euclid(12) as i32,
68 total.rem_euclid(12) as u32 + 1,
69 1,
70 )
71 .unwrap_or(NaiveDate::MIN)
72}
73
74pub fn add_months(date: NaiveDate, months: i32) -> NaiveDate {
76 let moved = if months >= 0 {
77 date.checked_add_months(Months::new(months as u32))
78 } else {
79 date.checked_sub_months(Months::new(months.unsigned_abs()))
80 };
81 moved.unwrap_or(date)
82}
83
84pub fn week_days(first_day: Weekday) -> [Weekday; 7] {
86 let mut day = first_day;
87 std::array::from_fn(|_| {
88 let current = day;
89 day = day.succ();
90 current
91 })
92}
93
94#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
96pub enum DayRange {
97 #[default]
99 None,
100 Start,
102 Middle,
104 End,
106}
107
108#[derive(Clone, Copy, Debug, PartialEq)]
110pub(crate) struct DayPaint {
111 pub fill: Rgba,
112 pub ink: Rgba,
113 pub layer: Rgba,
114 pub outline: Option<Rgba>,
115}
116
117pub(crate) fn day_paint(selected: f32, today: bool, range: bool, adjacent: bool) -> DayPaint {
120 let t = selected.clamp(0., 1.);
121 let rest_ink = if range {
122 color(ColorRole::ON_SECONDARY_CONTAINER)
123 } else if today {
124 color(ColorRole::PRIMARY)
125 } else if adjacent {
126 state::disabled(color(ColorRole::ON_SURFACE))
127 } else {
128 color(ColorRole::ON_SURFACE)
129 };
130 let rest_layer = if today && !range {
131 color(ColorRole::PRIMARY)
132 } else {
133 color(ColorRole::ON_SURFACE)
134 };
135 let primary = color(ColorRole::PRIMARY);
136 DayPaint {
137 fill: Rgba {
138 a: primary.a * t,
139 ..primary
140 },
141 ink: blend(rest_ink, color(ColorRole::ON_PRIMARY), t),
142 layer: blend(rest_layer, color(ColorRole::ON_PRIMARY), t),
143 outline: (today && t < 1.).then_some(Rgba {
144 a: primary.a * (1. - t),
145 ..primary
146 }),
147 }
148}
149
150pub(crate) fn disabled_day_paint(paint: DayPaint, selected: f32) -> DayPaint {
155 let on_surface = color(ColorRole::ON_SURFACE);
156 let content = state::disabled(on_surface);
157 DayPaint {
158 fill: on_surface.opacity(state::DISABLED_CONTAINER * selected.clamp(0., 1.)),
159 ink: content,
160 layer: paint.layer,
161 outline: paint.outline.map(|_| content),
162 }
163}
164
165pub(crate) fn disabled_year_paint(selected: bool) -> (Rgba, Rgba) {
167 let on_surface = color(ColorRole::ON_SURFACE);
168 (
169 on_surface.opacity(if selected {
170 state::DISABLED_CONTAINER
171 } else {
172 0.
173 }),
174 state::disabled(on_surface),
175 )
176}
177
178#[derive(gpui::IntoElement)]
180pub struct CalendarDay {
181 id: ElementId,
182 label: SharedString,
183 accessible: Option<SharedString>,
184 selected: bool,
185 today: bool,
186 adjacent: bool,
187 range: DayRange,
188 disabled: bool,
189 cursor: bool,
190 animate: bool,
191 preview: Option<InteractionPreview>,
192 on_click: Option<Activate>,
193}
194
195impl CalendarDay {
196 pub fn new(id: impl Into<ElementId>, label: impl Into<SharedString>) -> Self {
198 Self {
199 id: id.into(),
200 label: label.into(),
201 accessible: None,
202 selected: false,
203 today: false,
204 adjacent: false,
205 range: DayRange::None,
206 disabled: false,
207 cursor: false,
208 animate: false,
209 preview: None,
210 on_click: None,
211 }
212 }
213 pub fn accessible_label(mut self, label: impl Into<SharedString>) -> Self {
215 self.accessible = Some(label.into());
216 self
217 }
218 pub fn selected(mut self, selected: bool) -> Self {
220 self.selected = selected;
221 self
222 }
223 pub fn today(mut self, today: bool) -> Self {
225 self.today = today;
226 self
227 }
228 pub fn adjacent(mut self, adjacent: bool) -> Self {
230 self.adjacent = adjacent;
231 self
232 }
233 pub fn range(mut self, range: DayRange) -> Self {
235 self.range = range;
236 self
237 }
238 pub fn disabled(mut self, disabled: bool) -> Self {
239 self.disabled = disabled;
240 self
241 }
242 pub(crate) fn cursor(mut self, cursor: bool) -> Self {
245 self.cursor = cursor;
246 self
247 }
248 pub(crate) fn animate(mut self, animate: bool) -> Self {
250 self.animate = animate;
251 self
252 }
253 pub fn preview_state(mut self, state: InteractionPreview) -> Self {
255 self.preview = Some(state);
256 self
257 }
258 pub fn on_click(mut self, f: impl Fn(&mut Window, &mut App) + 'static) -> Self {
259 self.on_click = Some(Rc::new(f));
260 self
261 }
262}
263
264impl RenderOnce for CalendarDay {
265 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
266 let preview = self.preview;
267 let disabled = self.disabled || preview.is_some_and(InteractionPreview::is_disabled);
268 let live = !disabled && preview.is_none() && self.on_click.is_some();
269 let target = if self.selected { 1. } else { 0. };
270 let selected = if self.animate && preview.is_none() {
271 motion::token_value(
272 (self.id.clone(), "selected"),
273 target,
274 MotionToken::FastEffects,
275 window,
276 cx,
277 )
278 } else {
279 target
280 };
281 let range = self.range != DayRange::None;
282 let paint = day_paint(selected, self.today, range && !self.selected, self.adjacent);
283 let paint = if disabled {
284 disabled_day_paint(paint, selected)
285 } else {
286 paint
287 };
288 let forced = |state: InteractionPreview| !disabled && preview == Some(state);
289 let hover = state::hovered(paint.fill, paint.layer);
290 let press = state::pressed(paint.fill, paint.layer);
291 let focused_layer = state::layer(paint.fill, paint.layer, state::FOCUS);
292 let adjacent_hover = self.adjacent && !self.selected;
293 let hover_ink = color(ColorRole::ON_SURFACE_VARIANT);
294 let band = color(ColorRole::SECONDARY_CONTAINER);
295 let half = DAY_CELL / 2.;
296 let keyboard = self.cursor && live;
297 let container = div()
298 .id("day")
299 .size(rem(DAY_CONTAINER))
300 .flex()
301 .items_center()
302 .justify_center()
303 .rounded_full()
304 .bg(paint.fill)
305 .when_some(paint.outline, |c, outline| {
306 c.border(rem(1.)).border_color(outline)
307 })
308 .text_color(paint.ink)
309 .map(|c| type_style(c, TypeRole::BodyLarge))
310 .when(forced(InteractionPreview::Hovered), |c| {
311 c.bg(hover)
312 .when(adjacent_hover, |c| c.text_color(hover_ink))
313 })
314 .when(forced(InteractionPreview::Focused), |c| {
315 c.bg(focused_layer)
316 .when(adjacent_hover, |c| c.text_color(hover_ink))
317 })
318 .when(forced(InteractionPreview::Pressed), |c| c.bg(press))
319 .when(live, |c| {
320 c.cursor_pointer()
321 .hover(move |s| {
322 let s = s.bg(hover);
323 if adjacent_hover {
324 s.text_color(hover_ink)
325 } else {
326 s
327 }
328 })
329 .active(move |s| s.bg(press))
330 })
331 .child(self.label.clone());
332 let ring = ButtonFocusRing::resolved(container, keyboard, rem(DAY_CONTAINER / 2.))
333 .forced(forced(InteractionPreview::Focused));
334 let on_click = self.on_click.filter(|_| live);
335 div()
336 .id(self.id)
337 .relative()
338 .flex_shrink_0()
339 .size(rem(DAY_CELL))
340 .flex()
341 .items_center()
342 .justify_center()
343 .role(Role::GridCell)
344 .aria_label(self.accessible.unwrap_or(self.label))
345 .aria_selected(self.selected || range)
346 .when(self.cursor, |cell| cell.aria_active_descendant())
347 .when(range, |cell| {
348 let (left, width) = match self.range {
349 DayRange::Start => (half, half),
350 DayRange::End => (0., half),
351 _ => (0., DAY_CELL),
352 };
353 cell.child(
354 div()
355 .absolute()
356 .top(rem((DAY_CELL - DAY_CONTAINER) / 2.))
357 .left(rem(left))
358 .w(rem(width))
359 .h(rem(DAY_CONTAINER))
360 .bg(band)
361 .when(disabled, |band| band.opacity(state::DISABLED_CONTENT)),
362 )
363 })
364 .child(ring)
365 .when_some(on_click, |cell, f| {
366 cell.on_click(move |_, window, cx| f(window, cx))
367 })
368 }
369}
370
371#[derive(gpui::IntoElement)]
373pub struct YearChip {
374 id: ElementId,
375 label: SharedString,
376 selected: bool,
377 cursor: bool,
378 disabled: bool,
379 preview: Option<InteractionPreview>,
380 on_click: Option<Activate>,
381}
382
383impl YearChip {
384 pub fn new(id: impl Into<ElementId>, label: impl Into<SharedString>) -> Self {
385 Self {
386 id: id.into(),
387 label: label.into(),
388 selected: false,
389 cursor: false,
390 disabled: false,
391 preview: None,
392 on_click: None,
393 }
394 }
395 pub fn selected(mut self, selected: bool) -> Self {
396 self.selected = selected;
397 self
398 }
399 pub(crate) fn cursor(mut self, cursor: bool) -> Self {
400 self.cursor = cursor;
401 self
402 }
403 pub fn disabled(mut self, disabled: bool) -> Self {
404 self.disabled = disabled;
405 self
406 }
407 pub fn preview_state(mut self, state: InteractionPreview) -> Self {
408 self.preview = Some(state);
409 self
410 }
411 pub fn on_click(mut self, f: impl Fn(&mut Window, &mut App) + 'static) -> Self {
412 self.on_click = Some(Rc::new(f));
413 self
414 }
415}
416
417impl RenderOnce for YearChip {
418 fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
419 let preview = self.preview;
420 let disabled = self.disabled || preview.is_some_and(InteractionPreview::is_disabled);
421 let live = !disabled && preview.is_none() && self.on_click.is_some();
422 let forced = |state: InteractionPreview| !disabled && preview == Some(state);
423 let (fill, ink, layer, role) = if self.selected {
424 (
425 color(ColorRole::PRIMARY),
426 color(ColorRole::ON_PRIMARY),
427 color(ColorRole::ON_PRIMARY),
428 TypeRole::TitleMedium,
429 )
430 } else {
431 let clear = Rgba {
432 a: 0.,
433 ..color(ColorRole::ON_SURFACE)
434 };
435 (
436 clear,
437 color(ColorRole::ON_SURFACE_VARIANT),
438 color(ColorRole::ON_SURFACE),
439 TypeRole::BodyLarge,
440 )
441 };
442 let (fill, ink) = if disabled {
443 disabled_year_paint(self.selected)
444 } else {
445 (fill, ink)
446 };
447 let hover = state::hovered(fill, layer);
448 let press = state::pressed(fill, layer);
449 let focus = state::layer(fill, layer, state::FOCUS);
450 let pill = div()
451 .id("year")
452 .w(rem(YEAR_PILL.0))
453 .h(rem(YEAR_PILL.1))
454 .flex()
455 .items_center()
456 .justify_center()
457 .rounded_full()
458 .bg(fill)
459 .text_color(ink)
460 .map(|c| type_style(c, role))
461 .when(forced(InteractionPreview::Hovered), |c| c.bg(hover))
462 .when(forced(InteractionPreview::Focused), |c| c.bg(focus))
463 .when(forced(InteractionPreview::Pressed), |c| c.bg(press))
464 .when(live, |c| {
465 c.cursor_pointer()
466 .hover(move |s| s.bg(hover))
467 .active(move |s| s.bg(press))
468 })
469 .child(self.label.clone());
470 let ring = ButtonFocusRing::resolved(pill, self.cursor && live, rem(YEAR_PILL.1 / 2.))
471 .forced(forced(InteractionPreview::Focused));
472 let on_click = self.on_click.filter(|_| live);
473 div()
474 .id(self.id)
475 .flex_1()
476 .h(rem(YEAR_CELL_HEIGHT))
477 .flex()
478 .items_center()
479 .justify_center()
480 .role(Role::GridCell)
481 .aria_label(self.label)
482 .aria_selected(self.selected)
483 .when(self.cursor, |cell| cell.aria_active_descendant())
484 .child(ring)
485 .when_some(on_click, |cell, f| {
486 cell.on_click(move |_, window, cx| f(window, cx))
487 })
488 }
489}
490
491#[derive(gpui::IntoElement)]
495pub struct PickerMenuButton {
496 id: ElementId,
497 label: SharedString,
498 accessible: Option<SharedString>,
499 open: bool,
500 disabled: bool,
501 arrow: bool,
502 preview: Option<InteractionPreview>,
503 on_click: Option<Activate>,
504}
505
506impl PickerMenuButton {
507 pub fn new(id: impl Into<ElementId>, label: impl Into<SharedString>) -> Self {
508 Self {
509 id: id.into(),
510 label: label.into(),
511 accessible: None,
512 open: false,
513 disabled: false,
514 arrow: true,
515 preview: None,
516 on_click: None,
517 }
518 }
519 pub fn accessible_label(mut self, label: impl Into<SharedString>) -> Self {
521 self.accessible = Some(label.into());
522 self
523 }
524 pub fn open(mut self, open: bool) -> Self {
526 self.open = open;
527 self
528 }
529 pub fn disabled(mut self, disabled: bool) -> Self {
530 self.disabled = disabled;
531 self
532 }
533 pub fn arrow(mut self, arrow: bool) -> Self {
535 self.arrow = arrow;
536 self
537 }
538 pub fn preview_state(mut self, state: InteractionPreview) -> Self {
539 self.preview = Some(state);
540 self
541 }
542 pub fn on_click(mut self, f: impl Fn(&mut Window, &mut App) + 'static) -> Self {
543 self.on_click = Some(Rc::new(f));
544 self
545 }
546}
547
548fn drop_arrow() -> Icon {
549 Icon::from(Symbol::named("arrow_drop_down").expect("arrow_drop_down is a Material Symbol"))
550}
551
552impl RenderOnce for PickerMenuButton {
553 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
554 let preview = self.preview;
555 let disabled = self.disabled || preview.is_some_and(InteractionPreview::is_disabled);
556 let turn = if preview.is_some() {
557 if self.open { 1. } else { 0. }
558 } else {
559 motion::token_value(
560 (self.id.clone(), "arrow"),
561 if self.open { 1. } else { 0. },
562 MotionToken::FastSpatial,
563 window,
564 cx,
565 )
566 };
567 let ink = color(ColorRole::ON_SURFACE_VARIANT);
568 let clear = Rgba { a: 0., ..ink };
569 let mut button = gpui_base::Button::new(self.id.clone())
570 .accessibility_label(self.accessible.unwrap_or_else(|| self.label.clone()))
571 .disabled(disabled)
572 .h(rem(40.))
573 .pl(rem(8.))
574 .pr(rem(4.))
575 .flex()
576 .items_center()
577 .gap(rem(8.))
578 .rounded_full()
579 .text_color(ink)
580 .map(|b| type_style(b, TypeRole::LabelLarge))
581 .when(disabled, |b| b.opacity(state::DISABLED_CONTENT))
582 .child(self.label)
583 .when(self.arrow, |b| {
584 b.child(
585 drop_arrow()
586 .size(rem(18.))
587 .color(ink)
588 .rotate(std::f32::consts::PI * turn),
589 )
590 });
591 if let Some(f) = self.on_click.filter(|_| !disabled) {
592 button = button.on_click(move |_, window, cx| f(window, cx));
593 }
594 let hover = state::hovered(clear, ink);
595 let press = state::pressed(clear, ink);
596 let focus = window
597 .use_keyed_state((self.id, "focus"), cx, |_, cx| cx.focus_handle())
598 .read(cx)
599 .clone();
600 let enabled = !disabled;
601 button = match preview {
602 Some(InteractionPreview::Hovered) => button.bg(hover),
603 Some(InteractionPreview::Focused) => button.bg(state::layer(clear, ink, state::FOCUS)),
604 Some(InteractionPreview::Pressed) => button.bg(press),
605 Some(_) => button,
606 None if enabled => button
607 .cursor_pointer()
608 .hover(move |s| s.bg(hover))
609 .active(move |s| s.bg(press)),
610 None => button,
611 };
612 let button = button.track_focus(&focus).tab_stop(preview.is_none());
613 ButtonFocusRing::new(button, focus, enabled, rem(20.))
614 .forced(enabled && preview == Some(InteractionPreview::Focused))
615 }
616}
617
618#[derive(gpui::IntoElement)]
621pub struct PickerListItem {
622 id: ElementId,
623 label: SharedString,
624 selected: bool,
625 cursor: bool,
626 preview: Option<InteractionPreview>,
627 on_click: Option<Activate>,
628}
629
630impl PickerListItem {
631 pub fn new(id: impl Into<ElementId>, label: impl Into<SharedString>) -> Self {
632 Self {
633 id: id.into(),
634 label: label.into(),
635 selected: false,
636 cursor: false,
637 preview: None,
638 on_click: None,
639 }
640 }
641 pub fn selected(mut self, selected: bool) -> Self {
642 self.selected = selected;
643 self
644 }
645 pub(crate) fn cursor(mut self, cursor: bool) -> Self {
646 self.cursor = cursor;
647 self
648 }
649 pub fn preview_state(mut self, state: InteractionPreview) -> Self {
650 self.preview = Some(state);
651 self
652 }
653 pub fn on_click(mut self, f: impl Fn(&mut Window, &mut App) + 'static) -> Self {
654 self.on_click = Some(Rc::new(f));
655 self
656 }
657}
658
659impl RenderOnce for PickerListItem {
660 fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
661 let preview = self.preview;
662 let disabled = preview.is_some_and(InteractionPreview::is_disabled);
663 let live = !disabled && preview.is_none() && self.on_click.is_some();
664 let ink = color(ColorRole::ON_SURFACE);
665 let clear = Rgba { a: 0., ..ink };
666 let rest = if self.selected {
668 state::hovered(clear, ink)
669 } else {
670 clear
671 };
672 let hover = state::layer(rest, ink, state::HOVER);
673 let press = state::layer(rest, ink, state::PRESS);
674 let row = div()
675 .id("item")
676 .h(rem(LIST_ITEM_HEIGHT))
677 .w_full()
678 .flex()
679 .items_center()
680 .gap(rem(16.))
681 .px(rem(16.))
682 .bg(rest)
683 .text_color(ink)
684 .map(|r| type_style(r, TypeRole::BodyLarge))
685 .when(preview == Some(InteractionPreview::Hovered), |r| {
686 r.bg(hover)
687 })
688 .when(preview == Some(InteractionPreview::Pressed), |r| {
689 r.bg(press)
690 })
691 .when(live, |r| {
692 r.cursor_pointer()
693 .hover(move |s| s.bg(hover))
694 .active(move |s| s.bg(press))
695 })
696 .child(
697 div()
698 .size(rem(24.))
699 .flex_shrink_0()
700 .when(self.selected, |slot| {
701 slot.child(
702 Icon::from(crate::icons::IconName::Check)
703 .size(rem(24.))
704 .color(color(ColorRole::ON_SURFACE_VARIANT)),
705 )
706 }),
707 )
708 .child(self.label.clone());
709 let ring = ButtonFocusRing::resolved(row, self.cursor && live, rem(0.))
710 .inward()
711 .forced(preview == Some(InteractionPreview::Focused));
712 let on_click = self.on_click.filter(|_| live);
713 div()
714 .id(self.id)
715 .w_full()
716 .role(Role::ListBoxOption)
717 .aria_label(self.label)
718 .aria_selected(self.selected)
719 .when(self.cursor, |item| item.aria_active_descendant())
720 .child(ring)
721 .when_some(on_click, |item, f| {
722 item.on_click(move |_, window, cx| f(window, cx))
723 })
724 }
725}