Skip to main content

guise/input/
datepicker.rs

1//! `DatePicker` — a stateful date field (gpui entity).
2//!
3//! Owns its open state, the visible month, and the selection; renders a
4//! trigger plus a deferred [`Calendar`] dropdown and emits
5//! [`DatePickerEvent`] on selection. Supports single dates and ranges.
6
7use gpui::prelude::*;
8use gpui::{
9  deferred, div, px, App, Context, Entity, EventEmitter, FocusHandle, IntoElement, SharedString,
10  Window,
11};
12
13use super::calendar::Calendar;
14use super::control_metrics;
15use super::date::{Date, Weekday};
16use crate::devtools::Probed;
17use crate::icon::{Icon, IconName};
18use crate::reactive::Signal;
19use crate::style::TextOverflowExt;
20use crate::theme::{theme, Size};
21
22/// Emitted when the user completes a pick.
23#[derive(Debug, Clone)]
24pub enum DatePickerEvent {
25  /// Single-date mode: the chosen date.
26  Selected(Date),
27  /// Range mode: both endpoints chosen (start ≤ end).
28  Range(Date, Date),
29}
30
31/// A dropdown date field. Create with `cx.new(|cx| DatePicker::new(cx))`.
32pub struct DatePicker {
33  open: bool,
34  focus: FocusHandle,
35  view_year: i32,
36  view_month: u32,
37  selected: Option<Date>,
38  range_mode: bool,
39  range_start: Option<Date>,
40  range_end: Option<Date>,
41  min: Option<Date>,
42  max: Option<Date>,
43  week_start: Weekday,
44  format: SharedString,
45  placeholder: SharedString,
46  label: Option<SharedString>,
47  size: Size,
48  disabled: bool,
49}
50
51impl EventEmitter<DatePickerEvent> for DatePicker {}
52
53impl DatePicker {
54  pub fn new(cx: &mut Context<Self>) -> Self {
55    let today = Date::today();
56    DatePicker {
57      open: false,
58      focus: cx.focus_handle(),
59      view_year: today.year(),
60      view_month: today.month(),
61      selected: None,
62      range_mode: false,
63      range_start: None,
64      range_end: None,
65      min: None,
66      max: None,
67      week_start: Weekday::Sunday,
68      format: SharedString::new_static("MMM D, YYYY"),
69      placeholder: SharedString::new_static("Pick a date"),
70      label: None,
71      size: Size::Sm,
72      disabled: false,
73    }
74  }
75
76  /// Pick a start and an end instead of a single date.
77  pub fn range_mode(mut self) -> Self {
78    self.range_mode = true;
79    self.placeholder = SharedString::new_static("Pick a range");
80    self
81  }
82
83  pub fn value(mut self, value: Date) -> Self {
84    self.selected = Some(value);
85    self.view_year = value.year();
86    self.view_month = value.month();
87    self
88  }
89
90  pub fn min(mut self, min: Date) -> Self {
91    self.min = Some(min);
92    self
93  }
94
95  pub fn max(mut self, max: Date) -> Self {
96    self.max = Some(max);
97    self
98  }
99
100  pub fn week_start(mut self, week_start: Weekday) -> Self {
101    self.week_start = week_start;
102    self
103  }
104
105  /// Display pattern for the trigger (see [`Date::format`]).
106  pub fn format(mut self, pattern: impl Into<SharedString>) -> Self {
107    self.format = pattern.into();
108    self
109  }
110
111  pub fn placeholder(mut self, placeholder: impl Into<SharedString>) -> Self {
112    self.placeholder = placeholder.into();
113    self
114  }
115
116  pub fn label(mut self, label: impl Into<SharedString>) -> Self {
117    self.label = Some(label.into());
118    self
119  }
120
121  pub fn size(mut self, size: Size) -> Self {
122    self.size = size;
123    self
124  }
125
126  pub fn disabled(mut self, disabled: bool) -> Self {
127    self.disabled = disabled;
128    self
129  }
130
131  pub fn selected_date(&self) -> Option<Date> {
132    self.selected
133  }
134
135  pub fn selected_range(&self) -> Option<(Date, Date)> {
136    Some((self.range_start?, self.range_end?))
137  }
138
139  /// Two-way bind the single-date selection to a `Signal<Option<Date>>`.
140  /// The signal is the source of truth; equality guards on both directions
141  /// prevent update loops.
142  pub fn bind(entity: &Entity<DatePicker>, signal: &Signal<Option<Date>>, cx: &mut App) {
143    let initial = signal.get(cx);
144    entity.update(cx, |this, cx| this.sync_selected(initial, cx));
145    let sink = signal.clone();
146    cx.subscribe(entity, move |_picker, event: &DatePickerEvent, cx| {
147      if let DatePickerEvent::Selected(date) = event {
148        sink.set_if_changed(cx, Some(*date));
149      }
150    })
151    .detach();
152    let picker = entity.downgrade();
153    cx.observe(signal.entity(), move |observed, cx| {
154      let date = *observed.read(cx);
155      picker
156        .update(cx, |this, cx| this.sync_selected(date, cx))
157        .ok();
158    })
159    .detach();
160  }
161
162  /// Programmatic set: repaint without emitting an event.
163  fn sync_selected(&mut self, date: Option<Date>, cx: &mut Context<Self>) {
164    if self.selected != date {
165      self.selected = date;
166      if let Some(date) = date {
167        self.view_year = date.year();
168        self.view_month = date.month();
169      }
170      cx.notify();
171    }
172  }
173
174  fn pick(&mut self, date: Date, cx: &mut Context<Self>) {
175    if self.range_mode {
176      match (self.range_start, self.range_end) {
177        (Some(start), None) => {
178          let (lo, hi) = if date < start {
179            (date, start)
180          } else {
181            (start, date)
182          };
183          self.range_start = Some(lo);
184          self.range_end = Some(hi);
185          self.open = false;
186          cx.emit(DatePickerEvent::Range(lo, hi));
187        }
188        _ => {
189          self.range_start = Some(date);
190          self.range_end = None;
191        }
192      }
193    } else {
194      self.selected = Some(date);
195      self.open = false;
196      cx.emit(DatePickerEvent::Selected(date));
197    }
198    cx.notify();
199  }
200
201  fn show_month(&mut self, year: i32, month: u32, cx: &mut Context<Self>) {
202    self.view_year = year;
203    self.view_month = month;
204    cx.notify();
205  }
206
207  fn trigger_text(&self) -> Option<SharedString> {
208    if self.range_mode {
209      let start = self.range_start?;
210      let text = match self.range_end {
211        Some(end) => format!(
212          "{} – {}",
213          start.format(&self.format),
214          end.format(&self.format)
215        ),
216        None => format!("{} – …", start.format(&self.format)),
217      };
218      Some(text.into())
219    } else {
220      Some(self.selected?.format(&self.format).into())
221    }
222  }
223}
224
225impl Render for DatePicker {
226  fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
227    let t = theme(cx);
228    let (height, pad_x, font) = control_metrics(self.size);
229    let radius = t.radius(t.default_radius);
230    let surface = t.surface().hsla();
231    let border = t.border().hsla();
232    let text_color = t.text().hsla();
233    let dimmed = t.dimmed().hsla();
234    let font_sm = t.font_size(Size::Sm);
235
236    let value_text = self.trigger_text();
237    let has_value = value_text.is_some();
238    let shown: SharedString = value_text.unwrap_or_else(|| self.placeholder.clone());
239
240    let trigger = div()
241      .id("guise-datepicker-trigger")
242      .track_focus(&self.focus)
243      .flex()
244      .items_center()
245      .justify_between()
246      .gap(px(8.0))
247      .h(px(height))
248      .px(px(pad_x))
249      .rounded(px(radius))
250      .border_1()
251      .border_color(border)
252      .bg(surface)
253      .text_size(px(font))
254      .text_color(if has_value { text_color } else { dimmed })
255      .child(div().flex_1().truncate_text().child(shown))
256      .child(
257        div()
258          .flex_none()
259          .text_color(dimmed)
260          .child(Icon::new(IconName::CalendarDays).size(Size::Sm)),
261      )
262      .on_click(cx.listener(|this, _ev, _window, cx| {
263        if !this.disabled {
264          this.open = !this.open;
265          cx.notify();
266        }
267      }));
268
269    let mut wrap = div().relative().child(trigger);
270
271    if self.open && !self.disabled {
272      let this = cx.entity().downgrade();
273      let month_target = this.clone();
274      let mut calendar = Calendar::new("guise-datepicker-calendar")
275        .month(self.view_year, self.view_month)
276        .week_start(self.week_start)
277        .size(self.size)
278        .on_select(move |date, _window, cx| {
279          this.update(cx, |picker, cx| picker.pick(date, cx)).ok();
280        })
281        .on_month_change(move |year, month, _window, cx| {
282          month_target
283            .update(cx, |picker, cx| picker.show_month(year, month, cx))
284            .ok();
285        });
286      if self.range_mode {
287        if let Some(start) = self.range_start {
288          calendar = calendar.range(start, self.range_end);
289        }
290      } else {
291        calendar = calendar.value(self.selected);
292      }
293      if let Some(min) = self.min {
294        calendar = calendar.min(min);
295      }
296      if let Some(max) = self.max {
297        calendar = calendar.max(max);
298      }
299
300      let panel = div()
301        .absolute()
302        .top(px(height + 6.0))
303        .left(px(0.0))
304        .p(px(10.0))
305        .rounded(px(radius))
306        .border_1()
307        .border_color(border)
308        .bg(surface)
309        .shadow_md()
310        .occlude()
311        .child(calendar);
312      wrap = wrap.child(deferred(panel));
313    }
314
315    let mut column = div().flex().flex_col().min_w(px(0.0)).gap(px(4.0));
316    if let Some(label) = self.label.clone() {
317      column = column.child(
318        div()
319          .min_w(px(0.0))
320          .text_size(px(font_sm))
321          .text_color(text_color)
322          .child(label),
323      );
324    }
325    column = column.child(wrap);
326
327    let element = if self.disabled {
328      column.opacity(0.6)
329    } else {
330      column
331    };
332
333    element.probe("DatePicker")
334  }
335}