mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-03 02:25:55 +00:00
Settings: saved to disk as soon as they change, not only when the app closes normally (#8275) (#9711)
* claim: #8275 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Settings: written to disk in the frame they change, not only at a clean exit (#8275) eframe saves app.ron every 30 s and when the window closes normally. A change made since the last save was lost whenever the app didn't close normally: ended from the Task Manager, stopped by a sign-out or shutdown, or crashed or hung in the graphics driver on the way out. The next start then showed the old settings (View navigator back to Axes, Display quality back to Normal). The desktop app now writes its settings and flushes them in the frame they change (frames with input and the one after), as the web app already does (#8644). Unchanged settings are not written again. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Settings: write at most once a second, and only after input that can change them (#8275) Review: pointer motion came in every frame while the mouse moved, so the settings were serialized every frame; and some settings change in every frame (a panel's width while its edge is dragged), which would have flushed app.ron on the UI thread each frame. Only clicks, keys, text, paste, wheel and pinch count as input now, and the file is written at most once per second: a change made in between waits, a repaint is asked for when its second is over, and it is written then, so an idle app still writes it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+220
-2
@@ -99,6 +99,86 @@ struct App {
|
||||
nav_bridge: nav_bridge::NavBridge,
|
||||
/// Where the next file dialog opens: it follows the open design (#7756).
|
||||
dialog_folder: DialogFolder,
|
||||
/// Settings go to `app.ron` in the frame they change (#8275).
|
||||
prefs_writer: PrefsWriter,
|
||||
}
|
||||
|
||||
/// Settings written to the settings file soon after they change (#8275). eframe saves them
|
||||
/// only every 30 s and when the window closes normally, so a change was lost whenever the app
|
||||
/// did not get that far: ended from the Task Manager, stopped by a sign-out or shutdown, or
|
||||
/// crashed or hung in the graphics driver on the way out.
|
||||
///
|
||||
/// Some settings change continuously (a panel's width while its edge is dragged), so the file
|
||||
/// is written at most once per [`PrefsWriter::INTERVAL`], and a change made in between is written
|
||||
/// when that interval ends.
|
||||
#[derive(Default)]
|
||||
struct PrefsWriter {
|
||||
/// The settings written (or read at start) last.
|
||||
written: Option<String>,
|
||||
/// The previous frame had input that can change settings: what it changed shows in this
|
||||
/// frame's settings.
|
||||
input_before: bool,
|
||||
/// When the settings were last written by this writer.
|
||||
last_write: Option<std::time::Instant>,
|
||||
/// A change waits until then to be written.
|
||||
pending_until: Option<std::time::Instant>,
|
||||
}
|
||||
|
||||
impl PrefsWriter {
|
||||
/// The least time between two writes of the settings file.
|
||||
const INTERVAL: std::time::Duration = std::time::Duration::from_secs(1);
|
||||
|
||||
/// The settings as they were read at start: they are on disk already.
|
||||
fn read(prefs: String) -> Self {
|
||||
Self { written: Some(prefs), ..Self::default() }
|
||||
}
|
||||
|
||||
/// Whether this frame's settings are worth comparing: input that can change them came in this
|
||||
/// frame or the one before, or a waiting change's time has come. Pointer motion alone isn't
|
||||
/// such input; eframe's own save still catches anything else.
|
||||
fn due(&mut self, input: bool, now: std::time::Instant) -> bool {
|
||||
let due = input || self.input_before || self.pending_until.is_some_and(|at| now >= at);
|
||||
self.input_before = input;
|
||||
due
|
||||
}
|
||||
|
||||
/// Write `prefs` and flush them to disk if they differ from the settings written last and the
|
||||
/// last write is at least [`Self::INTERVAL`] old. Returns how long a change waits otherwise:
|
||||
/// the caller asks for a frame then, so an idle app still writes it.
|
||||
fn store(&mut self, storage: &mut dyn eframe::Storage, prefs: String, now: std::time::Instant) -> Option<std::time::Duration> {
|
||||
if self.written.as_deref() == Some(prefs.as_str()) {
|
||||
self.pending_until = None;
|
||||
return None;
|
||||
}
|
||||
if let Some(wait) = self.last_write.map(|at| Self::INTERVAL.saturating_sub(now.saturating_duration_since(at))).filter(|w| !w.is_zero()) {
|
||||
self.pending_until = Some(now + wait);
|
||||
return Some(wait);
|
||||
}
|
||||
storage.set_string(solidcraft_ui_egui::prefs::STORAGE_KEY, prefs.clone());
|
||||
storage.flush();
|
||||
self.written = Some(prefs);
|
||||
self.last_write = Some(now);
|
||||
self.pending_until = None;
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Input that can change a setting: a click, a key, typed or pasted text, a wheel or a pinch.
|
||||
/// Pointer motion, which comes every frame while the mouse moves, cannot by itself.
|
||||
fn changes_settings(event: &egui::Event) -> bool {
|
||||
matches!(
|
||||
event,
|
||||
egui::Event::PointerButton { .. }
|
||||
| egui::Event::Key { .. }
|
||||
| egui::Event::Text(_)
|
||||
| egui::Event::Paste(_)
|
||||
| egui::Event::Cut
|
||||
| egui::Event::Ime(_)
|
||||
| egui::Event::MouseWheel { .. }
|
||||
| egui::Event::Zoom(_)
|
||||
| egui::Event::Rotate(_)
|
||||
| egui::Event::AccessKitActionRequest(_)
|
||||
)
|
||||
}
|
||||
|
||||
impl eframe::App for App {
|
||||
@@ -186,11 +266,21 @@ impl eframe::App for App {
|
||||
solidcraft_ui_egui::space_mouse::buttons_hook(&mut self.solve, ctx, self.space_mouse.buttons(), raw);
|
||||
self.solve.raw_input_hook(raw);
|
||||
}
|
||||
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
||||
fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {
|
||||
content_ui(&mut self.solve, ui);
|
||||
// After the frame's UI, so a setting changed in it (Preferences' OK) is written now.
|
||||
let now = std::time::Instant::now();
|
||||
if self.prefs_writer.due(ui.input(|i| i.events.iter().any(changes_settings)), now)
|
||||
&& let Some(storage) = frame.storage_mut()
|
||||
&& let Some(wait) = self.prefs_writer.store(storage, self.solve.prefs(), now)
|
||||
{
|
||||
ui.ctx().request_repaint_after(wait);
|
||||
}
|
||||
}
|
||||
fn save(&mut self, storage: &mut dyn eframe::Storage) {
|
||||
storage.set_string(solidcraft_ui_egui::prefs::STORAGE_KEY, self.solve.prefs());
|
||||
let prefs = self.solve.prefs();
|
||||
storage.set_string(solidcraft_ui_egui::prefs::STORAGE_KEY, prefs.clone());
|
||||
self.prefs_writer.written = Some(prefs);
|
||||
}
|
||||
fn on_exit(&mut self) {
|
||||
// The agent chat's agent and everything it started end with the app.
|
||||
@@ -1122,6 +1212,8 @@ fn main() -> eframe::Result {
|
||||
mark("app created");
|
||||
solidcraft_engine::crash::phase("first frame");
|
||||
Ok(Box::new(App {
|
||||
// Before `app` moves: the settings as read, already on disk.
|
||||
prefs_writer: PrefsWriter::read(app.prefs()),
|
||||
solve: app,
|
||||
starting,
|
||||
frames: 0,
|
||||
@@ -1315,6 +1407,132 @@ mod backend_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod prefs_writer_tests {
|
||||
use super::PrefsWriter;
|
||||
use solidcraft_ui_egui::prefs::{NavigatorStyle, STORAGE_KEY};
|
||||
use std::collections::HashMap;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// eframe's settings file: what was flushed to disk is what the next start reads.
|
||||
#[derive(Default)]
|
||||
struct Disk {
|
||||
pending: HashMap<String, String>,
|
||||
flushed: HashMap<String, String>,
|
||||
flushes: usize,
|
||||
}
|
||||
|
||||
impl eframe::Storage for Disk {
|
||||
fn get_string(&self, key: &str) -> Option<String> {
|
||||
self.pending.get(key).cloned()
|
||||
}
|
||||
fn set_string(&mut self, key: &str, value: String) {
|
||||
self.pending.insert(key.into(), value);
|
||||
}
|
||||
fn remove_string(&mut self, key: &str) {
|
||||
self.pending.remove(key);
|
||||
}
|
||||
fn flush(&mut self) {
|
||||
self.flushed = self.pending.clone();
|
||||
self.flushes += 1;
|
||||
}
|
||||
}
|
||||
|
||||
fn new_app() -> solidcraft_ui_egui::SolveApp {
|
||||
solidcraft_ui_egui::SolveApp::new(solidcraft_engine::Session::default(), solidcraft_ui_egui::Services::default())
|
||||
}
|
||||
|
||||
/// #8275: Preferences' View navigator and Display quality, changed and confirmed, are on disk
|
||||
/// in that frame, so the next start has them even when this one never closes normally (ended
|
||||
/// from the Task Manager, a sign-out, a hang or crash in the graphics driver on the way out).
|
||||
#[test]
|
||||
fn changed_settings_reach_the_disk_in_the_frame_they_change() {
|
||||
let mut app = new_app();
|
||||
let mut disk = Disk::default();
|
||||
let mut writer = PrefsWriter::read(app.prefs());
|
||||
let now = Instant::now();
|
||||
assert_eq!(writer.store(&mut disk, app.prefs(), now), None);
|
||||
assert_eq!(disk.flushes, 0, "the settings read at start are not written again");
|
||||
app.preferences.navigator_style = NavigatorStyle::Cube;
|
||||
app.preferences.display_quality = "fine".into();
|
||||
assert!(writer.due(true, now), "a frame with input is checked");
|
||||
assert_eq!(writer.store(&mut disk, app.prefs(), now), None);
|
||||
assert_eq!(disk.flushes, 1, "flushed at once, not left for eframe's next save");
|
||||
assert_eq!(writer.store(&mut disk, app.prefs(), now + Duration::from_secs(5)), None);
|
||||
assert_eq!(disk.flushes, 1, "unchanged settings are not written again");
|
||||
|
||||
// The next start reads them.
|
||||
let mut next = new_app();
|
||||
let stored = solidcraft_ui_egui::prefs::stored(|k| disk.flushed.get(k).cloned()).expect("settings on disk");
|
||||
next.load_prefs(&stored);
|
||||
assert_eq!(next.preferences.navigator_style, NavigatorStyle::Cube);
|
||||
assert_eq!(next.preferences.display_quality, "fine");
|
||||
assert!(disk.flushed.contains_key(STORAGE_KEY));
|
||||
}
|
||||
|
||||
/// A frame without input is checked only right after one with input (whose clicks the UI
|
||||
/// handled in that frame); after that, idle frames cost nothing. Pointer motion, which comes
|
||||
/// every frame while the mouse moves, doesn't count as input.
|
||||
#[test]
|
||||
fn frames_are_checked_with_input_and_once_after_it() {
|
||||
let now = Instant::now();
|
||||
let mut writer = PrefsWriter::default();
|
||||
assert!(!writer.due(false, now));
|
||||
assert!(writer.due(true, now));
|
||||
assert!(writer.due(false, now), "the frame after input");
|
||||
assert!(!writer.due(false, now));
|
||||
|
||||
let click = egui::Event::PointerButton {
|
||||
pos: egui::Pos2::ZERO,
|
||||
button: egui::PointerButton::Primary,
|
||||
pressed: false,
|
||||
modifiers: egui::Modifiers::NONE,
|
||||
};
|
||||
assert!(super::changes_settings(&click));
|
||||
assert!(super::changes_settings(&egui::Event::Text("5".into())));
|
||||
assert!(!super::changes_settings(&egui::Event::PointerMoved(egui::Pos2::ZERO)));
|
||||
assert!(!super::changes_settings(&egui::Event::MouseMoved(egui::Vec2::X)));
|
||||
}
|
||||
|
||||
/// A setting that changes in every frame (a panel's width while its edge is dragged) doesn't
|
||||
/// write the file in every frame: at most once a second, and the last change is on disk at
|
||||
/// most a second after it was made, with a frame asked for then so an idle app writes it.
|
||||
#[test]
|
||||
fn a_continuous_change_is_written_once_a_second_and_its_end_soon_after() {
|
||||
let mut app = new_app();
|
||||
let mut disk = Disk::default();
|
||||
let mut writer = PrefsWriter::read(app.prefs());
|
||||
let start = Instant::now();
|
||||
let frame = Duration::from_millis(16);
|
||||
let mut asked = None;
|
||||
// 1.5 s of dragging at 60 frames a second, with input in every frame.
|
||||
for n in 0..94u32 {
|
||||
let now = start + frame * n;
|
||||
app.preferences.browser_width = 200.0 + n as f32;
|
||||
assert!(writer.due(true, now));
|
||||
asked = writer.store(&mut disk, app.prefs(), now);
|
||||
}
|
||||
assert_eq!(disk.flushes, 2, "the first change at once, then one a second later");
|
||||
let wait = asked.expect("the last width waits, and a frame is asked for when it can be written");
|
||||
assert!(wait <= PrefsWriter::INTERVAL);
|
||||
|
||||
// The drag ends: no more input. Frames before the wait is over don't even look.
|
||||
let end = start + frame * 93;
|
||||
assert!(writer.due(false, end + frame), "the frame right after input");
|
||||
assert!(!writer.due(false, end + frame * 2), "an idle frame before the wait is over");
|
||||
// The frame asked for writes the last width.
|
||||
let then = end + wait;
|
||||
assert!(writer.due(false, then));
|
||||
assert_eq!(writer.store(&mut disk, app.prefs(), then), None);
|
||||
assert_eq!(disk.flushes, 3);
|
||||
let mut next = new_app();
|
||||
let stored = solidcraft_ui_egui::prefs::stored(|k| disk.flushed.get(k).cloned()).expect("settings on disk");
|
||||
next.load_prefs(&stored);
|
||||
assert_eq!(next.preferences.browser_width, 293.0, "the width the drag ended at");
|
||||
assert!(!writer.due(false, then + frame), "nothing waits any more");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod dialog_folder_tests {
|
||||
use super::DialogFolder;
|
||||
|
||||
Reference in New Issue
Block a user