1use super::document::{Received, StoredDocument};
4use super::{ConflictChoice, Storage, StoreKind};
5use crate::config::DrawingToolType;
6use crate::model::{ReplaySession, Timeframe, Watchlist};
7use serde::{Deserialize, Deserializer, Serialize};
8use std::collections::BTreeMap;
9use std::sync::Arc;
10use std::time::Duration;
11
12#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
17#[serde(default)]
18#[non_exhaustive]
19pub struct UserSettings {
20 pub theme: Option<String>,
22 pub symbol: Option<String>,
24 pub interval: Option<Timeframe>,
26 #[serde(deserialize_with = "known_tools")]
29 pub drawing_favorites: Vec<DrawingToolType>,
30 pub drawing_favorites_hidden: bool,
32 pub drawing_favorites_pos: Option<(f32, f32)>,
35 pub magnet_hint_dismissed: bool,
38 pub layout: Option<String>,
40 pub layout_autosave: bool,
42 pub layout_favorites: Vec<String>,
44 pub indicator_favorites: Vec<String>,
46 pub interval_favorites: Vec<Timeframe>,
48 pub drawing_defaults: BTreeMap<String, serde_json::Value>,
52 pub watchlists: Vec<Watchlist>,
54 pub market_heatmap: MarketHeatmapSelection,
56 pub replay_session: Option<(ReplaySession, Timeframe)>,
58}
59
60#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
64#[serde(default)]
65#[non_exhaustive]
66pub struct MarketHeatmapSelection {
67 pub universe: Option<String>,
69 pub size: Option<String>,
71 pub colour: Option<String>,
73}
74
75fn known_tools<'de, D: Deserializer<'de>>(
76 deserializer: D,
77) -> Result<Vec<DrawingToolType>, D::Error> {
78 Ok(Vec::<serde_json::Value>::deserialize(deserializer)?
79 .into_iter()
80 .filter_map(|tool| serde_json::from_value(tool).ok())
81 .collect())
82}
83
84#[derive(Serialize, Deserialize)]
85struct Document<T> {
86 version: u32,
87 #[serde(flatten)]
88 settings: T,
89}
90
91impl UserSettings {
92 pub const VERSION: u32 = 1;
94
95 pub fn to_json(&self) -> String {
97 serde_json::to_string(&Document {
98 version: Self::VERSION,
99 settings: self,
100 })
101 .expect("settings always serialize")
102 }
103
104 pub fn from_json(json: &str) -> Result<Self, serde_json::Error> {
108 let document: Document<serde_json::Value> = serde_json::from_str(json)?;
109 match document.version {
110 Self::VERSION => serde_json::from_value(document.settings),
111 version => Err(serde::de::Error::custom(format!(
112 "unsupported settings version {version}, expected {}",
113 Self::VERSION
114 ))),
115 }
116 }
117}
118
119pub struct SettingsStore {
127 storage: Arc<dyn Storage>,
128 document: StoredDocument,
129 written: Option<UserSettings>,
131 changed: Option<(UserSettings, f64)>,
133 restored: Option<UserSettings>,
135 error: Option<String>,
136}
137
138impl SettingsStore {
139 pub const DELAY: Duration = Duration::from_secs(1);
141
142 pub fn new(storage: Arc<dyn Storage>) -> Self {
144 let mut document = StoredDocument::open(&*storage, StoreKind::Settings, "settings");
145 document.follow_changes(&*storage);
146 Self {
147 document,
148 storage,
149 written: None,
150 changed: None,
151 restored: None,
152 error: None,
153 }
154 }
155
156 pub fn poll(&mut self, ctx: &egui::Context, current: &UserSettings) -> Option<UserSettings> {
162 self.document.hold(
163 self.written
164 .as_ref()
165 .is_some_and(|written| written != current),
166 );
167 let received = self.document.poll(&*self.storage, ctx);
168 self.receive(received);
169 if let Some(settings) = self.restored.take() {
170 self.changed = None;
171 ctx.request_repaint();
173 return Some(settings);
174 }
175 if self
176 .written
177 .as_ref()
178 .is_none_or(|written| written == current)
179 {
180 self.changed = None;
181 return None;
182 }
183 let now = ctx.input(|i| i.time);
184 let since = match &self.changed {
185 Some((settings, since)) if settings == current => *since,
186 _ => {
187 self.changed = Some((current.clone(), now));
188 now
189 }
190 };
191 let wait = Self::DELAY.as_secs_f64() - (now - since);
192 if wait > 0.0 && !self.document.conflicted() {
193 ctx.request_repaint_after(Duration::from_secs_f64(wait));
194 } else {
195 self.document.save(&*self.storage, current.to_json());
196 }
197 None
198 }
199
200 pub fn flush(&mut self, current: &UserSettings) -> bool {
206 if self.written.is_none() {
207 return false;
208 }
209 self.document.hold(self.written.as_ref() != Some(current));
211 let received = self.document.receive(&*self.storage);
212 self.receive(received);
213 if self.restored.is_some() {
214 return false;
215 }
216 if self
217 .written
218 .as_ref()
219 .is_some_and(|written| written != current || self.document.saving())
220 {
221 self.changed = None;
222 self.document.save(&*self.storage, current.to_json());
223 }
224 let received = self.document.flush(&*self.storage);
225 self.receive(received);
226 self.written.as_ref() == Some(current) && !self.document.saving()
227 }
228
229 fn receive(&mut self, received: Vec<Received>) {
231 for received in received {
232 match received {
233 Received::Loaded(document) => {
234 match document.as_deref().map(UserSettings::from_json).transpose() {
235 Ok(settings) => {
236 if settings.is_some() || self.written.is_some() {
238 self.restored = Some(settings.clone().unwrap_or_default());
239 }
240 self.written = Some(settings.unwrap_or_default());
241 }
242 Err(error) => {
243 self.document.refuse_writes();
244 self.error = Some(format!("Could not read settings: {error}"));
245 }
246 }
247 }
248 Received::Failed(error) => self.error = Some(error),
249 Received::Conflict => {}
251 Received::Saved(document) => {
252 self.written =
253 Some(UserSettings::from_json(&document).expect("saved settings"));
254 }
255 }
256 }
257 }
258
259 pub fn conflict(&self) -> bool {
263 self.document.conflicted() && !self.document.copying()
264 }
265
266 pub fn resolve_conflict(&mut self, choice: ConflictChoice) {
271 self.document.resolve(&*self.storage, choice);
272 }
273
274 pub fn take_error(&mut self) -> Option<String> {
276 self.error.take()
277 }
278}
279
280#[cfg(test)]
281mod tests {
282 use super::*;
283 use crate::data::Reply;
284 use crate::services::document::tests::Revised;
285 use std::sync::Mutex;
286
287 fn layout(name: &str) -> UserSettings {
289 UserSettings {
290 layout: Some(name.into()),
291 ..UserSettings::default()
292 }
293 }
294
295 fn poll_at(
297 ctx: &egui::Context,
298 store: &mut SettingsStore,
299 time: f64,
300 current: &UserSettings,
301 ) -> Option<UserSettings> {
302 let input = egui::RawInput {
303 time: Some(time),
304 ..Default::default()
305 };
306 let mut restored = None;
307 ctx.run_ui(input, |ui| restored = store.poll(ui.ctx(), current))
308 .drop_without_applying_deltas();
309 restored
310 }
311
312 #[test]
313 fn remote_changes_are_returned_but_never_replace_unsaved_settings() {
314 for flush in [false, true] {
316 let ctx = egui::Context::default();
317 let host = Arc::new(Revised::default());
318 host.put("settings", &layout("base").to_json());
319 let mut store = SettingsStore::new(host.clone());
320 host.put("settings", &layout("theirs").to_json());
322 assert_eq!(
323 poll_at(&ctx, &mut store, 0.0, &UserSettings::default()),
324 Some(layout("theirs"))
325 );
326 let mine = layout("mine");
328 if flush {
329 host.put("settings", &layout("newest").to_json());
330 store.flush(&mine);
331 } else {
332 assert_eq!(poll_at(&ctx, &mut store, 0.1, &mine), None);
333 host.put("settings", &layout("newest").to_json());
334 }
335 for time in [0.2, 1.2, 1.3] {
336 assert_eq!(poll_at(&ctx, &mut store, time, &mine), None, "{flush}");
337 }
338 assert!(store.conflict(), "{flush}");
339 let stored = UserSettings::from_json(&host.get("settings").unwrap()).unwrap();
340 assert_eq!(stored, layout("newest"), "{flush}");
341 }
342 }
343
344 #[test]
345 fn settings_read_before_the_next_poll_are_returned_and_not_written_over() {
346 for (deleted, reload, flush) in [
349 (true, true, false),
350 (false, true, true),
351 (false, false, true),
352 ] {
353 let ctx = egui::Context::default();
354 let host = Arc::new(Revised::default());
355 host.put("settings", &layout("base").to_json());
356 let mut store = SettingsStore::new(host.clone());
357 poll_at(&ctx, &mut store, 0.0, &UserSettings::default());
358 let current = layout(if reload { "mine" } else { "base" });
359 if deleted {
360 host.remove("settings");
361 } else {
362 host.put("settings", &layout("theirs").to_json());
363 }
364 if reload {
365 for time in [0.1, 1.2, 1.3] {
366 assert_eq!(poll_at(&ctx, &mut store, time, ¤t), None);
367 }
368 assert!(store.conflict());
369 store.resolve_conflict(ConflictChoice::ReloadRemote);
370 }
371 if flush {
372 store.flush(¤t);
373 }
374 let theirs = if deleted {
375 UserSettings::default()
376 } else {
377 layout("theirs")
378 };
379 let case = (deleted, reload, flush);
380 assert_eq!(
381 poll_at(&ctx, &mut store, 1.4, ¤t),
382 Some(theirs.clone()),
383 "{case:?}"
384 );
385 assert_eq!(poll_at(&ctx, &mut store, 3.0, &theirs), None, "{case:?}");
386 let stored = host
387 .get("settings")
388 .map(|json| UserSettings::from_json(&json).unwrap());
389 assert_eq!(stored, (!deleted).then_some(theirs), "{case:?}");
390 }
391 }
392
393 #[derive(Default)]
394 struct Host(Mutex<Option<String>>);
395
396 #[derive(Default)]
397 struct DelayedHost(Mutex<Vec<(String, Reply<()>)>>);
398
399 impl Storage for DelayedHost {
400 fn list(&self, _: StoreKind, _: Reply<Vec<String>>) {
401 unreachable!()
402 }
403 fn load(&self, _: StoreKind, _: &str, reply: Reply<Option<String>>) {
404 reply.send(Ok(None));
405 }
406 fn save(&self, _: StoreKind, _: &str, document: String, reply: Reply<()>) {
407 self.0.lock().unwrap().push((document, reply));
408 }
409 fn delete(&self, _: StoreKind, _: &str, _: Reply<()>) {
410 unreachable!()
411 }
412 }
413
414 #[test]
415 fn failed_saves_remain_dirty_and_pending_edits_can_return_to_stored_settings() {
416 let host = Arc::new(DelayedHost::default());
417 let mut store = SettingsStore::new(host.clone());
418 let ctx = egui::Context::default();
419 let original = UserSettings::default();
420 store.poll(&ctx, &original);
421 let mut current = original.clone();
422 current.symbol = Some("A".into());
423 for attempt in 0..3 {
424 store.flush(¤t);
425 store.flush(¤t);
426 let (json, reply) = host
427 .0
428 .lock()
429 .unwrap()
430 .pop()
431 .expect("dirty settings must be retried");
432 assert_eq!(UserSettings::from_json(&json).unwrap(), current);
433 if attempt < 2 {
434 reply.send(Err(crate::data::FeedError::Network("temporary".into())));
435 store.poll(&ctx, ¤t);
436 assert_eq!(store.take_error().is_some(), attempt == 0);
437 } else {
438 reply.send(Ok(()));
439 }
440 }
441 assert!(store.flush(¤t));
442 assert!(
443 host.0.lock().unwrap().is_empty(),
444 "confirmed settings need no write"
445 );
446 current.symbol = Some("B".into());
447 assert!(!store.flush(¤t));
448 let (_, reply) = host.0.lock().unwrap().pop().unwrap();
449 current.symbol = Some("A".into());
450 store.flush(¤t);
451 reply.send(Ok(()));
452 store.flush(¤t);
453 let (json, reply) = host.0.lock().unwrap().pop().unwrap();
454 assert_eq!(UserSettings::from_json(&json).unwrap(), current);
455 reply.send(Ok(()));
456 assert!(store.flush(¤t));
457 assert!(host.0.lock().unwrap().is_empty());
458 }
459
460 impl Storage for Host {
461 fn list(&self, _: StoreKind, reply: Reply<Vec<String>>) {
462 reply.send(Ok(Vec::new()));
463 }
464 fn load(&self, kind: StoreKind, name: &str, reply: Reply<Option<String>>) {
465 assert_eq!((kind, name), (StoreKind::Settings, "settings"));
466 reply.send(Ok(self.0.lock().unwrap().clone()));
467 }
468 fn save(&self, _: StoreKind, _: &str, document: String, reply: Reply<()>) {
469 *self.0.lock().unwrap() = Some(document);
470 reply.send(Ok(()));
471 }
472 fn delete(&self, _: StoreKind, _: &str, reply: Reply<()>) {
473 reply.send(Ok(()));
474 }
475 }
476
477 #[test]
478 fn settings_restore_once_wait_a_second_and_keep_unreadable_documents() {
479 let ctx = egui::Context::default();
480 let poll = |store: &mut SettingsStore, time: f64, current: &UserSettings| {
481 let input = egui::RawInput {
482 time: Some(time),
483 ..Default::default()
484 };
485 let mut restored = None;
486 ctx.run_ui(input, |ui| restored = store.poll(ui.ctx(), current))
487 .drop_without_applying_deltas();
488 restored
489 };
490 let host = Arc::new(Host::default());
491 let mut store = SettingsStore::new(host.clone());
492 let mut settings = UserSettings::default();
493 assert_eq!(poll(&mut store, 0.0, &settings), None);
494 settings.symbol = Some("AAPL".into());
495 settings.drawing_favorites = vec![DrawingToolType::TrendLine];
496 settings.market_heatmap.size = Some("cap".into());
497 settings.drawing_favorites_pos = Some((12.0, 34.0));
498 settings.magnet_hint_dismissed = true;
499 for time in [0.5, 1.0, 1.4] {
500 assert_eq!(poll(&mut store, time, &settings), None);
501 }
502 assert!(host.0.lock().unwrap().is_none());
503 poll(&mut store, 1.5, &settings);
504 let written = host.0.lock().unwrap().clone().unwrap();
505 assert!(written.contains("\"TrendLine\""));
506 assert_eq!(
507 UserSettings::from_json(&written)
508 .unwrap()
509 .market_heatmap
510 .size,
511 Some("cap".into())
512 );
513
514 settings.symbol = Some("MSFT".into());
516 store.flush(&settings);
517 let written = host.0.lock().unwrap().clone().unwrap();
518 assert_eq!(UserSettings::from_json(&written).unwrap(), settings);
519
520 let older = written.replace(",\"magnet_hint_dismissed\":true", "");
523 assert_ne!(older, written);
524 let mut expected = settings.clone();
525 expected.magnet_hint_dismissed = false;
526 assert_eq!(UserSettings::from_json(&older).unwrap(), expected);
527
528 let document = written.replace("\"TrendLine\"", "\"TrendLine\",\"FutureTool\"");
530 *host.0.lock().unwrap() = Some(document);
531 let mut store = SettingsStore::new(host.clone());
532 assert_eq!(
533 poll(&mut store, 0.0, &UserSettings::default()),
534 Some(settings.clone())
535 );
536
537 let newer = written.replacen("\"version\":1", "\"version\":2", 1);
538 *host.0.lock().unwrap() = Some(newer.clone());
539 let mut store = SettingsStore::new(host.clone());
540 assert_eq!(poll(&mut store, 0.0, &settings), None);
541 assert!(store.take_error().is_some());
542 poll(&mut store, 5.0, &UserSettings::default());
543 store.flush(&UserSettings::default());
544 assert_eq!(host.0.lock().unwrap().as_deref(), Some(newer.as_str()));
545 }
546}