use std::rc::Rc; use gpui::{ App, Div, ElementId, FocusHandle, FontWeight, InteractiveElement, IntoElement, ParentElement, RenderOnce, SharedString, Stateful, StatefulInteractiveElement, Styled, Window, div, prelude::*, }; use jiff::civil::Date; use super::{ super::options::{Run, float, surface}, picker::{Face, dropdown, picker_field}, zoned_now, }; use crate::{ buttons::{ButtonVariant, IconButton}, layout::seeded::use_seeded, primitives::IconName, theme::{ActiveTheme, ControlSize, Radius, TextSize}, }; const MONTHS: [&str; 12] = [ "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", ]; /// What a period grid shows: a page title, cells, and which one is chosen and which is now. struct Page { title: String, cells: Vec, chosen: Option, now: Option, columns: u16, } type Turn = Rc; type PickPeriod = Rc; type OnYear = Rc; type PickCell = Rc; /// What a period grid does: turn the page, pick a cell, close, and the focus it keeps. struct Grid { turn: Turn, pick: PickCell, close: Run, focus: FocusHandle, } /// A page of periods under a header with arrows. Arrow keys move, Page keys turn, Enter picks. fn period_grid(id: &ElementId, page: Page, grid: Grid, window: &mut Window, cx: &mut App) -> Div { let Grid { turn, pick, close, focus, } = grid; let focus = &focus; let count = page.cells.len(); let cursor = use_seeded((id.clone(), "cursor"), page.chosen.unwrap_or(0), window, cx); let at = cursor.read(cx).value.min(count - 1); let focused = focus.is_focused(window); let theme = cx.theme(); let colors = &theme.colors; let arrow = |key: &'static str, icon: IconName, by: i16| { let turn = turn.clone(); IconButton::new((id.clone(), key), icon) .variant(ButtonVariant::Ghost) .size(ControlSize::Sm) .on_click(move |_, window, cx| turn(by, window, cx)) }; let cells: Vec<_> = page .cells .into_iter() .enumerate() .map(|(ix, label)| { let pick = pick.clone(); let chosen = page.chosen == Some(ix); div() .id(("period", ix)) .flex() .items_center() .justify_center() .h(theme.control_height(ControlSize::Md)) .rounded(theme.radius(Radius::Md)) .border_1() .border_color(if focused && ix == at { colors.focus } else if page.now == Some(ix) && !chosen { colors.border_strong } else { gpui::transparent_black() }) .cursor_pointer() .map(|cell| { if chosen { cell.bg(colors.accent).text_color(colors.on_accent) } else { cell.text_color(colors.fg) .hover(|style| style.bg(colors.hover)) } }) .on_click(move |_, window, cx| pick(ix, window, cx)) .child(label) }) .collect(); let columns = page.columns; let keys = (cursor.clone(), pick.clone(), turn.clone()); div() .track_focus(focus) .flex() .flex_col() .gap_2() .w(theme.control_height(ControlSize::Md) * 8.0) .p_2() .text_size(theme.text_size(TextSize::Sm)) .on_key_down(move |event, window, cx| { let (cursor, pick, turn) = &keys; let by = i16::try_from(columns).expect("a few columns"); let step = match event.keystroke.key.as_str() { "left" => -1, "right" => 1, "up" => -by, "down" => by, "pageup" => return turn(-1, window, cx), "pagedown" => return turn(1, window, cx), "enter" | "space" => { cx.stop_propagation(); return pick(at, window, cx); } "escape" => { cx.stop_propagation(); return close(window, cx); } _ => return, }; cx.stop_propagation(); let next = (at as i16 + step).clamp(0, count as i16 - 1) as usize; cursor.update(cx, |cursor, cx| { cursor.value = next; cx.notify(); }); }) .child( div() .flex() .items_center() .justify_between() .child( div() .pl_1() .text_size(theme.text_size(TextSize::Base)) .font_weight(FontWeight::SEMIBOLD) .text_color(colors.fg) .child(page.title), ) .child( div() .flex() .gap_0p5() .child(arrow("prev", IconName::ChevronLeft, -1)) .child(arrow("next", IconName::ChevronRight, 1)), ), ) .child(div().grid().grid_cols(columns).gap_1().children(cells)) } /// A period picker's words, first page, page size, and what its cells mean. struct Periods { shown: Option, placeholder: &'static str, seed: i16, step: i16, build: Rc Page>, pick: PickPeriod, } /// The field and popup shared by month, quarter and year pickers. fn period_picker( id: ElementId, periods: Periods, window: &mut Window, cx: &mut App, ) -> Stateful
{ let Periods { shown, placeholder, seed, step, build, pick, } = periods; let state = dropdown(&id, window, cx); let page = use_seeded((id.clone(), "page"), seed, window, cx); let inner = state.read(cx).inner.clone(); let key = id.clone(); picker_field( id, Face { icon: IconName::Calendar, shown, placeholder: placeholder.into(), size: ControlSize::default(), disabled: false, }, move |close, anchor, window, cx| { let shown = page.read(cx).value; let turn: Turn = { let page = page.clone(); Rc::new(move |by, _, cx| { page.update(cx, |page, cx| { page.value += by * step; cx.notify(); }) }) }; let chosen: PickCell = { let (pick, close) = (pick.clone(), close.clone()); Rc::new(move |ix, window, cx| { pick(shown, ix, window, cx); close(window, cx); }) }; let out = close.clone(); let grid = Grid { turn, pick: chosen, close, focus: inner.clone(), }; let grid = period_grid(&key, build(shown), grid, window, cx); float( anchor, 6, surface((key.clone(), "popup"), cx) .on_mouse_down_out(move |_, window, cx| out(window, cx)) .child(grid), window, cx, ) }, window, cx, ) } fn today(fixed: Option) -> Date { fixed.unwrap_or_else(|| zoned_now().date()) } type OnPeriod = Rc; /// A year's months as a grid. The value is a year and a month, 1 to 12. #[derive(IntoElement)] pub struct MonthPicker { id: ElementId, value: Option<(i16, i8)>, today: Option, on_change: Option, } impl MonthPicker { pub fn new(id: impl Into, value: Option<(i16, i8)>) -> Self { Self { id: id.into(), value, today: None, on_change: None, } } /// The date treated as now. Defaults to the system's date. pub fn today(mut self, date: Date) -> Self { self.today = Some(date); self } pub fn on_change( mut self, handler: impl Fn((i16, i8), &mut Window, &mut App) + 'static, ) -> Self { self.on_change = Some(Rc::new(handler)); self } } impl RenderOnce for MonthPicker { fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { let (value, now) = (self.value, today(self.today)); let on_change = self.on_change; let periods = Periods { shown: value .map(|(year, month)| format!("{} {year}", MONTH_NAMES[month as usize - 1]).into()), placeholder: "Pick a month", seed: value.map_or(now.year(), |(year, _)| year), step: 1, build: Rc::new(move |year| Page { title: year.to_string(), cells: MONTHS.iter().map(|month| month.to_string()).collect(), chosen: value .filter(|(chosen, _)| *chosen == year) .map(|(_, month)| month as usize - 1), now: (now.year() == year).then(|| now.month() as usize - 1), columns: 3, }), pick: Rc::new(move |year, ix, window, cx| { log::info!("month picker: {year}-{:02}", ix + 1); if let Some(on_change) = &on_change { on_change((year, ix as i8 + 1), window, cx); } }), }; period_picker(self.id, periods, window, cx) } } const MONTH_NAMES: [&str; 12] = [ "January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December", ]; /// A year's quarters. The value is a year and a quarter, 1 to 4. #[derive(IntoElement)] pub struct QuarterPicker { id: ElementId, value: Option<(i16, i8)>, today: Option, on_change: Option, } impl QuarterPicker { pub fn new(id: impl Into, value: Option<(i16, i8)>) -> Self { Self { id: id.into(), value, today: None, on_change: None, } } /// The date treated as now. Defaults to the system's date. pub fn today(mut self, date: Date) -> Self { self.today = Some(date); self } pub fn on_change( mut self, handler: impl Fn((i16, i8), &mut Window, &mut App) + 'static, ) -> Self { self.on_change = Some(Rc::new(handler)); self } } impl RenderOnce for QuarterPicker { fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { let (value, now) = (self.value, today(self.today)); let on_change = self.on_change; let quarter = (now.month() as usize - 1) / 3; let periods = Periods { shown: value.map(|(year, quarter)| format!("Q{quarter} {year}").into()), placeholder: "Pick a quarter", seed: value.map_or(now.year(), |(year, _)| year), step: 1, build: Rc::new(move |year| Page { title: year.to_string(), cells: (1..=4).map(|quarter| format!("Q{quarter}")).collect(), chosen: value .filter(|(chosen, _)| *chosen == year) .map(|(_, quarter)| quarter as usize - 1), now: (now.year() == year).then_some(quarter), columns: 4, }), pick: Rc::new(move |year, ix, window, cx| { log::info!("quarter picker: {year} Q{}", ix + 1); if let Some(on_change) = &on_change { on_change((year, ix as i8 + 1), window, cx); } }), }; period_picker(self.id, periods, window, cx) } } /// Twelve years to a page. The value is a year. #[derive(IntoElement)] pub struct YearPicker { id: ElementId, value: Option, today: Option, on_change: Option, } impl YearPicker { pub fn new(id: impl Into, value: Option) -> Self { Self { id: id.into(), value, today: None, on_change: None, } } pub fn today(mut self, date: Date) -> Self { self.today = Some(date); self } pub fn on_change(mut self, handler: impl Fn(i16, &mut Window, &mut App) + 'static) -> Self { self.on_change = Some(Rc::new(handler)); self } } /// The first year of the twelve-year page holding `year`. pub(crate) fn page_of(year: i16) -> i16 { year - year.rem_euclid(12) } impl RenderOnce for YearPicker { fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { let (value, now) = (self.value, today(self.today).year()); let on_change = self.on_change; let periods = Periods { shown: value.map(|year| year.to_string().into()), placeholder: "Pick a year", seed: page_of(value.unwrap_or(now)), step: 12, build: Rc::new(move |first| Page { title: format!("{first} \u{2013} {}", first + 11), cells: (first..first + 12).map(|year| year.to_string()).collect(), chosen: value .and_then(|year| (first..first + 12).position(|candidate| candidate == year)), now: (first..first + 12).position(|candidate| candidate == now), columns: 3, }), pick: Rc::new(move |first, ix, window, cx| { let year = first + ix as i16; log::info!("year picker: {year}"); if let Some(on_change) = &on_change { on_change(year, window, cx); } }), }; period_picker(self.id, periods, window, cx) } } #[cfg(test)] mod tests { use super::page_of; #[test] fn twelve_year_pages_start_on_a_multiple_of_twelve() { assert_eq!(page_of(2026), 2016); assert_eq!(page_of(2028), 2028); assert_eq!(page_of(-5), -12); } }