469 lines
14 KiB
Rust
469 lines
14 KiB
Rust
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<String>,
|
|
chosen: Option<usize>,
|
|
now: Option<usize>,
|
|
columns: u16,
|
|
}
|
|
|
|
type Turn = Rc<dyn Fn(i16, &mut Window, &mut App)>;
|
|
type PickPeriod = Rc<dyn Fn(i16, usize, &mut Window, &mut App)>;
|
|
type OnYear = Rc<dyn Fn(i16, &mut Window, &mut App)>;
|
|
type PickCell = Rc<dyn Fn(usize, &mut Window, &mut App)>;
|
|
|
|
/// 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<SharedString>,
|
|
placeholder: &'static str,
|
|
seed: i16,
|
|
step: i16,
|
|
build: Rc<dyn Fn(i16) -> 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<Div> {
|
|
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>) -> Date {
|
|
fixed.unwrap_or_else(|| zoned_now().date())
|
|
}
|
|
|
|
type OnPeriod = Rc<dyn Fn((i16, i8), &mut Window, &mut App)>;
|
|
|
|
/// 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<Date>,
|
|
on_change: Option<OnPeriod>,
|
|
}
|
|
|
|
impl MonthPicker {
|
|
pub fn new(id: impl Into<ElementId>, 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<Date>,
|
|
on_change: Option<OnPeriod>,
|
|
}
|
|
|
|
impl QuarterPicker {
|
|
pub fn new(id: impl Into<ElementId>, 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<i16>,
|
|
today: Option<Date>,
|
|
on_change: Option<OnYear>,
|
|
}
|
|
|
|
impl YearPicker {
|
|
pub fn new(id: impl Into<ElementId>, value: Option<i16>) -> 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);
|
|
}
|
|
}
|