1use super::InteractionPreview;
34use super::button::ButtonFocusRing;
35use super::date_parts::Activate;
36use super::text_field::type_style;
37use super::{ButtonVariant, IconButton, material_button};
38use crate::{
39 color::ColorRole,
40 icons::{Icon, Symbol},
41 motion::{self, MotionToken},
42 theme::color,
43 tokens::{
44 state,
45 typography::{TypeRole, rem},
46 },
47};
48use chrono::{NaiveTime, Timelike};
49use gpui::{prelude::*, *};
50use gpui_base::{HourCycle, TimeField, TimeFieldEvent, TimeFieldState, TimePrecision, TimeSegment};
51use std::{cell::Cell, f32::consts::PI, rc::Rc};
52
53type Format = Rc<dyn Fn(u32) -> SharedString>;
54type PeriodChange = Rc<dyn Fn(bool, &mut Window, &mut App)>;
55type TimeConfirm = Rc<dyn Fn(NaiveTime, &mut Window, &mut App)>;
56
57#[derive(Clone)]
59pub struct TimePickerLabels {
60 pub select_title: SharedString,
62 pub enter_title: SharedString,
64 pub hour: SharedString,
65 pub minute: SharedString,
66 pub am: SharedString,
67 pub pm: SharedString,
68 pub confirm: SharedString,
69 pub cancel: SharedString,
70 pub switch_to_input: SharedString,
72 pub switch_to_dial: SharedString,
73 pub number: Format,
75 pub two_digits: Format,
77}
78
79#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
81pub enum TimeFormat {
82 #[default]
83 TwelveHour,
84 TwentyFourHour,
85}
86
87#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
89pub enum TimeOrientation {
90 #[default]
91 Vertical,
92 Horizontal,
93}
94
95#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
97pub enum TimeInputMode {
98 #[default]
99 Dial,
100 Input,
101}
102
103#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
105pub enum TimeUnit {
106 #[default]
107 Hour,
108 Minute,
109 Second,
111}
112
113pub struct TimePickerState {
116 labels: Rc<TimePickerLabels>,
117 time: NaiveTime,
118 format: TimeFormat,
119 mode: TimeInputMode,
120 unit: TimeUnit,
121 hand: f32,
124 minute_step: u32,
126 second_label: Option<SharedString>,
128 limits: Option<(NaiveTime, NaiveTime)>,
130 dial_focus: FocusHandle,
131 field: Entity<TimeFieldState>,
132 _subscription: Subscription,
133}
134
135#[derive(Clone, Copy, Debug, PartialEq, Eq)]
137pub enum TimePickerEvent {
138 Change(NaiveTime),
139}
140
141impl EventEmitter<TimePickerEvent> for TimePickerState {}
142
143impl TimePickerState {
144 pub fn new(
145 labels: TimePickerLabels,
146 time: NaiveTime,
147 window: &mut Window,
148 cx: &mut Context<Self>,
149 ) -> Self {
150 let field = cx.new(|cx| {
151 let mut field = TimeFieldState::new(window, cx).hour_cycle(HourCycle::H12);
152 field.set_time(time, window, cx);
153 field
154 });
155 let subscription = cx.subscribe_in(
156 &field,
157 window,
158 |this, field, event: &TimeFieldEvent, window, cx| {
159 let TimeFieldEvent::Change(typed) = *event;
160 let time = this.clamp(typed);
162 if time != typed {
163 field.update(cx, |field, cx| field.set_time(time, window, cx));
164 }
165 this.apply(time);
166 cx.emit(TimePickerEvent::Change(time));
167 cx.notify();
168 },
169 );
170 let mut state = Self {
171 labels: Rc::new(labels),
172 time,
173 format: TimeFormat::TwelveHour,
174 mode: TimeInputMode::Dial,
175 unit: TimeUnit::Hour,
176 hand: 0.,
177 minute_step: 1,
178 second_label: None,
179 limits: None,
180 dial_focus: cx.focus_handle().tab_stop(true),
181 field,
182 _subscription: subscription,
183 };
184 state.hand = hand_turns(time, state.unit, None);
185 state
186 }
187 pub fn format(
189 mut self,
190 format: TimeFormat,
191 window: &mut Window,
192 cx: &mut Context<Self>,
193 ) -> Self {
194 self.set_format(format, window, cx);
195 self
196 }
197 pub fn input_mode(mut self) -> Self {
198 self.mode = TimeInputMode::Input;
199 self
200 }
201 pub fn seconds(
204 mut self,
205 label: impl Into<SharedString>,
206 window: &mut Window,
207 cx: &mut Context<Self>,
208 ) -> Self {
209 self.second_label = Some(label.into());
210 let time = self.time;
211 self.field.update(cx, |field, cx| {
212 field.set_precision(TimePrecision::Second, window, cx);
213 field.set_time(time, window, cx);
214 });
215 self
216 }
217 pub fn minute_step(mut self, step: u32) -> Self {
220 self.minute_step = step.clamp(1, 30);
221 self
222 }
223 pub fn limits(
226 mut self,
227 earliest: NaiveTime,
228 latest: NaiveTime,
229 window: &mut Window,
230 cx: &mut Context<Self>,
231 ) -> Self {
232 self.limits = Some((earliest.min(latest), earliest.max(latest)));
233 let time = self.clamp(self.time);
234 if time != self.time {
235 self.apply(time);
236 self.field
237 .update(cx, |field, cx| field.set_time(time, window, cx));
238 }
239 self
240 }
241 pub fn unit(mut self, unit: TimeUnit) -> Self {
242 self.unit = unit;
243 self.hand = hand_turns(self.time, unit, None);
244 self
245 }
246
247 pub fn labels(&self) -> &TimePickerLabels {
248 &self.labels
249 }
250 pub fn time(&self) -> NaiveTime {
251 self.time
252 }
253 pub fn current_format(&self) -> TimeFormat {
254 self.format
255 }
256 pub fn mode(&self) -> TimeInputMode {
257 self.mode
258 }
259 pub fn current_unit(&self) -> TimeUnit {
260 self.unit
261 }
262 pub fn shows_seconds(&self) -> bool {
263 self.second_label.is_some()
264 }
265 pub fn current_minute_step(&self) -> u32 {
266 self.minute_step
267 }
268 pub fn current_limits(&self) -> Option<(NaiveTime, NaiveTime)> {
269 self.limits
270 }
271 fn clamp(&self, time: NaiveTime) -> NaiveTime {
273 clamp_time(time, self.limits)
274 }
275 pub fn dial_focus(&self) -> &FocusHandle {
276 &self.dial_focus
277 }
278 pub fn field(&self) -> &Entity<TimeFieldState> {
280 &self.field
281 }
282
283 fn apply(&mut self, time: NaiveTime) {
284 self.time = time;
285 self.hand = hand_turns(time, self.unit, Some(self.hand));
286 }
287
288 pub fn set_format(&mut self, format: TimeFormat, window: &mut Window, cx: &mut Context<Self>) {
289 self.format = format;
290 let cycle = match format {
291 TimeFormat::TwelveHour => HourCycle::H12,
292 TimeFormat::TwentyFourHour => HourCycle::H23,
293 };
294 self.field
295 .update(cx, |field, cx| field.set_hour_cycle(cycle, window, cx));
296 cx.notify();
297 }
298
299 pub fn set_time(&mut self, time: NaiveTime, window: &mut Window, cx: &mut Context<Self>) {
301 let time = self.clamp(time);
302 if time == self.time {
303 return;
304 }
305 self.apply(time);
306 self.field
307 .update(cx, |field, cx| field.set_time(time, window, cx));
308 cx.emit(TimePickerEvent::Change(time));
309 cx.notify();
310 }
311
312 pub fn set_unit(&mut self, unit: TimeUnit, cx: &mut Context<Self>) {
314 if self.unit != unit {
315 self.unit = unit;
316 self.hand = hand_turns(self.time, unit, Some(self.hand));
317 cx.notify();
318 }
319 }
320
321 pub fn set_pm(&mut self, pm: bool, window: &mut Window, cx: &mut Context<Self>) {
323 let hour = self.time.hour() % 12 + if pm { 12 } else { 0 };
324 let time = self.time.with_hour(hour).unwrap_or(self.time);
325 self.set_time(time, window, cx);
326 }
327
328 pub fn toggle_mode(&mut self, window: &mut Window, cx: &mut Context<Self>) {
329 self.mode = match self.mode {
330 TimeInputMode::Dial => {
331 let field = self.field.clone();
332 window.defer(cx, move |window, cx| {
333 let handle = field.read(cx).focus_handle(cx);
334 handle.focus(window, cx);
335 });
336 TimeInputMode::Input
337 }
338 TimeInputMode::Input => TimeInputMode::Dial,
339 };
340 cx.notify();
341 }
342
343 pub fn step(&mut self, delta: i32, window: &mut Window, cx: &mut Context<Self>) {
345 let time = match self.unit {
346 TimeUnit::Minute if self.minute_step > 1 => {
347 let minute = step_minutes(self.time.minute(), self.minute_step, delta);
348 self.time.with_minute(minute).unwrap_or(self.time)
349 }
350 unit => step_time(self.time, unit, self.format, delta),
351 };
352 self.set_time(time, window, cx);
353 }
354
355 fn pick(&mut self, offset: Point<f32>, window: &mut Window, cx: &mut Context<Self>) {
358 let mut time = dial_pick(self.time, self.unit, self.format, offset);
359 if self.unit == TimeUnit::Minute && self.minute_step > 1 {
360 let step = self.minute_step as f32;
361 let minute = ((time.minute() as f32 / step).round() * step) as u32 % 60;
362 time = time.with_minute(minute).unwrap_or(time);
363 }
364 self.set_time(time, window, cx);
365 }
366}
367
368impl Render for TimePickerState {
369 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
370 Empty
371 }
372}
373
374pub const DIAL_SIZE: f32 = 256.;
376const OUTER_RADIUS: f32 = 104.;
377const INNER_RADIUS: f32 = 76.;
378const NUMBER_CELL: f32 = 48.;
379
380fn angle_of(time: NaiveTime, unit: TimeUnit) -> f32 {
382 match unit {
383 TimeUnit::Hour => (time.hour() % 12) as f32 / 12.,
384 TimeUnit::Minute => time.minute() as f32 / 60.,
385 TimeUnit::Second => time.second() as f32 / 60.,
386 }
387}
388
389pub(crate) fn clamp_time(time: NaiveTime, limits: Option<(NaiveTime, NaiveTime)>) -> NaiveTime {
391 match limits {
392 Some((earliest, latest)) => time.max(earliest).min(latest),
393 None => time,
394 }
395}
396
397pub(crate) fn step_minutes(minute: u32, step: u32, delta: i32) -> u32 {
400 let (minute, step) = (minute as i32, step.max(1) as i32);
401 let floor = minute.div_euclid(step) * step;
402 let next = if floor == minute || delta > 0 {
403 floor + step * delta
404 } else {
405 floor + step * (delta + 1)
406 };
407 next.rem_euclid(60) as u32
408}
409
410pub(crate) fn within_limits(
413 time: NaiveTime,
414 unit: TimeUnit,
415 value: u32,
416 limits: Option<(NaiveTime, NaiveTime)>,
417) -> bool {
418 let Some((earliest, latest)) = limits else {
419 return true;
420 };
421 match unit {
422 TimeUnit::Hour => {
423 let start = NaiveTime::from_hms_opt(value, 0, 0);
424 let end = NaiveTime::from_hms_opt(value, 59, 59);
425 matches!((start, end), (Some(start), Some(end)) if start <= latest && end >= earliest)
426 }
427 TimeUnit::Minute => match time.with_minute(value) {
428 Some(t) => {
429 let (start, end) = (
430 t.with_second(0).unwrap_or(t),
431 t.with_second(59).unwrap_or(t),
432 );
433 start <= latest && end >= earliest
434 }
435 None => false,
436 },
437 TimeUnit::Second => time
438 .with_second(value)
439 .is_some_and(|t| t >= earliest && t <= latest),
440 }
441}
442
443pub(crate) fn hand_turns(time: NaiveTime, unit: TimeUnit, previous: Option<f32>) -> f32 {
445 let angle = angle_of(time, unit);
446 match previous {
447 None => angle,
448 Some(previous) => {
449 let delta = (angle - previous).rem_euclid(1.);
450 let delta = if delta > 0.5 { delta - 1. } else { delta };
451 previous + delta
452 }
453 }
454}
455
456pub(crate) fn inner_ring(time: NaiveTime, unit: TimeUnit, format: TimeFormat) -> bool {
458 format == TimeFormat::TwentyFourHour
459 && unit == TimeUnit::Hour
460 && (time.hour() == 0 || time.hour() > 12)
461}
462
463pub(crate) fn step_time(
464 time: NaiveTime,
465 unit: TimeUnit,
466 format: TimeFormat,
467 delta: i32,
468) -> NaiveTime {
469 match unit {
470 TimeUnit::Minute => {
471 let minute = (time.minute() as i32 + delta).rem_euclid(60) as u32;
472 time.with_minute(minute).unwrap_or(time)
473 }
474 TimeUnit::Second => {
475 let second = (time.second() as i32 + delta).rem_euclid(60) as u32;
476 time.with_second(second).unwrap_or(time)
477 }
478 TimeUnit::Hour => {
479 let hour = match format {
480 TimeFormat::TwentyFourHour => (time.hour() as i32 + delta).rem_euclid(24) as u32,
481 TimeFormat::TwelveHour => {
483 time.hour() / 12 * 12 + (time.hour() as i32 % 12 + delta).rem_euclid(12) as u32
484 }
485 };
486 time.with_hour(hour).unwrap_or(time)
487 }
488 }
489}
490
491pub(crate) fn dial_pick(
493 time: NaiveTime,
494 unit: TimeUnit,
495 format: TimeFormat,
496 offset: Point<f32>,
497) -> NaiveTime {
498 let turn = (offset.x.atan2(-offset.y) / (2. * PI)).rem_euclid(1.);
499 match unit {
500 TimeUnit::Minute => {
501 let minute = (turn * 60.).round() as u32 % 60;
502 time.with_minute(minute).unwrap_or(time)
503 }
504 TimeUnit::Second => {
505 let second = (turn * 60.).round() as u32 % 60;
506 time.with_second(second).unwrap_or(time)
507 }
508 TimeUnit::Hour => {
509 let position = (turn * 12.).round() as u32 % 12;
510 let hour = match format {
511 TimeFormat::TwelveHour => time.hour() / 12 * 12 + position,
512 TimeFormat::TwentyFourHour => {
513 let distance = (offset.x * offset.x + offset.y * offset.y).sqrt();
514 let inner = distance < (OUTER_RADIUS + INNER_RADIUS) / 2.;
515 match (inner, position) {
516 (true, 0) => 0,
517 (true, p) => p + 12,
518 (false, 0) => 12,
519 (false, p) => p,
520 }
521 }
522 };
523 time.with_hour(hour).unwrap_or(time)
524 }
525 }
526}
527
528fn point_on(turn: f32, radius: f32) -> (f32, f32) {
529 let a = turn * 2. * PI;
530 (
531 DIAL_SIZE / 2. + radius * a.sin(),
532 DIAL_SIZE / 2. - radius * a.cos(),
533 )
534}
535
536#[derive(gpui::IntoElement)]
540pub struct ClockDial {
541 id: ElementId,
542 state: Option<Entity<TimePickerState>>,
543 time: NaiveTime,
544 unit: TimeUnit,
545 format: TimeFormat,
546 labels: Rc<TimePickerLabels>,
547 second_label: Option<SharedString>,
548 limits: Option<(NaiveTime, NaiveTime)>,
549}
550
551impl ClockDial {
552 pub fn new(id: impl Into<ElementId>, state: &Entity<TimePickerState>, cx: &App) -> Self {
554 let s = state.read(cx);
555 Self {
556 id: id.into(),
557 state: Some(state.clone()),
558 time: s.time,
559 unit: s.unit,
560 format: s.format,
561 labels: s.labels.clone(),
562 second_label: s.second_label.clone(),
563 limits: s.limits,
564 }
565 }
566 pub fn specimen(
568 id: impl Into<ElementId>,
569 labels: Rc<TimePickerLabels>,
570 time: NaiveTime,
571 unit: TimeUnit,
572 format: TimeFormat,
573 ) -> Self {
574 Self {
575 id: id.into(),
576 state: None,
577 time,
578 unit,
579 format,
580 labels,
581 second_label: None,
582 limits: None,
583 }
584 }
585}
586
587impl RenderOnce for ClockDial {
588 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
589 let (time, unit, format) = (self.time, self.unit, self.format);
590 let inner = inner_ring(time, unit, format);
591 let target_radius = if inner { INNER_RADIUS } else { OUTER_RADIUS };
592 let (turn, radius) = match &self.state {
593 Some(state) => {
594 let target = state.read(cx).hand;
595 (
596 motion::token_value(
597 (self.id.clone(), "hand"),
598 target,
599 MotionToken::DefaultSpatial,
600 window,
601 cx,
602 ),
603 motion::token_value(
604 (self.id.clone(), "radius"),
605 target_radius,
606 MotionToken::DefaultSpatial,
607 window,
608 cx,
609 ),
610 )
611 }
612 None => (angle_of(time, unit), target_radius),
613 };
614 let primary = color(ColorRole::PRIMARY);
615 let on_primary = color(ColorRole::ON_PRIMARY);
616 let between = match unit {
617 TimeUnit::Minute => time.minute() % 5 != 0,
618 TimeUnit::Second => time.second() % 5 != 0,
619 TimeUnit::Hour => false,
620 };
621 let bounds = Rc::new(Cell::new(Bounds::<Pixels>::default()));
622 let observed = bounds.clone();
623 let hand = canvas(
624 move |b, _, _| observed.set(b),
625 move |b, _, window, _| {
626 let scale = b.size.width / DIAL_SIZE;
627 let at = |(x, y): (f32, f32)| b.origin + point(scale * x, scale * y);
628 let center = at((DIAL_SIZE / 2., DIAL_SIZE / 2.));
629 let tip = at(point_on(turn, radius));
630 let mut line = PathBuilder::stroke(scale * 2.);
631 line.move_to(center);
632 line.line_to(tip);
633 if let Ok(path) = line.build() {
634 window.paint_path(path, primary);
635 }
636 let circle = |c: Point<Pixels>, r: Pixels, paint: Rgba, window: &mut Window| {
637 window.paint_quad(
638 fill(Bounds::new(c - point(r, r), size(r * 2., r * 2.)), paint)
639 .corner_radii(r),
640 );
641 };
642 circle(tip, scale * (NUMBER_CELL / 2.), primary, window);
643 circle(center, scale * 4., primary, window);
644 if between {
645 circle(tip, scale * 2., on_primary, window);
646 }
647 },
648 )
649 .absolute()
650 .inset_0();
651 let numbers: Vec<(u32, f32, f32, SharedString)> = match (unit, format) {
652 (TimeUnit::Minute | TimeUnit::Second, _) => (0..12)
653 .map(|i| {
654 (
655 i * 5,
656 i as f32 / 12.,
657 OUTER_RADIUS,
658 (self.labels.two_digits)(i * 5),
659 )
660 })
661 .collect(),
662 (TimeUnit::Hour, TimeFormat::TwelveHour) => (0..12)
663 .map(|i| {
664 let label = if i == 0 { 12 } else { i };
665 (
666 label,
667 i as f32 / 12.,
668 OUTER_RADIUS,
669 (self.labels.number)(label),
670 )
671 })
672 .collect(),
673 (TimeUnit::Hour, TimeFormat::TwentyFourHour) => (0..12)
674 .map(|i| {
675 let label = if i == 0 { 12 } else { i };
676 (
677 label,
678 i as f32 / 12.,
679 OUTER_RADIUS,
680 (self.labels.number)(label),
681 )
682 })
683 .chain((0..12).map(|i| {
684 let hour = if i == 0 { 0 } else { i + 12 };
685 (
686 hour,
687 i as f32 / 12.,
688 INNER_RADIUS,
689 (self.labels.two_digits)(hour),
690 )
691 }))
692 .collect(),
693 };
694 let selected = match (unit, format) {
695 (TimeUnit::Minute, _) => time.minute(),
696 (TimeUnit::Second, _) => time.second(),
697 (TimeUnit::Hour, TimeFormat::TwelveHour) => (time.hour() + 11) % 12 + 1,
698 (TimeUnit::Hour, TimeFormat::TwentyFourHour) => time.hour(),
699 };
700 let ink = color(ColorRole::ON_SURFACE);
701 let faded = state::disabled(ink);
702 let limits = self.limits;
703 let hour_of = move |label: u32| match format {
705 TimeFormat::TwelveHour => label % 12 + time.hour() / 12 * 12,
706 TimeFormat::TwentyFourHour => label,
707 };
708 let mut face = div()
709 .id(self.id.clone())
710 .relative()
711 .flex_shrink_0()
712 .size(rem(DIAL_SIZE))
713 .rounded_full()
714 .bg(color(ColorRole::SURFACE_CONTAINER_HIGHEST))
715 .role(Role::Slider)
716 .aria_label(match unit {
717 TimeUnit::Hour => self.labels.hour.clone(),
718 TimeUnit::Minute => self.labels.minute.clone(),
719 TimeUnit::Second => self
720 .second_label
721 .clone()
722 .unwrap_or_else(|| self.labels.minute.clone()),
723 })
724 .aria_numeric_value(selected as f64)
725 .child(hand)
726 .children(numbers.into_iter().map(|(value, turn, r, label)| {
727 let (x, y) = point_on(turn, r);
728 div()
729 .absolute()
730 .left(rem(x - NUMBER_CELL / 2.))
731 .top(rem(y - NUMBER_CELL / 2.))
732 .size(rem(NUMBER_CELL))
733 .flex()
734 .items_center()
735 .justify_center()
736 .text_color(if value == selected {
737 on_primary
738 } else {
739 let value = if unit == TimeUnit::Hour {
740 hour_of(value)
741 } else {
742 value
743 };
744 if within_limits(time, unit, value, limits) {
745 ink
746 } else {
747 faded
748 }
749 })
750 .map(|d| type_style(d, TypeRole::BodyLarge))
751 .child(label)
752 }));
753 let Some(state) = self.state else {
754 return face.into_any_element();
755 };
756 let focus = state.read(cx).dial_focus.clone();
757 let pressed = window.use_keyed_state((self.id.clone(), "dragging"), cx, |_, _| false);
758 let offset = move |position: Point<Pixels>, b: Bounds<Pixels>| {
759 let scale = f32::from(b.size.width) / DIAL_SIZE;
760 let c = b.center();
761 point(
762 f32::from(position.x - c.x) / scale,
763 f32::from(position.y - c.y) / scale,
764 )
765 };
766 face = face
767 .track_focus(&focus)
768 .cursor_pointer()
769 .on_mouse_down(MouseButton::Left, {
770 let (state, bounds, pressed) = (state.clone(), bounds.clone(), pressed.clone());
771 let focus = focus.clone();
772 move |e, window, cx| {
773 focus.focus(window, cx);
774 pressed.update(cx, |p, _| *p = true);
775 let o = offset(e.position, bounds.get());
776 state.update(cx, |s, cx| s.pick(o, window, cx));
777 }
778 })
779 .on_mouse_move({
780 let (state, bounds, pressed) = (state.clone(), bounds.clone(), pressed.clone());
781 move |e, window, cx| {
782 if *pressed.read(cx) && e.pressed_button == Some(MouseButton::Left) {
783 let o = offset(e.position, bounds.get());
784 state.update(cx, |s, cx| s.pick(o, window, cx));
785 }
786 }
787 })
788 .on_mouse_up(MouseButton::Left, {
789 let (state, pressed) = (state.clone(), pressed.clone());
790 move |_, _, cx| {
791 if *pressed.read(cx) {
792 pressed.update(cx, |p, _| *p = false);
793 state.update(cx, |s, cx| {
794 let next = match s.unit {
796 TimeUnit::Hour => TimeUnit::Minute,
797 TimeUnit::Minute if s.shows_seconds() => TimeUnit::Second,
798 unit => unit,
799 };
800 s.set_unit(next, cx)
801 });
802 }
803 }
804 })
805 .on_mouse_up_out(MouseButton::Left, move |_, _, cx| {
806 pressed.update(cx, |p, _| *p = false);
807 })
808 .on_key_down(move |e, window, cx| {
809 let delta = match e.keystroke.key.as_str() {
810 "up" | "right" => 1,
811 "down" | "left" => -1,
812 _ => return,
813 };
814 cx.stop_propagation();
815 state.update(cx, |s, cx| s.step(delta, window, cx));
816 });
817 ButtonFocusRing::new(face, focus, true, rem(DIAL_SIZE / 2.)).into_any_element()
818 }
819}
820
821#[derive(gpui::IntoElement)]
824pub struct TimeSelector {
825 id: ElementId,
826 value: SharedString,
827 label: SharedString,
828 selected: bool,
829 compact: bool,
830 preview: Option<InteractionPreview>,
831 on_click: Option<Activate>,
832}
833
834const COMPACT_SELECTOR: f32 = 72.;
837
838impl TimeSelector {
839 pub fn new(
841 id: impl Into<ElementId>,
842 value: impl Into<SharedString>,
843 label: impl Into<SharedString>,
844 ) -> Self {
845 Self {
846 id: id.into(),
847 value: value.into(),
848 label: label.into(),
849 selected: false,
850 compact: false,
851 preview: None,
852 on_click: None,
853 }
854 }
855 pub fn selected(mut self, selected: bool) -> Self {
856 self.selected = selected;
857 self
858 }
859 pub fn compact(mut self, compact: bool) -> Self {
862 self.compact = compact;
863 self
864 }
865 pub fn preview_state(mut self, state: InteractionPreview) -> Self {
866 self.preview = Some(state);
867 self
868 }
869 pub fn on_click(mut self, f: impl Fn(&mut Window, &mut App) + 'static) -> Self {
870 self.on_click = Some(Rc::new(f));
871 self
872 }
873}
874
875impl RenderOnce for TimeSelector {
876 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
877 let (fill, ink) = if self.selected {
878 (
879 color(ColorRole::PRIMARY_CONTAINER),
880 color(ColorRole::ON_PRIMARY_CONTAINER),
881 )
882 } else {
883 (
884 color(ColorRole::SURFACE_CONTAINER_HIGHEST),
885 color(ColorRole::ON_SURFACE),
886 )
887 };
888 let preview = self.preview;
889 let disabled = preview.is_some_and(InteractionPreview::is_disabled);
890 let hover = state::hovered(fill, ink);
891 let press = state::pressed(fill, ink);
892 let focus = window
893 .use_keyed_state((self.id.clone(), "focus"), cx, |_, cx| cx.focus_handle())
894 .read(cx)
895 .clone();
896 let mut button = gpui_base::Button::new(self.id)
897 .accessibility_label(self.label)
898 .disabled(disabled)
899 .w(rem(if self.compact { COMPACT_SELECTOR } else { 96. }))
900 .h(rem(80.))
901 .flex()
902 .items_center()
903 .justify_center()
904 .rounded(rem(8.))
905 .bg(fill)
906 .text_color(ink)
907 .map(|b| {
908 type_style(
909 b,
910 if self.compact {
911 TypeRole::DisplayMedium
912 } else {
913 TypeRole::DisplayLarge
914 },
915 )
916 })
917 .when(disabled, |b| b.opacity(state::DISABLED_CONTENT))
918 .child(self.value);
919 button = match preview {
920 Some(InteractionPreview::Hovered) => button.bg(hover),
921 Some(InteractionPreview::Focused) => button.bg(state::layer(fill, ink, state::FOCUS)),
922 Some(InteractionPreview::Pressed) => button.bg(press),
923 Some(_) => button,
924 None => button
925 .cursor_pointer()
926 .hover(move |s| s.bg(hover))
927 .active(move |s| s.bg(press)),
928 };
929 if let Some(f) = self.on_click.filter(|_| preview.is_none()) {
930 button = button.on_click(move |_, window, cx| f(window, cx));
931 }
932 let button = button.track_focus(&focus).tab_stop(preview.is_none());
933 ButtonFocusRing::new(button, focus, !disabled, rem(8.))
934 .forced(!disabled && preview == Some(InteractionPreview::Focused))
935 }
936}
937
938#[derive(gpui::IntoElement)]
941pub struct PeriodSelector {
942 id: ElementId,
943 am: SharedString,
944 pm: SharedString,
945 is_pm: bool,
946 horizontal: bool,
947 height: f32,
948 preview: Option<(bool, InteractionPreview)>,
949 on_change: Option<PeriodChange>,
950}
951
952impl PeriodSelector {
953 pub fn new(
954 id: impl Into<ElementId>,
955 am: impl Into<SharedString>,
956 pm: impl Into<SharedString>,
957 ) -> Self {
958 Self {
959 id: id.into(),
960 am: am.into(),
961 pm: pm.into(),
962 is_pm: false,
963 horizontal: false,
964 height: 80.,
965 preview: None,
966 on_change: None,
967 }
968 }
969 pub fn pm(mut self, pm: bool) -> Self {
970 self.is_pm = pm;
971 self
972 }
973 pub fn horizontal(mut self, horizontal: bool) -> Self {
974 self.horizontal = horizontal;
975 self
976 }
977 pub fn height(mut self, height: f32) -> Self {
980 self.height = height;
981 self
982 }
983 pub fn preview_state(mut self, pm: bool, state: InteractionPreview) -> Self {
985 self.preview = Some((pm, state));
986 self
987 }
988 pub fn on_change(mut self, f: impl Fn(bool, &mut Window, &mut App) + 'static) -> Self {
989 self.on_change = Some(Rc::new(f));
990 self
991 }
992}
993
994impl RenderOnce for PeriodSelector {
995 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
996 let outline = color(ColorRole::OUTLINE);
997 let specimen = self.preview.is_some();
998 let half = |pm: bool, label: SharedString, window: &mut Window, cx: &mut App| {
999 let selected = pm == self.is_pm;
1000 let (fill, ink) = if selected {
1001 (
1002 color(ColorRole::TERTIARY_CONTAINER),
1003 color(ColorRole::ON_TERTIARY_CONTAINER),
1004 )
1005 } else {
1006 let ink = color(ColorRole::ON_SURFACE_VARIANT);
1007 (Rgba { a: 0., ..ink }, ink)
1008 };
1009 let forced = self.preview.filter(|(p, _)| *p == pm).map(|(_, s)| s);
1010 let hover = state::hovered(fill, ink);
1011 let press = state::pressed(fill, ink);
1012 let focus = window
1013 .use_keyed_state(
1014 (self.id.clone(), if pm { "pm" } else { "am" }),
1015 cx,
1016 |_, cx| cx.focus_handle(),
1017 )
1018 .read(cx)
1019 .clone();
1020 let mut button = gpui_base::Button::new(if pm { "pm" } else { "am" })
1021 .accessibility_label(label.clone())
1022 .role(Role::RadioButton)
1023 .aria_toggled(if selected {
1024 accesskit::Toggled::True
1025 } else {
1026 accesskit::Toggled::False
1027 })
1028 .flex_1()
1029 .flex()
1030 .items_center()
1031 .justify_center()
1032 .bg(fill)
1033 .text_color(ink)
1034 .map(|b| type_style(b, TypeRole::TitleMedium))
1035 .child(label);
1036 button = match forced {
1037 Some(InteractionPreview::Hovered) => button.bg(hover),
1038 Some(InteractionPreview::Focused) => {
1039 button.bg(state::layer(fill, ink, state::FOCUS))
1040 }
1041 Some(InteractionPreview::Pressed) => button.bg(press),
1042 Some(_) => button,
1043 None if !specimen => button
1044 .cursor_pointer()
1045 .hover(move |s| s.bg(hover))
1046 .active(move |s| s.bg(press)),
1047 None => button,
1048 };
1049 if let Some(f) = self.on_change.clone().filter(|_| !specimen) {
1050 button = button.on_click(move |_, window, cx| f(pm, window, cx));
1051 }
1052 let button = button.track_focus(&focus).tab_stop(!specimen);
1053 ButtonFocusRing::new(button, focus, true, rem(0.))
1054 .inward()
1055 .forced(forced == Some(InteractionPreview::Focused))
1056 };
1057 let am = half(false, self.am.clone(), window, cx);
1058 let pm = half(true, self.pm.clone(), window, cx);
1059 let divider = div().flex_shrink_0().bg(outline);
1060 div()
1061 .id(self.id.clone())
1062 .flex()
1063 .flex_shrink_0()
1064 .overflow_hidden()
1065 .rounded(rem(8.))
1066 .border(rem(1.))
1067 .border_color(outline)
1068 .role(Role::RadioGroup)
1069 .map(|d| {
1070 if self.horizontal {
1071 d.flex_row()
1072 .w(rem(216.))
1073 .h(rem(38.))
1074 .child(am)
1075 .child(divider.w(rem(1.)).h_full())
1076 .child(pm)
1077 } else {
1078 d.flex_col()
1079 .w(rem(52.))
1080 .h(rem(self.height))
1081 .child(am)
1082 .child(divider.h(rem(1.)).w_full())
1083 .child(pm)
1084 }
1085 })
1086 }
1087}
1088
1089#[derive(gpui::IntoElement)]
1091pub struct TimePicker {
1092 id: ElementId,
1093 state: Entity<TimePickerState>,
1094 orientation: TimeOrientation,
1095 specimen: bool,
1096 on_confirm: Option<TimeConfirm>,
1097 on_cancel: Option<Activate>,
1098}
1099
1100impl TimePicker {
1101 pub fn new(id: impl Into<ElementId>, state: &Entity<TimePickerState>) -> Self {
1102 Self {
1103 id: id.into(),
1104 state: state.clone(),
1105 orientation: TimeOrientation::Vertical,
1106 specimen: false,
1107 on_confirm: None,
1108 on_cancel: None,
1109 }
1110 }
1111 pub fn orientation(mut self, orientation: TimeOrientation) -> Self {
1112 self.orientation = orientation;
1113 self
1114 }
1115 pub fn specimen(mut self) -> Self {
1117 self.specimen = true;
1118 self
1119 }
1120 pub fn on_confirm(mut self, f: impl Fn(NaiveTime, &mut Window, &mut App) + 'static) -> Self {
1121 self.on_confirm = Some(Rc::new(f));
1122 self
1123 }
1124 pub fn on_cancel(mut self, f: impl Fn(&mut Window, &mut App) + 'static) -> Self {
1125 self.on_cancel = Some(Rc::new(f));
1126 self
1127 }
1128}
1129
1130fn named(id: &ElementId, name: &'static str) -> ElementId {
1131 ElementId::NamedChild(id.clone().into(), name.into())
1132}
1133
1134fn symbol(name: &str) -> Icon {
1135 Icon::from(Symbol::named(name).expect("a Material Symbol in the pinned catalog"))
1136}
1137
1138impl RenderOnce for TimePicker {
1139 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
1140 let s = self.state.read(cx);
1141 let labels = s.labels.clone();
1142 let (time, format, mode, unit) = (s.time, s.format, s.mode, s.unit);
1143 let second_label = s.second_label.clone();
1144 let seconds = second_label.is_some();
1145 let field = s.field.clone();
1146 let id = self.id.clone();
1147 let specimen = self.specimen;
1148 let preview = specimen.then_some(InteractionPreview::Enabled);
1149 let twelve = format == TimeFormat::TwelveHour;
1150 let horizontal =
1151 self.orientation == TimeOrientation::Horizontal && mode == TimeInputMode::Dial;
1152 let update = |f: fn(&mut TimePickerState, &mut Window, &mut Context<TimePickerState>)| {
1153 let state = self.state.clone();
1154 move |window: &mut Window, cx: &mut App| state.update(cx, |s, cx| f(s, window, cx))
1155 };
1156 let state = self.state.clone();
1157 let period = |height: f32, horizontal: bool| {
1158 let state = state.clone();
1159 let mut period =
1160 PeriodSelector::new(named(&id, "period"), labels.am.clone(), labels.pm.clone())
1161 .pm(time.hour() >= 12)
1162 .horizontal(horizontal)
1163 .height(height)
1164 .on_change(move |pm, window, cx| {
1165 state.update(cx, |s, cx| s.set_pm(pm, window, cx))
1166 });
1167 if specimen {
1168 period = period.preview_state(false, InteractionPreview::Enabled);
1169 }
1170 period
1171 };
1172 let display_hour = if twelve {
1173 (time.hour() + 11) % 12 + 1
1174 } else {
1175 time.hour()
1176 };
1177 let selector = |name: &'static str, which: TimeUnit, value: u32, label: SharedString| {
1178 let state = self.state.clone();
1179 let mut selector =
1180 TimeSelector::new(named(&id, name), (labels.two_digits)(value), label)
1181 .selected(unit == which)
1182 .compact(seconds)
1183 .on_click(move |_, cx| state.update(cx, |s, cx| s.set_unit(which, cx)));
1184 if let Some(p) = preview {
1185 selector = selector.preview_state(p);
1186 }
1187 selector
1188 };
1189 let separator = |height: f32| {
1190 div()
1191 .w(rem(24.))
1192 .h(rem(height))
1193 .flex()
1194 .items_center()
1195 .justify_center()
1196 .text_color(color(ColorRole::ON_SURFACE))
1197 .map(|d| type_style(d, TypeRole::DisplayLarge))
1198 .child(":")
1199 };
1200 let time_row = div()
1201 .flex()
1202 .child(selector(
1203 "hour",
1204 TimeUnit::Hour,
1205 display_hour,
1206 labels.hour.clone(),
1207 ))
1208 .child(separator(80.))
1209 .child(selector(
1210 "minute",
1211 TimeUnit::Minute,
1212 time.minute(),
1213 labels.minute.clone(),
1214 ))
1215 .when_some(second_label.clone(), |row, label| {
1216 row.child(separator(80.)).child(selector(
1217 "second",
1218 TimeUnit::Second,
1219 time.second(),
1220 label,
1221 ))
1222 });
1223 let dial = if specimen {
1224 ClockDial::specimen(named(&id, "dial"), labels.clone(), time, unit, format)
1225 } else {
1226 ClockDial::new(named(&id, "dial"), &self.state, cx)
1227 };
1228 let title = if mode == TimeInputMode::Dial {
1229 labels.select_title.clone()
1230 } else {
1231 labels.enter_title.clone()
1232 };
1233 let header = div()
1234 .h(rem(40.))
1235 .pt(rem(24.))
1236 .px(rem(24.))
1237 .text_color(color(ColorRole::ON_SURFACE_VARIANT))
1238 .map(|d| type_style(d, TypeRole::LabelMedium))
1239 .child(title);
1240 let (mode_icon, mode_label) = match mode {
1241 TimeInputMode::Dial => ("keyboard", labels.switch_to_input.clone()),
1242 TimeInputMode::Input => ("schedule", labels.switch_to_dial.clone()),
1243 };
1244 let toggle = update(TimePickerState::toggle_mode);
1245 let mut mode_button = IconButton::new(named(&id, "mode"), symbol(mode_icon), mode_label)
1246 .on_click(move |_, window, cx| toggle(window, cx));
1247 if let Some(p) = preview {
1248 mode_button = mode_button.preview_state(p);
1249 }
1250 let text_button = |name: &'static str, label: SharedString, f: Activate| {
1251 let mut b = material_button(named(&id, name), ButtonVariant::Text)
1252 .child(label)
1253 .on_click(move |_, window, cx| f(window, cx));
1254 if let Some(p) = preview {
1255 b = b.preview_state(p);
1256 }
1257 b
1258 };
1259 let cancel = self.on_cancel.clone();
1260 let confirm = self.on_confirm.clone();
1261 let read = self.state.clone();
1262 let actions = div()
1263 .flex()
1264 .items_center()
1265 .justify_between()
1266 .pr(rem(24.))
1267 .pb(rem(20.))
1268 .pl(rem(12.))
1269 .when(horizontal, |a| a.pt(rem(20.)))
1270 .child(mode_button)
1271 .child(
1272 div()
1273 .flex()
1274 .gap(rem(8.))
1275 .child(text_button(
1276 "cancel",
1277 labels.cancel.clone(),
1278 Rc::new(move |window, cx| {
1279 if let Some(f) = &cancel {
1280 f(window, cx)
1281 }
1282 }),
1283 ))
1284 .child(text_button(
1285 "confirm",
1286 labels.confirm.clone(),
1287 Rc::new(move |window, cx| {
1288 let time = read.read(cx).time;
1289 if let Some(f) = &confirm {
1290 f(time, window, cx)
1291 }
1292 }),
1293 )),
1294 );
1295 let extra = if seconds { 48. } else { 0. };
1298 let width = extra
1299 + match (mode, horizontal, twelve) {
1300 (TimeInputMode::Input, _, false) => 264.,
1301 (_, true, _) => 572.,
1302 _ => 328.,
1303 };
1304 let surface = div()
1305 .id(self.id.clone())
1306 .w(rem(width))
1307 .flex()
1308 .flex_col()
1309 .flex_shrink_0()
1310 .rounded(rem(28.))
1311 .bg(color(ColorRole::SURFACE_CONTAINER_HIGH))
1312 .role(Role::Dialog)
1313 .aria_label(labels.select_title.clone());
1314 match mode {
1315 TimeInputMode::Input => {
1316 let field_focused = field.read(cx).focus_handle(cx).is_focused(window);
1317 let editing = field.read(cx).selected_segment();
1318 let segment_labels = labels.clone();
1319 let period_state = self.state.clone();
1320 let period_labels = labels.clone();
1321 let pm = time.hour() >= 12;
1322 let input = TimeField::new(named(&id, "field"), &field)
1323 .disabled(specimen)
1324 .flex()
1325 .items_start()
1326 .text_color(color(ColorRole::ON_SURFACE))
1327 .map(|d| type_style(d, TypeRole::DisplayLarge))
1328 .line_height(rem(72.))
1329 .render_segment(move |segment, segment_state, _, _| {
1330 let which = segment_state.segment();
1331 if which == TimeSegment::Period {
1332 let state = period_state.clone();
1333 return segment
1334 .clear_children()
1335 .ml(rem(12.))
1336 .child(
1337 PeriodSelector::new(
1338 "period",
1339 period_labels.am.clone(),
1340 period_labels.pm.clone(),
1341 )
1342 .pm(pm)
1343 .height(72.)
1344 .on_change(
1345 move |pm, window, cx| {
1346 state.update(cx, |s, cx| s.set_pm(pm, window, cx))
1347 },
1348 ),
1349 )
1350 .into_any_element();
1351 }
1352 let selected = which == editing;
1353 let (fill, ink) = if selected {
1354 (
1355 color(ColorRole::PRIMARY_CONTAINER),
1356 color(ColorRole::ON_PRIMARY_CONTAINER),
1357 )
1358 } else {
1359 (
1360 color(ColorRole::SURFACE_CONTAINER_HIGHEST),
1361 color(ColorRole::ON_SURFACE),
1362 )
1363 };
1364 let label = match which {
1365 TimeSegment::Hour => segment_labels.hour.clone(),
1366 TimeSegment::Second => second_label
1367 .clone()
1368 .unwrap_or_else(|| segment_labels.minute.clone()),
1369 _ => segment_labels.minute.clone(),
1370 };
1371 segment
1372 .clear_children()
1373 .flex()
1374 .flex_col()
1375 .gap(rem(7.))
1376 .w(rem(if seconds { COMPACT_SELECTOR } else { 96. }))
1377 .child(
1378 div()
1379 .h(rem(72.))
1380 .w_full()
1381 .flex()
1382 .items_center()
1383 .justify_center()
1384 .gap(rem(1.))
1385 .rounded(rem(8.))
1386 .bg(fill)
1387 .when(selected, |b| {
1388 b.border(rem(2.)).border_color(color(ColorRole::PRIMARY))
1389 })
1390 .text_color(ink)
1391 .map(|b| type_style(b, TypeRole::DisplayMedium))
1392 .child((segment_labels.two_digits)(segment_state.value()))
1393 .when(selected && field_focused, |b| {
1394 b.child(
1395 div()
1396 .w(rem(2.))
1397 .h(rem(42.))
1398 .bg(color(ColorRole::PRIMARY)),
1399 )
1400 }),
1401 )
1402 .child(
1403 div()
1404 .text_color(color(ColorRole::ON_SURFACE_VARIANT))
1405 .map(|d| type_style(d, TypeRole::BodySmall))
1406 .child(label),
1407 )
1408 .into_any_element()
1409 });
1410 surface
1411 .gap(rem(20.))
1412 .child(header)
1413 .child(div().px(rem(24.)).child(input))
1414 .child(actions)
1415 }
1416 TimeInputMode::Dial if horizontal => surface
1417 .child(header)
1418 .child(
1419 div()
1420 .flex()
1421 .items_center()
1422 .gap(rem(52.))
1423 .px(rem(24.))
1424 .child(
1425 div()
1426 .w(rem(216. + extra))
1427 .flex()
1428 .flex_col()
1429 .gap(rem(16.))
1430 .child(time_row)
1431 .when(twelve, |c| c.child(period(38., true))),
1432 )
1433 .child(dial),
1434 )
1435 .child(actions),
1436 TimeInputMode::Dial => surface
1437 .gap(rem(20.))
1438 .child(header)
1439 .child(
1440 div()
1441 .flex()
1442 .flex_col()
1443 .items_center()
1444 .gap(rem(36.))
1445 .px(rem(24.))
1446 .child(
1447 div()
1448 .flex()
1449 .gap(rem(12.))
1450 .child(time_row)
1451 .when(twelve, |r| r.child(period(80., false))),
1452 )
1453 .child(dial),
1454 )
1455 .child(actions),
1456 }
1457 }
1458}
1459
1460#[cfg(test)]
1461#[path = "time_picker_tests.rs"]
1462mod tests;