From 481f1c2b8bff703fe08fb73e3e86a744401763e0 Mon Sep 17 00:00:00 2001 From: Bryan Mark Date: Mon, 12 Oct 2026 10:38:55 +0900 Subject: [PATCH] File view: the recent list tells when each workbook was last changed The recent workbooks on the File view's Home and Open pages gain a Modified column: "Just now", "5 min ago", "3 h ago", "Yesterday", "4 days ago", "3 weeks ago", "5 months ago", "2 years ago", or "Not available" for a file that can't be reached. The file system is never asked while drawing. A thread asks for each file's metadata and the list shows each answer as it comes back, so a network drive that is asleep or a share that is away doesn't hold up the view. One thread asks at a time; a view opened anew asks anew. Only metadata is read: no workbook is opened until the user picks one. The list tells ages rather than dates because the UI has no time zone to tell a date by. From two days on a count is never one, so each language needs one form of "days", "weeks", "months" and "years"; the ten strings are in the nine catalogs, with the count placed by a {n} in each, as "Page {page} of {pages}" does. A long name or folder now stops short of the column instead of running under it. The web build, which has no files to ask about, and a narrow window show the list as before. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/backstage.rs | 236 ++++++++++++++++++++++++++++++-- crates/ui-egui/src/i18n.rs | 10 ++ crates/ui-egui/src/i18n/cs.rs | 10 ++ crates/ui-egui/src/i18n/de.rs | 10 ++ crates/ui-egui/src/i18n/es.rs | 10 ++ crates/ui-egui/src/i18n/fr.rs | 10 ++ crates/ui-egui/src/i18n/ko.rs | 10 ++ crates/ui-egui/src/i18n/pt.rs | 10 ++ crates/ui-egui/src/i18n/ru.rs | 10 ++ crates/ui-egui/src/i18n/zh.rs | 10 ++ docs/ui-parity.md | 5 +- 11 files changed, 315 insertions(+), 16 deletions(-) diff --git a/crates/ui-egui/src/backstage.rs b/crates/ui-egui/src/backstage.rs index 824b4da..42ee27c 100644 --- a/crates/ui-egui/src/backstage.rs +++ b/crates/ui-egui/src/backstage.rs @@ -4,8 +4,13 @@ //! A navigation column on the left picks a page (Home, New, Open, Info, Save As, Print, Export, //! Options); Save and Close act at once. Every action runs an existing command through //! [`SheetApp::run`] or an existing dialog through [`SheetApp::open_dialog`]; the state here is -//! UI-only and never saved. Recent workbooks are listed by path only: nothing on disk is touched -//! until the user picks one. +//! UI-only and never saved. Recent workbooks are listed by path, with when each was last changed: +//! that is asked of the file system on a thread of its own, never while drawing, and only the +//! files' metadata is read. No workbook is opened until the user picks one. + +use std::collections::HashMap; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex, PoisonError}; use egui::{Align2, Color32, Key, Modifiers, Rect, RichText, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; use serde_json::{Value as Json, json}; @@ -109,6 +114,8 @@ pub struct Backstage { preview: Option<((u64, u64, bool, bool), Json)>, /// Save As: the chosen file type (an extension from [`SAVE_TYPES`]). pub save_format: &'static str, + /// Home and Open: when the recent workbooks were last changed. + recent: RecentFiles, } /// Save As file types: (extension, label). The first is the workbook itself; the others are @@ -118,7 +125,16 @@ pub const SAVE_TYPES: [(&str, &str); 4] = impl Backstage { pub fn new(page: Page) -> Backstage { - Backstage { page, props: Default::default(), print_all: false, fit_to_page: false, preview_page: 0, preview: None, save_format: "xlsx" } + Backstage { + page, + props: Default::default(), + print_all: false, + fit_to_page: false, + preview_page: 0, + preview: None, + save_format: "xlsx", + recent: RecentFiles::default(), + } } } @@ -187,9 +203,9 @@ pub fn show(app: &mut SheetApp, ui: &mut Ui) { ui.label(RichText::new(lang.tr(bs.page.label())).font(theme::ui_font(28.0)).color(t.text)); ui.add_space(18.0); match bs.page { - Page::Home => home(app, ui, &mut out), + Page::Home => home(app, ui, &mut bs, &mut out), Page::New => new_page(app, ui, &mut out), - Page::Open => open_page(app, ui, &mut out), + Page::Open => open_page(app, ui, &mut bs, &mut out), Page::Info => info(app, ui, &mut bs), Page::SaveAs => save_as(app, ui, &mut bs, &mut out), Page::Print => print(app, ui, &mut bs, &mut out), @@ -325,7 +341,7 @@ fn new_tiles(app: &mut SheetApp, ui: &mut Ui, out: &mut Outcome) { }); } -/// File name and folder of a recent path, split as text (nothing on disk is read). +/// File name and folder of a recent path, split as text. fn split_path(path: &str) -> (String, String) { let p = std::path::Path::new(path); let name = p.file_name().and_then(|n| n.to_str()).unwrap_or(path).to_string(); @@ -333,8 +349,12 @@ fn split_path(path: &str) -> (String, String) { (name, folder) } -/// Recent workbooks, newest first. Opening one is the user's explicit choice. -fn recent_list(app: &mut SheetApp, ui: &mut Ui, out: &mut Outcome, limit: usize) { +/// Width of the column that tells when a recent workbook was last changed. +const MODIFIED_W: f32 = 150.0; + +/// Recent workbooks, newest first, each with when it was last changed. Opening one is the user's +/// explicit choice. +fn recent_list(app: &mut SheetApp, ui: &mut Ui, bs: &mut Backstage, out: &mut Outcome, limit: usize) { let t = Tokens::get(ui.ctx()); let lang = app.ui.language; if app.ui.recent.is_empty() { @@ -342,6 +362,21 @@ fn recent_list(app: &mut SheetApp, ui: &mut Ui, out: &mut Outcome, limit: usize) return; } let width = ui.available_width().min(CONTENT_W); + // What the file system has said so far; the rest arrives in later frames (see `RecentFiles`). + let known = bs.recent.known(ui.ctx(), &app.ui.recent); + // The web build has no files to ask about, and a narrow window leaves the names their room. + let modified_w = if cfg!(target_arch = "wasm32") || width < 420.0 { 0.0 } else { MODIFIED_W }; + if modified_w > 0.0 { + let (head, _) = ui.allocate_exact_size(vec2(width, 20.0), Sense::hover()); + ui.painter().text( + pos2(head.right() - modified_w, head.center().y), + Align2::LEFT_CENTER, + lang.tr("Modified"), + theme::ui_font(11.5), + t.text_dim, + ); + } + let now = crate::now_ms() / 1000.0; for path in app.ui.recent.iter().take(limit).cloned().collect::>() { let (name, folder) = split_path(&path); let (rect, resp) = ui.allocate_exact_size(vec2(width, 44.0), Sense::click()); @@ -351,8 +386,19 @@ fn recent_list(app: &mut SheetApp, ui: &mut Ui, out: &mut Outcome, limit: usize) p.rect_filled(rect, 4.0, t.hover); } icons::paint(p, Rect::from_center_size(pos2(rect.left() + 20.0, rect.center().y), vec2(20.0, 20.0)), Icon::Book, t.text); - p.text(pos2(rect.left() + 42.0, rect.center().y - 8.0), Align2::LEFT_CENTER, &name, theme::ui_font(13.5), t.text); - p.text(pos2(rect.left() + 42.0, rect.center().y + 9.0), Align2::LEFT_CENTER, &folder, theme::ui_font(11.5), t.text_dim); + // A long name or folder stops short of the column instead of running under it. + let room = if modified_w > 0.0 { rect.right() - modified_w - 14.0 } else { rect.right() }; + let text = p.with_clip_rect(Rect::from_min_max(rect.min, pos2(room, rect.bottom())).intersect(p.clip_rect())); + text.text(pos2(rect.left() + 42.0, rect.center().y - 8.0), Align2::LEFT_CENTER, &name, theme::ui_font(13.5), t.text); + text.text(pos2(rect.left() + 42.0, rect.center().y + 9.0), Align2::LEFT_CENTER, &folder, theme::ui_font(11.5), t.text_dim); + if modified_w > 0.0 { + let (words, color) = match known.get(&path) { + Some(Modified::At(seconds)) => (age(lang, now - seconds), t.text_dim), + Some(Modified::Unknown) => (lang.tr("Not available").to_string(), t.text_disabled), + None => (String::new(), t.text_dim), + }; + p.text(pos2(rect.right() - modified_w, rect.center().y), Align2::LEFT_CENTER, words, theme::ui_font(12.0), color); + } let resp = resp.on_hover_text(&path); if resp.clicked() { app.open_path(&path); @@ -363,6 +409,98 @@ fn recent_list(app: &mut SheetApp, ui: &mut Ui, out: &mut Outcome, limit: usize) } } +/// What the file system said about a recent workbook. +#[derive(Clone, Copy, Debug, PartialEq)] +enum Modified { + /// When it was last changed, in seconds since 1970. + At(f64), + /// It can't be reached: moved, deleted, or on a drive that is away. + Unknown, +} + +/// One thread at a time asks about recent workbooks, so a drive that never answers holds up one +/// thread, not one for every time the view is opened. +static ASKING: AtomicBool = AtomicBool::new(false); + +/// When the listed workbooks were last changed, as far as the file system has answered. +/// +/// Asking can take long (a network drive that is asleep, a share that is away), so it is never +/// done while drawing: a thread asks, and the list shows each answer as it comes back. Only the +/// files' metadata is read. A view opened anew asks anew. +#[derive(Clone, Debug, Default)] +pub struct RecentFiles { + /// The list the answers are for. + asked: Vec, + found: Arc>>, +} + +impl RecentFiles { + /// What is known this frame about the files in `recent`; starts asking when the list is new. + fn known(&mut self, ctx: &egui::Context, recent: &[String]) -> HashMap { + if self.asked != recent && !ASKING.swap(true, Ordering::SeqCst) { + self.asked = recent.to_vec(); + if !ask(ctx.clone(), recent.to_vec(), self.found.clone()) { + ASKING.store(false, Ordering::SeqCst); + } + } + self.found.lock().unwrap_or_else(PoisonError::into_inner).clone() + } +} + +/// Starts the thread that asks. Returns whether it started. +#[cfg(not(target_arch = "wasm32"))] +fn ask(ctx: egui::Context, paths: Vec, found: Arc>>) -> bool { + /// Lets the next list ask, however the thread ends. + struct Done; + impl Drop for Done { + fn drop(&mut self) { + ASKING.store(false, Ordering::SeqCst); + } + } + let asking = std::thread::Builder::new().name("gridcraft-recent-files".into()).spawn(move || { + let _done = Done; + for path in paths { + let changed = std::fs::metadata(&path).and_then(|m| m.modified()).ok().and_then(|at| at.duration_since(std::time::UNIX_EPOCH).ok()); + let answer = changed.map_or(Modified::Unknown, |since| Modified::At(since.as_secs_f64())); + found.lock().unwrap_or_else(PoisonError::into_inner).insert(path, answer); + ctx.request_repaint(); + } + }); + asking.is_ok() +} + +/// The web build has no file system to ask. +#[cfg(target_arch = "wasm32")] +fn ask(_ctx: egui::Context, _paths: Vec, _found: Arc>>) -> bool { + false +} + +/// How long ago, in words. The clock's time zone isn't known here, so the list tells ages rather +/// than dates, which could be a day out. From two days on the counts are never one, so each +/// language needs one form of "days", "weeks", "months" and "years". +fn age(lang: Language, seconds: f64) -> String { + const DAY: f64 = 86_400.0; + let s = if seconds.is_finite() { seconds.max(0.0) } else { 0.0 }; + let counted = |pattern: &str, n: f64| lang.tr(pattern).replace("{n}", &(n.floor() as u64).to_string()); + if s < 60.0 { + lang.tr("Just now").to_string() + } else if s < 3600.0 { + counted("{n} min ago", s / 60.0) + } else if s < DAY { + counted("{n} h ago", s / 3600.0) + } else if s < 2.0 * DAY { + lang.tr("Yesterday").to_string() + } else if s < 14.0 * DAY { + counted("{n} days ago", s / DAY) + } else if s < 61.0 * DAY { + counted("{n} weeks ago", s / (7.0 * DAY)) + } else if s < 731.0 * DAY { + counted("{n} months ago", s / (30.44 * DAY)) + } else { + counted("{n} years ago", s / (365.25 * DAY)) + } +} + /// A large clickable row (icon, title, one-line description). fn action_row(ui: &mut Ui, icon: Icon, title: &str, detail: &str) -> egui::Response { let t = Tokens::get(ui.ctx()); @@ -395,13 +533,13 @@ fn square_button(ui: &mut Ui, icon: Icon, label: &str) -> egui::Response { // ---------------------------------------------------------------- pages -fn home(app: &mut SheetApp, ui: &mut Ui, out: &mut Outcome) { +fn home(app: &mut SheetApp, ui: &mut Ui, bs: &mut Backstage, out: &mut Outcome) { let lang = app.ui.language; section(ui, lang, "New"); new_tiles(app, ui, out); ui.add_space(22.0); section(ui, lang, "Recent"); - recent_list(app, ui, out, 8); + recent_list(app, ui, bs, out, 8); if app.ui.recent.len() > 8 { ui.add_space(6.0); if ui.link(lang.tr("More workbooks")).clicked() { @@ -414,7 +552,7 @@ fn new_page(app: &mut SheetApp, ui: &mut Ui, out: &mut Outcome) { new_tiles(app, ui, out); } -fn open_page(app: &mut SheetApp, ui: &mut Ui, out: &mut Outcome) { +fn open_page(app: &mut SheetApp, ui: &mut Ui, bs: &mut Backstage, out: &mut Outcome) { let lang = app.ui.language; if square_button(ui, Icon::OpenFolder, lang.tr("Browse…")).clicked() { let before = app.session.active().map(|d| d.uid); @@ -426,7 +564,7 @@ fn open_page(app: &mut SheetApp, ui: &mut Ui, out: &mut Outcome) { } ui.add_space(22.0); section(ui, lang, "Recent"); - recent_list(app, ui, out, usize::MAX); + recent_list(app, ui, bs, out, usize::MAX); } /// Review › Protect Workbook and File › Info › Protect Workbook: the protect dialog (with an @@ -850,6 +988,76 @@ fn options(app: &mut SheetApp, ui: &mut Ui) { mod tests { use super::*; + #[test] + fn ages_are_told_in_words() { + const DAY: f64 = 86_400.0; + for (seconds, words) in [ + (0.0, "Just now"), + (59.9, "Just now"), + (60.0, "1 min ago"), + (150.0, "2 min ago"), + (3600.0, "1 h ago"), + (23.9 * 3600.0, "23 h ago"), + (DAY, "Yesterday"), + (1.99 * DAY, "Yesterday"), + // From here on a count is never one. + (2.0 * DAY, "2 days ago"), + (13.9 * DAY, "13 days ago"), + (14.0 * DAY, "2 weeks ago"), + (60.9 * DAY, "8 weeks ago"), + (61.0 * DAY, "2 months ago"), + (730.9 * DAY, "24 months ago"), + (731.0 * DAY, "2 years ago"), + (4000.0 * DAY, "10 years ago"), + // A clock that is behind the file's, or a number that isn't one. + (-5.0, "Just now"), + (f64::NAN, "Just now"), + (f64::NEG_INFINITY, "Just now"), + (f64::INFINITY, "Just now"), + ] { + assert_eq!(age(Language::En, seconds), words, "{seconds}"); + } + assert!(age(Language::En, f64::MAX).ends_with(" years ago")); + // Other languages put the count where their own words want it. + assert_eq!(age(Language::Ja, 3.0 * DAY), "3 日前"); + assert_eq!(age(Language::De, 3.0 * DAY), "vor 3 Tagen"); + assert_eq!(age(Language::Fr, 90.0), "il y a 1 min"); + for lang in Language::ALL { + for seconds in [10.0, 600.0, 7200.0, 1.5 * DAY, 5.0 * DAY, 30.0 * DAY, 200.0 * DAY, 2000.0 * DAY] { + let words = age(lang, seconds); + assert!(!words.is_empty() && !words.contains('{'), "{lang:?} {seconds}: {words}"); + } + } + } + + #[test] + fn the_recent_list_learns_when_its_files_were_last_changed() { + let dir = std::env::temp_dir().join(format!("gridcraft-recent-files-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let there = dir.join("There.xlsx").to_string_lossy().into_owned(); + let gone = dir.join("Gone.xlsx").to_string_lossy().into_owned(); + std::fs::write(&there, b"a file, if not a workbook").unwrap(); + let ctx = egui::Context::default(); + let recent = vec![there.clone(), gone.clone()]; + let mut files = RecentFiles::default(); + // Nothing is read while drawing: the answers arrive from a thread, in later frames. + let mut known = HashMap::new(); + for _ in 0..2_000 { + known = files.known(&ctx, &recent); + if known.len() == recent.len() { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + assert!(matches!(known.get(&there), Some(Modified::At(seconds)) if *seconds > 1.0e9), "{known:?}"); + assert_eq!(known.get(&gone), Some(&Modified::Unknown)); + // The same list is not asked about twice; an empty one is never asked about. + assert_eq!(files.known(&ctx, &recent), known); + assert!(RecentFiles::default().known(&ctx, &[]).is_empty()); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn page_ids_round_trip_and_unknown_ids_are_refused() { for page in Page::ALL { diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 406cc3d..aba2974 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -511,6 +511,16 @@ const JAPANESE: &[(&str, &str)] = &[ ("Recent", "最近使ったアイテム"), ("No recent workbooks yet.", "最近使ったブックはまだありません。"), ("More workbooks", "その他のブック"), + ("Modified", "更新日時"), + ("Not available", "利用できません"), + ("Just now", "たった今"), + ("Yesterday", "昨日"), + ("{n} min ago", "{n} 分前"), + ("{n} h ago", "{n} 時間前"), + ("{n} days ago", "{n} 日前"), + ("{n} weeks ago", "{n} 週間前"), + ("{n} months ago", "{n} か月前"), + ("{n} years ago", "{n} 年前"), ("Browse…", "参照…"), ("Not saved yet", "まだ保存されていません"), ("Location", "場所"), diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index ca2e559..2ed53f0 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -256,6 +256,16 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Recent" => "Naposledy použité", "No recent workbooks yet." => "Zatím žádné naposledy použité sešity.", "More workbooks" => "Další sešity", + "Modified" => "Změněno", + "Not available" => "Nedostupné", + "Just now" => "Právě teď", + "Yesterday" => "Včera", + "{n} min ago" => "před {n} min", + "{n} h ago" => "před {n} h", + "{n} days ago" => "před {n} dny", + "{n} weeks ago" => "před {n} týdny", + "{n} months ago" => "před {n} měsíci", + "{n} years ago" => "před {n} lety", "Browse…" => "Procházet…", "Not saved yet" => "Zatím neuloženo", "Location" => "Umístění", diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs index 2ad37f6..0b43bb7 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -254,6 +254,16 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Recent" => "Zuletzt verwendet", "No recent workbooks yet." => "Noch keine zuletzt verwendeten Arbeitsmappen.", "More workbooks" => "Weitere Arbeitsmappen", + "Modified" => "Geändert", + "Not available" => "Nicht verfügbar", + "Just now" => "Gerade eben", + "Yesterday" => "Gestern", + "{n} min ago" => "vor {n} Min.", + "{n} h ago" => "vor {n} Std.", + "{n} days ago" => "vor {n} Tagen", + "{n} weeks ago" => "vor {n} Wochen", + "{n} months ago" => "vor {n} Monaten", + "{n} years ago" => "vor {n} Jahren", "Browse…" => "Durchsuchen…", "Not saved yet" => "Noch nicht gespeichert", "Location" => "Speicherort", diff --git a/crates/ui-egui/src/i18n/es.rs b/crates/ui-egui/src/i18n/es.rs index da41bd7..11a2f77 100644 --- a/crates/ui-egui/src/i18n/es.rs +++ b/crates/ui-egui/src/i18n/es.rs @@ -255,6 +255,16 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Recent" => "Recientes", "No recent workbooks yet." => "Todavía no hay libros recientes.", "More workbooks" => "Más libros", + "Modified" => "Modificado", + "Not available" => "No disponible", + "Just now" => "Ahora mismo", + "Yesterday" => "Ayer", + "{n} min ago" => "hace {n} min", + "{n} h ago" => "hace {n} h", + "{n} days ago" => "hace {n} días", + "{n} weeks ago" => "hace {n} semanas", + "{n} months ago" => "hace {n} meses", + "{n} years ago" => "hace {n} años", "Browse…" => "Examinar…", "Not saved yet" => "Aún no se ha guardado", "Location" => "Ubicación", diff --git a/crates/ui-egui/src/i18n/fr.rs b/crates/ui-egui/src/i18n/fr.rs index fdf1f89..76134fc 100644 --- a/crates/ui-egui/src/i18n/fr.rs +++ b/crates/ui-egui/src/i18n/fr.rs @@ -336,6 +336,16 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Recent" => "Enregistrer automatiquement les classeurs .xlsx après chaque modification", "No recent workbooks yet." => "Rien à imprimer.", "More workbooks" => "Commentaire suivant", + "Modified" => "Modifié", + "Not available" => "Indisponible", + "Just now" => "À l’instant", + "Yesterday" => "Hier", + "{n} min ago" => "il y a {n} min", + "{n} h ago" => "il y a {n} h", + "{n} days ago" => "il y a {n} jours", + "{n} weeks ago" => "il y a {n} semaines", + "{n} months ago" => "il y a {n} mois", + "{n} years ago" => "il y a {n} ans", "Browse…" => "CSV (séparé par des virgules)", "Not saved yet" => "Nombre\nde pages", "Location" => "Nouveau", diff --git a/crates/ui-egui/src/i18n/ko.rs b/crates/ui-egui/src/i18n/ko.rs index b684c78..c23b60c 100644 --- a/crates/ui-egui/src/i18n/ko.rs +++ b/crates/ui-egui/src/i18n/ko.rs @@ -342,6 +342,16 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Recent" => "최근 항목", "No recent workbooks yet." => "최근 통합 문서가 아직 없습니다.", "More workbooks" => "더 많은 통합 문서", + "Modified" => "수정한 날짜", + "Not available" => "사용할 수 없음", + "Just now" => "방금", + "Yesterday" => "어제", + "{n} min ago" => "{n}분 전", + "{n} h ago" => "{n}시간 전", + "{n} days ago" => "{n}일 전", + "{n} weeks ago" => "{n}주 전", + "{n} months ago" => "{n}개월 전", + "{n} years ago" => "{n}년 전", "Browse…" => "찾아보기…", "Not saved yet" => "아직 저장되지 않음", "Location" => "위치", diff --git a/crates/ui-egui/src/i18n/pt.rs b/crates/ui-egui/src/i18n/pt.rs index b554ef9..1915146 100644 --- a/crates/ui-egui/src/i18n/pt.rs +++ b/crates/ui-egui/src/i18n/pt.rs @@ -343,6 +343,16 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Recent" => "Recentes", "No recent workbooks yet." => "Ainda não há pastas de trabalho recentes.", "More workbooks" => "Mais pastas de trabalho", + "Modified" => "Modificado", + "Not available" => "Indisponível", + "Just now" => "Agora mesmo", + "Yesterday" => "Ontem", + "{n} min ago" => "há {n} min", + "{n} h ago" => "há {n} h", + "{n} days ago" => "há {n} dias", + "{n} weeks ago" => "há {n} semanas", + "{n} months ago" => "há {n} meses", + "{n} years ago" => "há {n} anos", "Browse…" => "Procurar…", "Not saved yet" => "Ainda não salvo", "Location" => "Local", diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index c8f0445..66ec0a2 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -342,6 +342,16 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Recent" => "Последние", "No recent workbooks yet." => "Недавних книг пока нет.", "More workbooks" => "Другие книги", + "Modified" => "Изменён", + "Not available" => "Недоступен", + "Just now" => "Только что", + "Yesterday" => "Вчера", + "{n} min ago" => "{n} мин назад", + "{n} h ago" => "{n} ч назад", + "{n} days ago" => "{n} дн. назад", + "{n} weeks ago" => "{n} нед. назад", + "{n} months ago" => "{n} мес. назад", + "{n} years ago" => "{n} г. назад", "Browse…" => "Обзор…", "Not saved yet" => "Ещё не сохранено", "Location" => "Расположение", diff --git a/crates/ui-egui/src/i18n/zh.rs b/crates/ui-egui/src/i18n/zh.rs index d1cf704..4b783e6 100644 --- a/crates/ui-egui/src/i18n/zh.rs +++ b/crates/ui-egui/src/i18n/zh.rs @@ -340,6 +340,16 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Recent" => "最近", "No recent workbooks yet." => "还没有最近使用的工作簿。", "More workbooks" => "更多工作簿", + "Modified" => "修改时间", + "Not available" => "不可用", + "Just now" => "刚刚", + "Yesterday" => "昨天", + "{n} min ago" => "{n} 分钟前", + "{n} h ago" => "{n} 小时前", + "{n} days ago" => "{n} 天前", + "{n} weeks ago" => "{n} 周前", + "{n} months ago" => "{n} 个月前", + "{n} years ago" => "{n} 年前", "Browse…" => "浏览…", "Not saved yet" => "尚未保存", "Location" => "位置", diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 10abcbc..f4b5269 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (AutoFit reserves filter dropdown space, observed against Windows Excel) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (the File view's recent list tells when each workbook was last changed) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Interaction, controls and feel, against Excel. Part of [target-app-parity.md](target-app-parity.md). **UI/UX fidelity: ~55% ready, 50–80 h** (estimated). Ribbon command coverage is measured @@ -26,7 +26,7 @@ Sources: `crates/ui-egui/src` (ribbon, grid, editor, formula bar, dialogs, panes | Task panes (Format Chart, Comments, Watch, Selection, PivotTable fields) | yes | yes | element-level Format pane for shapes and pictures missing | | Backstage (File) view: Info, New with templates, Open recent, Export, Options | yes | partial | Options dialog and template gallery missing | | Dark mode, system appearance | yes | yes | done (10-09); follows the system on first run, title-bar switch and File › Display theme (#194, #239) | -| Backstage (File) view: Info, New with templates, Open recent, Export, Options | yes | yes (#174, 10-11): the File tab opens a full-window view (Esc or the back arrow return) with Home, New (blank + the three samples), Open (recent + Browse), Info (location, properties, Protect Workbook), Save, Save As, Print (page preview, active sheet or workbook, fit to page, Page Setup), Export (PDF, CSV, web page), Close and Options (`file.options`: theme, language, formula bar, AutoSave, calculation) | no Account, Share or template gallery beyond the samples; Options applies at once (no OK/Cancel dialog) and holds only the settings GridCraft has | +| Backstage (File) view: Info, New with templates, Open recent, Export, Options | yes | yes (#174, 10-11): the File tab opens a full-window view (Esc or the back arrow return) with Home, New (blank + the three samples), Open (recent + Browse; the recent list tells when each workbook was last changed, 10-12), Info (location, properties, Protect Workbook), Save, Save As, Print (page preview, active sheet or workbook, fit to page, Page Setup), Export (PDF, CSV, web page), Close and Options (`file.options`: theme, language, formula bar, AutoSave, calculation) | no Account, Share or template gallery beyond the samples; Options applies at once (no OK/Cancel dialog) and holds only the settings GridCraft has | | Dark mode, system appearance | yes | yes | done (10-09) | | Page Layout view (paper view with rulers and header editing) | yes | yes (#329, 10-11): per-sheet view saved in XLSX, pages with margins and gaps on the print pagination, "Click to add data" pages, rulers (inches or centimetres), headers and footers drawn and edited in place with a Header & Footer tab | header/footer pictures, Different First Page / Odd & Even, ruler margin dragging missing (1–3 h) | | Multiple windows per workbook | yes | no (#162) | see [hardware-parity.md](hardware-parity.md) | @@ -160,6 +160,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | The File view's recent list tells when each workbook was last changed, read off the UI thread | | 2026-10-11 | minor | AutoFit accounts for table and ordinary filter dropdowns, including low zoom; header text excludes the button area | | 2026-10-11 | minor | Outline band for grouped rows and columns, Show / Hide Detail on the Data tab, numpad navigation keys in shortcuts (#373) | | 2026-10-11 | minor | Shift+Space / Ctrl+Space no longer start an edit; Control+Space selects the column on Mac (#338) |