From cce71c7607ef2bc0266cdfd7ccecfe1d6e779e46 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 2 Oct 2026 04:17:02 -0400 Subject: [PATCH] M6: Date picker: date fields show a month calendar while focused; the picked day is written in the field's own format Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 2 +- crates/ui-egui/src/forms_ui.rs | 93 +++++++++++++++++++++++++++++++++- crates/ui-egui/tests/forms.rs | 24 +++++++++ parity/acrobat-features.toml | 8 +-- 4 files changed, 120 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index d5a82cea..2ad0d334 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1373,7 +1373,7 @@ pub fn document_area(app: &mut PrintCraftApp, index: usize, ui: &mut egui::Ui) { if let Some(e) = comments::composer(ui.ctx(), view, info, prefs) { view.pending_edit = Some(e); } - if let Some(e) = crate::forms_ui::overlay(ui.ctx(), view, info, &form) { + if let Some(e) = crate::forms_ui::overlay(ui.ctx(), view, info, &form, today) { view.pending_edit = Some(e); } if let Some(e) = crate::fill_sign::type_box(ui.ctx(), view, info, &author) { diff --git a/crates/ui-egui/src/forms_ui.rs b/crates/ui-egui/src/forms_ui.rs index 9e7875f4..9245f3fb 100644 --- a/crates/ui-egui/src/forms_ui.rs +++ b/crates/ui-egui/src/forms_ui.rs @@ -26,6 +26,9 @@ pub struct Focus { pub request_focus: bool, /// Select the whole value when the editor opens (entered with Tab), so typing replaces it. pub select_all: bool, + /// Date fields: the month the calendar shows (year, month 1–12), and where it was drawn. + pub calendar: Option<(i32, u32)>, + pub calendar_rect: Option, } #[derive(Clone, Debug, Default)] @@ -53,6 +56,8 @@ fn open_focus(f: &FormField, widget: usize) -> Focus { picked: f.value.clone(), request_focus: true, select_all: false, + calendar: None, + calendar_rect: None, } } @@ -130,6 +135,77 @@ fn commit(view: &mut DocView, form: &[FormField]) { view.pending_edit = Some(Edit::SetFieldValue { name: f.name.clone(), value }); } +const MONTHS: [&str; 12] = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]; + +/// The date picker under a focused date field. Returns the picked date, formatted with the +/// field's own format. +fn calendar(ctx: &egui::Context, view: &mut DocView, field: egui::Rect, fmt: &str, today: (i64, u32, u32)) -> Option { + use printcraft_engine::form_scripts::{DateTime, format_date, parse_date}; + let fx = view.forms.focus.as_mut()?; + let (mut y, mut m) = fx.calendar.unwrap_or_else(|| match parse_date(&fx.text, fmt) { + Some(d) => (d.y, d.m), + None => (today.0 as i32, today.1), + }); + let current = parse_date(&fx.text, fmt); + let mut picked = None; + let area = egui::Area::new(egui::Id::new(("date-picker", view.id.0))) + .order(egui::Order::Foreground) + .fixed_pos(field.left_bottom() + vec2(0.0, 2.0)) + .show(ctx, |ui| { + egui::Frame::popup(ui.style()).show(ui, |ui| { + ui.horizontal(|ui| { + if ui.small_button("‹").on_hover_text("Previous month").clicked() { + (y, m) = if m == 1 { (y - 1, 12) } else { (y, m - 1) }; + } + ui.label(egui::RichText::new(format!("{} {y}", MONTHS[(m.clamp(1, 12) - 1) as usize])).strong()); + if ui.small_button("›").on_hover_text("Next month").clicked() { + (y, m) = if m == 12 { (y + 1, 1) } else { (y, m + 1) }; + } + }); + // Weekday of the 1st (0 = Sunday), Zeller-style via days since 1970-01-01 (a Thursday). + let days_from_civil = |y: i32, m: u32, d: u32| -> i64 { + let (y, m) = if m <= 2 { (y as i64 - 1, m as i64 + 9) } else { (y as i64, m as i64 - 3) }; + let era = y.div_euclid(400); + let yoe = y - era * 400; + let doy = (153 * m + 2) / 5 + d as i64 - 1; + era * 146_097 + yoe * 365 + yoe / 4 - yoe / 100 + doy - 719_468 + }; + let first = (days_from_civil(y, m, 1) + 4).rem_euclid(7) as usize; + let len = (days_from_civil(if m == 12 { y + 1 } else { y }, if m == 12 { 1 } else { m + 1 }, 1) - days_from_civil(y, m, 1)) as usize; + egui::Grid::new(("date-grid", view.id.0)).spacing([4.0, 2.0]).show(ui, |ui| { + for d in ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"] { + ui.label(egui::RichText::new(d).small()); + } + ui.end_row(); + for cell in 0..(first + len).div_ceil(7) * 7 { + if cell >= first && cell < first + len { + let day = (cell - first + 1) as u32; + let selected = current.is_some_and(|c| (c.y, c.m, c.d) == (y, m, day)); + let is_today = (today.0 as i32, today.1, today.2) == (y, m, day); + let mut text = egui::RichText::new(day.to_string()); + if is_today { + text = text.strong(); + } + if ui.selectable_label(selected, text).on_hover_text(format!("{} {day}, {y}", MONTHS[(m - 1) as usize])).clicked() { + picked = Some(format_date(DateTime { y, m, d: day, hh: 0, mm: 0, ss: 0 }, fmt)); + } + } else { + ui.label(""); + } + if cell % 7 == 6 { + ui.end_row(); + } + } + }); + }); + }); + if let Some(fx) = view.forms.focus.as_mut() { + fx.calendar = Some((y, m)); + fx.calendar_rect = Some(area.response.rect); + } + picked +} + /// Paint focus and hover frames on a page. pub(crate) fn paint_page(ui: &egui::Ui, painter: &egui::Painter, xf: &PageXform, page: usize, info: &DocInfo, form: &[FormField], view: &DocView) { let pointer = ui.input(|i| i.pointer.hover_pos()); @@ -147,7 +223,7 @@ pub(crate) fn paint_page(ui: &egui::Ui, painter: &egui::Painter, xf: &PageXform, } /// The in-place editor or option list for the focused field. Returns an edit to apply. -pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, form: &[FormField]) -> Option { +pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, form: &[FormField], today: (i64, u32, u32)) -> Option { let focus = view.forms.focus.clone()?; let Some(f) = form.iter().find(|f| f.name == focus.name) else { view.forms.focus = None; @@ -205,11 +281,24 @@ pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, f close = true; } else if tab { next = Some(!shift); - } else if r.lost_focus() && (!multiline || !enter) { + } else if r.lost_focus() + && (!multiline || !enter) + && !fx.calendar_rect.is_some_and(|c| ui.input(|i| i.pointer.interact_pos().is_some_and(|p| c.contains(p)))) + { // Enter in a single-line field, or clicking elsewhere, commits. commit(view, form); } }); + // Date fields: a calendar under the field (Acrobat's date picker). + if let printcraft_engine::form_scripts::Format::Date(fmt) = &f.actions.format + && let Some(picked) = calendar(ctx, view, rect, fmt, today) + { + if let Some(fx) = view.forms.focus.as_mut() { + fx.text = picked; + } + commit(view, form); + return view.pending_edit.take(); + } } FormFieldKind::Combo | FormFieldKind::List => { let multi = f.kind == FormFieldKind::List && f.has(field_flags::MULTI_SELECT); diff --git a/crates/ui-egui/tests/forms.rs b/crates/ui-egui/tests/forms.rs index 8b038e45..9dcc5388 100644 --- a/crates/ui-egui/tests/forms.rs +++ b/crates/ui-egui/tests/forms.rs @@ -120,3 +120,27 @@ fn tabbing_into_a_filled_field_selects_it_so_typing_replaces() { h.run_steps(3); assert_eq!(value(&h, "city"), ["Lyon"]); } + +#[test] +fn date_fields_offer_a_calendar() { + use egui_kittest::kittest::Queryable; + let mut h = harness(); + h.state_mut().apply_edit(printcraft_engine::Edit::AddField { + page: 0, + rect: [50.0, 40.0, 200.0, 60.0], + kind: printcraft_engine::NewField::Date, + name: Some("due".into()), + }); + h.run_steps(4); + click_field(&mut h, "due", 0); + let (y, m, _) = h.state().session.today(); + let month = + ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"][(m - 1) as usize]; + h.get_by_label(&format!("{month} {y}")); + h.get_by_label("›").click(); + h.run_steps(2); + h.get_by_label("15").click(); + h.run_steps(4); + let (ny, nm) = if m == 12 { (y + 1, 1) } else { (y, m + 1) }; + assert_eq!(value(&h, "due"), vec![format!("{nm:02}/15/{ny}")], "picked in the field's format"); +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index f5ea8bf8..36691378 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -4503,11 +4503,11 @@ title = "Date picker for date fields" area = "F" tier = "P1" milestone = "M6" -status = "planned" +status = "shipped" commands = [] -tools = [] -evidence = [] -notes = "" +tools = ["form_fill"] +evidence = ["crates/ui-egui/tests/forms.rs::date_fields_offer_a_calendar"] +notes = "a month calendar under date fields; the pick is written in the field's own date format" [[feature]] id = "form.auto-complete"