mirror of
https://github.com/storytold/gridcraft.git
synced 2026-10-26 03:36:54 -06:00
Merge PR #1589: Web: keep preferences such as dark mode between visits
docs/ui-parity.md: header line kept as main's (newer); revision-history row for the web preferences added once.
This commit is contained in:
+3
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
**Stage: alpha** · next: beta, ~25 points of "ready for real work" and ~160–240 h away
|
||||
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (rows auto-fit oversized fonts on open and whole-row/column formatting) · **Target:** Microsoft Excel (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-16 · **Change:** trivial (progress log: web preferences kept between visits, #1562; previously minor: rows auto-fit oversized fonts on open and whole-row/column formatting) · **Target:** Microsoft Excel (Microsoft 365)
|
||||
|
||||
GridCraft aims at full Microsoft Excel parity, and at being better in places: faster, open (XLSX
|
||||
is the native format), fully scriptable by agents (MCP, control channel, CLI), and on the web.
|
||||
@@ -132,6 +132,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
|
||||
|
||||
| Date | What landed |
|
||||
|---|---|
|
||||
| 2026-10-16 | The web app keeps its preferences between visits (#1562): theme (dark mode), interface size, language, editing options and the rest of what the desktop app saves in `ui.json` and `prefs.json` are kept in the browser's `localStorage`, written as soon as they change |
|
||||
| 2026-10-12 | Rows auto-fit oversized fonts: opening a file and whole-row/column formatting grow non-custom rows to their tallest font (#833) |
|
||||
| 2026-10-12 | Number formats: parsing a format code is linear in its length (long runs of `[`, `,` or `/` no longer take quadratic time), and an unclosed `[` is a literal when splitting sections as well as when tokenizing (#810) |
|
||||
| 2026-10-12 | Number formats: a fraction denominator with 65,536 zeros no longer crashes; format codes are capped at 255 characters like Excel (longer codes display as General, `TEXT` gives `#VALUE!`) (#807) |
|
||||
@@ -193,6 +194,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-16 | trivial | Progress log: web preferences kept between visits (#1562) |
|
||||
| 2026-10-12 | minor | Rows auto-fit oversized fonts on open and whole-row/column formatting (#833); progress log row |
|
||||
| 2026-10-12 | minor | Progress log: multi-area row/column delete (#758) |
|
||||
| 2026-10-12 | trivial | Progress log: XLSX chart sheets kept on save (#727) |
|
||||
|
||||
@@ -26,4 +26,4 @@ rfd = { workspace = true }
|
||||
wasm-bindgen = "0.2"
|
||||
wasm-bindgen-futures = "0.4"
|
||||
js-sys = "0.3"
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "HtmlIFrameElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Clipboard", "ClipboardItem", "Navigator", "MessageEvent", "UrlSearchParams"] }
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "HtmlIFrameElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Clipboard", "ClipboardItem", "Navigator", "MessageEvent", "UrlSearchParams", "Storage"] }
|
||||
|
||||
@@ -11,11 +11,14 @@
|
||||
//! URL query flags: `?webgl` forces WebGL2; `?sample=sales|budget|grades` opens a sample;
|
||||
//! `?host=parent` (inside an `<iframe>`) talks to a same-origin parent page instead: it sends the
|
||||
//! workbook to open and stores what GridCraft saves (`host`), and `&author=` names the user for
|
||||
//! notes and comments.
|
||||
//! notes and comments. Preferences (theme, interface size, editing options…) are kept in
|
||||
//! `localStorage` (`prefs`).
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod host;
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
mod prefs;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod web;
|
||||
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
//! Preferences between visits (#1562): what the desktop app keeps in `ui.json` (theme, interface
|
||||
//! size, language, ribbon state…) and `prefs.json` (editing options, user name…), kept in the
|
||||
//! browser's `localStorage` under [`UI_KEY`] and [`PREFS_KEY`]. Restored at startup; written back
|
||||
//! whenever they change, since a reload or a closed tab runs no exit code.
|
||||
|
||||
use gridcraft_ui_egui::SheetApp;
|
||||
|
||||
/// The `localStorage` key for the desktop's `ui.json`.
|
||||
pub const UI_KEY: &str = "gridcraft.ui";
|
||||
/// The `localStorage` key for the desktop's `prefs.json`.
|
||||
pub const PREFS_KEY: &str = "gridcraft.prefs";
|
||||
/// Largest stored value read back (`localStorage` holds about 5 MB per site).
|
||||
const MAX_STORED: usize = 1 << 20;
|
||||
|
||||
/// Apply preferences stored on an earlier visit, as the desktop app reads `ui.json` and
|
||||
/// `prefs.json`. Missing, oversized or unreadable values leave what the app starts with.
|
||||
pub fn restore(app: &mut SheetApp, ui: Option<&str>, prefs: Option<&str>) {
|
||||
let readable = |s: &&str| s.len() <= MAX_STORED;
|
||||
if let Some(s) = ui.filter(readable)
|
||||
&& let Ok(json) = serde_json::from_str::<serde_json::Value>(s)
|
||||
&& let Ok(state) = serde_json::from_value::<gridcraft_ui_egui::UiState>(json.clone())
|
||||
{
|
||||
// No usable saved language: keep the browser's, as the desktop keeps the system's.
|
||||
let language = gridcraft_ui_egui::i18n::saved_language(&json).unwrap_or(app.ui.language);
|
||||
app.ui = state;
|
||||
app.ui.language = language;
|
||||
}
|
||||
if let Some(s) = prefs.filter(readable)
|
||||
&& let Ok(p) = serde_json::from_str(s)
|
||||
{
|
||||
app.session.prefs = p;
|
||||
}
|
||||
}
|
||||
|
||||
/// Tracks what was last written, so the store is written only when a preference changed.
|
||||
#[derive(Default)]
|
||||
pub struct PrefsSync {
|
||||
ui: Option<String>,
|
||||
prefs: Option<String>,
|
||||
/// The user name to store in place of the session's (see [`Self::keep_user_name`]).
|
||||
user_name: Option<String>,
|
||||
}
|
||||
|
||||
impl PrefsSync {
|
||||
/// Starts from what is already stored, so restoring doesn't write it straight back.
|
||||
pub fn new(ui: Option<String>, prefs: Option<String>) -> Self {
|
||||
Self { ui, prefs, user_name: None }
|
||||
}
|
||||
|
||||
/// Stores `name` as the user name whatever the session's becomes: under `?host=parent` the
|
||||
/// framing page names the author for this visit only, and must not replace the user's own.
|
||||
pub fn keep_user_name(&mut self, name: String) {
|
||||
self.user_name = Some(name);
|
||||
}
|
||||
|
||||
/// The `(key, JSON)` pairs to store because they differ from what was last written.
|
||||
pub fn changed(&mut self, app: &SheetApp) -> Vec<(&'static str, String)> {
|
||||
let mut out = Vec::new();
|
||||
let prefs = match &self.user_name {
|
||||
Some(name) => serde_json::to_string(&gridcraft_engine::Prefs { user_name: name.clone(), ..app.session.prefs.clone() }),
|
||||
None => serde_json::to_string(&app.session.prefs),
|
||||
};
|
||||
for (key, last, json) in [(UI_KEY, &mut self.ui, serde_json::to_string(&app.ui)), (PREFS_KEY, &mut self.prefs, prefs)] {
|
||||
match json {
|
||||
Ok(json) if last.as_deref() != Some(json.as_str()) => {
|
||||
*last = Some(json.clone());
|
||||
out.push((key, json));
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(e) => log::warn!("preferences: cannot serialize {key}: {e}"),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use gridcraft_engine::Session;
|
||||
use gridcraft_ui_egui::Services;
|
||||
use gridcraft_ui_egui::i18n::Language;
|
||||
use serde_json::json;
|
||||
|
||||
fn app() -> SheetApp {
|
||||
let mut app = SheetApp::new(Session::new(), Services::default());
|
||||
// As `web::start` sets it up before restoring.
|
||||
app.ui.system_theme = true;
|
||||
app.ui.language = Language::De;
|
||||
app
|
||||
}
|
||||
|
||||
fn stored(writes: &[(&'static str, String)], key: &str) -> Option<String> {
|
||||
writes.iter().find(|(k, _)| *k == key).map(|(_, v)| v.clone())
|
||||
}
|
||||
|
||||
/// #1562: dark mode chosen on one visit is there after a reload, with the editing options.
|
||||
#[test]
|
||||
fn dark_mode_survives_reload() {
|
||||
let mut first = app();
|
||||
let mut sync = PrefsSync::new(None, None);
|
||||
assert_eq!(sync.changed(&first).len(), 2, "first frame stores the preferences");
|
||||
assert!(sync.changed(&first).is_empty(), "unchanged preferences are not rewritten");
|
||||
first.run("view.theme", json!({"mode": "dark"})).unwrap();
|
||||
first.session.prefs.enter_direction = "right".into();
|
||||
let writes = sync.changed(&first);
|
||||
let (ui, prefs) = (stored(&writes, UI_KEY), stored(&writes, PREFS_KEY));
|
||||
assert!(ui.is_some() && prefs.is_some(), "both changes are stored: {writes:?}");
|
||||
|
||||
let mut reloaded = app();
|
||||
restore(&mut reloaded, ui.as_deref(), prefs.as_deref());
|
||||
assert!(reloaded.ui.dark && !reloaded.ui.system_theme, "dark mode is back");
|
||||
assert_eq!(reloaded.session.prefs.enter_direction, "right");
|
||||
assert!(PrefsSync::new(ui, prefs).changed(&reloaded).is_empty(), "restoring doesn't write back");
|
||||
}
|
||||
|
||||
/// `?host=parent`: the author the framing page names is not stored over the user's own name.
|
||||
#[test]
|
||||
fn host_author_is_not_stored() {
|
||||
let mut first = app();
|
||||
first.session.prefs.user_name = "Ada".into();
|
||||
let mut sync = PrefsSync::new(None, None);
|
||||
let stored_prefs = stored(&sync.changed(&first), PREFS_KEY);
|
||||
|
||||
let mut hosted = app();
|
||||
restore(&mut hosted, None, stored_prefs.as_deref());
|
||||
let mut sync = PrefsSync::new(None, stored_prefs);
|
||||
sync.keep_user_name(hosted.session.prefs.user_name.clone());
|
||||
hosted.session.prefs.user_name = "Host Author".into();
|
||||
hosted.session.prefs.r1c1 = true;
|
||||
let writes = sync.changed(&hosted);
|
||||
let prefs = stored(&writes, PREFS_KEY).expect("other preferences are still stored");
|
||||
|
||||
let mut reloaded = app();
|
||||
restore(&mut reloaded, None, Some(&prefs));
|
||||
assert_eq!(reloaded.session.prefs.user_name, "Ada");
|
||||
assert!(reloaded.session.prefs.r1c1);
|
||||
}
|
||||
|
||||
/// A stored `ui` without a language keeps the browser's, as the desktop keeps the system's.
|
||||
#[test]
|
||||
fn missing_language_keeps_the_browsers() {
|
||||
let mut a = app();
|
||||
restore(&mut a, Some(r#"{"dark":true}"#), None);
|
||||
assert!(a.ui.dark);
|
||||
assert_eq!(a.ui.language, Language::De);
|
||||
restore(&mut a, Some(r#"{"language":"fr"}"#), None);
|
||||
assert_eq!(a.ui.language, Language::Fr);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn junk_storage_keeps_defaults() {
|
||||
let big = format!("{{\"dark\":true,\"recent\":[\"{}\"]}}", "x".repeat(MAX_STORED));
|
||||
for junk in [None, Some(""), Some("not json"), Some("[1,2]"), Some("{\"dark\":42}"), Some(big.as_str())] {
|
||||
let mut a = app();
|
||||
restore(&mut a, junk, junk);
|
||||
assert!(a.ui.system_theme && !a.ui.dark, "{junk:?}");
|
||||
assert_eq!(a.ui.language, Language::De);
|
||||
assert_eq!(a.session.prefs.enter_direction, "down");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -58,14 +58,20 @@ pub fn start() {
|
||||
) {
|
||||
app.ui.language = browser;
|
||||
}
|
||||
// Preferences from an earlier visit (#1562), as the desktop app reads `ui.json`
|
||||
// and `prefs.json`; a saved language replaces the browser's.
|
||||
let (stored_ui, stored_prefs) = (stored(crate::prefs::UI_KEY), stored(crate::prefs::PREFS_KEY));
|
||||
crate::prefs::restore(&mut app, stored_ui.as_deref(), stored_prefs.as_deref());
|
||||
let mut prefs = crate::prefs::PrefsSync::new(stored_ui, stored_prefs);
|
||||
SheetApp::setup_context_for_language(&cc.egui_ctx, false, app.ui.language);
|
||||
if let Some(bridge) = &app_bridge {
|
||||
prefs.keep_user_name(app.session.prefs.user_name.clone());
|
||||
if let Some(author) = query_param("author") {
|
||||
crate::host::Bridge::set_author(&mut app, &author);
|
||||
}
|
||||
bridge.post_ready();
|
||||
}
|
||||
Ok(Box::new(WebShell { app, inbox, bridge: app_bridge }))
|
||||
Ok(Box::new(WebShell { app, inbox, bridge: app_bridge, prefs }))
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
@@ -93,6 +99,16 @@ struct WebShell {
|
||||
app: SheetApp,
|
||||
inbox: Inbox,
|
||||
bridge: Option<crate::host::Bridge>,
|
||||
prefs: crate::prefs::PrefsSync,
|
||||
}
|
||||
|
||||
fn local_storage() -> Option<web_sys::Storage> {
|
||||
web_sys::window()?.local_storage().ok()?
|
||||
}
|
||||
|
||||
/// A value stored on an earlier visit, if the browser allows storage.
|
||||
fn stored(key: &str) -> Option<String> {
|
||||
local_storage()?.get_item(key).ok()?
|
||||
}
|
||||
|
||||
impl eframe::App for WebShell {
|
||||
@@ -113,6 +129,20 @@ impl eframe::App for WebShell {
|
||||
});
|
||||
}
|
||||
self.app.logic(ctx);
|
||||
// Store changed preferences straight away: a reload or closed tab runs no exit code (#1562).
|
||||
let changed = self.prefs.changed(&self.app);
|
||||
if !changed.is_empty() {
|
||||
match local_storage() {
|
||||
Some(store) => {
|
||||
for (key, json) in changed {
|
||||
if let Err(e) = store.set_item(key, &json) {
|
||||
log::warn!("preferences: cannot save {key}: {e:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
None => log::warn!("preferences: browser storage is unavailable"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
|
||||
@@ -163,6 +163,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12.
|
||||
|---|---|---|
|
||||
| 2026-10-19 | minor | Ctrl+Enter (`range.fill`) gives each cell what typing the input there gives: a General cell gets the automatic number format (`5%`, `$5`, `3/4/2026`, `=TODAY()`) even when it already held a value, and a cell with its own format, a formatted row or column, or Text keeps it. Cells that held a value used to keep General (`5%` showed 0.05), and empty cells took the active cell's format |
|
||||
| 2026-10-18 | minor | Keytip letters typed with left Alt held continue the sequence (Alt+H, O, A / Alt+H, O, I ran nothing before: Alt+letter cancelled keytips); other Alt combinations, AltGr and macOS Option are unchanged (#1846) |
|
||||
| 2026-10-16 | trivial | Web: preferences (theme, interface size, language, editing options…) are kept in the browser's `localStorage` between visits, as the desktop app keeps `ui.json` and `prefs.json`; before, a reload brought back light mode and the defaults (#1562) |
|
||||
| 2026-10-12 | minor | The File view's recent list tells when each workbook was last changed, read off the UI thread |
|
||||
| 2026-10-13 | minor | Horizontal scrollbar in the sheet tab bar: drag, track click pages, frozen columns excluded from the page (#840) |
|
||||
| 2026-10-12 | minor | Ctrl+wheel zoom steps along a ladder of percentages about 20% apart that never skips 100%, gliding to each step with animations on, so a notch out undoes a notch in (it drifted: 100 → 122 → 101); Excel steps 15 points, see decisions.md |
|
||||
|
||||
Reference in New Issue
Block a user