From e1487a7673561c879a66c150e295d79491e8d939 Mon Sep 17 00:00:00 2001 From: sedax90 <5001801+sedax90@users.noreply.github.com> Date: Thu, 29 Oct 2026 15:33:22 +0100 Subject: [PATCH 01/13] feat(linux-grid): set a time or a timestamp's time from a clock in Row Details --- .../app/src/ui/data_grid/row_detail/clock.rs | 239 ++++++++++++++++++ .../app/src/ui/data_grid/row_detail/imp.rs | 14 + .../app/src/ui/data_grid/row_detail/mod.rs | 3 + .../app/src/ui/data_grid/row_detail/tests.rs | 43 ++++ .../ui/data_grid/row_detail/value_editor.rs | 4 + linux/data/resources/ui/row-detail-pane.ui | 97 +++++++ linux/po/POTFILES.in | 1 + 7 files changed, 401 insertions(+) create mode 100644 linux/crates/app/src/ui/data_grid/row_detail/clock.rs diff --git a/linux/crates/app/src/ui/data_grid/row_detail/clock.rs b/linux/crates/app/src/ui/data_grid/row_detail/clock.rs new file mode 100644 index 0000000000..5f63148bdc --- /dev/null +++ b/linux/crates/app/src/ui/data_grid/row_detail/clock.rs @@ -0,0 +1,239 @@ +use chrono::{FixedOffset, Local, NaiveDate, NaiveTime, Offset, Timelike}; +use gtk4::glib; +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; +use tablepro_core::column::{ColumnKind, TemporalZone, TimeRange}; + +use super::RowDetailPane; +use super::calendar::{day_of, day_start_offset}; +use super::value_editor::Commit; + +/// `HH:MM:SS`, the part of a value's text the clock sets. +const CLOCK_LEN: usize = 8; +/// Where the time starts in a timestamp's text: after `YYYY-MM-DD `. +const TIMESTAMP_TIME_AT: usize = 11; + +/// What the clock edits in a column's text. +#[derive(Debug, Clone, PartialEq)] +pub enum ClockField { + /// A time of day, with an offset after it when `with_offset`. + Time { with_offset: bool }, + /// The time of a timestamp, after its day. + Timestamp { zone: TemporalZone }, +} + +impl ClockField { + /// A time of day or a timestamp. A duration such as MySQL's TIME, + /// which runs past 24 hours, is typed. + pub fn of(kind: &ColumnKind) -> Option { + match kind { + ColumnKind::Time { + with_offset, + range: TimeRange::TimeOfDay, + .. + } => Some(Self::Time { + with_offset: *with_offset, + }), + ColumnKind::Timestamp { zone, .. } => Some(Self::Timestamp { zone: zone.clone() }), + _ => None, + } + } + + fn time_at(&self) -> usize { + match self { + Self::Time { .. } => 0, + Self::Timestamp { .. } => TIMESTAMP_TIME_AT, + } + } +} + +/// The time a value's text holds, to the second. +pub fn time_of(text: &str, field: &ClockField) -> Option { + let at = field.time_at(); + if matches!(field, ClockField::Timestamp { .. }) && day_of(text).is_none() { + return None; + } + let clock = text.get(at..at + CLOCK_LEN)?; + NaiveTime::parse_from_str(clock, "%H:%M:%S").ok() +} + +/// The value's text with its time set to `time`, keeping its day, its +/// fraction of a second and its offset. A value with no time yet takes +/// `today` for a timestamp's day, and `offset` where the column needs +/// one. +pub fn with_time(text: &str, time: NaiveTime, field: &ClockField, today: NaiveDate, offset: FixedOffset) -> String { + let clock = time.format("%H:%M:%S").to_string(); + let at = field.time_at(); + if time_of(text, field).is_some() { + let (head, rest) = text.split_at(at); + return format!("{head}{clock}{}", &rest[CLOCK_LEN..]); + } + match field { + ClockField::Time { with_offset: true } => format!("{clock}{offset}"), + ClockField::Time { with_offset: false } => clock, + ClockField::Timestamp { zone } => { + let day = day_of(text).unwrap_or(today).format("%Y-%m-%d"); + match zone { + TemporalZone::Offset => format!("{day} {clock}{offset}"), + _ => format!("{day} {clock}"), + } + } + } +} + +impl RowDetailPane { + pub(super) fn connect_clock(&self) { + let imp = self.imp(); + imp.clock_popover.connect_show(glib::clone!( + #[weak(rename_to = pane)] + self, + move |_| pane.clock_opens() + )); + imp.clock_set.connect_clicked(glib::clone!( + #[weak(rename_to = pane)] + self, + move |_| pane.pick_time() + )); + // Enter in a field sets the time, as Set does. + for spin in [&imp.hour.get(), &imp.minute.get(), &imp.second.get()] { + spin.connect_activate(glib::clone!( + #[weak(rename_to = pane)] + self, + move |spin| { + spin.update(); + pane.pick_time(); + } + )); + } + } + + fn clock_field(&self) -> Option { + self.field_column() + .and_then(|column| ClockField::of(&column.value_type().kind)) + } + + /// The clock opens on the value's time, else on now, for the row and + /// field shown now. + fn clock_opens(&self) { + let imp = self.imp(); + imp.clock_target.replace(self.current_pin().zip(imp.field.get())); + let time = self + .clock_field() + .and_then(|field| time_of(&self.typed_text(), &field)) + .unwrap_or_else(|| Local::now().time()); + imp.hour.set_value(f64::from(time.hour())); + imp.minute.set_value(f64::from(time.minute())); + imp.second.set_value(f64::from(time.second())); + imp.hour.grab_focus(); + } + + /// Write the time into the value as if typed, and commit it, while + /// the pane still shows the row and field it opened on. + fn pick_time(&self) { + let imp = self.imp(); + let target = imp.clock_target.take(); + if target.is_none() || target != self.current_pin().zip(imp.field.get()) { + imp.clock_popover.popdown(); + return; + } + let Some(field) = self.clock_field().filter(|_| imp.value_view.is_editable()) else { + return; + }; + let part = |spin: >k4::SpinButton| u32::try_from(spin.value_as_int()).unwrap_or(0); + let Some(time) = NaiveTime::from_hms_opt(part(&imp.hour), part(&imp.minute), part(&imp.second)) else { + return; + }; + let today = Local::now().date_naive(); + let offset = match &field { + ClockField::Timestamp { .. } => day_start_offset(&Local, today), + ClockField::Time { .. } => Local::now().offset().fix(), + }; + let text = with_time(&self.typed_text(), time, &field, today, offset); + imp.clock_popover.popdown(); + imp.value_buffer.set_text(&text); + self.commit(Commit::Keep); + } +} + +#[cfg(test)] +mod tests { + use chrono::{FixedOffset, NaiveDate, NaiveTime}; + use tablepro_core::column::{ColumnKind, TemporalZone, TimeRange}; + + use super::{ClockField, time_of, with_time}; + + fn at(h: u32, m: u32, s: u32) -> NaiveTime { + NaiveTime::from_hms_opt(h, m, s).expect("a time") + } + + fn today() -> NaiveDate { + NaiveDate::from_ymd_opt(2026, 10, 29).expect("a day") + } + + fn plus_two() -> FixedOffset { + FixedOffset::east_opt(2 * 3600).expect("an offset") + } + + const LOCAL: ClockField = ClockField::Timestamp { + zone: TemporalZone::Naive, + }; + + #[test] + fn a_timestamp_keeps_its_day_fraction_and_offset() { + let zoned = ClockField::Timestamp { + zone: TemporalZone::Offset, + }; + let set = |text: &str, field: &ClockField| with_time(text, at(9, 5, 0), field, today(), plus_two()); + + assert_eq!(set("2024-02-29 23:59:58", &LOCAL), "2024-02-29 09:05:00"); + assert_eq!(set("2024-02-29 23:59:58.125", &LOCAL), "2024-02-29 09:05:00.125"); + assert_eq!(set("2024-02-29 23:59:58+05:30", &zoned), "2024-02-29 09:05:00+05:30"); + assert_eq!(time_of("2024-02-29 23:59:58.125", &LOCAL), Some(at(23, 59, 58))); + } + + #[test] + fn a_value_with_no_time_takes_today_and_the_offset_it_needs() { + let zoned = ClockField::Timestamp { + zone: TemporalZone::Offset, + }; + let set = |text: &str, field: &ClockField| with_time(text, at(9, 5, 0), field, today(), plus_two()); + + assert_eq!(set("", &LOCAL), "2026-10-29 09:05:00"); + assert_eq!(set("", &zoned), "2026-10-29 09:05:00+02:00"); + assert_eq!( + set("2024-02-29", &LOCAL), + "2024-02-29 09:05:00", + "a day with no time keeps it" + ); + assert_eq!(set("", &ClockField::Time { with_offset: false }), "09:05:00"); + assert_eq!(set("", &ClockField::Time { with_offset: true }), "09:05:00+02:00"); + } + + #[test] + fn a_time_keeps_its_fraction_and_offset() { + let field = ClockField::Time { with_offset: true }; + + assert_eq!( + with_time("13:45:07.5+01", at(8, 0, 0), &field, today(), plus_two()), + "08:00:00.5+01" + ); + assert_eq!(time_of("13:45:07.5+01", &field), Some(at(13, 45, 7))); + assert_eq!(time_of("not a time", &field), None); + } + + #[test] + fn only_a_time_of_day_and_a_timestamp_get_the_clock() { + let time = |range| ColumnKind::Time { + fractional_digits: None, + with_offset: false, + range, + }; + + assert!(ClockField::of(&time(TimeRange::TimeOfDay)).is_some()); + assert!( + ClockField::of(&time(TimeRange::Duration)).is_none(), + "MySQL TIME runs past a day" + ); + assert!(ClockField::of(&ColumnKind::Date).is_none()); + } +} diff --git a/linux/crates/app/src/ui/data_grid/row_detail/imp.rs b/linux/crates/app/src/ui/data_grid/row_detail/imp.rs index 1ae83b565a..ccff098532 100644 --- a/linux/crates/app/src/ui/data_grid/row_detail/imp.rs +++ b/linux/crates/app/src/ui/data_grid/row_detail/imp.rs @@ -48,6 +48,18 @@ pub struct RowDetailPane { #[template_child] pub calendar: TemplateChild, #[template_child] + pub clock_button: TemplateChild, + #[template_child] + pub clock_popover: TemplateChild, + #[template_child] + pub hour: TemplateChild, + #[template_child] + pub minute: TemplateChild, + #[template_child] + pub second: TemplateChild, + #[template_child] + pub clock_set: TemplateChild, + #[template_child] pub value_menu_button: TemplateChild, #[template_child] pub value_view: TemplateChild, @@ -81,6 +93,8 @@ pub struct RowDetailPane { pub quiet_calendar: Cell, /// The row and field the open calendar picks a day for. pub calendar_target: RefCell>, + /// The row and field the open clock sets a time for. + pub clock_target: RefCell>, pub sync_queued: Cell, /// The field rows bound now, which redraw when the row changes. pub bound: RefCell>, diff --git a/linux/crates/app/src/ui/data_grid/row_detail/mod.rs b/linux/crates/app/src/ui/data_grid/row_detail/mod.rs index d40b030092..3f0fb18c4f 100644 --- a/linux/crates/app/src/ui/data_grid/row_detail/mod.rs +++ b/linux/crates/app/src/ui/data_grid/row_detail/mod.rs @@ -2,6 +2,7 @@ //! editor for the whole value of one of them. mod calendar; +mod clock; mod fields; mod imp; #[cfg(test)] @@ -40,6 +41,7 @@ impl RowDetailPane { self.connect_value_editor(grid); self.connect_value_menu(grid); self.connect_calendar(); + self.connect_clock(); self.connect_close(grid); self.watch_details(grid); self.watch_rows(grid); @@ -145,6 +147,7 @@ impl RowDetailPane { }; if !same { self.imp().calendar_popover.popdown(); + self.imp().clock_popover.popdown(); self.commit(Commit::Teardown); self.watch_row(row.as_ref()); } diff --git a/linux/crates/app/src/ui/data_grid/row_detail/tests.rs b/linux/crates/app/src/ui/data_grid/row_detail/tests.rs index 653dfb4429..e1d8aaba83 100644 --- a/linux/crates/app/src/ui/data_grid/row_detail/tests.rs +++ b/linux/crates/app/src/ui/data_grid/row_detail/tests.rs @@ -871,3 +871,46 @@ fn alt_return_closes_row_details_from_the_pane() { }) .expect("Alt+Return to close Row Details"); } + +fn open_clock(pane: &RowDetailPane) { + for _ in 0..5 { + pane.imp().clock_button.popup(); + if wait_until(Duration::from_secs(2), || pane.imp().hour.is_mapped()).is_ok() { + return; + } + pane.imp().clock_button.popdown(); + drain_main_context(); + } + panic!("the clock never showed"); +} + +/// The clock sets a timestamp's time and keeps its day; a date has none. +#[gtk4::test] +fn the_clock_sets_a_timestamps_time() { + let (grid, pane) = opened( + vec![ + named("id", "integer", true), + named("at", "timestamp", false), + named("day", "date", false), + ], + vec![vec![Value::Int(1), Value::Null, a_date(2024, 2, 29)]], + ); + grid.focus(0, 2); + drain_main_context(); + assert!(!pane.imp().clock_button.is_visible(), "a date has no time"); + grid.focus(0, 1); + drain_main_context(); + assert!(pane.imp().clock_button.is_visible(), "a timestamp has no clock"); + + open_clock(&pane); + pane.imp().hour.set_value(9.0); + pane.imp().minute.set_value(5.0); + pane.imp().second.set_value(30.0); + pane.imp().clock_set.emit_clicked(); + drain_main_context(); + + assert!(pending(&grid, 1, 1).is_some(), "nothing was written"); + let text = pane.typed_text(); + assert!(text.ends_with(" 09:05:30"), "{text}"); + assert!(day_of(&text).is_some(), "the new time has no day: {text}"); +} diff --git a/linux/crates/app/src/ui/data_grid/row_detail/value_editor.rs b/linux/crates/app/src/ui/data_grid/row_detail/value_editor.rs index 457e316e58..df4fb6cff7 100644 --- a/linux/crates/app/src/ui/data_grid/row_detail/value_editor.rs +++ b/linux/crates/app/src/ui/data_grid/row_detail/value_editor.rs @@ -79,6 +79,7 @@ pub struct ValueContent { pub emptied_placeholder: Option, pub language: Option<&'static str>, pub calendar: bool, + pub clock: bool, } pub fn value_content(column: &GridColumn, row: &GridRowId, lock: Option, input: &CellInput) -> ValueContent { @@ -106,6 +107,7 @@ pub fn value_content(column: &GridColumn, row: &GridRowId, lock: Option None, }, calendar: editable && matches!(kind, ColumnKind::Date | ColumnKind::Timestamp { .. }), + clock: editable && super::clock::ClockField::of(kind).is_some(), message, } } @@ -339,6 +341,7 @@ impl RowDetailPane { emptied_placeholder: None, language: None, calendar: false, + clock: false, }; self.show_value("", &content); self.imp().value_view.set_editable(false); @@ -373,6 +376,7 @@ impl RowDetailPane { .update_property(&[gtk::accessible::Property::Label(name)]); self.show_message(content.message.as_deref(), false); imp.calendar_button.set_visible(content.calendar); + imp.clock_button.set_visible(content.clock); self.update_placeholder(); } diff --git a/linux/data/resources/ui/row-detail-pane.ui b/linux/data/resources/ui/row-detail-pane.ui index 6c49dfaf69..a02a7c6843 100644 --- a/linux/data/resources/ui/row-detail-pane.ui +++ b/linux/data/resources/ui/row-detail-pane.ui @@ -184,6 +184,103 @@ + + + preferences-system-time-symbolic + false + Pick a Time + + Pick a Time + + + + + + + 6 + 6 + 6 + 6 + 6 + + + vertical + true + true + + + 23 + 1 + 6 + + + + Hour + + + + + + : + + + + + vertical + true + true + + + 59 + 1 + 10 + + + + Minute + + + + + + : + + + + + vertical + true + true + + + 59 + 1 + 10 + + + + Second + + + + + + Set + center + 6 + + + + + + + + + view-more-symbolic diff --git a/linux/po/POTFILES.in b/linux/po/POTFILES.in index af8dd18f8f..471b784245 100644 --- a/linux/po/POTFILES.in +++ b/linux/po/POTFILES.in @@ -260,6 +260,7 @@ crates/app/src/ui/data_grid/references/mod.rs crates/app/src/ui/data_grid/references/tests.rs crates/app/src/ui/data_grid/reversed_list.rs crates/app/src/ui/data_grid/row_detail/calendar.rs +crates/app/src/ui/data_grid/row_detail/clock.rs crates/app/src/ui/data_grid/row_detail/fields.rs crates/app/src/ui/data_grid/row_detail/imp.rs crates/app/src/ui/data_grid/row_detail/mod.rs From 2a9350631270db022e58bbfc2e286272253c53ed Mon Sep 17 00:00:00 2001 From: sedax90 <5001801+sedax90@users.noreply.github.com> Date: Thu, 29 Oct 2026 15:33:34 +0100 Subject: [PATCH 02/13] docs(linux-grid): add the changelog entry --- linux/changelog.d/3881.added.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 linux/changelog.d/3881.added.md diff --git a/linux/changelog.d/3881.added.md b/linux/changelog.d/3881.added.md new file mode 100644 index 0000000000..c10bc9f100 --- /dev/null +++ b/linux/changelog.d/3881.added.md @@ -0,0 +1 @@ +Row Details sets a time, or a timestamp's time, from a clock beside the calendar. From bd944d920df71412e9a92853e3760cc707c5a9e5 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 29 Oct 2026 23:06:56 +0700 Subject: [PATCH 03/13] fix(linux-grid): set only a value's clock, at the moment's offset, with two-digit fields --- .../src/ui/data_grid/row_detail/calendar.rs | 18 +- .../app/src/ui/data_grid/row_detail/clock.rs | 246 ++++++------------ .../app/src/ui/data_grid/row_detail/imp.rs | 1 - .../app/src/ui/data_grid/row_detail/tests.rs | 246 ++++++++++++++++-- linux/crates/app/src/ui/icons.rs | 2 + linux/data/resources/ui/row-detail-pane.ui | 15 +- 6 files changed, 327 insertions(+), 201 deletions(-) diff --git a/linux/crates/app/src/ui/data_grid/row_detail/calendar.rs b/linux/crates/app/src/ui/data_grid/row_detail/calendar.rs index ee4cc47678..d5ba6a00a5 100644 --- a/linux/crates/app/src/ui/data_grid/row_detail/calendar.rs +++ b/linux/crates/app/src/ui/data_grid/row_detail/calendar.rs @@ -1,4 +1,4 @@ -use chrono::{Datelike, FixedOffset, Local, NaiveDate, NaiveTime, Offset, TimeDelta, TimeZone}; +use chrono::{Datelike, FixedOffset, Local, NaiveDate, NaiveDateTime, NaiveTime, Offset, TimeDelta, TimeZone}; use gtk4::prelude::*; use gtk4::subclass::prelude::*; use gtk4::{self as gtk, glib}; @@ -30,18 +30,16 @@ pub fn with_day(text: &str, day: NaiveDate, zone: Option<&TemporalZone>, offset: } } -/// The offset `zone` has as `day` starts. Where a clock change skips -/// midnight, the day starts at the change, which is midnight at the -/// offset before it. pub fn day_start_offset(zone: &Tz, day: NaiveDate) -> FixedOffset { - let midnight = day.and_time(NaiveTime::MIN); - [midnight, midnight - TimeDelta::hours(1)] + offset_at(zone, day.and_time(NaiveTime::MIN)) +} + +// A wall-clock time a clock change skips takes the offset before the change. +pub fn offset_at(zone: &Tz, local: NaiveDateTime) -> FixedOffset { + [local, local - TimeDelta::hours(1)] .iter() .find_map(|local| zone.from_local_datetime(local).earliest()) - .map_or_else( - || zone.offset_from_utc_datetime(&midnight).fix(), - |at| at.offset().fix(), - ) + .map_or_else(|| zone.offset_from_utc_datetime(&local).fix(), |at| at.offset().fix()) } impl RowDetailPane { diff --git a/linux/crates/app/src/ui/data_grid/row_detail/clock.rs b/linux/crates/app/src/ui/data_grid/row_detail/clock.rs index 5f63148bdc..2e8912f6ef 100644 --- a/linux/crates/app/src/ui/data_grid/row_detail/clock.rs +++ b/linux/crates/app/src/ui/data_grid/row_detail/clock.rs @@ -1,83 +1,96 @@ -use chrono::{FixedOffset, Local, NaiveDate, NaiveTime, Offset, Timelike}; -use gtk4::glib; +use std::ops::Range; + +use chrono::{Local, NaiveDate, NaiveTime, TimeZone, Timelike}; use gtk4::prelude::*; use gtk4::subclass::prelude::*; +use gtk4::{self as gtk, glib}; use tablepro_core::column::{ColumnKind, TemporalZone, TimeRange}; use super::RowDetailPane; -use super::calendar::{day_of, day_start_offset}; +use super::calendar::{day_of, offset_at}; use super::value_editor::Commit; -/// `HH:MM:SS`, the part of a value's text the clock sets. -const CLOCK_LEN: usize = 8; -/// Where the time starts in a timestamp's text: after `YYYY-MM-DD `. -const TIMESTAMP_TIME_AT: usize = 11; - -/// What the clock edits in a column's text. -#[derive(Debug, Clone, PartialEq)] -pub enum ClockField { - /// A time of day, with an offset after it when `with_offset`. - Time { with_offset: bool }, - /// The time of a timestamp, after its day. - Timestamp { zone: TemporalZone }, +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ClockField { + pub dated: bool, + pub with_offset: bool, } impl ClockField { - /// A time of day or a timestamp. A duration such as MySQL's TIME, - /// which runs past 24 hours, is typed. + // A duration such as MySQL's TIME runs past a day, so it gets no clock. pub fn of(kind: &ColumnKind) -> Option { match kind { ColumnKind::Time { with_offset, range: TimeRange::TimeOfDay, .. - } => Some(Self::Time { + } => Some(Self { + dated: false, with_offset: *with_offset, }), - ColumnKind::Timestamp { zone, .. } => Some(Self::Timestamp { zone: zone.clone() }), + ColumnKind::Timestamp { zone, .. } => Some(Self { + dated: true, + with_offset: *zone == TemporalZone::Offset, + }), _ => None, } } +} - fn time_at(&self) -> usize { - match self { - Self::Time { .. } => 0, - Self::Timestamp { .. } => TIMESTAMP_TIME_AT, +// A timestamp's clock follows its day and a space or `T`; it may lack seconds. +fn clock_span(text: &str, field: ClockField) -> Option> { + let start = match field.dated { + true => { + let day = day_len(text)?; + matches!(text.as_bytes().get(day), Some(b' ' | b'T')).then_some(day + 1)? } + false => 0, + }; + let digits = |at: usize| { + text.get(start + at..start + at + 2) + .is_some_and(|pair| pair.bytes().all(|byte| byte.is_ascii_digit())) + }; + let colon = |at: usize| text.as_bytes().get(start + at) == Some(&b':'); + if !(digits(0) && colon(2) && digits(3)) { + return None; } + let len = if colon(5) && digits(6) { 8 } else { 5 }; + Some(start..start + len) } -/// The time a value's text holds, to the second. -pub fn time_of(text: &str, field: &ClockField) -> Option { - let at = field.time_at(); - if matches!(field, ClockField::Timestamp { .. }) && day_of(text).is_none() { - return None; - } - let clock = text.get(at..at + CLOCK_LEN)?; - NaiveTime::parse_from_str(clock, "%H:%M:%S").ok() +// PostgreSQL writes a year past 9999 with every digit. +fn day_len(text: &str) -> Option { + let year = text.bytes().take_while(u8::is_ascii_digit).count(); + let dated = matches!( + text.get(year..year + 6)?.as_bytes(), + [b'-', m1, m2, b'-', d1, d2] if [m1, m2, d1, d2].iter().all(|byte| byte.is_ascii_digit()) + ); + (year >= 4 && dated).then_some(year + 6) +} + +pub fn time_of(text: &str, field: ClockField) -> Option { + let clock = text.get(clock_span(text, field)?)?; + NaiveTime::parse_from_str(clock, "%H:%M:%S") + .or_else(|_| NaiveTime::parse_from_str(clock, "%H:%M")) + .ok() } -/// The value's text with its time set to `time`, keeping its day, its -/// fraction of a second and its offset. A value with no time yet takes -/// `today` for a timestamp's day, and `offset` where the column needs -/// one. -pub fn with_time(text: &str, time: NaiveTime, field: &ClockField, today: NaiveDate, offset: FixedOffset) -> String { +// A value with no clock yet takes `today` for a missing day and `zone`'s offset then. +pub fn with_time(text: &str, time: NaiveTime, field: ClockField, today: NaiveDate, zone: &Tz) -> String { let clock = time.format("%H:%M:%S").to_string(); - let at = field.time_at(); - if time_of(text, field).is_some() { - let (head, rest) = text.split_at(at); - return format!("{head}{clock}{}", &rest[CLOCK_LEN..]); - } - match field { - ClockField::Time { with_offset: true } => format!("{clock}{offset}"), - ClockField::Time { with_offset: false } => clock, - ClockField::Timestamp { zone } => { - let day = day_of(text).unwrap_or(today).format("%Y-%m-%d"); - match zone { - TemporalZone::Offset => format!("{day} {clock}{offset}"), - _ => format!("{day} {clock}"), - } - } + if let Some(span) = clock_span(text, field) { + let mut text = text.to_owned(); + text.replace_range(span, &clock); + return text; + } + let day = day_of(text).filter(|_| field.dated).unwrap_or(today); + let offset = field + .with_offset + .then(|| offset_at(zone, day.and_time(time)).to_string()) + .unwrap_or_default(); + match field.dated { + true => format!("{} {clock}{offset}", day.format("%Y-%m-%d")), + false => format!("{clock}{offset}"), } } @@ -94,16 +107,11 @@ impl RowDetailPane { self, move |_| pane.pick_time() )); - // Enter in a field sets the time, as Set does. - for spin in [&imp.hour.get(), &imp.minute.get(), &imp.second.get()] { - spin.connect_activate(glib::clone!( - #[weak(rename_to = pane)] - self, - move |spin| { - spin.update(); - pane.pick_time(); - } - )); + for spin in [imp.hour.get(), imp.minute.get(), imp.second.get()] { + spin.connect_output(|spin| { + spin.set_text(&format!("{:02}", spin.value_as_int())); + glib::Propagation::Stop + }); } } @@ -112,128 +120,38 @@ impl RowDetailPane { .and_then(|column| ClockField::of(&column.value_type().kind)) } - /// The clock opens on the value's time, else on now, for the row and - /// field shown now. fn clock_opens(&self) { let imp = self.imp(); imp.clock_target.replace(self.current_pin().zip(imp.field.get())); let time = self .clock_field() - .and_then(|field| time_of(&self.typed_text(), &field)) + .and_then(|field| time_of(&self.typed_text(), field)) .unwrap_or_else(|| Local::now().time()); imp.hour.set_value(f64::from(time.hour())); imp.minute.set_value(f64::from(time.minute())); imp.second.set_value(f64::from(time.second())); - imp.hour.grab_focus(); } - /// Write the time into the value as if typed, and commit it, while - /// the pane still shows the row and field it opened on. + // Written as if typed, and only into the row and field the clock opened on. fn pick_time(&self) { let imp = self.imp(); let target = imp.clock_target.take(); - if target.is_none() || target != self.current_pin().zip(imp.field.get()) { - imp.clock_popover.popdown(); + imp.clock_popover.popdown(); + if target.is_none() || target != self.current_pin().zip(imp.field.get()) || !imp.value_view.is_editable() { return; } - let Some(field) = self.clock_field().filter(|_| imp.value_view.is_editable()) else { + let part = |spin: >k::SpinButton| u32::try_from(spin.value_as_int()).ok(); + let (Some(field), Some(time)) = ( + self.clock_field(), + part(&imp.hour) + .zip(part(&imp.minute)) + .zip(part(&imp.second)) + .and_then(|((hour, minute), second)| NaiveTime::from_hms_opt(hour, minute, second)), + ) else { return; }; - let part = |spin: >k4::SpinButton| u32::try_from(spin.value_as_int()).unwrap_or(0); - let Some(time) = NaiveTime::from_hms_opt(part(&imp.hour), part(&imp.minute), part(&imp.second)) else { - return; - }; - let today = Local::now().date_naive(); - let offset = match &field { - ClockField::Timestamp { .. } => day_start_offset(&Local, today), - ClockField::Time { .. } => Local::now().offset().fix(), - }; - let text = with_time(&self.typed_text(), time, &field, today, offset); - imp.clock_popover.popdown(); + let text = with_time(&self.typed_text(), time, field, Local::now().date_naive(), &Local); imp.value_buffer.set_text(&text); self.commit(Commit::Keep); } } - -#[cfg(test)] -mod tests { - use chrono::{FixedOffset, NaiveDate, NaiveTime}; - use tablepro_core::column::{ColumnKind, TemporalZone, TimeRange}; - - use super::{ClockField, time_of, with_time}; - - fn at(h: u32, m: u32, s: u32) -> NaiveTime { - NaiveTime::from_hms_opt(h, m, s).expect("a time") - } - - fn today() -> NaiveDate { - NaiveDate::from_ymd_opt(2026, 10, 29).expect("a day") - } - - fn plus_two() -> FixedOffset { - FixedOffset::east_opt(2 * 3600).expect("an offset") - } - - const LOCAL: ClockField = ClockField::Timestamp { - zone: TemporalZone::Naive, - }; - - #[test] - fn a_timestamp_keeps_its_day_fraction_and_offset() { - let zoned = ClockField::Timestamp { - zone: TemporalZone::Offset, - }; - let set = |text: &str, field: &ClockField| with_time(text, at(9, 5, 0), field, today(), plus_two()); - - assert_eq!(set("2024-02-29 23:59:58", &LOCAL), "2024-02-29 09:05:00"); - assert_eq!(set("2024-02-29 23:59:58.125", &LOCAL), "2024-02-29 09:05:00.125"); - assert_eq!(set("2024-02-29 23:59:58+05:30", &zoned), "2024-02-29 09:05:00+05:30"); - assert_eq!(time_of("2024-02-29 23:59:58.125", &LOCAL), Some(at(23, 59, 58))); - } - - #[test] - fn a_value_with_no_time_takes_today_and_the_offset_it_needs() { - let zoned = ClockField::Timestamp { - zone: TemporalZone::Offset, - }; - let set = |text: &str, field: &ClockField| with_time(text, at(9, 5, 0), field, today(), plus_two()); - - assert_eq!(set("", &LOCAL), "2026-10-29 09:05:00"); - assert_eq!(set("", &zoned), "2026-10-29 09:05:00+02:00"); - assert_eq!( - set("2024-02-29", &LOCAL), - "2024-02-29 09:05:00", - "a day with no time keeps it" - ); - assert_eq!(set("", &ClockField::Time { with_offset: false }), "09:05:00"); - assert_eq!(set("", &ClockField::Time { with_offset: true }), "09:05:00+02:00"); - } - - #[test] - fn a_time_keeps_its_fraction_and_offset() { - let field = ClockField::Time { with_offset: true }; - - assert_eq!( - with_time("13:45:07.5+01", at(8, 0, 0), &field, today(), plus_two()), - "08:00:00.5+01" - ); - assert_eq!(time_of("13:45:07.5+01", &field), Some(at(13, 45, 7))); - assert_eq!(time_of("not a time", &field), None); - } - - #[test] - fn only_a_time_of_day_and_a_timestamp_get_the_clock() { - let time = |range| ColumnKind::Time { - fractional_digits: None, - with_offset: false, - range, - }; - - assert!(ClockField::of(&time(TimeRange::TimeOfDay)).is_some()); - assert!( - ClockField::of(&time(TimeRange::Duration)).is_none(), - "MySQL TIME runs past a day" - ); - assert!(ClockField::of(&ColumnKind::Date).is_none()); - } -} diff --git a/linux/crates/app/src/ui/data_grid/row_detail/imp.rs b/linux/crates/app/src/ui/data_grid/row_detail/imp.rs index ccff098532..d8882ecf2b 100644 --- a/linux/crates/app/src/ui/data_grid/row_detail/imp.rs +++ b/linux/crates/app/src/ui/data_grid/row_detail/imp.rs @@ -93,7 +93,6 @@ pub struct RowDetailPane { pub quiet_calendar: Cell, /// The row and field the open calendar picks a day for. pub calendar_target: RefCell>, - /// The row and field the open clock sets a time for. pub clock_target: RefCell>, pub sync_queued: Cell, /// The field rows bound now, which redraw when the row changes. diff --git a/linux/crates/app/src/ui/data_grid/row_detail/tests.rs b/linux/crates/app/src/ui/data_grid/row_detail/tests.rs index e1d8aaba83..1172f79038 100644 --- a/linux/crates/app/src/ui/data_grid/row_detail/tests.rs +++ b/linux/crates/app/src/ui/data_grid/row_detail/tests.rs @@ -2,17 +2,18 @@ use std::cell::RefCell; use std::rc::Rc; use std::time::Duration; -use chrono::NaiveDate; +use chrono::{NaiveDate, NaiveTime}; use gtk4::prelude::*; use gtk4::subclass::prelude::*; use gtk4::{self as gtk, gdk}; -use tablepro_core::column::TemporalZone; +use tablepro_core::column::{TemporalZone, TimeRange}; use tablepro_core::edit::CellInput; -use tablepro_core::value::{JsonText, Temporal}; +use tablepro_core::value::{JsonText, SqlTime, Temporal}; use tablepro_core::{ColumnInfo, Value}; use super::RowDetailPane; use super::calendar::{day_of, day_start_offset, with_day}; +use super::clock::{ClockField, time_of, with_time}; use super::fields::field_order; use super::value_editor::value_content; use crate::services::change_tracker::RowKey; @@ -872,6 +873,7 @@ fn alt_return_closes_row_details_from_the_pane() { .expect("Alt+Return to close Row Details"); } +/// Open the pane's clock until it shows, as `open_calendar` does. fn open_clock(pane: &RowDetailPane) { for _ in 0..5 { pane.imp().clock_button.popup(); @@ -884,33 +886,233 @@ fn open_clock(pane: &RowDetailPane) { panic!("the clock never showed"); } -/// The clock sets a timestamp's time and keeps its day; a date has none. +fn set_clock(pane: &RowDetailPane, (hour, minute, second): (u32, u32, u32)) { + pane.imp().hour.set_value(f64::from(hour)); + pane.imp().minute.set_value(f64::from(minute)); + pane.imp().second.set_value(f64::from(second)); +} + +fn clock_text(pane: &RowDetailPane) -> [String; 3] { + let imp = pane.imp(); + [&imp.hour, &imp.minute, &imp.second].map(|spin| spin.text().to_string()) +} + +fn a_time(hour: u32, minute: u32, second: u32) -> NaiveTime { + NaiveTime::from_hms_opt(hour, minute, second).expect("a time") +} + +fn a_timestamp(day: (i32, u32, u32), time: NaiveTime) -> Value { + let (year, month, date) = day; + Value::Timestamp(Temporal::Finite( + NaiveDate::from_ymd_opt(year, month, date) + .expect("a day") + .and_time(time), + )) +} + +const NAIVE: ClockField = ClockField { + dated: true, + with_offset: false, +}; +const ZONED: ClockField = ClockField { + dated: true, + with_offset: true, +}; +const TIME: ClockField = ClockField { + dated: false, + with_offset: false, +}; +const TIMETZ: ClockField = ClockField { + dated: false, + with_offset: true, +}; + +fn set_at_nine(text: &str, field: ClockField) -> String { + let today = NaiveDate::from_ymd_opt(2026, 10, 29).expect("a day"); + let plus_two = chrono::FixedOffset::east_opt(2 * 3600).expect("an offset"); + with_time(text, a_time(9, 5, 30), field, today, &plus_two) +} + +#[test] +fn the_clock_sets_only_the_time_a_value_shows() { + assert_eq!( + set_at_nine("2024-02-29 23:59:58.125000", NAIVE), + "2024-02-29 09:05:30.125000" + ); + assert_eq!(set_at_nine("2024-02-29T23:59:58", NAIVE), "2024-02-29T09:05:30"); + assert_eq!(set_at_nine("2024-02-29 23:59", NAIVE), "2024-02-29 09:05:30"); + assert_eq!( + set_at_nine("2024-02-29 23:59:58+05:30", ZONED), + "2024-02-29 09:05:30+05:30" + ); + assert_eq!(set_at_nine("0044-03-15 12:00:00 BC", NAIVE), "0044-03-15 09:05:30 BC"); + assert_eq!( + set_at_nine("12000-01-01 10:00:00", NAIVE), + "12000-01-01 09:05:30", + "a year past 9999 lost its day" + ); + assert_eq!(set_at_nine("13:45:07.5+01", TIMETZ), "09:05:30.5+01"); + assert_eq!(set_at_nine("24:00:00+01", TIMETZ), "09:05:30+01"); +} + +#[test] +fn the_clock_starts_a_value_that_has_no_time() { + assert_eq!(set_at_nine("", NAIVE), "2026-10-29 09:05:30"); + assert_eq!(set_at_nine("infinity", NAIVE), "2026-10-29 09:05:30"); + assert_eq!(set_at_nine("2024-02-29", NAIVE), "2024-02-29 09:05:30"); + assert_eq!(set_at_nine("", TIME), "09:05:30"); + let zoned = set_at_nine("", ZONED); + assert_eq!(zoned, "2026-10-29 09:05:30+02:00"); + assert!(tablepro_core::edit::parse_timestamp_with_offset(&zoned).is_ok()); + let timetz = set_at_nine("", TIMETZ); + assert_eq!(timetz, "09:05:30+02:00"); + assert!(tablepro_core::edit::parse_time_with_offset(&timetz).is_ok()); +} + +#[test] +fn a_new_zoned_time_takes_the_offset_of_its_own_moment() { + let hours = |hours: i32| chrono::FixedOffset::east_opt(hours * 3600).expect("an offset"); + // Sydney moves to +11 at 02:00 local on 2026-10-04. + let sydney = OneChange { + before: hours(10), + after: hours(11), + change: chrono::NaiveDateTime::parse_from_str("2026-10-03 16:00", "%Y-%m-%d %H:%M").expect("a UTC time"), + }; + let day = NaiveDate::from_ymd_opt(2026, 10, 4).expect("a day"); + let set = |text: &str, time: NaiveTime, field: ClockField| with_time(text, time, field, day, &sydney); + + assert_eq!(set("", a_time(9, 5, 0), ZONED), "2026-10-04 09:05:00+11:00"); + assert_eq!(set("", a_time(1, 30, 0), ZONED), "2026-10-04 01:30:00+10:00"); + assert_eq!( + set("", a_time(2, 30, 0), ZONED), + "2026-10-04 02:30:00+10:00", + "a time the change skips" + ); + assert_eq!(set("", a_time(9, 5, 0), TIMETZ), "09:05:00+11:00"); + let earlier = NaiveDate::from_ymd_opt(2026, 7, 1).expect("a day"); + assert_eq!( + with_time("2026-10-04", a_time(9, 5, 0), ZONED, earlier, &sydney), + "2026-10-04 09:05:00+11:00", + "the offset was today's, not the value's day" + ); +} + +#[test] +fn the_clock_reads_the_time_a_value_shows() { + assert_eq!(time_of("2024-02-29 23:59:58.125", NAIVE), Some(a_time(23, 59, 58))); + assert_eq!(time_of("12000-01-01 10:00:00", NAIVE), Some(a_time(10, 0, 0))); + assert_eq!(time_of("2024-02-29 10:30", NAIVE), Some(a_time(10, 30, 0))); + assert_eq!(time_of("13:45:07.5+01", TIMETZ), Some(a_time(13, 45, 7))); + assert_eq!(time_of("24:00:00", TIME), None); + assert_eq!(time_of("10:00:00", NAIVE), None, "a timestamp with no day"); + assert_eq!(time_of("2024-02-2é 10:00:00", NAIVE), None, "a cut inside a character"); + assert_eq!(time_of("not a time", TIME), None); +} + +#[test] +fn a_clock_is_offered_where_a_time_of_day_can_be_typed() { + let saved = GridRowId::Persisted(RowKey::from_pk_values(&[Value::Int(1)]).expect("a key")); + let clock = |data_type: &str, lock: Option| { + value_content( + &column_of(named("t", data_type, false)), + &saved, + lock, + &CellInput::Value(Value::Null), + ) + .clock + }; + + assert!(clock("timestamp", None)); + assert!(clock("timestamptz", None)); + assert!(clock("time", None)); + assert!(!clock("date", None)); + assert!(!clock("interval", None)); + assert!( + !clock("timestamp", Some(CellLock::PendingDelete)), + "a locked cell offered a clock" + ); + let time = |range| tablepro_core::column::ColumnKind::Time { + fractional_digits: None, + with_offset: false, + range, + }; + assert_eq!(ClockField::of(&time(TimeRange::TimeOfDay)), Some(TIME)); + assert_eq!( + ClockField::of(&time(TimeRange::Duration)), + None, + "MySQL TIME runs past a day" + ); + assert_eq!(ClockField::of(&time(TimeRange::LongDuration)), None); +} + #[gtk4::test] -fn the_clock_sets_a_timestamps_time() { +fn the_clock_opens_on_the_value_and_sets_its_time() { let (grid, pane) = opened( - vec![ - named("id", "integer", true), - named("at", "timestamp", false), - named("day", "date", false), - ], - vec![vec![Value::Int(1), Value::Null, a_date(2024, 2, 29)]], + vec![named("id", "integer", true), named("at", "timestamp", false)], + vec![vec![Value::Int(1), a_timestamp((2024, 2, 29), a_time(23, 59, 8))]], ); - grid.focus(0, 2); - drain_main_context(); - assert!(!pane.imp().clock_button.is_visible(), "a date has no time"); + take_keyboard(&grid); grid.focus(0, 1); - drain_main_context(); assert!(pane.imp().clock_button.is_visible(), "a timestamp has no clock"); open_clock(&pane); - pane.imp().hour.set_value(9.0); - pane.imp().minute.set_value(5.0); - pane.imp().second.set_value(30.0); + assert_eq!( + clock_text(&pane), + ["23", "59", "08"], + "the clock did not open on the value" + ); + set_clock(&pane, (9, 5, 30)); + assert_eq!(clock_text(&pane), ["09", "05", "30"], "a clock shows two digits"); + pane.imp().clock_set.emit_clicked(); + + assert_eq!( + pending(&grid, 1, 1), + Some(CellInput::Value(a_timestamp((2024, 2, 29), a_time(9, 5, 30)))) + ); + assert!(!pane.imp().clock_popover.is_visible()); +} + +#[gtk4::test] +fn enter_in_a_clock_field_sets_the_time() { + let (grid, pane) = opened( + vec![named("id", "integer", true), named("at", "time", false)], + vec![vec![Value::Int(1), Value::Null]], + ); + take_keyboard(&grid); + grid.focus(0, 1); + open_clock(&pane); + set_clock(&pane, (9, 5, 30)); + + let text = descendants(&pane.imp().second.get()) + .into_iter() + .find_map(|widget| widget.downcast::().ok()) + .expect("the field's text"); + text.emit_by_name::<()>("activate", &[]); + + let set = SqlTime::from_time_of_day(a_time(9, 5, 30)); + assert_eq!(pending(&grid, 1, 1), Some(CellInput::Value(Value::Time(set)))); +} + +#[gtk4::test] +fn a_page_that_takes_the_row_closes_its_clock_and_writes_nothing() { + let (grid, pane) = opened( + vec![named("id", "integer", true), named("at", "time", false)], + vec![vec![Value::Int(1), Value::Null], vec![Value::Int(2), Value::Null]], + ); + take_keyboard(&grid); + grid.focus(0, 1); + open_clock(&pane); + + grid.grid + .replace_rows(persisted(&[vec![Value::Int(2), Value::Null]]), 0); + drain_main_context(); pane.imp().clock_set.emit_clicked(); drain_main_context(); - assert!(pending(&grid, 1, 1).is_some(), "nothing was written"); - let text = pane.typed_text(); - assert!(text.ends_with(" 09:05:30"), "{text}"); - assert!(day_of(&text).is_some(), "the new time has no day: {text}"); + assert_eq!( + grid.tracker.pending_count(), + 0, + "the clock wrote into the row that took the slot" + ); + assert!(!pane.imp().clock_popover.is_visible()); } diff --git a/linux/crates/app/src/ui/icons.rs b/linux/crates/app/src/ui/icons.rs index 055cdab1c9..c13f5ac819 100644 --- a/linux/crates/app/src/ui/icons.rs +++ b/linux/crates/app/src/ui/icons.rs @@ -38,6 +38,7 @@ pub const OPEN_MENU: &str = "open-menu-symbolic"; pub const PACKAGE: &str = "package-x-generic-symbolic"; pub const PAN_DOWN: &str = "pan-down-symbolic"; pub const PREFERENCES_SYSTEM: &str = "preferences-system-symbolic"; +pub const PREFERENCES_SYSTEM_TIME: &str = "preferences-system-time-symbolic"; pub const PROCESS_STOP: &str = "process-stop-symbolic"; pub const SECURITY_HIGH: &str = "security-high-symbolic"; pub const STARRED: &str = "starred-symbolic"; @@ -116,6 +117,7 @@ pub const ALL: &[&str] = &[ PACKAGE, PAN_DOWN, PREFERENCES_SYSTEM, + PREFERENCES_SYSTEM_TIME, PROCESS_STOP, SECURITY_HIGH, SIDEBAR_SHOW, diff --git a/linux/data/resources/ui/row-detail-pane.ui b/linux/data/resources/ui/row-detail-pane.ui index a02a7c6843..5e266afad8 100644 --- a/linux/data/resources/ui/row-detail-pane.ui +++ b/linux/data/resources/ui/row-detail-pane.ui @@ -197,6 +197,7 @@ + clock_set 6 @@ -209,6 +210,8 @@ vertical true true + 0.5 + true 23 @@ -217,7 +220,7 @@ - Hour + Hour @@ -231,6 +234,8 @@ vertical true true + 0.5 + true 59 @@ -239,7 +244,7 @@ - Minute + Minute @@ -253,6 +258,8 @@ vertical true true + 0.5 + true 59 @@ -261,13 +268,13 @@ - Second + Second - Set + Set center 6