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