diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index 2b3ada3a..1dea9db1 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -93,6 +93,7 @@ fn format_shape(app: &mut SlideApp, ui: &mut Ui) { return; }; let x = deckcraft_engine::cmd::xfrm_of(&st.doc, &st.selection, &sh); + let uid = st.uid; // Tabs as icons. ui.horizontal(|ui| { for (tab, icon, tip) in [ @@ -173,13 +174,14 @@ fn format_shape(app: &mut SlideApp, ui: &mut Ui) { fields(ui, "Vertical", "fy", x.y, "y", "shape.move", 72.0); ui.add_space(6.0); ui.label(egui::RichText::new("Alt Text").font(theme::bold(13.0))); - let id = ui.id().with(("alt", sh.id.0)); - let mut alt: String = ui.data_mut(|d| d.get_temp(id).unwrap_or_else(|| sh.descr.clone())); + let id = ui.id().with(("alt", uid, sh.id.0)); + let stored: Option<(String, String)> = ui.data_mut(|d| d.get_temp(id)); + let mut alt = widgets::text_draft(stored, &sh.descr); let r = ui.add(egui::TextEdit::multiline(&mut alt).desired_rows(3).desired_width(f32::INFINITY)); if r.lost_focus() && alt != sh.descr { run(app, "shape.altText", json!({"text": alt})); } - ui.data_mut(|d| d.insert_temp(id, alt)); + ui.data_mut(|d| d.insert_temp(id, (sh.descr.clone(), alt))); } "text" => { ui.label(egui::RichText::new("Text Box").font(theme::bold(13.0))); diff --git a/crates/ui-egui/src/selpane.rs b/crates/ui-egui/src/selpane.rs index 414a61f5..36e634c7 100644 --- a/crates/ui-egui/src/selpane.rs +++ b/crates/ui-egui/src/selpane.rs @@ -28,6 +28,18 @@ pub struct State { pub anchor: Option, /// Row being dragged and the drop it would make: (index among siblings, line y). pub drag: Option<(u32, Option<(usize, f32)>)>, + /// Document (by uid) the state above belongs to. + owner: Option, +} + +impl State { + /// Ties the pane state to a document: shape ids repeat between documents, so switching + /// documents drops the pending rename, folds, anchor and drag. + pub fn bind(&mut self, uid: u64) { + if self.owner != Some(uid) { + *self = State { owner: Some(uid), ..State::default() }; + } + } } /// One line of the list. @@ -108,6 +120,7 @@ fn small_icon_button(ui: &mut Ui, icon: Icon, label: &str, enabled: bool) -> egu pub fn show(app: &mut SlideApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); let Some(st) = app.session.active() else { return }; + app.selpane.bind(st.uid); let rows = rows(st.shapes(), &app.selpane.collapsed); let selected: Vec = st.selection.shapes.iter().map(|i| i.0).collect(); let has_sel = !selected.is_empty(); @@ -388,6 +401,19 @@ fn context_menu(app: &mut SlideApp, resp: &egui::Response, row: &Row) { mod tests { use super::*; + #[test] + fn switching_document_drops_a_pending_rename() { + let mut s = State::default(); + s.bind(1); + s.renaming = Some((5, "draft".to_string())); + s.collapsed.insert(2); + s.bind(1); + assert!(s.renaming.is_some(), "same document keeps the draft"); + s.bind(2); + assert!(s.renaming.is_none()); + assert!(s.collapsed.is_empty()); + } + fn r(id: u32, top: f32) -> (u32, Rect) { (id, Rect::from_min_size(pos2(0.0, top), vec2(100.0, ROW_H))) } diff --git a/crates/ui-egui/src/sorter.rs b/crates/ui-egui/src/sorter.rs index dd26dd85..a8e3054c 100644 --- a/crates/ui-egui/src/sorter.rs +++ b/crates/ui-egui/src/sorter.rs @@ -3,8 +3,8 @@ use egui::{Align2, Color32, CornerRadius, Rect, Sense, Stroke, Ui, pos2, vec2}; use serde_json::json; -use crate::SlideApp; use crate::theme::{self, Tokens}; +use crate::{SlideApp, widgets}; /// Grid of slides; click selects, double-click opens in Normal view, drag reorders. pub fn show(app: &mut SlideApp, ui: &mut Ui) { @@ -133,8 +133,9 @@ pub fn notes_page(app: &mut SlideApp, ui: &mut Ui) { let notes_rect = Rect::from_min_max(pos2(srect.min.x, srect.max.y + 24.0), pos2(srect.max.x, page.max.y - page_h * 0.06)); let mut child = ui.new_child(egui::UiBuilder::new().max_rect(notes_rect)); let current = st.current_slide().map(|s| s.notes_text()).unwrap_or_default(); - let id = egui::Id::new(("notespage", idx)); - let mut buf: String = child.data_mut(|d| d.get_temp(id).unwrap_or_else(|| current.clone())); + let id = egui::Id::new(("notespage", st.uid, idx)); + let stored: Option<(String, String)> = child.data_mut(|d| d.get_temp(id)); + let mut buf = widgets::text_draft(stored, ¤t); let r = child.add( egui::TextEdit::multiline(&mut buf) .hint_text("Click to add text") @@ -147,7 +148,7 @@ pub fn notes_page(app: &mut SlideApp, ui: &mut Ui) { if r.lost_focus() && buf != current { let _ = app.run("slide.notes", json!({"text": buf, "index": idx})); } - child.data_mut(|d| d.insert_temp(id, buf)); + child.data_mut(|d| d.insert_temp(id, (current, buf))); let nav = ui.input(|i| (i.key_pressed(egui::Key::PageDown), i.key_pressed(egui::Key::PageUp))); if nav.0 { let _ = app.run("slide.next", json!({})); diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 5827b396..a97e47fb 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -423,6 +423,15 @@ pub fn fmt_num(v: f64, suffix: &str) -> String { format!("{s}{suffix}") } +/// The text a kept-open text box shows: the typed draft while the stored value is still what the +/// draft started from, otherwise the stored value (another document, a reorder or an undo changed it). +pub fn text_draft(stored: Option<(String, String)>, current: &str) -> String { + match stored { + Some((base, buf)) if base == current => buf, + _ => current.to_string(), + } +} + /// Section header inside panes. pub fn section(ui: &mut Ui, title: &str, open: bool) -> bool { let t = Tokens::get(ui.ctx()); @@ -485,4 +494,19 @@ mod tests { assert_eq!(*picked.borrow(), Some(Some(ColorRef::rgb(Rgba::from_hex("00FF00").unwrap())))); assert!(harness.query_by_role(Role::TextInput).is_none(), "picking a colour closes the menu"); } + + /// #413: a draft is kept while the stored text is unchanged ... + #[test] + fn text_draft_keeps_typing_while_the_stored_text_is_unchanged() { + let stored = Some(("old".to_string(), "old plus typing".to_string())); + assert_eq!(text_draft(stored, "old"), "old plus typing"); + } + + /// ... and dropped when the stored text changes underneath it. + #[test] + fn text_draft_drops_a_stale_draft_when_the_stored_text_changes() { + let stored = Some(("old".to_string(), "old plus typing".to_string())); + assert_eq!(text_draft(stored, "undone"), "undone"); + assert_eq!(text_draft(None, "fresh"), "fresh"); + } }