Skip to main content

egui_charts/ui/dialogs/
storage_conflict.rs

1//! The dialogs that settle a write the host refused because the stored document changed, and
2//! that offer the copies kept of a chart's drawings.
3
4use crate::Chart;
5use crate::localization as l10n;
6use crate::services::ConflictChoice;
7use crate::theme::ThemeExt;
8use crate::ui_kit::buttons::Button;
9use crate::ui_kit::dialog::DialogFooter;
10use crate::ui_kit::modal::Modal;
11
12/// Asks how to settle a write the host refused because another client changed the document:
13/// keep both versions, reload the stored one or overwrite it. It has no close button and ignores
14/// Escape, since the unsaved document waits for a choice; Enter keeps both.
15///
16/// ```no_run
17/// # use egui_charts::services::SettingsStore;
18/// # use egui_charts::ui::dialogs::StorageConflictDialog;
19/// # fn frame(ctx: &egui::Context, settings: &mut SettingsStore) {
20/// if settings.conflict()
21///     && let Some(choice) = StorageConflictDialog::new(
22///         "Settings changed elsewhere",
23///         "Another window or device saved the settings while this window had unsaved changes.",
24///     )
25///     .show(ctx)
26/// {
27///     settings.resolve_conflict(choice);
28/// }
29/// # }
30/// ```
31pub struct StorageConflictDialog {
32    title: String,
33    message: String,
34}
35
36impl StorageConflictDialog {
37    /// A dialog titled `title` that describes the conflict with `message`, both in English and
38    /// translated when shown.
39    pub fn new(title: impl Into<String>, message: impl Into<String>) -> Self {
40        Self {
41            title: title.into(),
42            message: message.into(),
43        }
44    }
45
46    /// Shows the dialog; call it every frame while the conflict is unsettled. Returns the user's
47    /// choice in the frame it is made.
48    pub fn show(self, ctx: &egui::Context) -> Option<ConflictChoice> {
49        let mut overwrite = false;
50        let response = Modal::new(("storage_conflict", &self.title), &self.title)
51            .closable(false)
52            .show(ctx, &mut true, |ui| {
53                ui.label(l10n::text(ui.ctx(), &self.message));
54                ui.add_space(ui.active_theme().spacing.md);
55                for choice in [
56                    "Keep both saves your changes as a copy, then loads the other version.",
57                    "Reload loads the other version and discards your changes.",
58                    "Overwrite replaces the other version with your changes.",
59                ] {
60                    ui.label(l10n::text(ui.ctx(), choice));
61                }
62                DialogFooter::new("Keep both")
63                    .secondary("Reload")
64                    .show_with_left(ui, |ui| {
65                        overwrite = Button::new("Overwrite").ghost().show(ui).clicked();
66                    })
67            })?;
68        match response.inner {
69            (true, _) => Some(ConflictChoice::KeepLocalCopy),
70            (_, true) => Some(ConflictChoice::ReloadRemote),
71            _ => overwrite.then_some(ConflictChoice::Retry),
72        }
73    }
74}
75
76/// Offers the copies of a chart's drawings that "Keep both" in a [`StorageConflictDialog`] kept
77/// ([`ConflictChoice::KeepLocalCopy`]), for the symbol the chart shows: load one, which replaces
78/// the drawings on the chart as one step that undo reverts, or discard one once the user
79/// confirms. Open it with [`Self::open`], from a menu or after a copy was kept, and call
80/// [`Self::show`] every frame. The chart needs storage, set with [`Chart::set_storage`].
81///
82/// ```no_run
83/// # use egui_charts::Chart;
84/// # use egui_charts::ui::dialogs::LocalCopiesDialog;
85/// # fn frame(ctx: &egui::Context, chart: &mut Chart, copies: &mut LocalCopiesDialog, menu: bool) {
86/// if menu {
87///     copies.open(chart);
88/// }
89/// copies.show(ctx, chart);
90/// # }
91/// ```
92#[derive(Debug, Default)]
93pub struct LocalCopiesDialog {
94    open: bool,
95    /// The symbol whose copies were last asked for.
96    symbol: String,
97    /// The copy whose discard waits for the user's confirmation.
98    discarding: Option<String>,
99}
100
101impl LocalCopiesDialog {
102    /// Opens the dialog and asks `chart`'s storage for the kept copies of its symbol's drawings.
103    pub fn open(&mut self, chart: &mut Chart) {
104        self.open = true;
105        self.discarding = None;
106        self.symbol = chart.symbol().to_owned();
107        chart.list_local_copies();
108    }
109
110    /// Whether the dialog is shown.
111    pub fn is_open(&self) -> bool {
112        self.open
113    }
114
115    /// Shows the dialog while it is open; call it every frame. Loading a copy closes it, and
116    /// failures are reported through [`Chart::take_storage_error`].
117    pub fn show(&mut self, ctx: &egui::Context, chart: &mut Chart) {
118        if !self.open {
119            return;
120        }
121        let symbol = chart.symbol().to_owned();
122        // Another symbol shown while open, for example by a linked chart: list its copies.
123        if symbol != self.symbol {
124            self.discarding = None;
125            self.symbol.clone_from(&symbol);
126            chart.list_local_copies();
127        }
128        let copies = chart.local_copies().map(<[String]>::to_vec);
129        let (mut load, mut discard) = (None, None);
130        let discarding = &mut self.discarding;
131        Modal::new("local_copies", "Kept copies of drawings")
132            .width(440.0)
133            .show(ctx, &mut self.open, |ui| {
134                let values = [("symbol", symbol.clone())];
135                ui.label(l10n::format_text(
136                    ui.ctx(),
137                    "Copies of the {symbol} drawings kept when another window or device changed \
138                     them. Loading one replaces the drawings on the chart; Undo brings them back.",
139                    &values,
140                ));
141                ui.add_space(ui.active_theme().spacing.md);
142                let Some(copies) = &copies else {
143                    ui.label(l10n::text(ui.ctx(), "Looking for kept copies…"));
144                    return;
145                };
146                if copies.is_empty() {
147                    let text = "No copies of the {symbol} drawings are kept.";
148                    ui.label(l10n::format_text(ui.ctx(), text, &values));
149                }
150                egui::ScrollArea::vertical()
151                    .max_height(ui.available_height())
152                    .show(ui, |ui| {
153                        for name in copies {
154                            ui.add(egui::Label::new(name).truncate())
155                                .on_hover_text(name);
156                            if discarding.as_deref() == Some(name.as_str()) {
157                                let text = "Discard this copy? This cannot be undone.";
158                                ui.label(l10n::text(ui.ctx(), text));
159                                ui.horizontal(|ui| {
160                                    if Button::new("Discard").danger().show(ui).clicked() {
161                                        discard = Some(name.clone());
162                                    }
163                                    if Button::new("Cancel").ghost().show(ui).clicked() {
164                                        *discarding = None;
165                                    }
166                                });
167                            } else {
168                                ui.horizontal(|ui| {
169                                    if Button::new("Load").secondary().show(ui).clicked() {
170                                        load = Some(name.clone());
171                                    }
172                                    if Button::new("Discard").ghost().show(ui).clicked() {
173                                        *discarding = Some(name.clone());
174                                    }
175                                });
176                            }
177                        }
178                    });
179            });
180        if let Some(name) = discard {
181            self.discarding = None;
182            chart.discard_local_copy(&name);
183        }
184        if let Some(name) = load {
185            self.open = false;
186            chart.load_local_copy(&name);
187        }
188        if !self.open {
189            self.discarding = None;
190        }
191    }
192}
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197    use crate::drawings::persistence::DrawingStorage;
198    use crate::model::{Bar, BarData};
199    use crate::services::document::tests::Revised;
200    use chrono::{TimeZone, Utc};
201    use egui_kittest::{Harness, kittest::Queryable};
202    use std::sync::Arc;
203
204    /// Guards #1604: opening lists the kept copies, a discard waits for the user's confirmation,
205    /// and loading a copy closes the dialog.
206    #[test]
207    fn a_kept_copy_is_discarded_only_once_confirmed() {
208        let host = Arc::new(Revised::default());
209        let empty = DrawingStorage::new("A").to_json().unwrap();
210        for name in ["A", "A (local copy)", "A (local copy 2)"] {
211            host.put(name, &empty);
212        }
213        let bar = Bar::new(Utc.timestamp_opt(0, 0).unwrap(), 10.0, 11.0, 9.0, 10.0, 1.0);
214        let mut chart = Chart::new(BarData::from_bars(vec![bar]));
215        chart.set_symbol("A");
216        chart.set_storage(host.clone());
217        let mut dialog = LocalCopiesDialog::default();
218        dialog.open(&mut chart);
219        assert!(chart.flush_stored_drawings());
220        let mut h = Harness::builder()
221            .with_context_setup(|ctx| {
222                crate::theme::apply_to_egui(ctx, &crate::theme::Theme::dark());
223                egui_extras::install_image_loaders(ctx);
224            })
225            .build_ui_state(
226                |ui, state: &mut (LocalCopiesDialog, Chart)| state.0.show(ui.ctx(), &mut state.1),
227                (dialog, chart),
228            );
229        h.run();
230        // The first row's Discard, then the first Discard in the tree: its confirmation.
231        for confirmed in [false, true] {
232            h.query_all_by_label("Discard").next().unwrap().click();
233            h.run();
234            assert!(h.state_mut().1.flush_stored_drawings());
235            assert_eq!(host.get("A (local copy)").is_none(), confirmed);
236        }
237        // The modal centres on its new height a frame after the row left.
238        h.run_steps(2);
239        h.get_by_label("Load").click();
240        h.run();
241        assert!(!h.state().0.is_open());
242    }
243}