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. 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..2298906675 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 { @@ -50,10 +48,7 @@ impl RowDetailPane { // Dropped below a button halfway down the pane, the calendar fits // neither below nor above it on a short screen and GTK closes it; // opened toward the rows, it slides up or down to fit. - imp.calendar_button.set_direction(match self.direction() { - gtk::TextDirection::Rtl => gtk::ArrowType::Right, - _ => gtk::ArrowType::Left, - }); + imp.calendar_button.set_direction(self.toward_rows()); imp.calendar_popover.connect_show(glib::clone!( #[weak(rename_to = pane)] self, @@ -99,6 +94,13 @@ impl RowDetailPane { imp.calendar.add_controller(keys); } + pub(super) fn toward_rows(&self) -> gtk::ArrowType { + match self.direction() { + gtk::TextDirection::Rtl => gtk::ArrowType::Right, + _ => gtk::ArrowType::Left, + } + } + /// The calendar opens on the value's day, else on today, for the row /// and field shown now. fn calendar_opens(&self) { 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..02e36ac66f --- /dev/null +++ b/linux/crates/app/src/ui/data_grid/row_detail/clock.rs @@ -0,0 +1,172 @@ +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::offset_at; +use super::value_editor::Commit; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ClockField { + pub dated: bool, + pub with_offset: bool, +} + +impl ClockField { + // 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 { + dated: false, + with_offset: *with_offset, + }), + ColumnKind::Timestamp { zone, .. } => Some(Self { + dated: true, + with_offset: *zone == TemporalZone::Offset, + }), + _ => None, + } + } +} + +// By the text's own form, as chrono reads it for the commit: SQLite keeps any date or time text in a +// DATE, TIME or DATETIME column, and chrono takes whitespace before a number and one digit for two. +fn clock_span(text: &str) -> Option> { + let after_day = day_len(text).map_or(0, |day| day + usize::from(text.as_bytes().get(day) == Some(&b'T'))); + let start = after_day + spaces(text.get(after_day..)?); + let minute = number(text, literal(text, number(text, start, 2)?, b':')?, 2)?; + let end = literal(text, minute, b':').and_then(|second| number(text, second, 2)); + Some(start..end.unwrap_or(minute)) +} + +// A year may run past 9999, as PostgreSQL writes one, and carry a sign. +fn day_len(text: &str) -> Option { + let lead = spaces(text); + let year = lead + usize::from(matches!(text.as_bytes().get(lead), Some(b'-' | b'+'))); + let month = number(text, literal(text, number(text, year, usize::MAX)?, b'-')?, 2)?; + number(text, literal(text, month, b'-')?, 2) +} + +// The end of the number at `at`, past any whitespace before it. +fn number(text: &str, at: usize, most: usize) -> Option { + let start = at + spaces(text.get(at..)?); + let run = text + .get(start..)? + .bytes() + .take(most) + .take_while(u8::is_ascii_digit) + .count(); + (run > 0).then_some(start + run) +} + +fn literal(text: &str, at: usize, byte: u8) -> Option { + (text.as_bytes().get(at) == Some(&byte)).then_some(at + 1) +} + +fn spaces(text: &str) -> usize { + text.len() - text.trim_start().len() +} + +pub fn time_of(text: &str) -> Option { + let mut parts = text + .get(clock_span(text)?)? + .split(':') + .map(|part| part.trim().parse::()); + let (hour, minute) = (parts.next()?.ok()?, parts.next()?.ok()?); + let second = parts.next().transpose().ok()?.unwrap_or(0); + NaiveTime::from_hms_opt(hour, minute, second) +} + +// Only the clock changes; text with no day or clock, such as NULL or infinity, becomes a new value. +pub fn with_time(text: &str, time: NaiveTime, field: ClockField, today: NaiveDate, zone: &Tz) -> String { + let mut clock = time.format("%H:%M:%S").to_string(); + let mut text = text.to_owned(); + if let Some(span) = clock_span(&text) { + text.replace_range(span, &clock); + return text; + } + let day_end = day_len(&text); + let day = day_end + .and_then(|end| NaiveDate::parse_from_str(text.get(..end)?.trim_start(), "%Y-%m-%d").ok()) + .unwrap_or(today); + if field.with_offset { + clock.push_str(&offset_at(zone, day.and_time(time)).to_string()); + } + match day_end { + Some(end) => { + text.insert_str(end, &format!(" {clock}")); + text + } + None if field.dated => format!("{} {clock}", today.format("%Y-%m-%d")), + None => clock, + } +} + +impl RowDetailPane { + pub(super) fn connect_clock(&self) { + let imp = self.imp(); + // Centred below a button at the window's edge, the clock would run past it. + imp.clock_button.set_direction(self.toward_rows()); + 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() + )); + 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 + }); + } + } + + fn clock_field(&self) -> Option { + self.field_column() + .and_then(|column| ClockField::of(&column.value_type().kind)) + } + + fn clock_opens(&self) { + let imp = self.imp(); + imp.clock_target.replace(self.current_pin().zip(imp.field.get())); + let time = time_of(&self.typed_text()).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())); + } + + // 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(); + imp.clock_popover.popdown(); + if target.is_none() || target != self.current_pin().zip(imp.field.get()) || !imp.value_view.is_editable() { + return; + } + 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 text = with_time(&self.typed_text(), time, field, Local::now().date_naive(), &Local); + imp.value_buffer.set_text(&text); + self.commit(Commit::Keep); + } +} 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..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 @@ -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,7 @@ pub struct RowDetailPane { pub quiet_calendar: Cell, /// The row and field the open calendar picks a day for. pub calendar_target: RefCell>, + 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..135979f0ca 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; @@ -871,3 +872,293 @@ 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(); + 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"); +} + +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("2024-03-10T02:30:00.125-08:00", TIME), + "2024-03-10T09:05:30.125-08:00", + "SQLite keeps a timestamp in a TIME column" + ); + assert_eq!(set_at_nine("10:00", NAIVE), "09:05:30", "a time in a DATETIME column"); + assert_eq!(set_at_nine(" 2024-01-01 10:00:00", NAIVE), " 2024-01-01 09:05:30"); + assert_eq!( + set_at_nine("2024-01-01 12:34:56.789+02:00", ZONED), + "2024-01-01 09:05:30.789+02:00" + ); + assert_eq!(set_at_nine("-0001-01-01 12:34:56", NAIVE), "-0001-01-01 09:05:30"); + assert_eq!( + set_at_nine("2024-02-29T 12:34:56.125+05:30", ZONED), + "2024-02-29T 09:05:30.125+05:30" + ); + assert_eq!(set_at_nine("2024-2-9 9:5:3.5", NAIVE), "2024-2-9 09:05:30.5"); + assert_eq!(set_at_nine("2024- 2-29 12: 34", NAIVE), "2024- 2-29 09:05:30"); + assert_eq!( + set_at_nine("2024-\u{a0}02-29\u{a0}12:34:56", NAIVE), + "2024-\u{a0}02-29\u{a0}09:05:30" + ); + 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(" 2024-02-29", NAIVE), " 2024-02-29 09:05:30"); + assert_eq!(set_at_nine("4713-01-01 BC", NAIVE), "4713-01-01 09:05:30 BC"); + assert_eq!(set_at_nine("4713-01-01 BC", ZONED), "4713-01-01 09:05:30+02:00 BC"); + assert_eq!(set_at_nine("12000-01-01", NAIVE), "12000-01-01 09:05:30"); + assert_eq!( + set_at_nine("2024-02-29", TIME), + "2024-02-29 09:05:30", + "SQLite keeps a date in a TIME column" + ); + 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-4", a_time(9, 5, 0), ZONED, earlier, &sydney), + "2026-10-4 09:05:00+11:00", + "a one-digit day took today's offset" + ); + 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"), Some(a_time(23, 59, 58))); + assert_eq!(time_of("12000-01-01 10:00:00"), Some(a_time(10, 0, 0))); + assert_eq!(time_of("2024-02-29 10:30"), Some(a_time(10, 30, 0))); + assert_eq!(time_of("13:45:07.5+01"), Some(a_time(13, 45, 7))); + assert_eq!(time_of(" 2024-02-29T10:00:00Z"), Some(a_time(10, 0, 0))); + assert_eq!(time_of("2024-2-9 9:5:3"), Some(a_time(9, 5, 3))); + assert_eq!(time_of("2024- 2-29 12: 34"), Some(a_time(12, 34, 0))); + assert_eq!(time_of("24:00:00"), None); + assert_eq!(time_of("2024-02-29"), None); + assert_eq!(time_of("2024-02-2é 10:00:00"), None, "a cut inside a character"); + assert_eq!(time_of("not a 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_opens_on_the_value_and_sets_its_time() { + let (grid, pane) = opened( + vec![named("id", "integer", true), named("at", "timestamp", false)], + vec![vec![Value::Int(1), a_timestamp((2024, 2, 29), a_time(23, 59, 8))]], + ); + take_keyboard(&grid); + grid.focus(0, 1); + assert!(pane.imp().clock_button.is_visible(), "a timestamp has no clock"); + assert_eq!( + pane.imp().clock_button.direction(), + gtk::ArrowType::Left, + "a clock centred below the pane's button runs past the window" + ); + + open_clock(&pane); + 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", &[]); + // A button activated from the keyboard clicks once its pressed look has shown. + wait_until(WAIT, || pending(&grid, 1, 1).is_some()).expect("Enter to set the time"); + + 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_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/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/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 6c49dfaf69..5e266afad8 100644 --- a/linux/data/resources/ui/row-detail-pane.ui +++ b/linux/data/resources/ui/row-detail-pane.ui @@ -184,6 +184,110 @@ + + + preferences-system-time-symbolic + false + Pick a Time + + Pick a Time + + + + + clock_set + + + 6 + 6 + 6 + 6 + 6 + + + vertical + true + true + 0.5 + true + + + 23 + 1 + 6 + + + + Hour + + + + + + : + + + + + vertical + true + true + 0.5 + true + + + 59 + 1 + 10 + + + + Minute + + + + + + : + + + + + vertical + true + true + 0.5 + 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