Skip to main content

gpui_kit/datetime/
range.rs

1//! Two ends of a range over a host-owned calendar.
2//!
3//! The picker reports and never judges. An incomplete range is a state of its
4//! own rather than an error; an end before its start is said out loud instead
5//! of quietly swapped; and a blocked day inside a range is named, leaving the
6//! host to decide whether that makes the range unusable.
7
8use gpui::{
9    App, AppContext as _, Context, Entity, EventEmitter, FocusHandle, Focusable,
10    InteractiveElement, IntoElement, ParentElement, Render, SharedString, Styled, Subscription,
11    Window, div, px,
12};
13use gpui_kit_semantics::{NodeSpec, Role, Semantic};
14use gpui_kit_theme::{ActiveTheme, Space, TextTone, TypeScale};
15
16use crate::datetime::adapter::{Day, SharedDateAdapter};
17use crate::datetime::calendar::{Calendar, CalendarEvent, DayMark};
18use crate::display::badge::Tone;
19use crate::display::status::StatusLine;
20use crate::foundation::{Disableable, Ident, StyledExt, text as foundation_text};
21use crate::strings::{ActiveStrings, StringKey};
22
23/// A range as the caller holds it: a start, and an end once there is one.
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub struct DayRange {
26    pub start: Day,
27    pub end: Option<Day>,
28}
29
30impl DayRange {
31    pub fn starting(start: Day) -> Self {
32        Self { start, end: None }
33    }
34
35    pub fn new(start: Day, end: Day) -> Self {
36        Self {
37            start,
38            end: Some(end),
39        }
40    }
41}
42
43/// What the picker currently holds.
44///
45/// Incomplete and inverted are facts, not failures. Naming them separately is
46/// what stops a half-finished range being drawn as a broken one, and an end
47/// before a start being drawn as a range nobody asked for.
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
49pub enum RangeState {
50    #[default]
51    Unset,
52    Incomplete {
53        start: Day,
54    },
55    Complete {
56        start: Day,
57        end: Day,
58    },
59    /// The end sits before the start, exactly as it was given.
60    Inverted {
61        start: Day,
62        end: Day,
63    },
64}
65
66impl RangeState {
67    /// The word the picker publishes for this state.
68    pub fn name(self) -> &'static str {
69        match self {
70            Self::Unset => "unset",
71            Self::Incomplete { .. } => "incomplete",
72            Self::Complete { .. } => "complete",
73            Self::Inverted { .. } => "end before start",
74        }
75    }
76
77    pub fn from_range(range: Option<DayRange>) -> Self {
78        match range {
79            None => Self::Unset,
80            Some(DayRange { start, end: None }) => Self::Incomplete { start },
81            Some(DayRange {
82                start,
83                end: Some(end),
84            }) if end < start => Self::Inverted { start, end },
85            Some(DayRange {
86                start,
87                end: Some(end),
88            }) => Self::Complete { start, end },
89        }
90    }
91}
92
93/// One day inside a range the adapter refuses, in the host's own words.
94#[derive(Debug, Clone, PartialEq, Eq)]
95pub struct BlockedDay {
96    pub day: Day,
97    pub reason: SharedString,
98}
99
100/// What the picker can say about the days between the two ends.
101#[derive(Debug, Clone, PartialEq, Eq)]
102pub enum BlockedReport {
103    /// There is no complete range to look at yet.
104    NotApplicable,
105    /// The host cannot enumerate the days in the range, so nothing was
106    /// checked. This is not the same as finding nothing.
107    Unchecked,
108    Clear,
109    Blocked(Vec<BlockedDay>),
110}
111
112/// What a range picker reports. The owner decides what any of it means.
113#[derive(Debug, Clone, PartialEq, Eq)]
114pub enum RangePickerEvent {
115    /// A day was asked for as the start of a new range.
116    StartPicked(Day),
117    /// A day was asked for as the end of the range in progress.
118    EndPicked(Day),
119}
120
121impl EventEmitter<RangePickerEvent> for RangePicker {}
122
123/// A calendar with two ends and a running account of what they add up to.
124pub struct RangePicker {
125    ident: Ident,
126    focus_handle: FocusHandle,
127    adapter: SharedDateAdapter,
128    calendar: Entity<Calendar>,
129    range: Option<DayRange>,
130    disabled: bool,
131    /// Held so the calendar subscription lives as long as the picker does.
132    _subscriptions: Vec<Subscription>,
133}
134
135impl std::fmt::Debug for RangePicker {
136    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
137        formatter
138            .debug_struct("RangePicker")
139            .field("ident", &self.ident)
140            .field("range", &self.range)
141            .field("disabled", &self.disabled)
142            .finish()
143    }
144}
145
146impl RangePicker {
147    pub fn new(
148        ident: impl Into<Ident>,
149        adapter: SharedDateAdapter,
150        window: &mut Window,
151        cx: &mut Context<Self>,
152    ) -> Self {
153        let ident = ident.into();
154        let calendar =
155            cx.new(|cx| Calendar::new(ident.child("calendar"), adapter.clone(), window, cx));
156        let subscription = cx.subscribe(&calendar, |picker, _calendar, event, cx| match event {
157            CalendarEvent::Picked(day) => picker.take(*day, cx),
158            CalendarEvent::Hovered(_) => cx.notify(),
159            CalendarEvent::MonthShown(_) => {}
160        });
161
162        Self {
163            ident,
164            focus_handle: cx.focus_handle(),
165            adapter,
166            calendar,
167            range: None,
168            disabled: false,
169            _subscriptions: vec![subscription],
170        }
171    }
172
173    /// Seeds the range the caller holds.
174    pub fn range(mut self, range: DayRange) -> Self {
175        self.range = Some(range);
176        self
177    }
178
179    /// Marks days with a dot the host supplies, on the calendar underneath.
180    pub fn set_overlay(
181        &mut self,
182        overlay: impl Fn(Day) -> Option<DayMark> + 'static,
183        cx: &mut Context<Self>,
184    ) {
185        self.calendar
186            .update(cx, |calendar, cx| calendar.set_overlay(overlay, cx));
187    }
188
189    pub fn set_range(&mut self, range: Option<DayRange>, cx: &mut Context<Self>) {
190        if self.range == range {
191            return;
192        }
193        self.range = range;
194        cx.notify();
195    }
196
197    pub fn set_disabled(&mut self, disabled: bool, cx: &mut Context<Self>) {
198        if self.disabled == disabled {
199            return;
200        }
201        self.disabled = disabled;
202        self.calendar
203            .update(cx, |calendar, cx| calendar.set_disabled(disabled, cx));
204        cx.notify();
205    }
206
207    pub fn calendar(&self) -> &Entity<Calendar> {
208        &self.calendar
209    }
210
211    pub fn state(&self) -> RangeState {
212        RangeState::from_range(self.range)
213    }
214
215    /// The days the adapter refuses inside the range, or the reason the
216    /// question could not be answered.
217    pub fn blocked(&self) -> BlockedReport {
218        let (start, end) = match self.state() {
219            RangeState::Complete { start, end } => (start, end),
220            RangeState::Inverted { start, end } => (end, start),
221            _ => return BlockedReport::NotApplicable,
222        };
223        let Some(days) = self.adapter.days_in(start, end) else {
224            return BlockedReport::Unchecked;
225        };
226        let blocked: Vec<BlockedDay> = days
227            .into_iter()
228            .filter_map(|day| {
229                self.adapter
230                    .is_selectable(day)
231                    .reason()
232                    .cloned()
233                    .map(|reason| BlockedDay { day, reason })
234            })
235            .collect();
236        if blocked.is_empty() {
237            BlockedReport::Clear
238        } else {
239            BlockedReport::Blocked(blocked)
240        }
241    }
242
243    /// Which end the next pick fills. A range that is complete, inverted, or
244    /// unset starts a new one.
245    fn take(&mut self, day: Day, cx: &mut Context<Self>) {
246        if self.disabled {
247            return;
248        }
249        match self.state() {
250            RangeState::Incomplete { .. } => cx.emit(RangePickerEvent::EndPicked(day)),
251            _ => cx.emit(RangePickerEvent::StartPicked(day)),
252        }
253    }
254
255    fn summary(&self, cx: &App) -> (SharedString, Tone) {
256        let strings = cx.strings();
257        match self.state() {
258            RangeState::Unset => (strings.text(StringKey::RangeUnset), Tone::Neutral),
259            RangeState::Incomplete { start } => (
260                strings.format(
261                    StringKey::RangeIncomplete,
262                    &[self.adapter.format_day(start).as_ref()],
263                ),
264                Tone::Info,
265            ),
266            RangeState::Complete { start, end } => (
267                strings.format(
268                    StringKey::RangeComplete,
269                    &[
270                        self.adapter.format_day(start).as_ref(),
271                        self.adapter.format_day(end).as_ref(),
272                    ],
273                ),
274                Tone::Success,
275            ),
276            RangeState::Inverted { start, end } => (
277                strings.format(
278                    StringKey::RangeInverted,
279                    &[
280                        self.adapter.format_day(end).as_ref(),
281                        self.adapter.format_day(start).as_ref(),
282                    ],
283                ),
284                Tone::Warning,
285            ),
286        }
287    }
288}
289
290impl Disableable for RangePicker {
291    fn disabled(mut self, disabled: bool) -> Self {
292        self.disabled = disabled;
293        self
294    }
295}
296
297impl Focusable for RangePicker {
298    fn focus_handle(&self, _cx: &App) -> FocusHandle {
299        self.focus_handle.clone()
300    }
301}
302
303impl Render for RangePicker {
304    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
305        let theme = cx.theme().clone();
306        let range = self.range;
307        self.calendar
308            .update(cx, |calendar, cx| calendar.set_range(range, cx));
309        let (summary, tone) = self.summary(cx);
310        let state = self.state();
311        let blocked = self.blocked();
312
313        let blocked_line = match &blocked {
314            BlockedReport::Unchecked => Some(
315                foundation_text(
316                    &theme,
317                    TypeScale::Caption,
318                    cx.strings().text(StringKey::RangeUncheckable),
319                )
320                .text_tone(&theme, TextTone::Muted)
321                .semantic_in(
322                    cx,
323                    NodeSpec::new(self.ident.child("blocked").semantic_id(), Role::Status)
324                        .parent(self.ident.semantic_id())
325                        .value("unchecked")
326                        .text(cx.strings().text(StringKey::RangeUncheckable)),
327                ),
328            ),
329            _ => None,
330        };
331
332        let named: Vec<gpui::AnyElement> = match &blocked {
333            BlockedReport::Blocked(days) => days
334                .iter()
335                .map(|blocked| {
336                    let blocked_id = format!("blocked-{}", blocked.day.0);
337                    let ident = self.ident.child(blocked_id);
338                    let text = cx.strings().format(
339                        StringKey::RangeBlockedDay,
340                        &[
341                            self.adapter.format_day(blocked.day).as_ref(),
342                            blocked.reason.as_ref(),
343                        ],
344                    );
345                    foundation_text(&theme, TypeScale::Caption, text.clone())
346                        .text_color(theme.colors.warning)
347                        .semantic_in(
348                            cx,
349                            NodeSpec::new(ident.semantic_id(), Role::Status)
350                                .parent(self.ident.semantic_id())
351                                .text(text)
352                                .value(blocked.reason.clone()),
353                        )
354                        .into_any_element()
355                })
356                .collect(),
357            _ => Vec::new(),
358        };
359
360        div()
361            .id(self.ident.element_id())
362            .column()
363            .flex_none()
364            .gap_token(&theme, Space::Sm)
365            .track_focus(&self.focus_handle)
366            .child(self.calendar.clone())
367            .child(
368                div()
369                    .column()
370                    .gap(px(theme.space(Space::Xs)))
371                    .child(StatusLine::new(summary.clone(), tone).id(self.ident.child("summary")))
372                    .children(blocked_line)
373                    .children(named),
374            )
375            .semantic_in(
376                cx,
377                NodeSpec::new(self.ident.semantic_id(), Role::Group)
378                    .disabled(self.disabled)
379                    .value(state.name()),
380            )
381    }
382}
383
384#[cfg(test)]
385mod tests {
386    use super::*;
387
388    #[test]
389    fn an_incomplete_range_is_its_own_state_rather_than_an_error() {
390        assert_eq!(
391            RangeState::from_range(Some(DayRange::starting(Day(4)))),
392            RangeState::Incomplete { start: Day(4) }
393        );
394        assert_eq!(RangeState::from_range(None), RangeState::Unset);
395    }
396
397    #[test]
398    fn an_end_before_the_start_is_reported_rather_than_swapped() {
399        let state = RangeState::from_range(Some(DayRange::new(Day(9), Day(4))));
400        assert_eq!(
401            state,
402            RangeState::Inverted {
403                start: Day(9),
404                end: Day(4)
405            }
406        );
407        assert_eq!(state.name(), "end before start");
408    }
409
410    #[test]
411    fn a_range_that_runs_forwards_is_complete() {
412        assert_eq!(
413            RangeState::from_range(Some(DayRange::new(Day(4), Day(9)))),
414            RangeState::Complete {
415                start: Day(4),
416                end: Day(9)
417            }
418        );
419    }
420}