1use 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#[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#[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 Inverted {
61 start: Day,
62 end: Day,
63 },
64}
65
66impl RangeState {
67 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#[derive(Debug, Clone, PartialEq, Eq)]
95pub struct BlockedDay {
96 pub day: Day,
97 pub reason: SharedString,
98}
99
100#[derive(Debug, Clone, PartialEq, Eq)]
102pub enum BlockedReport {
103 NotApplicable,
105 Unchecked,
108 Clear,
109 Blocked(Vec<BlockedDay>),
110}
111
112#[derive(Debug, Clone, PartialEq, Eq)]
114pub enum RangePickerEvent {
115 StartPicked(Day),
117 EndPicked(Day),
119}
120
121impl EventEmitter<RangePickerEvent> for RangePicker {}
122
123pub struct RangePicker {
125 ident: Ident,
126 focus_handle: FocusHandle,
127 adapter: SharedDateAdapter,
128 calendar: Entity<Calendar>,
129 range: Option<DayRange>,
130 disabled: bool,
131 _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 pub fn range(mut self, range: DayRange) -> Self {
175 self.range = Some(range);
176 self
177 }
178
179 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 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 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}