diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 0dcd5df0..c4b618d4 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -4,95 +4,25 @@ use super::services::{InboxEvent, Services}; use super::state::{FilePlace, FilePurpose, ImeDescriptorKey, ImeDestination, ImeTerminalStamp, LocatedPlace, RECENT_MAX, UiState, ViewMode}; use super::*; -fn purpose_of(purpose: &str) -> FilePurpose { - match purpose { - "open" => FilePurpose::Open, - "picture" => FilePurpose::Picture, - "audio" => FilePurpose::Audio, - "video" => FilePurpose::Video, - _ => FilePurpose::Infer, - } -} +mod clipboard; +mod commands; +mod control; +mod files; +mod frame; +mod ime; +mod input; +mod keys; +mod playback; -fn is_presentation_name(lower: &str) -> bool { - lower.ends_with(".deckcraft") - || lower.ends_with(".slidecraft") - || lower.ends_with(".pptx") - || lower.ends_with(".potx") - || lower.ends_with(".ppsx") -} - -/// File → Open keeps the requested operation: a presentation opens, an outline becomes slides. -fn open_command(name: &str) -> Option<&'static str> { - let lower = name.to_ascii_lowercase(); - if is_presentation_name(&lower) { - Some("file.openBytes") - } else if lower.ends_with(".txt") || lower.ends_with(".md") || lower.ends_with(".rtf") { - Some("slide.fromOutline") - } else { - None - } -} - -/// A drop has no requested operation, so the name and bytes decide: a presentation opens, -/// then a recognised picture, sound or video is inserted, then an outline becomes slides. -fn infer_command(name: &str, bytes: &[u8]) -> Option<&'static str> { - let lower = name.to_ascii_lowercase(); - if is_presentation_name(&lower) { - return Some("file.openBytes"); - } - let ct = deckcraft_engine::cmd::insert::content_type(name, bytes); - if ct.starts_with("image/") { - Some("insert.picture") - } else if ct.starts_with("audio/") { - Some("insert.audio") - } else if ct.starts_with("video/") { - Some("insert.video") - } else if lower.ends_with(".txt") || lower.ends_with(".md") || lower.ends_with(".rtf") { - Some("slide.fromOutline") - } else { - None - } -} - -fn command_for(purpose: FilePurpose, name: &str, bytes: &[u8]) -> Result<&'static str, String> { - let cmd = match purpose { - FilePurpose::Picture => Some("insert.picture"), - FilePurpose::Audio => Some("insert.audio"), - FilePurpose::Video => Some("insert.video"), - FilePurpose::ChangePicture => deckcraft_engine::cmd::insert::content_type(name, bytes).starts_with("image/").then_some("picture.change"), - FilePurpose::Open => open_command(name), - FilePurpose::Infer => infer_command(name, bytes), - }; - cmd.ok_or_else(|| format!("{name}: DeckCraft can't open this kind of file")) -} +#[cfg(test)] +pub(crate) use clipboard::{OBJECTS_MARKER, system_clipboard_text}; +#[cfg(test)] +pub(crate) use files::web_image_files; +pub use keys::key_name; /// How long a transient status message stays visible. const STATUS_LINGER_MS: f64 = 6000.0; -/// Text put on the system clipboard when the copied objects (a rectangle, a picture, slides…) -/// carry no text. egui-winit drops Ctrl+V when the system clipboard is empty, so without it the -/// shortcut never reached the session and only the context menu could paste objects (#182). An -/// invisible word joiner, so a paste into another app adds nothing visible. -pub(crate) const OBJECTS_MARKER: &str = "\u{2060}"; - -/// What a copy/cut leaves on the system clipboard for the session's plain-text rendering `t`. -pub(crate) fn system_clipboard_text(t: &str) -> String { - if t.is_empty() { OBJECTS_MARKER.to_string() } else { t.to_string() } -} - -fn grid_options_touched(p: &Value) -> bool { - ["spacing", "snap", "smartGuides", "gridlines", "guides"].iter().any(|k| p.get(*k).is_some()) -} - -fn grid_flag(p: &Value, key: &str) -> Result, String> { - match p.get(key) { - None => Ok(None), - Some(Value::Bool(b)) => Ok(Some(*b)), - Some(_) => Err(format!("`{key}` must be true or false")), - } -} - impl SlideApp { /// Build application UI state, taking ownership of audio output and initialising media, dialogs, /// synthetic input, and timing fields. @@ -213,66 +143,6 @@ impl SlideApp { } } - /// Does this recent path exist on disk? Probed once per path per session and only on - /// desktop (the web build has no file system to ask): a stat on a dead network share can - /// block for seconds, so it must not repeat on every frame. A stale answer is harmless — - /// opening still tries and reports, and a successful open re-probes. - pub fn recent_exists(&mut self, path: &str) -> bool { - #[cfg(target_arch = "wasm32")] - { - let _ = path; - true - } - #[cfg(not(target_arch = "wasm32"))] - { - if let Some(ok) = self.recent_checked.get(path) { - return *ok; - } - // A stat on a dead network location (UNC share, URL) can block for the OS timeout, - // so remote-looking paths are never probed: they stay unmarked and are judged when - // opened. A disconnected mapped drive would still stall once — accepted. - if path.starts_with("\\\\") || path.contains("://") { - return true; - } - let ok = std::path::Path::new(path).is_file(); - if self.recent_checked.len() > 64 { - let live: std::collections::HashSet<&String> = self.ui.recent.iter().collect(); - self.recent_checked.retain(|p, _| live.contains(p)); - } - self.recent_checked.insert(path.to_string(), ok); - ok - } - } - - /// Drop a path from the Recent list (its file is gone or the user asked for it). - pub fn forget_recent(&mut self, path: &str) { - self.ui.recent.retain(|p| p != path); - self.recent_checked.remove(path); - } - - /// Run a command by id (UI commands here, everything else in the engine). Errors show in the - /// status bar and are returned. - pub fn run(&mut self, id: &str, params: Value) -> Result { - let r = if UI_COMMANDS.iter().any(|c| c.0 == id) { - self.run_ui(id, ¶ms) - } else { - self.session.execute(id, ¶ms).map_err(|e| e.to_string()) - }; - match &r { - // Copy and Cut from the ribbon, menus, the slide's context menu or the control channel - // put the text on the system clipboard, as Ctrl+C and Ctrl+X do (#431). - Ok(v) if matches!(id, "edit.copy" | "edit.cut") => { - if let Some(t) = v.get("text").and_then(Value::as_str) { - self.pending_copy = Some(t.to_string()); - } - } - Ok(_) => {} - Err(e) => self.set_status(e.clone()), - } - self.drain_requests(); - r - } - /// Return the current document count plus the active document uid, so callers can detect a /// replacement document rather than only an index change. pub(crate) fn docs_key(&self) -> (usize, Option) { @@ -281,66 +151,6 @@ impl SlideApp { (self.session.documents().len(), self.session.active().map(|d| d.uid)) } - /// What a queued synthetic event is meant for. The control protocol hands out window - /// coordinates, so a document, slide, view, zoom or canvas-layout change between queueing and - /// the next UI pass makes the event stale. - pub(crate) fn input_target(&self) -> InputTarget { - let bits = |v: f32| if v.is_finite() { v.to_bits() } else { 0 }; - InputTarget { - documents: self.session.documents().len(), - active: self.session.active().map(|d| d.uid), - slide: self.session.active().map(|d| d.selection.slide), - view: self.ui.view, - zoom: self.ui.zoom.map(bits), - canvas: self.canvas_rect.map(|r| [bits(r.min.x), bits(r.min.y), bits(r.max.x), bits(r.max.y)]), - canvas_scale: bits(self.canvas_scale), - dialog: self.dialog.as_ref().map(|d| egui::Id::unique(("dialog", d.id.as_str()))), - palette: self.palette.as_ref().map(|(q, sel)| egui::Id::unique(("palette", q.as_str(), *sel))), - backstage: self.backstage.as_ref().map(|b| egui::Id::unique(("backstage", b.page.id()))), - ribbon_tab: egui::Id::unique(("ribbon-tab", self.ui.tab.as_str())), - ribbon_collapsed: self.ui.ribbon_collapsed, - ribbon_scroll: self.ribbon_scroll.get(&self.ui.tab).copied().unwrap_or(0), - show: self.show.as_ref().map(show::Show::input_state), - opening: self.opening.as_ref().map(|o| (egui::Id::unique(("opening", o.path.as_str())), o.generation)), - } - } - - /// Queue one synthetic control-channel event. The target is recorded when the batch starts, - /// before any host command in the same logic pass can change the document. - pub fn queue_input(&mut self, event: egui::Event) { - let target = self.input_target(); - // A batch that was already injected keeps the target it was injected for until its UI - // pass consumes it or retracts it. New events queued meanwhile belong to the current - // target, so they wait in a separate batch instead of joining the old one and being - // cleared with it when that batch turns out to be stale. - if self.synthetic_pending { - if self.synthetic_next_target != Some(target) { - self.synthetic_next.clear(); - self.synthetic_next_target = Some(target); - } - self.synthetic_next.push(event); - return; - } - if self.synthetic.is_empty() { - self.synthetic_target = Some(target); - } else if self.synthetic_target != Some(target) { - // The unsent queue belongs to a target that has already changed; drop it (and any - // older batch still waiting behind it) rather than appending a click intended for the - // new target to a stale batch. - self.synthetic.clear(); - self.synthetic_next.clear(); - self.synthetic_next_target = None; - self.synthetic_target = Some(target); - } - self.synthetic.push(event); - } - - /// Events admitted by the control channel but not yet injected (including a batch waiting - /// behind the one currently in flight). - pub(crate) fn synthetic_queued_len(&self) -> usize { - self.synthetic.len() + self.synthetic_next.len() - } - /// The File tab's view is open over a presentation (not just the start screen with nothing /// open). pub fn backstage_open(&self) -> bool { @@ -376,945 +186,6 @@ impl SlideApp { } } - /// File › Close and Ctrl+W: closes the active presentation, asking first when it has unsaved - /// changes (Save, Don't Save, Cancel), as PowerPoint does. - pub fn close_presentation(&mut self) { - match self.session.active() { - None => {} - Some(d) if d.is_dirty() => self.dialog = Some(dialogs::Dialog::new("close")), - Some(_) => { - let _ = self.run("file.close", json!({})); - } - } - } - - /// The file name a save or export suggests: the presentation's title with `ext`. - fn suggested_name(&self, ext: &str) -> String { - let name = self.session.active().map(|d| d.title()).unwrap_or_else(|| "Presentation".into()); - format!("{name}.{ext}") - } - - /// Exports the active presentation as `fmt` (`pdf`, `png`, `pngAll`, `jpeg`, `pptx`, - /// `deckcraft`, `outline`) through `file.export`: the save picker on the desktop, a download - /// on the web. `extra` adds `file.export` params (the PDF's layout, slides…). The - /// presentation keeps its own file. Returns whether a file was written or handed over. - pub fn export_as(&mut self, fmt: &str, extra: Value) -> bool { - let Some(slide) = self.session.active().map(|d| d.selection.slide) else { return false }; - let (ext, format, all) = match fmt { - "pdf" => ("pdf", "pdf", false), - "png" => ("png", "png", false), - "pngAll" => ("png", "png", true), - "jpeg" | "jpg" => ("jpg", "jpeg", false), - "pptx" => ("pptx", "pptx", false), - "outline" | "txt" => ("txt", "outline", false), - "rtf" => ("rtf", "rtf", false), - "deckcraft" => ("deckcraft", "deckcraft", false), - "gif" => ("gif", "gif", false), - other => { - self.set_status(format!("DeckCraft can't export “{other}”")); - return false; - } - }; - let suggested = self.suggested_name(ext); - if let Some(pick) = self.services.pick_save.as_mut() { - let Some(path) = pick(&suggested) else { return false }; - let mut params = json!({"path": path, "format": format, "all": all}); - if let (Some(o), Some(x)) = (params.as_object_mut(), extra.as_object()) { - o.extend(x.clone()); - } - return match self.run("file.export", params) { - Ok(_) => { - self.set_status(format!("Exported {path}")); - true - } - Err(_) => false, - }; - } - let Some(doc) = self.session.active().map(|d| d.doc.clone()) else { return false }; - let bytes = match format { - "png" | "jpeg" => { - // Every slide for "all slides", one download each, named like the desktop export (#64). - let name = self.session.active().map(|d| d.title()).unwrap_or_else(|| "Presentation".into()); - let mut any = false; - let transparent = extra.get("transparentBackground").and_then(Value::as_bool).unwrap_or(false); - for (file, bytes) in web_image_files(&doc, slide, all, &name, ext, transparent) { - any = self.download(&file, Ok(bytes)) || any; - } - return any; - } - "pdf" => deckcraft_engine::cmd::file::pdf_bytes(&doc, &extra), - "gif" => deckcraft_engine::cmd::file::gif_bytes(&doc, &extra), - other => deckcraft_engine::cmd::file::save_bytes(&doc, other), - }; - self.download(&suggested, bytes) - } - - /// Hands `bytes` to the browser as a download (web); false (with the reason in the status - /// bar) when there are no bytes or no way to download. - fn download(&mut self, name: &str, bytes: deckcraft_engine::Result>) -> bool { - match (bytes, self.services.download.as_mut()) { - (Ok(b), Some(dl)) => { - dl(name, &b); - true - } - (Err(e), _) => { - self.set_status(e.to_string()); - false - } - (Ok(_), None) => { - self.set_status("Saving a copy needs a file picker, which this window doesn't have."); - false - } - } - } - - /// File › Print: DeckCraft prints through a PDF. On the desktop the PDF (with the Print page's - /// layout, slides and options) is written to the temporary folder and opened in the system's - /// PDF viewer, which has the printers; on the web it downloads. - pub fn print_pdf(&mut self, params: Value) -> bool { - let Some((title, doc)) = self.session.active().map(|d| (d.title(), d.doc.clone())) else { return false }; - let mut params = if params.is_object() { params } else { json!({}) }; - if self.services.pick_save.is_none() { - // Web: the browser hands the PDF to the user. - let bytes = deckcraft_engine::cmd::file::pdf_bytes(&doc, ¶ms); - return self.download(&format!("{title}.pdf"), bytes); - } - let safe: String = title.chars().map(|c| if c.is_alphanumeric() || c == ' ' || c == '-' { c } else { '_' }).collect(); - let path = std::env::temp_dir().join(format!("{safe} (print).pdf")).to_string_lossy().to_string(); - if let Some(o) = params.as_object_mut() { - o.insert("path".into(), json!(path)); - o.insert("format".into(), json!("pdf")); - } - match self.run("file.export", params) { - Ok(_) => { - self.open_url(&format!("file:///{}", path.replace('\\', "/").trim_start_matches('/'))); - self.set_status("Opened the presentation as a PDF to print"); - true - } - Err(_) => false, - } - } - - fn flag(&mut self, params: &Value, cur: bool) -> bool { - params.get("on").and_then(Value::as_bool).unwrap_or(!cur) - } - - fn run_ui(&mut self, id: &str, p: &Value) -> Result { - match id { - "view.normal" => self.ui.view = ViewMode::Normal, - "view.outline" => self.ui.view = ViewMode::Outline, - "view.sorter" => self.ui.view = ViewMode::Sorter, - "view.notesPage" => self.ui.view = ViewMode::NotesPage, - "view.reading" => self.start_show(self.session.active().map(|d| d.selection.slide).unwrap_or(0), true), - "view.notes" => self.ui.notes = self.flag(p, self.ui.notes), - "view.ruler" => self.ui.ruler = self.flag(p, self.ui.ruler), - "view.gridlines" => self.ui.gridlines = self.flag(p, self.ui.gridlines), - "view.guides" => { - self.ui.guides = self.flag(p, self.ui.guides); - // A presentation without guides gets PowerPoint's two centre guides. - if self.ui.guides && self.session.active().is_some_and(|d| d.doc.guides.is_empty()) { - let _ = self.session.execute("guide.add", &json!({"defaults": true})); - } - } - "view.gridOptions" => { - if grid_options_touched(p) { - let spacing = match p.get("spacing") { - None => None, - Some(raw) => { - let n = raw.as_f64().filter(|v| v.is_finite()).ok_or("`spacing` must be a finite number of points")?; - if !(1.0..=288.0).contains(&n) { - return Err("`spacing` must be from 1 to 288 points".into()); - } - Some(n) - } - }; - let snap = grid_flag(p, "snap")?; - let smart = grid_flag(p, "smartGuides")?; - let gridlines = grid_flag(p, "gridlines")?; - let guides = grid_flag(p, "guides")?; - if let Some(n) = spacing { - self.session.prefs.grid_spacing = n; - } - if let Some(b) = snap { - self.session.prefs.snap_to_grid = b; - } - if let Some(b) = smart { - self.session.prefs.smart_guides = b; - self.ui.smart_guides = b; - } - if let Some(b) = gridlines { - self.ui.gridlines = b; - } - if let Some(b) = guides { - self.ui.guides = b; - // As View › Guides: a presentation without guides gets the two centre guides. - if b && self.session.active().is_some_and(|d| d.doc.guides.is_empty()) { - let _ = self.session.execute("guide.add", &json!({"defaults": true})); - } - } - } else { - self.dialog = Some(dialogs::Dialog::new("gridOptions")); - } - return Ok(json!({ - "spacing": self.session.prefs.grid_spacing, - "snap": self.session.prefs.snap_to_grid, - "smartGuides": self.session.prefs.smart_guides, - "gridlines": self.ui.gridlines, - "guides": self.ui.guides, - })); - } - "view.zoomIn" | "view.zoomOut" => { - let cur = self.ui.zoom.unwrap_or(self.canvas_scale); - let steps = [0.1, 0.25, 0.33, 0.5, 0.66, 0.75, 1.0, 1.25, 1.5, 2.0, 3.0, 4.0]; - let next = if id == "view.zoomIn" { - steps.iter().copied().find(|s| *s > cur + 0.01).unwrap_or(4.0) - } else { - steps.iter().rev().copied().find(|s| *s < cur - 0.01).unwrap_or(0.1) - }; - self.ui.zoom = Some(next); - } - "view.zoom" => { - let pct = p.get("percent").and_then(Value::as_f64).ok_or("missing `percent`")?; - self.ui.zoom = Some((pct as f32 / 100.0).clamp(0.1, 4.0)); - } - "view.zoomDialog" => self.dialog = Some(dialogs::Dialog::new("zoom")), - "view.fit" => self.ui.zoom = None, - "review.thesaurusPane" => { - let word = match p.get("word").and_then(Value::as_str) { - Some(w) => w.to_string(), - None => self - .session - .execute("review.thesaurus", &json!({})) - .ok() - .and_then(|r| r["word"].as_str().map(String::from)) - .unwrap_or_default(), - }; - self.thesaurus_query = word; - self.ui.pane = Some("thesaurus".into()); - } - "view.pane" => { - let pane = p.get("pane").and_then(Value::as_str).map(String::from); - self.ui.pane = - if pane.is_some() && pane == self.ui.pane && p.get("toggle").and_then(Value::as_bool).unwrap_or(false) { None } else { pane }; - if let Some(tab) = p.get("tab").and_then(Value::as_str) { - self.ui.format_tab = tab.to_string(); - } - } - "view.tab" => { - let tab = p.get("tab").and_then(Value::as_str).ok_or("missing `tab`")?; - self.ui.tab = tab.to_string(); - self.ui.ribbon_collapsed = false; - } - "view.collapseRibbon" => self.ui.ribbon_collapsed = !self.ui.ribbon_collapsed, - "view.dark" => { - let on = self.flag(p, self.ui.brightness == theme::Brightness::Dark); - self.ui.brightness = if on { theme::Brightness::Dark } else { theme::Brightness::Light }; - self.restyle = true; - } - "view.grayscale" => self.ui.grayscale = self.flag(p, self.ui.grayscale), - "view.newWindow" => return doc_window::command(self, p), - "show.start" => { - if let Some(name) = p.get("custom").and_then(Value::as_str).filter(|s| !s.is_empty()) { - self.start_custom_show(name, false); - } else { - let from = self.show_from(p)?.unwrap_or_else(|| self.show_start_index()); - self.start_show(from, false); - } - } - "show.presenter" => self.ui.presenter_view = self.flag(p, self.ui.presenter_view), - "show.end" => { - self.show = None; - self.media.stop_owned(media::Owner::Show); - } - "animation.preview" => { - if p.get("on").and_then(Value::as_bool) == Some(false) { - self.anim_preview = None; - return Ok(json!({"playing": false})); - } - let from = match p.get("from") { - None | Some(Value::Null) => None, - Some(v) => Some(v.as_u64().map(|v| usize::try_from(v).unwrap_or(usize::MAX)).ok_or("`from` must be an effect index")?), - }; - return self.start_anim_preview(from); - } - "app.home" => { - let on = self.flag(p, self.backstage_open()); - if on { - self.open_backstage(backstage::Page::Home); - } else { - self.close_backstage(); - } - } - "app.backstage" => { - if !p.get("on").and_then(Value::as_bool).unwrap_or(true) { - self.close_backstage(); - return Ok(json!({"page": self.backstage.as_ref().map(|b| b.page.id())})); - } - let page = match p.get("page").and_then(Value::as_str) { - Some(id) => backstage::Page::parse(id).ok_or_else(|| format!("unknown File page `{id}`"))?, - None => backstage::Page::Home, - }; - if page.needs_doc() && self.session.active().is_none() { - return Err(format!("File › {} needs an open presentation", page.label())); - } - self.open_backstage(page); - return Ok(json!({"page": page.id()})); - } - "app.closePresentation" => self.close_presentation(), - "app.print" => { - if self.session.active().is_none() { - return Err("nothing to print: no presentation is open".into()); - } - return Ok(json!({"printed": self.print_pdf(p.clone())})); - } - "app.about" => self.dialog = Some(dialogs::Dialog::new("about")), - "app.checkUpdates" => { - #[cfg(not(target_arch = "wasm32"))] - updates_ui::request_check(self); - #[cfg(target_arch = "wasm32")] - return Err("updates are handled by the browser: reload the page".into()); - } - "app.palette" => self.palette = Some((String::new(), 0)), - "app.preferences" => self.dialog = Some(dialogs::Dialog::new("preferences")), - "app.openDialog" => self.pick_and_open(), - "app.insertPictureDialog" => self.pick_and_insert("picture", "insert.picture"), - "app.smartArtPictureDialog" => { - if self.services.pick_open.is_none() { - return Err("picking a SmartArt picture needs the desktop app".into()); - } - self.pick_and_run("picture", "smartArt.picture", p); - } - "app.changePictureDialog" => self.pick_and_insert("picture", "picture.change"), - "app.insertAudioDialog" => self.pick_and_insert("audio", "insert.audio"), - "app.reuseSlidesDialog" => self.pick_and_reuse(p.get("keepFormatting").and_then(Value::as_bool).unwrap_or(false)), - "app.insertVideoDialog" => self.pick_and_insert("video", "insert.video"), - "app.saveAsDialog" => self.save_as_dialog("deckcraft"), - "app.exportDialog" => self.dialog = Some(dialogs::Dialog::new("export")), - "app.pasteSpecial" => self.dialog = Some(dialogs::Dialog::new("pasteSpecial")), - "app.browseThemeDialog" => { - if self.session.active().is_none() { - return Err("open a presentation first".into()); - } - if self.services.pick_open.is_some() { - self.pick_and_insert("theme", "design.browseTheme"); - } else { - // The browser's asynchronous picker would open the file as a new document. - self.set_status("Browse for Themes needs the desktop app for now".to_string()); - } - } - "app.qat" => return qat::command(self, p), - "app.find" | "app.replace" if self.session.active().is_some() => { - let mut d = dialogs::Dialog::new("find"); - d.fields.insert("replace".into(), if id == "app.replace" { "1" } else { "0" }.into()); - self.dialog = Some(d); - } - "app.find" | "app.replace" => self.palette = Some((String::new(), 0)), - "app.compareDialog" => { - if self.session.active().is_none() { - return Err("open the presentation to compare first".into()); - } - if self.services.pick_open.is_some() { - self.pick_and_insert("open", "review.compare"); - } else { - // The browser's asynchronous picker would open the file as a new document. - self.set_status("Compare needs the desktop app for now".to_string()); - } - } - "app.dialog" => { - let d = p.get("id").and_then(Value::as_str).ok_or("missing `id`")?; - self.dialog = Some(dialogs::Dialog::new(d)); - } - "app.links" => { - use deckcraft_engine::links::*; - return Ok(json!({"discord": DISCORD, "website": WEBSITE, "apps": APPS, "appPage": APP_PAGE, "github": GITHUB})); - } - _ => return Err(format!("unknown UI command `{id}`")), - } - Ok(Value::Null) - } - - /// Whether ⌘A should act on the slide list: always in the sorter, in Normal view once the - /// thumbnail pane has been clicked. - pub(crate) fn slides_list_focused(&self) -> bool { - self.ui.view == ViewMode::Sorter || (self.ui.view == ViewMode::Normal && self.slides_focused) - } - - /// First slide Play from Start should open: the custom show or range, skipping hidden slides. - pub fn show_start_index(&self) -> usize { - self.session.active().and_then(|d| deckcraft_anim::show_order(&d.doc).first().copied()).unwrap_or(0) - } - - /// The slide `show.start {from}` names: `Ok(None)` when absent, `Err` when it is not a slide of - /// the open deck (negative, non-integer, past the end), so a bad index never plays slide 0 or - /// opens on the end screen. Hidden slides are allowed, as in "from current slide". - pub(crate) fn show_from(&self, p: &Value) -> Result, String> { - let from = match p.get("from") { - None | Some(Value::Null) => return Ok(None), - Some(v) => v.as_u64().and_then(|v| usize::try_from(v).ok()).ok_or_else(|| format!("`from` is not a valid index: {v}"))?, - }; - let count = self.session.active().map_or(0, |d| d.doc.slides.len()); - if from >= count { - return Err(format!("no slide {from}")); - } - Ok(Some(from)) - } - - /// Leave animation preview, stop editor-owned media, and create a show at the requested slide - /// with the current reading/presenter configuration; an out-of-range slide starts an ended show. - pub fn start_show(&mut self, from: usize, reading: bool) { - self.anim_preview = None; - self.media.stop_owned(media::Owner::Editor); - if let Some(d) = self.session.active() { - self.show_instance = self.show_instance.wrapping_add(1).max(1); - let mut show = show::Show::new(&d.doc, from, reading, self.ui.presenter_view && !reading); - show.instance = self.show_instance; - self.show = Some(show); - } - } - - /// Play custom show `name` from its first slide. Set Up Slide Show is left as it was. - pub fn start_custom_show(&mut self, name: &str, reading: bool) { - let state = self.session.active().and_then(|d| deckcraft_anim::ShowState::play_custom(&d.doc, name)); - let Some(state) = state else { return }; - let from = state.slide; - self.start_show(from, reading); - if let Some(show) = self.show.as_mut() { - show.state = state; - } - } - - /// The current slide's animations are playing on the editor canvas. - pub fn anim_preview_active(&self) -> bool { - self.anim_preview.is_some() - } - - /// `(slide, seconds played, sequence length)` while a preview is running. - pub fn anim_preview_report(&self) -> Option<(usize, f64, f64)> { - self.anim_preview.as_ref().map(|p| (p.slide, p.elapsed, p.timeline.total_duration())) - } - - pub fn stop_anim_preview(&mut self) { - self.anim_preview = None; - } - - /// Timeline and playhead for this frame's canvas raster. `None` when the editor should draw the slide at rest. - pub fn anim_preview_draw(&self) -> Option<(deckcraft_anim::Timeline, f64)> { - self.anim_preview.as_ref().map(|p| (p.timeline.clone(), p.elapsed)) - } - - /// Advance the preview clock. Stops when the slide changes, the slide is edited, or the sequence - /// (plus a short hold) has finished. - pub fn tick_anim_preview(&mut self, now: f64) -> bool { - let Some(p) = self.anim_preview.as_ref() else { return false }; - let (uid, slide, ptr) = (p.uid, p.slide, p.slide_ptr); - let alive = self.show.is_none() - && self.session.active().is_some_and(|d| { - d.uid == uid - && d.selection.target == deckcraft_engine::Target::Slides - && d.selection.slide == slide - && d.doc.slides.get(slide).is_some_and(|s| std::sync::Arc::as_ptr(s) as usize == ptr) - }); - if !alive { - self.anim_preview = None; - return false; - } - let Some(p) = self.anim_preview.as_mut() else { return false }; - let now = if now.is_finite() { now } else { p.started.unwrap_or(0.0) }; - let start = *p.started.get_or_insert(now); - let elapsed = p.from + (now - start).max(0.0); - if !elapsed.is_finite() || elapsed > p.timeline.total_duration() + preview::HOLD_SECS { - self.anim_preview = None; - return false; - } - p.elapsed = elapsed; - true - } - - /// Play the current slide's animations on the canvas, from effect `from` (index into the slide's - /// animations) or from the start. - fn start_anim_preview(&mut self, from: Option) -> Result { - let (uid, slide, ptr, timeline) = { - let Some(d) = self.session.active() else { - return Err("no presentation is open".into()); - }; - if d.selection.target != deckcraft_engine::Target::Slides { - return Err("animation preview plays the current slide".into()); - } - let slide = d.selection.slide; - let Some(slide_arc) = d.doc.slides.get(slide) else { - return Err("no slide to preview".into()); - }; - let ptr = std::sync::Arc::as_ptr(slide_arc) as usize; - let timeline = deckcraft_anim::ShowState::timeline(&d.doc, slide); - (d.uid, slide, ptr, timeline) - }; - if timeline.is_empty() { - self.anim_preview = None; - self.set_status("This slide has no animations"); - return Ok(json!({"playing": false})); - } - let from_secs = match from { - Some(i) => timeline.start_of(i).ok_or_else(|| format!("no effect {i} in this slide's click sequence"))?, - None => 0.0, - }; - if matches!(self.ui.view, ViewMode::Sorter | ViewMode::NotesPage) { - self.ui.view = ViewMode::Normal; - } - self.close_backstage(); - let duration = timeline.total_duration(); - self.anim_preview = Some(AnimPreview { uid, slide, slide_ptr: ptr, started: None, from: from_secs, elapsed: from_secs, timeline }); - self.set_status("Previewing animations"); - Ok(json!({"playing": true, "slide": slide, "duration": duration, "from": from_secs})) - } - - pub(crate) fn pick_and_open(&mut self) { - if let Some(pick) = self.services.pick_open.as_mut() { - if let Some(path) = pick("open") { - self.open_path_in_background(&path); - } - } else if self.services.open_async.is_some() { - let id = self.begin_external_file("open"); - if let Some(f) = self.services.open_async.as_mut() { - f("open", id); - } - } - } - - pub fn open_path(&mut self, path: &str) -> Result<(), String> { - self.session.execute("file.open", &json!({"path": path})).map_err(|e| e.to_string())?; - self.note_recent(path); - Ok(()) - } - - /// Files the system hands over (the command line, a double-click through the file - /// association), opened in order. When the last one is a PowerPoint Show (`.ppsx`), its slide - /// show starts at once from where Play from Start begins, as PowerPoint does; anything else - /// opens for editing, and File › Open opens a `.ppsx` for editing too. Returns the files that - /// couldn't be opened, with the reason. - pub fn open_from_system(&mut self, paths: &[String]) -> Vec<(String, String)> { - let mut failed = Vec::new(); - let mut last = None; - for p in paths { - match self.open_path(p) { - Ok(()) => last = Some(p), - Err(e) => failed.push((p.clone(), e)), - } - } - if last.is_some_and(|p| p.to_ascii_lowercase().ends_with(".ppsx")) { - self.start_show(self.show_start_index(), false); - } - failed - } - - /// A presentation was opened from `path`: first in the recent list, shown in Normal view. - pub(crate) fn note_recent(&mut self, path: &str) { - self.status = None; // an older error (a missing file just before this one) must not linger - self.recent_checked.remove(path); - self.ui.recent.retain(|p| p != path); - self.ui.recent.insert(0, path.to_string()); - self.ui.recent.truncate(RECENT_MAX); - self.ui.view = ViewMode::Normal; - } - - /// Transitions › Sound › Other Sound…: pick an audio file for the selected slides. - pub(crate) fn pick_transition_sound(&mut self) { - if self.services.pick_open.is_some() { - self.pick_and_insert("audio", "transition.sound"); - } - } - - /// Home › New Slide › Reuse Slides…: pick a presentation and insert all its slides after the - /// current one (desktop only: the browser picker opens what it picks as a document). - fn pick_and_reuse(&mut self, keep: bool) { - let Some(pick) = self.services.pick_open.as_mut() else { - self.set_status("Reuse Slides needs the desktop app".to_string()); - return; - }; - let Some(path) = pick("open") else { return }; - // Read the file once: the list shown and the slides inserted come from the same bytes, even - // if the file changes while the dialog is open. Without a reader the path is used as is. - let mut source = json!({}); - match self.services.read.as_ref().map(|r| r(&path)) { - Some(Ok(bytes)) => { - let name = std::path::Path::new(&path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); - source["name"] = json!(name); - source["data"] = json!(deckcraft_engine::cmd::base64_encode(&bytes)); - } - Some(Err(e)) => { - self.set_status(e.to_string()); - return; - } - None => source["path"] = json!(path), - } - // Offer the file's slides to choose from; all of them are ticked to begin with. - match self.session.execute("slide.reuseList", &source) { - Ok(list) => { - let mut d = dialogs::Dialog::new("reuseSlides"); - d.params = json!({"path": path, "source": source, "keep": keep, "slides": list["slides"]}); - self.reuse_target = Some(self.capture_file_target()); - self.dialog = Some(d); - } - Err(e) => self.set_status(e.to_string()), - } - } - - /// Insert the slides `picked` (indices) of `source` (the `name` + `data`, or `path`, that - /// `slide.reuseList` listed) after the slide that was current when the picker opened, in that - /// presentation, even if another one is active now; if it is closed or the slide is gone, - /// nothing is inserted. Returns whether they were inserted. - pub fn reuse_slides(&mut self, source: &Value, keep: bool, picked: &[usize]) -> bool { - let mut args = if source.is_object() { source.clone() } else { json!({}) }; - args["keepFormatting"] = json!(keep); - args["slides"] = json!(picked); - let result = match self.reuse_target { - Some(target) if !self.viewing_target(&target) => self.execute_on_target(&target, "Reuse Slides", "slide.reuse", &args), - _ => self.session.execute("slide.reuse", &args).map(|_| ()).map_err(|e| e.to_string()), - }; - match result { - Ok(()) => { - self.reuse_target = None; - self.set_status(format!("Inserted {} slides", picked.len())); - true - } - Err(e) => { - self.set_status(e); - false - } - } - } - - pub(crate) fn pick_and_insert(&mut self, purpose: &str, cmd: &str) { - self.pick_and_run(purpose, cmd, &json!({})); - } - - /// Pick a file and run `cmd` with it (`name` + `data`, or `path`) added to `extra`. - fn pick_and_run(&mut self, purpose: &str, cmd: &str, extra: &Value) { - if let Some(pick) = self.services.pick_open.as_mut() { - if let Some(path) = pick(purpose) { - let mut args = if extra.is_object() { extra.clone() } else { json!({}) }; - let r = match self.services.read.as_ref().map(|r| r(&path)) { - Some(Ok(bytes)) => { - let name = std::path::Path::new(&path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); - args["name"] = json!(name); - args["data"] = json!(deckcraft_engine::cmd::base64_encode(&bytes)); - self.session.execute(cmd, &args) - } - _ => { - args["path"] = json!(path); - self.session.execute(cmd, &args) - } - }; - if let Err(e) = r { - self.set_status(e.to_string()); - } - } - } else if self.services.open_async.is_some() { - let id = self.begin_external_file(purpose); - // Change Picture: the picture that arrives replaces the selected one (#115). - if cmd == "picture.change" - && let Some(r) = self.pending_files.iter_mut().find(|r| r.id == id) - { - r.purpose = FilePurpose::ChangePicture; - } - if let Some(f) = self.services.open_async.as_mut() { - f(purpose, id); - } - } - } - - pub fn save_as_dialog(&mut self, format: &str) { - let name = self.session.active().map(|d| d.title()).unwrap_or_else(|| "Presentation".into()); - let ext = if format == "pptx" { "pptx" } else { "deckcraft" }; - let suggested = format!("{name}.{ext}"); - if let Some(pick) = self.services.pick_save.as_mut() { - if let Some(path) = pick(&suggested) { - match self.session.execute("file.saveAs", &json!({"path": path})) { - Ok(_) => { - self.set_status(format!("Saved {path}")); - self.ui.recent.retain(|p| *p != path); - self.ui.recent.insert(0, path); - } - Err(e) => self.set_status(e.to_string()), - } - } - } else if let Some(dl) = self.services.download.as_mut() - && let Some(d) = self.session.active() - { - match deckcraft_engine::cmd::file::save_bytes(&d.doc, ext) { - Ok(bytes) => dl(&suggested, &bytes), - Err(e) => self.set_status(e.to_string()), - } - } - } - - /// Save every presentation with unsaved changes (asking for a location where needed). - /// Returns false if one wasn't saved (cancelled or failed). - pub fn save_all(&mut self) -> bool { - let dirty: Vec = self.session.documents().iter().enumerate().filter(|(_, d)| d.is_dirty()).map(|(i, _)| i).collect(); - for i in dirty { - self.session.set_active(i); - self.save(); - if self.session.documents().get(i).is_some_and(|d| d.is_dirty()) { - return false; - } - } - true - } - - pub fn save(&mut self) { - if self.session.active().and_then(|d| d.path.clone()).is_some() { - match self.session.execute("file.save", &json!({})) { - Ok(_) => self.set_status("Saved"), - Err(e) => self.set_status(e.to_string()), - } - } else { - self.save_as_dialog("deckcraft"); - } - } - - /// Act on requests commands left for the UI. - fn drain_requests(&mut self) { - for r in std::mem::take(&mut self.session.ui_requests) { - match r { - UiRequest::Dialog { id, params } => { - let mut d = dialogs::Dialog::new(&id); - d.params = params; - self.dialog = Some(d); - } - UiRequest::Pane { id } => self.ui.pane = Some(id), - UiRequest::StartShow { from } => self.start_show(from, false), - UiRequest::Message { text } => self.set_status(text), - UiRequest::PickFile { purpose } => { - if purpose == "saveAs" { - self.save_as_dialog("deckcraft"); - } - } - UiRequest::Media { action, shape, ms } => { - let owner = if self.show.is_some() { media::Owner::Show } else { media::Owner::Editor }; - // The command resolved `shape` on the editor's slide, also while a show runs. - if let Some((uid, doc, slide)) = self.session.active().map(|d| (d.uid, d.doc.clone(), d.selection.slide)) { - self.media.request(uid, &doc, slide, &action, shape, ms, owner); - } - } - } - } - } - - pub(crate) fn drain_inbox(&mut self) { - let Some(inbox) = self.services.inbox.clone() else { return }; - let events = match inbox.lock() { - Ok(mut v) => std::mem::take(&mut *v), - Err(poisoned) => std::mem::take(&mut *poisoned.into_inner()), - }; - for event in events { - match event { - InboxEvent::Cancelled { id } => self.pending_files.retain(|r| r.id != id), - InboxEvent::File(file) => self.apply_external_file(file.id, &file.name, &file.bytes), - } - } - } - - /// Remember the open presentation, the slide (or master/layout) and the operation for a - /// file the host will read asynchronously. Returns the cancellation identity the host must - /// echo on the [`InboxEvent`]. Call this when the pick or the drop starts, not when the - /// bytes arrive. - pub fn begin_external_file(&mut self, purpose: &str) -> u64 { - let id = self.next_file_id; - self.next_file_id = self.next_file_id.wrapping_add(1).max(1); - self.pending_files.push(PendingFile { id, purpose: purpose_of(purpose), target: self.capture_file_target() }); - id - } - - /// A dropped or picked file read synchronously: presentations open, media is inserted on - /// the slide that is current now. - pub fn open_or_insert_bytes(&mut self, name: &str, bytes: &[u8]) { - let id = self.begin_external_file("drop"); - self.apply_external_file(id, name, bytes); - } - - fn capture_file_target(&self) -> FileTarget { - let Some(d) = self.session.active() else { return FileTarget::Absent }; - let place = match d.selection.target { - Target::Slides => match d.current_slide() { - Some(s) => FilePlace::Slide(s.id), - None => FilePlace::Unspecified, - }, - Target::Master { master } => match d.doc.masters.get(master) { - Some(m) => FilePlace::Master(m.id), - None => FilePlace::Unspecified, - }, - Target::Layout { master, layout } => match d.doc.masters.get(master) { - Some(m) => match m.layouts.get(layout) { - Some(l) => FilePlace::Layout { master: m.id, layout: l.id }, - None => FilePlace::Unspecified, - }, - None => FilePlace::Unspecified, - }, - t @ (Target::Handout | Target::NotesMaster) => FilePlace::NotesPage(t), - }; - FileTarget::Document { doc: d.uid, place } - } - - /// True when the user is still looking at the place this request captured. - fn viewing_target(&self, target: &FileTarget) -> bool { - let FileTarget::Document { doc, place } = target else { return false }; - let Some(d) = self.session.active() else { return false }; - if d.uid != *doc { - return false; - } - match place { - FilePlace::Slide(id) => d.selection.target == Target::Slides && d.current_slide().is_some_and(|s| s.id == *id), - FilePlace::Master(id) => { - matches!(d.selection.target, Target::Master { master } if d.doc.masters.get(master).is_some_and(|m| m.id == *id)) - } - FilePlace::Layout { master, layout } => { - matches!(d.selection.target, Target::Layout { master: mi, layout: li } - if d.doc.masters.get(mi).is_some_and(|m| m.id == *master && m.layouts.get(li).is_some_and(|l| l.id == *layout))) - } - FilePlace::NotesPage(t) => d.selection.target == *t, - FilePlace::Unspecified => false, - } - } - - fn apply_external_file(&mut self, id: u64, name: &str, bytes: &[u8]) { - let Some(pos) = self.pending_files.iter().position(|r| r.id == id) else { return }; - let req = self.pending_files.remove(pos); - let cmd = match command_for(req.purpose, name, bytes) { - Ok(cmd) => cmd, - Err(e) => { - self.set_status(e); - return; - } - }; - let params = if cmd == "slide.fromOutline" { - json!({"text": deckcraft_engine::cmd::file::outline_text(bytes), "markdown": name.to_ascii_lowercase().ends_with(".md")}) - } else { - json!({"name": name, "data": deckcraft_engine::cmd::base64_encode(bytes)}) - }; - let result = if cmd == "file.openBytes" || self.viewing_target(&req.target) { - self.session.execute(cmd, ¶ms).map(|_| ()).map_err(|e| e.to_string()) - } else { - self.execute_on_target(&req.target, name, cmd, ¶ms) - }; - match result { - Err(e) if cmd == "file.openBytes" => self.notify(e), - Err(e) => self.set_status(e), - Ok(()) => {} - } - self.drain_requests(); - } - - /// Run `cmd` on the document and slide captured for the request, then put the view back - /// if the user has since moved. A missing document or slide cancels the file. - fn execute_on_target(&mut self, target: &FileTarget, name: &str, cmd: &str, params: &Value) -> Result<(), String> { - let FileTarget::Document { doc, place } = target else { - self.session.execute("file.new", &json!({})).map_err(|e| e.to_string())?; - return self.session.execute(cmd, params).map(|_| ()).map_err(|e| e.to_string()); - }; - let Some(idx) = self.session.documents().iter().position(|d| d.uid == *doc) else { - return Err(format!("{name} was not applied: the presentation it was chosen for is closed")); - }; - let view = self.session.active_index(); - let saved = self.session.documents().get(idx).map(|d| d.selection.clone()); - self.session.set_active(idx); - let located = match self.locate_place(place) { - Ok(located) => located, - Err(e) => { - self.restore_file_view(view); - return Err(format!("{name} was not applied: {e}")); - } - }; - if let Some(st) = self.session.active_mut() { - st.selection.target = located.target; - if let Some(slide) = located.slide { - st.selection.slide = slide; - st.selection.slides.clear(); - } - st.selection.shapes.clear(); - st.selection.text = None; - st.selection.cells = None; - } - let result = self.session.execute(cmd, params).map(|_| ()).map_err(|e| e.to_string()); - if view == Some(idx) { - if let (Some(sel), Some(st)) = (saved, self.session.active_mut()) { - st.selection = sel; - } - } else { - self.restore_file_view(view); - } - result - } - - fn restore_file_view(&mut self, view: Option) { - if let Some(i) = view - && i < self.session.documents().len() - { - self.session.set_active(i); - } - } - - fn locate_place(&self, place: &FilePlace) -> Result { - let d = self.session.doc().map_err(|e| e.to_string())?; - match place { - FilePlace::Slide(id) => { - let slide = d.doc.slide_index(*id).ok_or_else(|| "the slide it was chosen for was deleted".to_string())?; - Ok(LocatedPlace { target: Target::Slides, slide: Some(slide) }) - } - FilePlace::Master(id) => { - let master = - d.doc.masters.iter().position(|m| m.id == *id).ok_or_else(|| "the slide master it was chosen for was deleted".to_string())?; - Ok(LocatedPlace { target: Target::Master { master }, slide: None }) - } - FilePlace::Layout { master, layout } => { - let mi = - d.doc.masters.iter().position(|m| m.id == *master).ok_or_else(|| "the slide master it was chosen for was deleted".to_string())?; - let li = d - .doc - .masters - .get(mi) - .and_then(|m| m.layouts.iter().position(|l| l.id == *layout)) - .ok_or_else(|| "the layout it was chosen for was deleted".to_string())?; - Ok(LocatedPlace { target: Target::Layout { master: mi, layout: li }, slide: None }) - } - FilePlace::NotesPage(t) => Ok(LocatedPlace { target: *t, slide: None }), - FilePlace::Unspecified => Err("there was no slide to put it on".into()), - } - } - - /// Keep a hidden show playing from logic alone. `run_logic` does not refresh - /// `InputState::time`, so the show anchors its last visible time to this monotonic instant - /// and advances from there. A forced hidden paint draws the current state but does not - /// advance it a second time, and no paused-show product policy is invented. - pub(crate) fn tick_hidden_show(&mut self, ctx: &egui::Context, now: web_time::Instant) { - let visible = show::viewport_visible(ctx); - let Some(doc) = self.session.active().map(|d| d.doc.clone()) else { return }; - let Some(show) = self.show.as_mut() else { return }; - if visible { - // A visible logic-only pass (`run_logic`) carries the last full frame's input time. - // Anchoring that stale time to `now` would move the hidden clock backwards if the - // window hides before the next full pass. `show::ui` already records the anchor on - // every visible full pass, where `InputState::time` is current. - return; - } - if !show.hidden_started() { - return; - } - let input_time = ctx.input(|i| i.time); - let show_time = show.hidden_logic_time(input_time, now); - let keep = show.advance_hidden(&doc, show_time); - let time_driven = show.hidden_time_driven(&doc); - if !keep { - self.show = None; - return; - } - if time_driven { - // A hidden event loop has no draw to piggyback on. A delayed repaint keeps the - // logic clock ticking; with no paint request pending eframe services it as a - // logic-only pass, not a visible frame. - ctx.request_repaint_after_for(std::time::Duration::from_millis(100), ctx.viewport_id()); - } - } - /// Background work, run on every logic call (visible, hidden or `run_logic`). /// /// Only consumed queues and monotonic deadlines live here: control requests, asynchronous @@ -1374,1209 +245,6 @@ impl SlideApp { } } - /// Style, fonts and zoom setup. Runs in a real UI pass, not from `run_logic`, because - /// `set_fonts`/`set_visuals` only take effect for the pass that lays out the UI. - fn ui_setup(&mut self, ctx: &egui::Context) -> bool { - let scale = self.ui.ui_scale.clamp(0.5, 3.0); - if (ctx.zoom_factor() - scale).abs() > 1e-3 { - ctx.set_zoom_factor(scale); - } - if !self.styled { - theme::install_fonts_with(ctx, &self.ui_fonts.faces); - self.styled = true; - self.restyle = true; - // Font definitions take effect at the next pass boundary. Do not paint a language - // or symbol with the old font set while the new set is still pending. - ctx.request_discard("install interface fonts before layout"); - return false; - } - if self.restyle { - theme::apply(ctx, &theme::Tokens::for_brightness(self.ui.brightness)); - self.restyle = false; - } - self.fonts_ready = true; - true - } - - /// AutoRecover on a monotonic deadline, even when nothing is painted. - fn autosave_if_due(&mut self, ctx: &egui::Context) { - if self.session.recovery_dir.is_none() { - return; - } - let every = (self.session.prefs.recovery_minutes * 60.0).max(10.0); - let now = monotonic_secs(); - if now - self.last_recovery > every { - self.last_recovery = now; - if self.session.documents().iter().any(|d| d.is_dirty()) - && let Err(e) = self.session.execute("file.recovery.save", &json!({})) - { - self.set_status(format!("Couldn't save AutoRecover information: {e}")); - } - } - if self.session.documents().iter().any(|d| d.is_dirty()) { - let wait = (every - (monotonic_secs() - self.last_recovery)).clamp(0.5, every); - ctx.request_repaint_after(std::time::Duration::from_secs_f64(wait)); - } - } - - /// Issue due screenshots and finish pass-local UI bookkeeping. Call once at the end of a real - /// pass after layout; the outer [`Self::ui`] wrapper observes IME output after this returns. - fn ui_end(&mut self, ctx: &egui::Context) { - self.ime_frame_viewport = None; - self.capture.observe_ui(ctx); - if !ctx.will_discard() { - let doc = self.session.active().map(|d| d.uid); - self.capture.issue(ctx, doc, web_time::Instant::now()); - } - if self.fonts_ready && !ctx.will_discard() { - self.synthetic_pending = false; - self.synthetic_injected.clear(); - self.synthetic_injected_at = None; - self.synthetic_injected_time = None; - self.synthetic_focus = None; - if self.synthetic.is_empty() { - self.synthetic_target = None; - } - self.promote_next_synthetic(); - } - if self.fonts_ready && !ctx.will_discard() { - let now = ctx.input(|i| i.time); - let dt = now - self.last_time; - if dt > 0.0 { - self.perf.fps = self.perf.fps * 0.9 + (1.0 / dt).min(240.0) * 0.1; - } - self.last_time = now; - } - } - - /// Borrow faces for interface text in scripts the interface fonts lack (#101): the open - /// presentations' names and text, recent files, and what is typed or composed this frame. - fn note_ui_text(&mut self, ctx: &egui::Context) { - let typed: String = ctx.input(|i| { - i.events - .iter() - .filter_map(|e| match e { - egui::Event::Text(t) | egui::Event::Paste(t) => Some(t.as_str()), - egui::Event::Ime(egui::ImeEvent::Preedit { text, .. } | egui::ImeEvent::Commit(text)) => Some(text.as_str()), - _ => None, - }) - .collect() - }); - self.ui_fonts.note(ctx, &typed); - for d in self.session.documents() { - self.ui_fonts.note(ctx, &d.title()); - } - for r in &self.ui.recent { - self.ui_fonts.note(ctx, r); - } - if let Some(st) = self.session.active() { - self.ui_fonts.note_doc(ctx, st); - } - self.ui_fonts.apply(ctx); - } - - /// Stable IME owner for the canvas text target: document, view, shape and table cell. - /// - /// The caret, selection, text, revision and geometry are deliberately absent, so ordinary - /// edits and caret movement keep the same owner. A new document, shape or cell is a new - /// owner; [`egui::ime_context::ImeTarget::replacement_epoch`] stays zero because that key - /// itself changes on replacement. `file.revert` keeps the ids, so a composition it settles is - /// covered by the settle interrupt and by [`Self::ime_event`]'s staleness check instead. - pub(crate) fn ime_target(&self) -> Option { - let st = self.session.active()?; - let (shape, cell, notes) = match &st.selection.text { - Some(t) => (t.shape, t.cell, t.notes), - None => (*st.selection.shapes.first()?, None, false), - }; - if st.selection.text.is_none() && st.selection.shapes.len() != 1 { - return None; - } - let target = match st.selection.target { - Target::Slides => (0u8, 0usize, 0usize), - Target::Master { master } => (1, master, 0), - Target::Layout { master, layout } => (2, master, layout), - Target::Handout => (3, 0, 0), - Target::NotesMaster => (4, 0, 0), - }; - let owner = egui::Id::unique(("deckcraft-ime", st.uid, target, shape.0, cell, notes)); - Some(egui::ime_context::ImeTarget::new(owner, 0)) - } - - /// The committed surrounding snapshot for the current edit target, or `Unavailable` when - /// there is an owner but no committed text to describe. - /// - /// An unavailable stamp carries the document revision. The tracker requires a strictly - /// increasing revision on both sides of a receipt that involves `Unavailable`; without it, a - /// wide selection or a commit that starts from a selected shape would poison the whole frame. - fn ime_receipt_stamp(&self) -> Option { - let st = self.session.active()?; - self.ime_target()?; - Some(st.selection.text.as_ref().and_then(|t| deckcraft_engine::cmd::text::ime_surrounding(st, t)).map_or_else( - || egui::ime_context::ImeMutationStamp::unavailable_at_revision(st.revision), - |s| egui::ime_context::ImeMutationStamp::from_committed(&s.text, s.cursor, s.anchor, None), - )) - } - - /// Report an application-origin edit in this frame. The tracker lets `Other` dominate any - /// earlier or later IME receipt in the same frame. - fn note_application_edit(&mut self, ctx: &egui::Context) { - let Some(viewport) = self.ime_frame_viewport else { return }; - if let Some(target) = self.ime_target() { - let frame = ctx.cumulative_frame_nr_for(viewport); - self.ime_tracker.note_application_change(viewport, frame, target); - } - } - - /// Record the expected selection after an accepted IME event. Its own deletion may move - /// the caret/revision; that is not an application interruption of the following commit. - fn refresh_ime_terminal_selection(&mut self) { - let state = self.session.active().and_then(|st| st.selection.text.as_ref().map(|t| (st.revision, t.anchor, t.caret))); - if let (Some(pending), Some((revision, anchor, caret))) = (&mut self.ime_terminal_pending, state) { - pending.revision = revision; - pending.anchor = anchor; - pending.caret = caret; - } - } - - /// The current live destination for an IME reset fence. - fn ime_destination(&self) -> Option { - let st = self.session.active()?; - let target = self.ime_target()?; - let (anchor, caret) = st.selection.text.as_ref().map_or((None, None), |t| (Some(t.anchor), Some(t.caret))); - Some(ImeDestination { owner: target.owner, revision: st.revision, anchor, caret }) - } - - /// Use the recorded IME viewport only while it matches the current context viewport. - fn ime_viewport_or(&self, ctx: &egui::Context) -> egui::ViewportId { - self.ime_frame_viewport.filter(|v| *v == ctx.viewport_id()).unwrap_or_else(|| ctx.viewport_id()) - } - - /// The reset guard belonging to exactly this viewport. There is deliberately no fallback to - /// another viewport's phase. - pub(crate) fn ime_reset_for(&self, viewport: egui::ViewportId) -> ImeResetPhase { - self.ime_reset.get(&viewport).copied().unwrap_or(ImeResetPhase::Clear) - } - - /// Store a non-clear reset phase for one viewport, or remove the entry when the phase is `Clear`. - pub(crate) fn set_ime_reset(&mut self, viewport: egui::ViewportId, phase: ImeResetPhase) { - if matches!(phase, ImeResetPhase::Clear) { - let _ = self.ime_reset.remove(&viewport); - } else { - self.ime_reset.insert(viewport, phase); - } - } - - /// The live stamp used to compare against the origin/interrupted terminal destination. - fn ime_terminal_stamp(&self) -> Option { - let st = self.session.active()?; - let (anchor, caret) = st.selection.text.as_ref().map_or((None, None), |t| (Some(t.anchor), Some(t.caret))); - Some(ImeTerminalStamp { owner: self.ime_target().map(|t| t.owner), revision: st.revision, anchor, caret }) - } - - /// Positive route predicate for the canvas IME producer: this pass must be one that can - /// actually paint `canvas::show` for the active document, with no modal, stock field or - /// overlay owning input. - /// - /// Sorter and Notes Page are editor views without a canvas producer, so they are named here - /// even though the retained selection still yields an `ime_target`. A hidden, unfocused or - /// closing window, a missing document/target, a focused stock field, modal dialog, palette, - /// Backstage, slide show and opening overlay lose admission too. The finalizer remains the - /// authority for this pass's actual descriptor; keeping this predicate positive means a view - /// added later is not admitted by omission simply because it does not call `canvas::show`. - pub(crate) fn ime_custom_canvas_route(&self, ctx: &egui::Context) -> bool { - matches!(self.ui.view, ViewMode::Normal | ViewMode::Outline | ViewMode::Reading) - && self.session.active().is_some() - && self.ime_target().is_some() - && ctx.input(|i| i.viewport().focused.unwrap_or(true)) - && ctx.input(|i| i.viewport().visible() != Some(false)) - && !ctx.input(|i| i.viewport().close_requested()) - && ctx.memory(|m| m.focused().is_none()) - && self.dialog.as_ref().is_none_or(|d| !d.modal()) - && self.palette.is_none() - && self.backstage.is_none() - && self.show.is_none() - && self.opening.is_none() - } - - /// The singleton app's session, composition tracker and terminal guard belong to its owning - /// viewport. View › New Window and Presenter View render through their own immediate - /// viewports and never call `logic`/`ui`; admitting such a callback would let a render-only - /// viewport mutate root composition state while only the reset map is per-viewport. Reject it - /// before any state changes instead of pretending to support multi-view editing. - fn root_viewport_admitted(ctx: &egui::Context) -> bool { - ctx.viewport_id() == egui::ViewportId::ROOT - } - - /// Report whether the current pass is a full UI pass for the IME-owning viewport rather than - /// standalone logic. - fn ime_in_ui_pass(&self, ctx: &egui::Context) -> bool { - let key = ime_in_ui_pass_key(self.ime_viewport_or(ctx)); - ctx.data(|d| d.get_temp::(key).unwrap_or(false)) - } - - /// A new application-origin interruption supersedes any previous pending fence. - /// - /// This runs from the logic half of a full pass and from raw input handling, before the - /// finalizer has seen this pass's actual custom descriptor. No request is allocated here: - /// putting a token into egui `Memory` now would let egui attach it to a later stock - /// descriptor, or to a canvas this route does not paint. Retire the superseded id - /// immediately and store an unbound `Suspended { request: None }`; only the finalizer may - /// allocate its one replacement after confirming an eligible custom output. - pub(crate) fn note_ime_interruption(&mut self, ctx: &egui::Context, fresh: bool) { - let viewport = self.ime_viewport_or(ctx); - if !fresh - && matches!( - self.ime_reset_for(viewport), - ImeResetPhase::Awaiting { viewport: guard_viewport, .. } - | ImeResetPhase::Suspended { viewport: guard_viewport, .. } - if guard_viewport == viewport - ) - { - // This viewport already has a live or suspended guard. The finalizer replaces it - // once if the actual destination changed; do not churn a request per event. - return; - } - self.set_ime_reset(viewport, ImeResetPhase::Suspended { viewport, needs_rearm: true, request: None }); - } - - /// Custom admission was lost on this route. Retire the old id without inventing an - /// acknowledgement and block until a later eligible pass can request a replacement. - fn suspend_ime_reset(&mut self, viewport: egui::ViewportId) { - if self.ime_reset_for(viewport).blocks_custom_input() { - self.set_ime_reset(viewport, ImeResetPhase::Suspended { viewport, needs_rearm: true, request: None }); - } - } - - /// An ordered completion for the exact newest request. Wrong/foreign/obsolete markers are - /// inert; this never mutates text, caret or undo history. - fn ime_reset_completed(&mut self, request_id: egui::ImeResetId, ctx: &egui::Context) { - let viewport = self.ime_viewport_or(ctx); - if request_id.viewport_id() != viewport { - return; - } - if let ImeResetPhase::Awaiting { id, viewport: waiting_viewport, .. } = self.ime_reset_for(viewport) - && id == request_id - && waiting_viewport == viewport - { - self.set_ime_reset(viewport, ImeResetPhase::Clear); - // The exact ordered completion is the platform-reset boundary. It also releases a - // cleared-preedit terminal guard: the app no longer has to wait for the old - // Commit/Enabled/Disabled before a fresh composition may start. Events ordered - // before this marker remain rejected; a late old terminal after the reset has the - // same documented residual as any other event crossing a platform reset. - if self.ime_terminal_pending.as_ref().is_some_and(|p| p.cancelled) { - self.ime_terminal_pending = None; - } - if self.ime_composition.is_some_and(|c| c.settled) { - self.ime_composition = None; - } - self.ime_cancelled = None; - self.ime_interrupt_pending = false; - } - } - - /// Controls-only routing for a route where the custom editor cannot mutate. Markers are - /// visited in raw order; stock text/key events stay untouched for the stock widgets. - fn route_ime_controls_only(&mut self, ctx: &egui::Context, events: &[egui::Event]) { - for e in events { - if let egui::Event::ImeResetCompleted { request_id } = e { - self.ime_reset_completed(*request_id, ctx); - } - } - } - - /// Bind a suspended reset to the actual custom descriptor, or retire it on loss. - pub(crate) fn finalize_ime_reset(&mut self, ctx: &egui::Context, custom: Option) { - let viewport = self.ime_viewport_or(ctx); - let phase = self.ime_reset_for(viewport); - let actual = ctx.output(|o| o.ime.as_ref().map(|i| ImeDescriptorKey::new(i.owner, i.context_generation, i.purpose))); - let destination = self.ime_destination(); - // The finalizer is the allocation authority, so it rechecks the positive canvas route - // and that this pass is not already known to be discarded. Sorter/Notes Page, a modal, - // palette, Backstage, show, opening overlay, hidden window, stock focus, font/layout - // discard or a missing target must suspend without putting a token into egui Memory. - let eligible = self.ime_custom_canvas_route(ctx) && !ctx.will_discard(); - let custom_output = match (eligible, custom, actual, destination) { - (true, Some(custom), Some(actual), Some(destination)) if custom == actual => Some((custom, destination)), - _ => None, - }; - let Some((output_context, destination)) = custom_output else { - match phase { - ImeResetPhase::Clear => {} - ImeResetPhase::Awaiting { .. } => { - self.set_ime_reset(viewport, ImeResetPhase::Suspended { viewport, needs_rearm: true, request: None }); - } - ImeResetPhase::Suspended { viewport: guard_viewport, request, .. } => { - // A request that was not bound to the actual custom output in this pass is - // retired locally; a later eligible activation obtains a fresh one. A guard - // stamped for another viewport (or a request issued for one) is never reused. - if guard_viewport != viewport || request.is_some() { - self.set_ime_reset(viewport, ImeResetPhase::Suspended { viewport, needs_rearm: true, request: None }); - } - } - ImeResetPhase::AllocationFailed { .. } => { - self.set_ime_reset(viewport, ImeResetPhase::Suspended { viewport, needs_rearm: true, request: None }); - } - } - return; - }; - match phase { - ImeResetPhase::Clear => {} - ImeResetPhase::Awaiting { viewport: old_viewport, destination: old_destination, output_context: old_context, .. } => { - if old_viewport != viewport || old_destination != destination || old_context != output_context { - self.request_ime_rearm(ctx, viewport, destination, output_context); - } - } - ImeResetPhase::Suspended { viewport: guard_viewport, request, needs_rearm } => { - let request = if needs_rearm && guard_viewport == viewport { - // Only a request issued for this exact viewport may be bound here. - request.filter(|id| id.viewport_id() == viewport) - } else { - None - }; - match request.or_else(|| ctx.request_ime_reset(viewport)) { - Some(id) => { - self.set_ime_reset(viewport, ImeResetPhase::Awaiting { id, viewport, destination, output_context }); - } - None => { - self.set_ime_reset(viewport, ImeResetPhase::AllocationFailed { viewport, destination, output_context }); - } - } - } - ImeResetPhase::AllocationFailed { viewport: old_viewport, destination: old_destination, output_context: old_context } => { - // Repeated idle output for the same destination does not retry allocation. - if old_viewport != viewport || old_destination != destination || old_context != output_context { - self.request_ime_rearm(ctx, viewport, destination, output_context); - } - } - } - } - - /// Request one replacement IME reset token and record `Awaiting` on success or - /// `AllocationFailed` when no token can be allocated. - fn request_ime_rearm(&mut self, ctx: &egui::Context, viewport: egui::ViewportId, destination: ImeDestination, output_context: ImeDescriptorKey) { - match ctx.request_ime_reset(viewport) { - Some(id) => self.set_ime_reset(viewport, ImeResetPhase::Awaiting { id, viewport, destination, output_context }), - None => self.set_ime_reset(viewport, ImeResetPhase::AllocationFailed { viewport, destination, output_context }), - } - } - - /// A standalone run_logic tick paints no custom geometry. Suspend once, without replaying - /// stale input; the next full UI pass can rearm from its actual descriptor. - fn finalize_ime_logic(&mut self, ctx: &egui::Context) { - let viewport = self.ime_viewport_or(ctx); - if self.ime_reset_for(viewport).blocks_custom_input() { - // Only this viewport's guard is touched. A root/child logic tick must not suspend or - // rebind another viewport's independent reset lifetime. - self.set_ime_reset(viewport, ImeResetPhase::Suspended { viewport, needs_rearm: true, request: None }); - } - } - - /// Note compositions the application settled since the last check (a click, a key, another - /// command). Those are announced to the platform as an interrupt in [`Self::observe_ime`], - /// and a platform event for them is stale from that moment on, even in the same pass. - pub(crate) fn note_settled_composition(&mut self, ctx: &egui::Context) { - let settles = self.session.composition_settles(); - let current_stamp = self.ime_terminal_stamp(); - // `file.close` then `file.new` can replace the active uid before any pass observes the - // empty document. That is not a safe boundary for the old terminal: the platform may - // still deliver its suffix. Keep the guard bound to its retired owner and treat the new - // destination as a fresh interruption; only exact reset completion or an observed - // teardown may clear the terminal metadata. - let pending_interrupted = self.ime_terminal_pending.as_ref().is_some_and(|p| { - let reference = if p.interrupted { - p.interrupted_at - } else { - Some(ImeTerminalStamp { owner: Some(p.owner), revision: p.revision, anchor: Some(p.anchor), caret: Some(p.caret) }) - }; - reference != current_stamp - }); - if settles == self.ime_settles_seen && !pending_interrupted { - return; - } - let fresh = settles != self.ime_settles_seen; - self.ime_settles_seen = settles; - if let Some(pending) = &mut self.ime_terminal_pending { - pending.cancelled = true; - pending.interrupted = true; - // Compare later passes against the destination where interruption actually happened, - // not forever against the retired composition's origin. The destination may be absent - // (the interruption closed its target); `interrupted` keeps that distinct from "not - // interrupted yet". - pending.interrupted_at = current_stamp; - } - self.ime_interrupt_pending = true; - self.note_ime_interruption(ctx, fresh); - let cancelled = self.ime_composition.as_mut().map(|c| { - c.settled = true; - (c.owner, c.generation) - }); - if let Some((owner, generation)) = cancelled { - // Retain a tombstone beyond the first stale event: clearing the composition must - // not let the rest of this input batch commit into the next target. `observe_ime` - // retires it in the pass that publishes the matching interrupt. - self.ime_cancelled = Some(ImeCancelled { owner, generation }); - } - } - - /// An input method's event for the slide's text (#101): the preedit is shown in the text and - /// replaced on each update; the commit replaces it as one undo step. - /// - /// An event only ever changes the target its composition began in. The application settles a - /// composition itself on a click, a key, a command or a target change, and the platform may - /// still hold it: pre-edits and commits queued for that generation in the settling input batch - /// are dropped (with [`egui::Memory::interrupt_ime`]) instead of being typed into whatever is - /// selected now. After the interrupt has been published the platform is reset, so the next - /// batch is treated as new input; egui's event payload has no generation, so an OS-queued - /// event that crosses that reset cannot be distinguished from a legitimate one. - /// - /// A receipt is captured immediately before and after a successful commit or - /// delete-surrounding mutation. Preedit, rejected/failed events and no-ops never earn - /// [`egui::output::ImeChangeCause::InputMethod`]. - pub(crate) fn ime_event(&mut self, ctx: &egui::Context, e: &egui::ImeEvent) { - self.note_settled_composition(ctx); - let viewport = self.ime_frame_viewport.unwrap_or_else(|| ctx.viewport_id()); - let frame = ctx.cumulative_frame_nr_for(viewport); - let target = self.ime_target(); - let owner = target.map(|t| t.owner); - let accepted = - matches!(e, egui::ImeEvent::Commit(_) | egui::ImeEvent::DeleteSurrounding { .. } | egui::ImeEvent::DeleteSurroundingBytes { .. }); - if self.reject_stale_ime_event(ctx, e, owner) { - return; - } - // Until the exact newest reset request completes at its ordered stream position, custom - // mutations stay blocked. Platform enable/disable signals are not acknowledgements and do - // not release this guard; they still settle the old composition below. - #[allow(deprecated)] - if self.ime_reset_for(viewport).blocks_custom_input() && !matches!(e, egui::ImeEvent::Disabled | egui::ImeEvent::Enabled) { - return; - } - let before = if accepted { self.ime_receipt_stamp() } else { None }; - let r = self.apply_ime_event(e); - self.settle_ime_event_state(e, owner, r.is_ok()); - if r.is_ok() && accepted { - self.refresh_ime_terminal_selection(); - } - if let Err(e) = &r { - self.set_status(e.to_string()); - } else if accepted - && let (Some(target), Some(before), Some(after)) = (target, before, self.ime_receipt_stamp()) - && before != after - && r.as_ref().is_ok_and(|v| !v.is_null() && v.get("handled").and_then(Value::as_bool) != Some(false)) - { - // A no-op receipt (an empty commit, a zero-length delete, same text reinserted) is - // not a mutation and must not poison the frame for a real commit later in the batch. - self.ime_tracker.note_input_method_apply(viewport, frame, target, before, after); - } - } - - /// Drop an event that belongs to a composition the application already settled, or to another - /// owner; returns `true` when the event must not be applied. - /// - /// A commit or deletion belongs to the composition that began the pre-edit: another owner - /// (the user clicked elsewhere) or a composition the app already settled makes it stale. - /// While the reset is still pending, the tombstone rejects pre-edits too (the platform has - /// not yet been told to cancel its copy). `observe_ime` retires the tombstone in the pass - /// that publishes the interrupt (or in an accepted no-descriptor pass, which resets the - /// backend just the same), so a later commit-only is not lost forever: it is a - /// legitimate direct commit or a new platform composition. egui's events carry no - /// generation, so an event queued by the OS across that reset is indistinguishable from - /// one the user just typed; only events in the same input batch as the settle are rejected - /// deterministically. A cleared-preedit terminal is a distinct case: its pending owner - /// remains guarded across that output boundary until its actual commit/enable/disable - /// arrives. Publishing a reset is not evidence that the separately queued terminal was - /// delivered. With no incoming event epoch, the first ambiguous new composition after an - /// unobserved reset may be rejected; no timer guesses that it became safe. - /// The tombstone is not filtered by the *current* target: a click can change the owner in - /// the settling pass, and every remaining event of that batch still belongs to the - /// pre-reset composition. Filtering would let the event after the first dropped pre-edit - /// start composing in the new target before the platform is reset. - fn reject_stale_ime_event(&mut self, ctx: &egui::Context, e: &egui::ImeEvent, owner: Option) -> bool { - let cancelled = self.ime_cancelled; - let pending_cancelled = self.ime_terminal_pending.as_ref().is_some_and(|p| p.cancelled); - #[allow(deprecated)] - let stale = !matches!(e, egui::ImeEvent::Disabled | egui::ImeEvent::Enabled) - && (self.ime_composition.is_some_and(|c| c.settled || Some(c.owner) != owner) || cancelled.is_some() || pending_cancelled); - if !stale { - return false; - } - if pending_cancelled && matches!(e, egui::ImeEvent::Commit(_)) { - // The old terminal finally arrived: reject it and release its pending owner now, - // so a new preedit later in this same raw batch can belong to the current target. - self.ime_terminal_pending = None; - self.ime_composition = None; - self.ime_cancelled = None; - self.ime_interrupt_pending = false; - return true; - } - if pending_cancelled { - // note_settled_composition already queued the reset. Do not also latch the - // frame's memory interrupt: a terminal later in this batch can consume it and - // permit a fresh preedit without resetting that new composition at pass end. - self.ime_composition = None; - return true; - } - if let Some(c) = cancelled { - log::debug!("IGNORING IME event for settled composition owner {:?} generation {}", c.owner, c.generation); - } - self.ime_composition = None; - // Ask the backend to drop its copy, so this is one stale event and not a stream. - ctx.memory_mut(|m| m.interrupt_ime()); - true - } - - /// Apply the document mutation an IME event asks for, before any receipt or tracker state. - fn apply_ime_event(&mut self, e: &egui::ImeEvent) -> deckcraft_engine::Result { - match e { - egui::ImeEvent::Preedit { text, active_range_chars } => { - self.session.compose_text(text, active_range_chars.as_ref().map(|r| (r.start, r.end))) - } - // A commit only replaces text that is being edited. It must never start editing a - // merely selected shape: without a tracked pre-edit, a stale platform commit would - // replace that shape's whole text. - egui::ImeEvent::Commit(text) => { - if self.session.active().is_some_and(|d| d.selection.text.is_some()) { - self.session.commit_text(text) - } else { - Ok(Value::Null) - } - } - egui::ImeEvent::DeleteSurrounding { before_chars, after_chars } => { - if self.session.active().is_some_and(|d| d.selection.text.is_some()) { - self.session.delete_surrounding(*before_chars, *after_chars, false) - } else { - Ok(Value::Null) - } - } - egui::ImeEvent::DeleteSurroundingBytes { before_bytes, after_bytes } => { - if self.session.active().is_some_and(|d| d.selection.text.is_some()) { - self.session.delete_surrounding(*before_bytes, *after_bytes, true) - } else { - Ok(Value::Null) - } - } - #[allow(deprecated)] - egui::ImeEvent::Disabled | egui::ImeEvent::Enabled => { - // The platform ended (or is about to restart) its composition itself. - self.session.end_composition(); - Ok(Value::Null) - } - } - } - - /// Update the tracked composition after `e` has been applied; `applied` says whether the - /// document mutation succeeded. - fn settle_ime_event_state(&mut self, e: &egui::ImeEvent, owner: Option, applied: bool) { - match e { - // The platform ends its composition on a commit (and on Disabled/Enabled): nothing is - // left to interrupt. A pre-edit keeps (or starts) the tracked composition. - egui::ImeEvent::Commit(_) => { - self.ime_terminal_pending = None; - self.ime_composition = None; - self.ime_cancelled = None; - self.ime_interrupt_pending = false; - } - #[allow(deprecated)] - egui::ImeEvent::Disabled | egui::ImeEvent::Enabled => { - // A platform-side disable/enable already reset the composition. Do not carry an - // older app-settled interrupt into the next (possibly new) composition. - self.ime_terminal_pending = None; - self.ime_composition = None; - self.ime_cancelled = None; - self.ime_interrupt_pending = false; - } - egui::ImeEvent::Preedit { text, .. } => { - match (owner, applied, self.session.active().is_some_and(|d| d.composition.is_some())) { - (Some(owner), true, true) => { - // Updating the same composition keeps its generation. After a settle (or - // a first pre-edit) this is a new generation and clears the tombstone. - let generation = match self.ime_composition { - Some(c) if c.owner == owner && !c.settled => c.generation, - _ => { - self.ime_generation = self.ime_generation.wrapping_add(1).max(1); - self.ime_generation - } - }; - self.ime_composition = Some(ImeComposition { owner, generation, settled: false }); - self.ime_terminal_pending = None; - self.ime_cancelled = None; - } - (Some(owner), true, false) if text.is_empty() && self.ime_composition.is_some_and(|c| !c.settled && c.owner == owner) => { - // The model restored committed text, but no terminal was delivered. Keep - // this owner across passes; a later pointer/key/target change must reject - // its separately queued old delete/commit, even after an outgoing reset. - if let Some(st) = self.session.active() - && let Some(t) = &st.selection.text - { - self.ime_terminal_pending = Some(ImeTerminalPending { - owner, - revision: st.revision, - anchor: t.anchor, - caret: t.caret, - cancelled: false, - interrupted: false, - interrupted_at: None, - }); - } - } - _ => { - self.ime_composition = None; - self.ime_terminal_pending = None; - } - } - } - _ => {} - } - } - - /// Observe the canvas IME owner once per pass and publish its descriptor. - /// - /// No canvas target, a Sorter/Notes Page view, a focused egui field, a modal, the palette, - /// the Backstage view, a slide show, an opening overlay, or an unfocused/hidden window means - /// the custom owner is hidden: the tracker is told so and the next focus starts a new - /// generation. The descriptor is rebuilt from the current committed text every pass. - /// - /// A composition the application settled itself is announced once as - /// `should_interrupt_composition`, so the backend cancels the platform's copy of it. That - /// settled pass is also the active-composition tombstone's lifecycle end. A cleared-preedit - /// terminal retains its separate pending owner until the incoming terminal is observed. - pub(crate) fn observe_ime(&mut self, ctx: &egui::Context) -> Option { - let viewport = self.ime_frame_viewport.unwrap_or_else(|| ctx.viewport_id()); - self.note_settled_composition(ctx); - // The editing window going away really ends the viewport's context; a merely hidden - // owner (modal, palette, unfocused field) stays observed as unfocused instead. - if self.session.active().is_none() || ctx.input(|i| i.viewport().close_requested()) { - self.ime_interrupt_pending = false; - if let Some(old) = self.ime_viewport.take() { - self.ime_tracker.end_viewport(old); - // Explicit viewport destruction removes only that viewport's guard. - let _ = self.ime_reset.remove(&old); - } - let _ = self.ime_reset.remove(&viewport); - self.ime_composition = None; - self.ime_cancelled = None; - self.ime_terminal_pending = None; - return None; - } - if !ctx.input(|i| i.viewport().focused.unwrap_or(true)) { - // The native window itself lost focus: no custom editor is eligible this pass, even - // if the model still has a text selection and a last-pass geometry rect. - self.ime_tracker.observe(viewport, None); - return None; - } - let Some(rect) = self.ime_pending.take() else { - // No canvas editor was painted in this pass. A discarded font/layout pass publishes - // nothing the platform sees, so keep the interrupt and tombstone for a later pass. - // An accepted pass is a backend reset only when it publishes *no* IME descriptor at - // all: a focused stock `TextEdit` (notes pane, dialog, palette field) publishes its - // own `o.ime` while `ime_pending` is `None`, and that descriptor is not a reset. Gate - // retirement on the actual committed output, not on the absence of the canvas rect; - // otherwise the next legitimate commit in the focused field would be rejected. - let no_descriptor = ctx.output(|o| o.ime.is_none()); - if !ctx.will_discard() && no_descriptor { - self.ime_interrupt_pending = false; - self.ime_cancelled = None; - if self.ime_composition.is_some_and(|c| c.settled) { - self.ime_composition = None; - } - } - self.ime_tracker.observe(viewport, None); - return None; - }; - if !self.ime_custom_canvas_route(ctx) { - // The canvas can still paint behind an opening overlay or a Sorter/Notes Page - // reroute, and a forced hidden paint can run after the window lost focus or - // visibility. The positive route and the UI wrapper both reject those, so a pending - // rect must not republish the custom owner. - self.ime_tracker.observe(viewport, None); - return None; - } - let Some(st) = self.session.active() else { - self.ime_interrupt_pending = false; - self.ime_tracker.observe(viewport, None); - return None; - }; - let Some(target) = self.ime_target() else { - self.ime_interrupt_pending = false; - self.ime_cancelled = None; - self.ime_tracker.observe(viewport, None); - return None; - }; - if self.ime_viewport != Some(viewport) - && let Some(old) = self.ime_viewport.replace(viewport) - { - self.ime_tracker.end_viewport(old); - let _ = self.ime_reset.remove(&old); - } - let committed = st.selection.text.as_ref().and_then(|t| deckcraft_engine::cmd::text::ime_surrounding(st, t)); - let mut observation = - egui::ime_context::ImeObservation::new(ctx.cumulative_frame_nr_for(viewport), ctx.cumulative_pass_nr_for(viewport), target) - .purpose(egui::IMEPurpose::Normal) - .committed(committed.as_ref().map(|s| egui::ime_context::CommittedText::new(&s.text, s.cursor, s.anchor))); - if committed.is_none() { - observation = observation.unavailable_revision(st.revision); - } - let update = self.ime_tracker.observe(viewport, Some(observation))?; - // Clear the interrupt only once it is part of a settled pass's output descriptor. A - // discarded font/layout pass publishes nothing the platform sees, so keep it pending; - // that pass is not the boundary at which stale pre-edits become a new generation. - let interrupt = if ctx.will_discard() { false } else { std::mem::take(&mut self.ime_interrupt_pending) }; - if interrupt { - // This pass's output carries the backend reset. The tombstone did its job for the - // events already processed in this batch; from the next batch on every pre-edit or - // commit is a new platform generation. A late event the OS queued across the reset - // has no generation in its payload, so it cannot be rejected without also dropping - // legitimate direct commits (documented residual in `ime_event`). - self.ime_cancelled = None; - if self.ime_composition.is_some_and(|c| c.settled) { - self.ime_composition = None; - } - } - let key = ImeDescriptorKey::new(update.owner, update.context_generation, update.purpose); - ctx.output_mut(|o| { - o.ime = Some(egui::output::IMEOutput { - owner: update.owner, - context_generation: update.context_generation, - purpose: update.purpose, - rect, - cursor_rect: rect, - should_interrupt_composition: interrupt, - reset_request: None, - change_cause: update.change_cause, - surrounding_text: update.surrounding_text, - }); - }); - Some(key) - } - - /// Forget the injected or queued synthetic batch and everything recorded about it. Events - /// waiting in `synthetic_next` are a separate, newer batch and are promoted by the caller. - fn clear_synthetic_batch(&mut self) { - self.synthetic.clear(); - self.synthetic_pending = false; - self.synthetic_injected.clear(); - self.synthetic_injected_at = None; - self.synthetic_injected_time = None; - self.synthetic_target = None; - self.synthetic_focus = None; - } - - /// Start the batch that was queued behind an older one once the older batch is gone. - fn promote_next_synthetic(&mut self) { - if self.synthetic_pending || !self.synthetic.is_empty() || self.synthetic_next.is_empty() { - return; - } - self.synthetic = std::mem::take(&mut self.synthetic_next); - self.synthetic_target = self.synthetic_next_target.take(); - self.synthetic_focus = None; - } - - /// A font/layout pass will be discarded: egui already consumed the injected events but the - /// app deliberately lays nothing out, so put them back at the head of the queue. The next - /// frame injects them again after the new fonts are installed; otherwise a control click that - /// happened to arrive on the very first frame would be acknowledged without ever reaching the - /// UI. - fn requeue_injected_synthetic(&mut self) { - if !self.synthetic_pending { - return; - } - let mut combined = std::mem::take(&mut self.synthetic_injected); - combined.append(&mut self.synthetic); - self.synthetic = combined; - self.synthetic_pending = false; - self.synthetic_injected_at = None; - self.synthetic_injected_time = None; - self.synthetic_focus = None; - if self.synthetic.is_empty() { - self.synthetic_target = None; - self.promote_next_synthetic(); - } - } - - /// Remove exactly the recorded synthetic events from `events`. The recorded index is tried - /// first so an identical host event earlier in the batch is never removed; the last exact - /// match is only a fallback for a raw input that was rewritten around the events. - fn drain_synthetic_from(&self, events: &mut Vec) -> bool { - let injected = &self.synthetic_injected; - let n = injected.len(); - if n == 0 { - return false; - } - let exact = self.synthetic_injected_at.filter(|start| events.get(*start..*start + n).is_some_and(|w| w == injected.as_slice())); - let at = exact.or_else(|| events.windows(n).rposition(|w| w == injected.as_slice())); - if let Some(at) = at { - events.drain(at..at + n); - true - } else { - false - } - } - - /// Drop an injected batch whose document, canvas, focus or modal owner changed during - /// `logic`. eframe calls `raw_input_hook` before `App::logic`, so a control request drained - /// there can still retarget the same `RawInput`. - /// - /// This must not infer the pass phase from `ViewportInfo::visible()`: a visible root can run - /// logic-only (an overdue redraw), and a hidden root can run a full pass for a screenshot or - /// `RequestPaintWhileHidden`. A logic-only callback leaves `InputState` from the *previous* - /// pass; remove the batch only when the live input carries exactly what this pass injected - /// (same `RawInput::time` and the exact recorded event window). Otherwise keep the pending - /// bookkeeping so the next `raw_input_hook` can retract the events from the carried - /// `RawInput` before eframe delivers them in a full pass. - fn retract_synthetic_after_logic(&mut self, ctx: &egui::Context) { - if !self.synthetic_pending { - return; - } - let current = self.input_target(); - let focus = ctx.memory(|m| m.focused()); - if self.synthetic_target == Some(current) && focus == self.synthetic_focus { - return; - } - let injected_time = self.synthetic_injected_time; - let mut removed = false; - ctx.input_mut(|i| { - // A logic-only call keeps the previous pass's `InputState`; its `raw.time` is not the - // time of the RawInput this batch was injected into. Never drain an older batch that - // happens to look similar. - if i.raw.time != injected_time { - return; - } - let mut events = std::mem::take(&mut i.events); - let ok = self.drain_synthetic_from(&mut events); - i.events = events; - if ok { - // Keep `InputState::raw` consistent with what UI code reads as `events`. - let mut raw_events = std::mem::take(&mut i.raw.events); - let _ = self.drain_synthetic_from(&mut raw_events); - i.raw.events = raw_events; - removed = true; - } - }); - if removed { - self.clear_synthetic_batch(); - self.promote_next_synthetic(); - } else { - // Logic-only (or a pass whose input does not contain the batch): the events are in - // eframe's carried RawInput. Keep `synthetic_pending`, `synthetic_injected` and the - // stale target so the next hook retracts them there. Events queued after the batch - // belong to the same stale target as well. - self.synthetic.clear(); - } - } - - /// True when the pending synthetic batch is in this pass's live `InputState`, i.e. this is - /// the full pass the batch was admitted for and egui has already derived pointer/key state - /// from it. A logic-only callback leaves the previous pass's `InputState` (with another - /// `raw.time`) and must not be mistaken for the live pass, so control/background work is - /// still drained there and the next `raw_input_hook` retracts a batch that went stale. - fn synthetic_batch_is_live(&self, ctx: &egui::Context) -> bool { - if !self.synthetic_pending || self.synthetic_injected.is_empty() { - return false; - } - let Some(injected_time) = self.synthetic_injected_time else { return false }; - let injected = &self.synthetic_injected; - ctx.input(|i| i.raw.time == Some(injected_time) && i.events.windows(injected.len()).any(|w| w == injected.as_slice())) - } - - /// Input that belongs to the pass about to run: native dropped files and one synthetic - /// control-channel batch. The synthetic batch is injected at most once; while it is - /// unacknowledged, later passes cannot replay it. A document, canvas, focus or modal change - /// drops the whole queued batch rather than typing into the wrong place. - /// - /// The hook always injects into the `RawInput`; it does not try to guess whether eframe will - /// run this as a full or a logic-only pass. `run_logic` does not consume events but eframe - /// carries the whole `RawInput` into the next full pass, so an already-injected batch is - /// retracted at the next hook if a control request changed the target, and otherwise is - /// delivered exactly once in the next real UI pass. `ViewportInfo::visible()` is not a phase - /// signal: a hidden root still gets a full pass for a screenshot or `RequestPaintWhileHidden`. - pub fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) { - #[cfg(not(target_arch = "wasm32"))] - for f in std::mem::take(&mut raw.dropped_files) { - let name = f.path().file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped file".into()); - match f.bytes() { - Ok(bytes) => self.open_or_insert_bytes(&name, &bytes), - Err(e) => self.set_status(e), - } - } - let focus = ctx.memory(|m| m.focused()); - if self.synthetic_pending { - let current = self.input_target(); - if self.synthetic_target != Some(current) || focus != self.synthetic_focus { - // eframe keeps the whole RawInput from a logic-only pass in `pending_raw_input` - // and appends the next host input to it. The events are already in `raw.events`; - // clearing only the unsent queue would still deliver the stale click or key into - // the new document on the next full pass. Retract exactly the batch we appended. - let _ = self.drain_synthetic_from(&mut raw.events); - self.clear_synthetic_batch(); - // New control input queued for the current target while the stale batch was in - // flight is a separate batch; keep it instead of clearing it with the old one. - self.promote_next_synthetic(); - } else { - // This RawInput is the carrier for the next pass, and eframe refreshes its time - // on every call. Record the current time so `retract_synthetic_after_logic` can - // tell this full pass's live `InputState` from a logic-only callback's stale one. - self.synthetic_injected_time = raw.time; - } - } - if self.synthetic_pending || self.synthetic.is_empty() { - return; - } - let current = self.input_target(); - if self.synthetic_target != Some(current) { - // The batch was queued for a document/slide/canvas that has since changed. - self.synthetic.clear(); - self.synthetic_target = None; - self.promote_next_synthetic(); - if self.synthetic.is_empty() { - return; - } - if self.synthetic_target != Some(current) { - // The newer batch is stale too: drop it rather than delivering it to this target. - self.synthetic.clear(); - self.synthetic_target = None; - self.synthetic_next.clear(); - self.synthetic_next_target = None; - return; - } - } - self.synthetic_generation = self.synthetic_generation.wrapping_add(1).max(1); - log::debug!("injecting synthetic input batch {}", self.synthetic_generation); - let n = match self.synthetic.first() { - Some(egui::Event::PointerMoved(_) | egui::Event::PointerButton { .. }) => 1, - _ => self.synthetic.iter().position(|e| matches!(e, egui::Event::Key { pressed: false, .. })).map_or(self.synthetic.len(), |i| i + 1), - }; - let n = n.min(self.synthetic.len()); - let batch: Vec = self.synthetic.drain(..n).collect(); - let at = raw.events.len(); - raw.events.extend(batch.iter().cloned()); - self.synthetic_injected = batch; - self.synthetic_injected_at = Some(at); - self.synthetic_injected_time = raw.time; - self.synthetic_pending = true; - self.synthetic_focus = focus; - } - - /// The app shortcuts that still make sense on the empty File/start screen: create, open, - /// the command palette, preferences and the File tab itself. Document commands (save, find, - /// hyperlink, show, zoom) stay out; none of them can act on a presentation that is not open. - fn start_screen_shortcut(key: egui::Key, m: Mods) -> bool { - use egui::Key::*; - matches!( - (key, m.cmd, m.shift, m.alt), - (O, true, false, false) | (P, true, true, false) | (Comma, true, false, false) | (F, false, false, true) - ) - } - - /// File-tab navigation and document-free shortcuts, also used on the start screen. - fn route_backstage_keys(&mut self, events: Vec) { - if self.backstage.is_none() { - return; - } - // The File tab: its own keys (Esc goes back, arrows walk the side menu) and app - // shortcuts (New, Open, Command Palette…); typing, pasting or deleting would edit the - // hidden presentation. - let start_screen = self.session.active().is_none(); - for e in events { - if let egui::Event::Key { key, pressed: true, modifiers, .. } = e { - let mods = Mods { shift: modifiers.shift, alt: modifiers.alt, cmd: modifiers.command || modifiers.ctrl }; - let handled = backstage::key(self, key, mods) - // With no presentation the File home only owns the shortcuts that create or - // open one. Borrowing the whole document shortcut table here would also arm - // Save/Save As, hyperlink, find/replace, show and zoom against the empty - // start screen, which is a scope expansion no editing route needs. - || (mods.cmd || mods.alt) - && (!start_screen || Self::start_screen_shortcut(key, mods)) - && menus::ui_shortcut(self, key, mods); - if handled { - continue; - } - // Engine shortcuts that don't touch the hidden slide (Ctrl+N). - if mods.cmd - && matches!(key, egui::Key::N) - && let Err(e) = self.session.key(key_name(key), mods) - { - self.set_status(e.to_string()); - } - } - } - } - - /// Keyboard and clipboard for the slide (when no text field has focus). - fn keyboard(&mut self, ctx: &egui::Context) { - let events = ctx.input(|i| i.events.clone()); - // Ctrl+V with only an image on the clipboard: egui-winit sends no Paste event and swallows the - // key press, so the release of a V press never seen is the paste. Tracked even while a text - // field has focus, so that a V typed there is not taken for one. - let mut image_paste = false; - let mut pasted_image_event = false; - for e in &events { - match e { - egui::Event::PasteImage(_) => { - pasted_image_event = true; - self.v_seen = true; - } - egui::Event::Paste(_) | egui::Event::Key { key: egui::Key::V, pressed: true, .. } => self.v_seen = true, - egui::Event::Key { key: egui::Key::V, pressed: false, modifiers, .. } => { - // Only a release with Command/Ctrl held is a paste: a bare V release is not. - let seen = std::mem::take(&mut self.v_seen); - image_paste = !seen && (modifiers.command || modifiers.ctrl); - } - _ => {} - } - } - let viewport = self.ime_viewport_or(ctx); - if self.session.active().is_none() { - // No document: keep slide-editing input suspended, while the start screen's - // File-tab navigation and document-free shortcuts remain available. - self.suspend_ime_reset(viewport); - self.route_ime_controls_only(ctx, &events); - if self.palette.is_none() && self.dialog.as_ref().is_none_or(|d| !d.modal()) && ctx.memory(|m| m.focused().is_none()) { - self.route_backstage_keys(events); - } - self.drain_requests(); - return; - } - let modal = self.dialog.as_ref().is_some_and(|d| d.modal()) || self.palette.is_some(); - // While slide text is being edited, the text owns the keyboard (#93). Tab and the arrow - // keys move egui's keyboard focus onto ordinary widgets (the window's drag handle, ribbon - // buttons), and that stray focus would swallow every later key and end text entry. Let it - // go; a real text field or an open menu keeps its keys. - if !modal - && self.session.active().is_some_and(|d| d.selection.text.is_some()) - && !ctx.text_edit_focused() - && !ctx.any_popup_open() - && let Some(id) = ctx.memory(|m| m.focused()) - { - ctx.memory_mut(|m| m.surrender_focus(id)); - } - if ctx.memory(|m| m.focused().is_some()) || modal { - // A stock field/modal/palette owns input now. Retire the custom fence, then visit - // controls in raw order; stock text/key events are left for the stock widgets. - self.suspend_ime_reset(viewport); - self.route_ime_controls_only(ctx, &events); - return; - } - if self.backstage.is_some() { - self.suspend_ime_reset(viewport); - self.route_ime_controls_only(ctx, &events); - self.route_backstage_keys(events); - self.drain_requests(); - return; - } - let img = if image_paste { self.services.clipboard_image.as_mut().and_then(|f| f()) } else { None }; - if let Some(png) = img { - let data = deckcraft_engine::cmd::base64_encode(&png); - if let Err(e) = self.session.execute("insert.picture", &json!({"name": "Pasted image.png", "data": data})) { - self.set_status(e.to_string()); - } - } - for e in events { - // An earlier command in this raw batch may have settled the composition. Request the - // replacement at this ordered boundary, before the current event is considered. - self.note_settled_composition(ctx); - match e { - egui::Event::ImeResetCompleted { request_id } => self.ime_reset_completed(request_id, ctx), - egui::Event::PasteImage(image) => match color_image_png(&image) { - Ok(png) => { - let params = json!({"name": "Pasted image.png", "data": deckcraft_engine::cmd::base64_encode(&png)}); - if let Err(e) = self.session.execute("insert.picture", ¶ms) { - self.set_status(e.to_string()); - } - } - Err(e) => self.set_status(e), - }, - egui::Event::Text(t) => { - let revision = self.session.active().map(|d| d.revision); - if self.session.active().is_some_and(|d| d.selection.text.is_some() || d.selection.shapes.len() == 1) - && let Err(e) = self.session.type_text(&t) - { - self.set_status(e.to_string()); - } - if self.session.active().map(|d| d.revision) != revision { - self.note_application_edit(ctx); - } - } - egui::Event::Ime(ime) => self.ime_event(ctx, &ime), - egui::Event::Copy => self.publish_clipboard(ctx, false), - egui::Event::Cut => self.publish_clipboard(ctx, true), - egui::Event::Paste(text) => { - // Our own marker for objects without text: paste what the session copied. - let text = if text == OBJECTS_MARKER { String::new() } else { text }; - // A picture (TIFF from Keynote, a screenshot) lands when the paste has no text. - // Text keeps its HTML (bold, italic, underline) when the host can read it. - let html = self.services.clipboard_html.as_mut().and_then(|f| f()).filter(|h| !h.is_empty()); - // egui 0.37 sends `PasteImage` directly; the services fallback stays for - // integrations that still send an empty `Paste`. Never insert twice. - let img = if text.is_empty() && !pasted_image_event { self.services.clipboard_image.as_mut().and_then(|f| f()) } else { None }; - let revision = self.session.active().map(|d| d.revision); - let r = match img { - Some(png) => self - .session - .execute("insert.picture", &json!({"name": "Pasted image.png", "data": deckcraft_engine::cmd::base64_encode(&png)})), - None => { - let mut params = json!({"text": text}); - if let Some(h) = html { - params["html"] = Value::String(h); - } - self.session.execute("edit.paste", ¶ms) - } - }; - if let Err(e) = r { - self.set_status(e.to_string()); - } - if self.session.active().map(|d| d.revision) != revision { - self.note_application_edit(ctx); - } - } - egui::Event::Key { key, pressed: true, modifiers, .. } => { - if key == egui::Key::Tab { - // The slide has the keyboard, so Tab is ours (indent, tab character, next - // shape). Left to egui it would also focus the first ribbon widget, and - // every key after it would go there instead of the slide (#78). - ctx.memory_mut(|m| m.move_focus(egui::FocusDirection::None)); - } - if key == egui::Key::Escape && self.anim_preview.is_some() && !modifiers.command && !modifiers.ctrl && !modifiers.alt { - self.anim_preview = None; - continue; - } - let mods = Mods { shift: modifiers.shift, alt: modifiers.alt, cmd: modifiers.command || modifiers.ctrl }; - if !self.session.tool.drawing_freeform() && menus::ui_shortcut(self, key, mods) { - continue; - } - let name = key_name(key); - let revision = self.session.active().map(|d| d.revision); - match self.session.key(name, mods) { - Ok(_) => {} - Err(e) => self.set_status(e.to_string()), - } - if self.session.active().map(|d| d.revision) != revision { - self.note_application_edit(ctx); - } - } - _ => {} - } - } - self.drain_requests(); - } - - /// Offer the copy or cut on the system clipboard. - /// - /// Slides go out as plain text. Text goes out as HTML plus the same plain words, so a paste - /// back into DeckCraft still uses the internal clipboard. A textless shape leaves the - /// invisible [`OBJECTS_MARKER`] rather than a picture: with only an image on the clipboard - /// egui-winit drops Ctrl+V, and the shortcut could no longer paste the shape here (#182). - fn publish_clipboard(&mut self, ctx: &egui::Context, cut: bool) { - let cmd = if cut { "edit.cut" } else { "edit.copy" }; - let Ok(v) = self.session.execute(cmd, &json!({})) else { return }; - let text = v.get("text").and_then(Value::as_str).unwrap_or("").to_string(); - let html = v.get("html").and_then(Value::as_str).unwrap_or("").to_string(); - if self.session.clipboard.slides.is_empty() && !html.is_empty() && self.services.clipboard_set_html.as_mut().is_some_and(|f| f(&html, &text)) - { - return; - } - ctx.copy_text(system_clipboard_text(&text)); - } - /// Lay out the whole window. /// /// Every return route, including fonts/layout discards, show mode, modal/Backstage and the @@ -2684,150 +352,7 @@ impl SlideApp { self.ui_end(&ctx); } - /// Media clocks. Voices belong to the open presentation: closing the last one, or switching - /// decks, stops them. The editor's media also stops when its slide is left. This runs from - /// `logic` on every call so a minimized window keeps playing; texture upload stays in `ui`. - fn tick_media_clock(&mut self, ctx: &egui::Context) { - let uid = self.session.active().map(|d| d.uid); - self.media.set_document(uid); - if !self.media.any_active() { - self.session.media_status.clear(); - return; - } - if self.show.is_none() - && let Some(d) = self.session.active() - { - let here: Vec = if d.selection.target == deckcraft_engine::Target::Slides { - media::slide_media(&d.doc, d.selection.slide).into_iter().map(|m| m.0).collect() - } else { - vec![] - }; - let gone: Vec<_> = - self.media.ids().into_iter().filter(|id| !here.contains(id) && self.media.owner(*id) == Some(media::Owner::Editor)).collect(); - for id in gone { - self.media.stop(id); - } - } - let playing = self.media.tick_clock(monotonic_secs(), &mut self.session); - if playing { - ctx.request_repaint_after(std::time::Duration::from_millis(15)); - } - } - pub fn open_url(&mut self, url: &str) { self.pending_urls.push(url.to_string()); } - - /// Drain pending control requests, executing ordinary requests and admitting screenshots for - /// later saving and reply. - fn drain_control(&mut self, ctx: &egui::Context) { - let Some(rx) = self.control_rx.take() else { return }; - while let Ok(req) = rx.try_recv() { - // A request whose sender gave up waiting (reply timeout) must not run late (#719). - if !req.take() { - continue; - } - let reply = req.reply.clone(); - let revision = self.session.active().map(|d| d.revision); - match control::handle(self, ctx, &req) { - control::Outcome::Done(v) => { - let _ = reply.send(v); - } - control::Outcome::Screenshot { path } => { - let doc = self.session.active().map(|d| d.uid); - if let Err(e) = self.capture.admit(path, reply.clone(), doc, web_time::Instant::now()) { - let _ = reply.send(json!({"ok": false, "error": e})); - } - } - } - if self.session.active().map(|d| d.revision) != revision { - self.note_application_edit(ctx); - } - } - self.control_rx = Some(rx); - } - - /// Save completed screenshots on the app thread and send exactly one reply each. - fn drain_screenshots(&mut self) { - let doc = self.session.active().map(|d| d.uid); - let completed = self.capture.drain(web_time::Instant::now(), doc); - for c in completed { - let v = control::save_screenshot(self, c.image.as_ref(), c.path.as_deref()); - // Keep the aggregate charged through RGBA/PNG/base64 encoding and the write. - self.capture.release(c.reserved); - let _ = c.reply.send(v); - } - } -} - -/// Engine key name for an egui key. -pub fn key_name(key: egui::Key) -> &'static str { - use egui::Key::*; - match key { - Enter => "Enter", - Escape => "Escape", - Backspace => "Backspace", - Delete => "Delete", - Tab => "Tab", - ArrowLeft => "Left", - ArrowRight => "Right", - ArrowUp => "Up", - ArrowDown => "Down", - Home => "Home", - End => "End", - PageUp => "PageUp", - PageDown => "PageDown", - Space => "Space", - F2 => "F2", - F5 => "F5", - F7 => "F7", - other => other.name(), - } -} - -/// Largest clipboard image accepted into the document: 16 megapixels, the same bound as -/// background removal. The image was already allocated by the integration; this keeps the -/// RGBA copy, PNG and base64 from multiplying an arbitrarily large paste before `insert.picture`. -const MAX_PASTE_IMAGE_PIXELS: usize = 16_000_000; - -/// Encode an egui color image as the PNG bytes `insert.picture` stores. -fn color_image_png(image: &egui::ColorImage) -> Result, String> { - let [w, h] = image.size; - let pixels = w.checked_mul(h).ok_or_else(|| "the pasted image is too large".to_string())?; - if pixels == 0 { - return Err("the pasted image is empty".into()); - } - if pixels > MAX_PASTE_IMAGE_PIXELS { - return Err(format!("the pasted image is {w}×{h} pixels; the limit is 16 megapixels")); - } - let rgba: Vec = image.pixels.iter().flat_map(|c| c.to_array()).collect(); - Ok(deckcraft_render::Image { width: w as u32, height: h as u32, pixels: rgba }.to_png()) -} - -/// Images for the browser download: the current slide, or every slide (named `1.png`, -/// `2.png`... like the native export) when `all`. `transparent` leaves the slide -/// background out (PNG only, #784). -pub(crate) fn web_image_files( - doc: &deckcraft_model::Presentation, - current: usize, - all: bool, - name: &str, - ext: &str, - transparent: bool, -) -> Vec<(String, Vec)> { - let slides: Vec = if all { (0..doc.slides.len()).collect() } else { vec![current] }; - let single = slides.len() == 1; - let scale = 2.0; - let dpi = deckcraft_render::dpi_for_scale(scale); - slides - .into_iter() - .enumerate() - .map(|(k, i)| { - let opts = deckcraft_render::RenderOpts { scale, transparent_background: transparent && ext == "png", ..Default::default() }; - let img = deckcraft_render::render_slide(doc, i, &opts); - let bytes = if ext == "png" { img.to_png_at(dpi) } else { img.to_jpeg_at(92, Some(dpi)) }; - let file = if single { format!("{name}.{ext}") } else { format!("{name}{}.{ext}", k + 1) }; - (file, bytes) - }) - .collect() } diff --git a/crates/ui-egui/src/app/clipboard.rs b/crates/ui-egui/src/app/clipboard.rs new file mode 100644 index 00000000..10778afc --- /dev/null +++ b/crates/ui-egui/src/app/clipboard.rs @@ -0,0 +1,32 @@ +use super::*; + +/// Text put on the system clipboard when the copied objects (a rectangle, a picture, slides…) +/// carry no text. egui-winit drops Ctrl+V when the system clipboard is empty, so without it the +/// shortcut never reached the session and only the context menu could paste objects (#182). An +/// invisible word joiner, so a paste into another app adds nothing visible. +pub(crate) const OBJECTS_MARKER: &str = "\u{2060}"; + +/// What a copy/cut leaves on the system clipboard for the session's plain-text rendering `t`. +pub(crate) fn system_clipboard_text(t: &str) -> String { + if t.is_empty() { OBJECTS_MARKER.to_string() } else { t.to_string() } +} + +impl SlideApp { + /// Offer the copy or cut on the system clipboard. + /// + /// Slides go out as plain text. Text goes out as HTML plus the same plain words, so a paste + /// back into DeckCraft still uses the internal clipboard. A textless shape leaves the + /// invisible [`OBJECTS_MARKER`] rather than a picture: with only an image on the clipboard + /// egui-winit drops Ctrl+V, and the shortcut could no longer paste the shape here (#182). + pub(super) fn publish_clipboard(&mut self, ctx: &egui::Context, cut: bool) { + let cmd = if cut { "edit.cut" } else { "edit.copy" }; + let Ok(v) = self.session.execute(cmd, &json!({})) else { return }; + let text = v.get("text").and_then(Value::as_str).unwrap_or("").to_string(); + let html = v.get("html").and_then(Value::as_str).unwrap_or("").to_string(); + if self.session.clipboard.slides.is_empty() && !html.is_empty() && self.services.clipboard_set_html.as_mut().is_some_and(|f| f(&html, &text)) + { + return; + } + ctx.copy_text(system_clipboard_text(&text)); + } +} diff --git a/crates/ui-egui/src/app/commands.rs b/crates/ui-egui/src/app/commands.rs new file mode 100644 index 00000000..8f0e462f --- /dev/null +++ b/crates/ui-egui/src/app/commands.rs @@ -0,0 +1,276 @@ +use super::*; + +fn grid_options_touched(p: &Value) -> bool { + ["spacing", "snap", "smartGuides", "gridlines", "guides"].iter().any(|k| p.get(*k).is_some()) +} + +fn grid_flag(p: &Value, key: &str) -> Result, String> { + match p.get(key) { + None => Ok(None), + Some(Value::Bool(b)) => Ok(Some(*b)), + Some(_) => Err(format!("`{key}` must be true or false")), + } +} + +impl SlideApp { + /// Run a command by id (UI commands here, everything else in the engine). Errors show in the + /// status bar and are returned. + pub fn run(&mut self, id: &str, params: Value) -> Result { + let r = if UI_COMMANDS.iter().any(|c| c.0 == id) { + self.run_ui(id, ¶ms) + } else { + self.session.execute(id, ¶ms).map_err(|e| e.to_string()) + }; + match &r { + // Copy and Cut from the ribbon, menus, the slide's context menu or the control channel + // put the text on the system clipboard, as Ctrl+C and Ctrl+X do (#431). + Ok(v) if matches!(id, "edit.copy" | "edit.cut") => { + if let Some(t) = v.get("text").and_then(Value::as_str) { + self.pending_copy = Some(t.to_string()); + } + } + Ok(_) => {} + Err(e) => self.set_status(e.clone()), + } + self.drain_requests(); + r + } + + fn flag(&mut self, params: &Value, cur: bool) -> bool { + params.get("on").and_then(Value::as_bool).unwrap_or(!cur) + } + + fn run_ui(&mut self, id: &str, p: &Value) -> Result { + match id { + "view.normal" => self.ui.view = ViewMode::Normal, + "view.outline" => self.ui.view = ViewMode::Outline, + "view.sorter" => self.ui.view = ViewMode::Sorter, + "view.notesPage" => self.ui.view = ViewMode::NotesPage, + "view.reading" => self.start_show(self.session.active().map(|d| d.selection.slide).unwrap_or(0), true), + "view.notes" => self.ui.notes = self.flag(p, self.ui.notes), + "view.ruler" => self.ui.ruler = self.flag(p, self.ui.ruler), + "view.gridlines" => self.ui.gridlines = self.flag(p, self.ui.gridlines), + "view.guides" => { + self.ui.guides = self.flag(p, self.ui.guides); + // A presentation without guides gets PowerPoint's two centre guides. + if self.ui.guides && self.session.active().is_some_and(|d| d.doc.guides.is_empty()) { + let _ = self.session.execute("guide.add", &json!({"defaults": true})); + } + } + "view.gridOptions" => { + if grid_options_touched(p) { + let spacing = match p.get("spacing") { + None => None, + Some(raw) => { + let n = raw.as_f64().filter(|v| v.is_finite()).ok_or("`spacing` must be a finite number of points")?; + if !(1.0..=288.0).contains(&n) { + return Err("`spacing` must be from 1 to 288 points".into()); + } + Some(n) + } + }; + let snap = grid_flag(p, "snap")?; + let smart = grid_flag(p, "smartGuides")?; + let gridlines = grid_flag(p, "gridlines")?; + let guides = grid_flag(p, "guides")?; + if let Some(n) = spacing { + self.session.prefs.grid_spacing = n; + } + if let Some(b) = snap { + self.session.prefs.snap_to_grid = b; + } + if let Some(b) = smart { + self.session.prefs.smart_guides = b; + self.ui.smart_guides = b; + } + if let Some(b) = gridlines { + self.ui.gridlines = b; + } + if let Some(b) = guides { + self.ui.guides = b; + // As View › Guides: a presentation without guides gets the two centre guides. + if b && self.session.active().is_some_and(|d| d.doc.guides.is_empty()) { + let _ = self.session.execute("guide.add", &json!({"defaults": true})); + } + } + } else { + self.dialog = Some(dialogs::Dialog::new("gridOptions")); + } + return Ok(json!({ + "spacing": self.session.prefs.grid_spacing, + "snap": self.session.prefs.snap_to_grid, + "smartGuides": self.session.prefs.smart_guides, + "gridlines": self.ui.gridlines, + "guides": self.ui.guides, + })); + } + "view.zoomIn" | "view.zoomOut" => { + let cur = self.ui.zoom.unwrap_or(self.canvas_scale); + let steps = [0.1, 0.25, 0.33, 0.5, 0.66, 0.75, 1.0, 1.25, 1.5, 2.0, 3.0, 4.0]; + let next = if id == "view.zoomIn" { + steps.iter().copied().find(|s| *s > cur + 0.01).unwrap_or(4.0) + } else { + steps.iter().rev().copied().find(|s| *s < cur - 0.01).unwrap_or(0.1) + }; + self.ui.zoom = Some(next); + } + "view.zoom" => { + let pct = p.get("percent").and_then(Value::as_f64).ok_or("missing `percent`")?; + self.ui.zoom = Some((pct as f32 / 100.0).clamp(0.1, 4.0)); + } + "view.zoomDialog" => self.dialog = Some(dialogs::Dialog::new("zoom")), + "view.fit" => self.ui.zoom = None, + "review.thesaurusPane" => { + let word = match p.get("word").and_then(Value::as_str) { + Some(w) => w.to_string(), + None => self + .session + .execute("review.thesaurus", &json!({})) + .ok() + .and_then(|r| r["word"].as_str().map(String::from)) + .unwrap_or_default(), + }; + self.thesaurus_query = word; + self.ui.pane = Some("thesaurus".into()); + } + "view.pane" => { + let pane = p.get("pane").and_then(Value::as_str).map(String::from); + self.ui.pane = + if pane.is_some() && pane == self.ui.pane && p.get("toggle").and_then(Value::as_bool).unwrap_or(false) { None } else { pane }; + if let Some(tab) = p.get("tab").and_then(Value::as_str) { + self.ui.format_tab = tab.to_string(); + } + } + "view.tab" => { + let tab = p.get("tab").and_then(Value::as_str).ok_or("missing `tab`")?; + self.ui.tab = tab.to_string(); + self.ui.ribbon_collapsed = false; + } + "view.collapseRibbon" => self.ui.ribbon_collapsed = !self.ui.ribbon_collapsed, + "view.dark" => { + let on = self.flag(p, self.ui.brightness == theme::Brightness::Dark); + self.ui.brightness = if on { theme::Brightness::Dark } else { theme::Brightness::Light }; + self.restyle = true; + } + "view.grayscale" => self.ui.grayscale = self.flag(p, self.ui.grayscale), + "view.newWindow" => return doc_window::command(self, p), + "show.start" => { + if let Some(name) = p.get("custom").and_then(Value::as_str).filter(|s| !s.is_empty()) { + self.start_custom_show(name, false); + } else { + let from = self.show_from(p)?.unwrap_or_else(|| self.show_start_index()); + self.start_show(from, false); + } + } + "show.presenter" => self.ui.presenter_view = self.flag(p, self.ui.presenter_view), + "show.end" => { + self.show = None; + self.media.stop_owned(media::Owner::Show); + } + "animation.preview" => { + if p.get("on").and_then(Value::as_bool) == Some(false) { + self.anim_preview = None; + return Ok(json!({"playing": false})); + } + let from = match p.get("from") { + None | Some(Value::Null) => None, + Some(v) => Some(v.as_u64().map(|v| usize::try_from(v).unwrap_or(usize::MAX)).ok_or("`from` must be an effect index")?), + }; + return self.start_anim_preview(from); + } + "app.home" => { + let on = self.flag(p, self.backstage_open()); + if on { + self.open_backstage(backstage::Page::Home); + } else { + self.close_backstage(); + } + } + "app.backstage" => { + if !p.get("on").and_then(Value::as_bool).unwrap_or(true) { + self.close_backstage(); + return Ok(json!({"page": self.backstage.as_ref().map(|b| b.page.id())})); + } + let page = match p.get("page").and_then(Value::as_str) { + Some(id) => backstage::Page::parse(id).ok_or_else(|| format!("unknown File page `{id}`"))?, + None => backstage::Page::Home, + }; + if page.needs_doc() && self.session.active().is_none() { + return Err(format!("File › {} needs an open presentation", page.label())); + } + self.open_backstage(page); + return Ok(json!({"page": page.id()})); + } + "app.closePresentation" => self.close_presentation(), + "app.print" => { + if self.session.active().is_none() { + return Err("nothing to print: no presentation is open".into()); + } + return Ok(json!({"printed": self.print_pdf(p.clone())})); + } + "app.about" => self.dialog = Some(dialogs::Dialog::new("about")), + "app.checkUpdates" => { + #[cfg(not(target_arch = "wasm32"))] + updates_ui::request_check(self); + #[cfg(target_arch = "wasm32")] + return Err("updates are handled by the browser: reload the page".into()); + } + "app.palette" => self.palette = Some((String::new(), 0)), + "app.preferences" => self.dialog = Some(dialogs::Dialog::new("preferences")), + "app.openDialog" => self.pick_and_open(), + "app.insertPictureDialog" => self.pick_and_insert("picture", "insert.picture"), + "app.smartArtPictureDialog" => { + if self.services.pick_open.is_none() { + return Err("picking a SmartArt picture needs the desktop app".into()); + } + self.pick_and_run("picture", "smartArt.picture", p); + } + "app.changePictureDialog" => self.pick_and_insert("picture", "picture.change"), + "app.insertAudioDialog" => self.pick_and_insert("audio", "insert.audio"), + "app.reuseSlidesDialog" => self.pick_and_reuse(p.get("keepFormatting").and_then(Value::as_bool).unwrap_or(false)), + "app.insertVideoDialog" => self.pick_and_insert("video", "insert.video"), + "app.saveAsDialog" => self.save_as_dialog("deckcraft"), + "app.exportDialog" => self.dialog = Some(dialogs::Dialog::new("export")), + "app.pasteSpecial" => self.dialog = Some(dialogs::Dialog::new("pasteSpecial")), + "app.browseThemeDialog" => { + if self.session.active().is_none() { + return Err("open a presentation first".into()); + } + if self.services.pick_open.is_some() { + self.pick_and_insert("theme", "design.browseTheme"); + } else { + // The browser's asynchronous picker would open the file as a new document. + self.set_status("Browse for Themes needs the desktop app for now".to_string()); + } + } + "app.qat" => return qat::command(self, p), + "app.find" | "app.replace" if self.session.active().is_some() => { + let mut d = dialogs::Dialog::new("find"); + d.fields.insert("replace".into(), if id == "app.replace" { "1" } else { "0" }.into()); + self.dialog = Some(d); + } + "app.find" | "app.replace" => self.palette = Some((String::new(), 0)), + "app.compareDialog" => { + if self.session.active().is_none() { + return Err("open the presentation to compare first".into()); + } + if self.services.pick_open.is_some() { + self.pick_and_insert("open", "review.compare"); + } else { + // The browser's asynchronous picker would open the file as a new document. + self.set_status("Compare needs the desktop app for now".to_string()); + } + } + "app.dialog" => { + let d = p.get("id").and_then(Value::as_str).ok_or("missing `id`")?; + self.dialog = Some(dialogs::Dialog::new(d)); + } + "app.links" => { + use deckcraft_engine::links::*; + return Ok(json!({"discord": DISCORD, "website": WEBSITE, "apps": APPS, "appPage": APP_PAGE, "github": GITHUB})); + } + _ => return Err(format!("unknown UI command `{id}`")), + } + Ok(Value::Null) + } +} diff --git a/crates/ui-egui/src/app/control.rs b/crates/ui-egui/src/app/control.rs new file mode 100644 index 00000000..f6d73e23 --- /dev/null +++ b/crates/ui-egui/src/app/control.rs @@ -0,0 +1,45 @@ +use super::*; +use crate::control; + +impl SlideApp { + /// Drain pending control requests, executing ordinary requests and admitting screenshots for + /// later saving and reply. + pub(super) fn drain_control(&mut self, ctx: &egui::Context) { + let Some(rx) = self.control_rx.take() else { return }; + while let Ok(req) = rx.try_recv() { + // A request whose sender gave up waiting (reply timeout) must not run late (#719). + if !req.take() { + continue; + } + let reply = req.reply.clone(); + let revision = self.session.active().map(|d| d.revision); + match control::handle(self, ctx, &req) { + control::Outcome::Done(v) => { + let _ = reply.send(v); + } + control::Outcome::Screenshot { path } => { + let doc = self.session.active().map(|d| d.uid); + if let Err(e) = self.capture.admit(path, reply.clone(), doc, web_time::Instant::now()) { + let _ = reply.send(json!({"ok": false, "error": e})); + } + } + } + if self.session.active().map(|d| d.revision) != revision { + self.note_application_edit(ctx); + } + } + self.control_rx = Some(rx); + } + + /// Save completed screenshots on the app thread and send exactly one reply each. + pub(super) fn drain_screenshots(&mut self) { + let doc = self.session.active().map(|d| d.uid); + let completed = self.capture.drain(web_time::Instant::now(), doc); + for c in completed { + let v = control::save_screenshot(self, c.image.as_ref(), c.path.as_deref()); + // Keep the aggregate charged through RGBA/PNG/base64 encoding and the write. + self.capture.release(c.reserved); + let _ = c.reply.send(v); + } + } +} diff --git a/crates/ui-egui/src/app/files.rs b/crates/ui-egui/src/app/files.rs new file mode 100644 index 00000000..c035fcbf --- /dev/null +++ b/crates/ui-egui/src/app/files.rs @@ -0,0 +1,664 @@ +use super::*; + +fn purpose_of(purpose: &str) -> FilePurpose { + match purpose { + "open" => FilePurpose::Open, + "picture" => FilePurpose::Picture, + "audio" => FilePurpose::Audio, + "video" => FilePurpose::Video, + _ => FilePurpose::Infer, + } +} + +fn is_presentation_name(lower: &str) -> bool { + lower.ends_with(".deckcraft") + || lower.ends_with(".slidecraft") + || lower.ends_with(".pptx") + || lower.ends_with(".potx") + || lower.ends_with(".ppsx") +} + +/// File → Open keeps the requested operation: a presentation opens, an outline becomes slides. +fn open_command(name: &str) -> Option<&'static str> { + let lower = name.to_ascii_lowercase(); + if is_presentation_name(&lower) { + Some("file.openBytes") + } else if lower.ends_with(".txt") || lower.ends_with(".md") || lower.ends_with(".rtf") { + Some("slide.fromOutline") + } else { + None + } +} + +/// A drop has no requested operation, so the name and bytes decide: a presentation opens, +/// then a recognised picture, sound or video is inserted, then an outline becomes slides. +fn infer_command(name: &str, bytes: &[u8]) -> Option<&'static str> { + let lower = name.to_ascii_lowercase(); + if is_presentation_name(&lower) { + return Some("file.openBytes"); + } + let ct = deckcraft_engine::cmd::insert::content_type(name, bytes); + if ct.starts_with("image/") { + Some("insert.picture") + } else if ct.starts_with("audio/") { + Some("insert.audio") + } else if ct.starts_with("video/") { + Some("insert.video") + } else if lower.ends_with(".txt") || lower.ends_with(".md") || lower.ends_with(".rtf") { + Some("slide.fromOutline") + } else { + None + } +} + +fn command_for(purpose: FilePurpose, name: &str, bytes: &[u8]) -> Result<&'static str, String> { + let cmd = match purpose { + FilePurpose::Picture => Some("insert.picture"), + FilePurpose::Audio => Some("insert.audio"), + FilePurpose::Video => Some("insert.video"), + FilePurpose::ChangePicture => deckcraft_engine::cmd::insert::content_type(name, bytes).starts_with("image/").then_some("picture.change"), + FilePurpose::Open => open_command(name), + FilePurpose::Infer => infer_command(name, bytes), + }; + cmd.ok_or_else(|| format!("{name}: DeckCraft can't open this kind of file")) +} + +impl SlideApp { + /// Does this recent path exist on disk? Probed once per path per session and only on + /// desktop (the web build has no file system to ask): a stat on a dead network share can + /// block for seconds, so it must not repeat on every frame. A stale answer is harmless — + /// opening still tries and reports, and a successful open re-probes. + pub fn recent_exists(&mut self, path: &str) -> bool { + #[cfg(target_arch = "wasm32")] + { + let _ = path; + true + } + #[cfg(not(target_arch = "wasm32"))] + { + if let Some(ok) = self.recent_checked.get(path) { + return *ok; + } + // A stat on a dead network location (UNC share, URL) can block for the OS timeout, + // so remote-looking paths are never probed: they stay unmarked and are judged when + // opened. A disconnected mapped drive would still stall once — accepted. + if path.starts_with("\\\\") || path.contains("://") { + return true; + } + let ok = std::path::Path::new(path).is_file(); + if self.recent_checked.len() > 64 { + let live: std::collections::HashSet<&String> = self.ui.recent.iter().collect(); + self.recent_checked.retain(|p, _| live.contains(p)); + } + self.recent_checked.insert(path.to_string(), ok); + ok + } + } + + /// Drop a path from the Recent list (its file is gone or the user asked for it). + pub fn forget_recent(&mut self, path: &str) { + self.ui.recent.retain(|p| p != path); + self.recent_checked.remove(path); + } + + /// File › Close and Ctrl+W: closes the active presentation, asking first when it has unsaved + /// changes (Save, Don't Save, Cancel), as PowerPoint does. + pub fn close_presentation(&mut self) { + match self.session.active() { + None => {} + Some(d) if d.is_dirty() => self.dialog = Some(dialogs::Dialog::new("close")), + Some(_) => { + let _ = self.run("file.close", json!({})); + } + } + } + + /// The file name a save or export suggests: the presentation's title with `ext`. + fn suggested_name(&self, ext: &str) -> String { + let name = self.session.active().map(|d| d.title()).unwrap_or_else(|| "Presentation".into()); + format!("{name}.{ext}") + } + + /// Exports the active presentation as `fmt` (`pdf`, `png`, `pngAll`, `jpeg`, `pptx`, + /// `deckcraft`, `outline`) through `file.export`: the save picker on the desktop, a download + /// on the web. `extra` adds `file.export` params (the PDF's layout, slides…). The + /// presentation keeps its own file. Returns whether a file was written or handed over. + pub fn export_as(&mut self, fmt: &str, extra: Value) -> bool { + let Some(slide) = self.session.active().map(|d| d.selection.slide) else { return false }; + let (ext, format, all) = match fmt { + "pdf" => ("pdf", "pdf", false), + "png" => ("png", "png", false), + "pngAll" => ("png", "png", true), + "jpeg" | "jpg" => ("jpg", "jpeg", false), + "pptx" => ("pptx", "pptx", false), + "outline" | "txt" => ("txt", "outline", false), + "rtf" => ("rtf", "rtf", false), + "deckcraft" => ("deckcraft", "deckcraft", false), + "gif" => ("gif", "gif", false), + other => { + self.set_status(format!("DeckCraft can't export “{other}”")); + return false; + } + }; + let suggested = self.suggested_name(ext); + if let Some(pick) = self.services.pick_save.as_mut() { + let Some(path) = pick(&suggested) else { return false }; + let mut params = json!({"path": path, "format": format, "all": all}); + if let (Some(o), Some(x)) = (params.as_object_mut(), extra.as_object()) { + o.extend(x.clone()); + } + return match self.run("file.export", params) { + Ok(_) => { + self.set_status(format!("Exported {path}")); + true + } + Err(_) => false, + }; + } + let Some(doc) = self.session.active().map(|d| d.doc.clone()) else { return false }; + let bytes = match format { + "png" | "jpeg" => { + // Every slide for "all slides", one download each, named like the desktop export (#64). + let name = self.session.active().map(|d| d.title()).unwrap_or_else(|| "Presentation".into()); + let mut any = false; + let transparent = extra.get("transparentBackground").and_then(Value::as_bool).unwrap_or(false); + for (file, bytes) in web_image_files(&doc, slide, all, &name, ext, transparent) { + any = self.download(&file, Ok(bytes)) || any; + } + return any; + } + "pdf" => deckcraft_engine::cmd::file::pdf_bytes(&doc, &extra), + "gif" => deckcraft_engine::cmd::file::gif_bytes(&doc, &extra), + other => deckcraft_engine::cmd::file::save_bytes(&doc, other), + }; + self.download(&suggested, bytes) + } + + /// Hands `bytes` to the browser as a download (web); false (with the reason in the status + /// bar) when there are no bytes or no way to download. + fn download(&mut self, name: &str, bytes: deckcraft_engine::Result>) -> bool { + match (bytes, self.services.download.as_mut()) { + (Ok(b), Some(dl)) => { + dl(name, &b); + true + } + (Err(e), _) => { + self.set_status(e.to_string()); + false + } + (Ok(_), None) => { + self.set_status("Saving a copy needs a file picker, which this window doesn't have."); + false + } + } + } + + /// File › Print: DeckCraft prints through a PDF. On the desktop the PDF (with the Print page's + /// layout, slides and options) is written to the temporary folder and opened in the system's + /// PDF viewer, which has the printers; on the web it downloads. + pub fn print_pdf(&mut self, params: Value) -> bool { + let Some((title, doc)) = self.session.active().map(|d| (d.title(), d.doc.clone())) else { return false }; + let mut params = if params.is_object() { params } else { json!({}) }; + if self.services.pick_save.is_none() { + // Web: the browser hands the PDF to the user. + let bytes = deckcraft_engine::cmd::file::pdf_bytes(&doc, ¶ms); + return self.download(&format!("{title}.pdf"), bytes); + } + let safe: String = title.chars().map(|c| if c.is_alphanumeric() || c == ' ' || c == '-' { c } else { '_' }).collect(); + let path = std::env::temp_dir().join(format!("{safe} (print).pdf")).to_string_lossy().to_string(); + if let Some(o) = params.as_object_mut() { + o.insert("path".into(), json!(path)); + o.insert("format".into(), json!("pdf")); + } + match self.run("file.export", params) { + Ok(_) => { + self.open_url(&format!("file:///{}", path.replace('\\', "/").trim_start_matches('/'))); + self.set_status("Opened the presentation as a PDF to print"); + true + } + Err(_) => false, + } + } + + pub(crate) fn pick_and_open(&mut self) { + if let Some(pick) = self.services.pick_open.as_mut() { + if let Some(path) = pick("open") { + self.open_path_in_background(&path); + } + } else if self.services.open_async.is_some() { + let id = self.begin_external_file("open"); + if let Some(f) = self.services.open_async.as_mut() { + f("open", id); + } + } + } + + pub fn open_path(&mut self, path: &str) -> Result<(), String> { + self.session.execute("file.open", &json!({"path": path})).map_err(|e| e.to_string())?; + self.note_recent(path); + Ok(()) + } + + /// Files the system hands over (the command line, a double-click through the file + /// association), opened in order. When the last one is a PowerPoint Show (`.ppsx`), its slide + /// show starts at once from where Play from Start begins, as PowerPoint does; anything else + /// opens for editing, and File › Open opens a `.ppsx` for editing too. Returns the files that + /// couldn't be opened, with the reason. + pub fn open_from_system(&mut self, paths: &[String]) -> Vec<(String, String)> { + let mut failed = Vec::new(); + let mut last = None; + for p in paths { + match self.open_path(p) { + Ok(()) => last = Some(p), + Err(e) => failed.push((p.clone(), e)), + } + } + if last.is_some_and(|p| p.to_ascii_lowercase().ends_with(".ppsx")) { + self.start_show(self.show_start_index(), false); + } + failed + } + + /// A presentation was opened from `path`: first in the recent list, shown in Normal view. + pub(crate) fn note_recent(&mut self, path: &str) { + self.status = None; // an older error (a missing file just before this one) must not linger + self.recent_checked.remove(path); + self.ui.recent.retain(|p| p != path); + self.ui.recent.insert(0, path.to_string()); + self.ui.recent.truncate(RECENT_MAX); + self.ui.view = ViewMode::Normal; + } + + /// Transitions › Sound › Other Sound…: pick an audio file for the selected slides. + pub(crate) fn pick_transition_sound(&mut self) { + if self.services.pick_open.is_some() { + self.pick_and_insert("audio", "transition.sound"); + } + } + + /// Home › New Slide › Reuse Slides…: pick a presentation and insert all its slides after the + /// current one (desktop only: the browser picker opens what it picks as a document). + pub(super) fn pick_and_reuse(&mut self, keep: bool) { + let Some(pick) = self.services.pick_open.as_mut() else { + self.set_status("Reuse Slides needs the desktop app".to_string()); + return; + }; + let Some(path) = pick("open") else { return }; + // Read the file once: the list shown and the slides inserted come from the same bytes, even + // if the file changes while the dialog is open. Without a reader the path is used as is. + let mut source = json!({}); + match self.services.read.as_ref().map(|r| r(&path)) { + Some(Ok(bytes)) => { + let name = std::path::Path::new(&path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); + source["name"] = json!(name); + source["data"] = json!(deckcraft_engine::cmd::base64_encode(&bytes)); + } + Some(Err(e)) => { + self.set_status(e.to_string()); + return; + } + None => source["path"] = json!(path), + } + // Offer the file's slides to choose from; all of them are ticked to begin with. + match self.session.execute("slide.reuseList", &source) { + Ok(list) => { + let mut d = dialogs::Dialog::new("reuseSlides"); + d.params = json!({"path": path, "source": source, "keep": keep, "slides": list["slides"]}); + self.reuse_target = Some(self.capture_file_target()); + self.dialog = Some(d); + } + Err(e) => self.set_status(e.to_string()), + } + } + + /// Insert the slides `picked` (indices) of `source` (the `name` + `data`, or `path`, that + /// `slide.reuseList` listed) after the slide that was current when the picker opened, in that + /// presentation, even if another one is active now; if it is closed or the slide is gone, + /// nothing is inserted. Returns whether they were inserted. + pub fn reuse_slides(&mut self, source: &Value, keep: bool, picked: &[usize]) -> bool { + let mut args = if source.is_object() { source.clone() } else { json!({}) }; + args["keepFormatting"] = json!(keep); + args["slides"] = json!(picked); + let result = match self.reuse_target { + Some(target) if !self.viewing_target(&target) => self.execute_on_target(&target, "Reuse Slides", "slide.reuse", &args), + _ => self.session.execute("slide.reuse", &args).map(|_| ()).map_err(|e| e.to_string()), + }; + match result { + Ok(()) => { + self.reuse_target = None; + self.set_status(format!("Inserted {} slides", picked.len())); + true + } + Err(e) => { + self.set_status(e); + false + } + } + } + + pub(crate) fn pick_and_insert(&mut self, purpose: &str, cmd: &str) { + self.pick_and_run(purpose, cmd, &json!({})); + } + + /// Pick a file and run `cmd` with it (`name` + `data`, or `path`) added to `extra`. + pub(super) fn pick_and_run(&mut self, purpose: &str, cmd: &str, extra: &Value) { + if let Some(pick) = self.services.pick_open.as_mut() { + if let Some(path) = pick(purpose) { + let mut args = if extra.is_object() { extra.clone() } else { json!({}) }; + let r = match self.services.read.as_ref().map(|r| r(&path)) { + Some(Ok(bytes)) => { + let name = std::path::Path::new(&path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); + args["name"] = json!(name); + args["data"] = json!(deckcraft_engine::cmd::base64_encode(&bytes)); + self.session.execute(cmd, &args) + } + _ => { + args["path"] = json!(path); + self.session.execute(cmd, &args) + } + }; + if let Err(e) = r { + self.set_status(e.to_string()); + } + } + } else if self.services.open_async.is_some() { + let id = self.begin_external_file(purpose); + // Change Picture: the picture that arrives replaces the selected one (#115). + if cmd == "picture.change" + && let Some(r) = self.pending_files.iter_mut().find(|r| r.id == id) + { + r.purpose = FilePurpose::ChangePicture; + } + if let Some(f) = self.services.open_async.as_mut() { + f(purpose, id); + } + } + } + + pub fn save_as_dialog(&mut self, format: &str) { + let name = self.session.active().map(|d| d.title()).unwrap_or_else(|| "Presentation".into()); + let ext = if format == "pptx" { "pptx" } else { "deckcraft" }; + let suggested = format!("{name}.{ext}"); + if let Some(pick) = self.services.pick_save.as_mut() { + if let Some(path) = pick(&suggested) { + match self.session.execute("file.saveAs", &json!({"path": path})) { + Ok(_) => { + self.set_status(format!("Saved {path}")); + self.ui.recent.retain(|p| *p != path); + self.ui.recent.insert(0, path); + } + Err(e) => self.set_status(e.to_string()), + } + } + } else if let Some(dl) = self.services.download.as_mut() + && let Some(d) = self.session.active() + { + match deckcraft_engine::cmd::file::save_bytes(&d.doc, ext) { + Ok(bytes) => dl(&suggested, &bytes), + Err(e) => self.set_status(e.to_string()), + } + } + } + + /// Save every presentation with unsaved changes (asking for a location where needed). + /// Returns false if one wasn't saved (cancelled or failed). + pub fn save_all(&mut self) -> bool { + let dirty: Vec = self.session.documents().iter().enumerate().filter(|(_, d)| d.is_dirty()).map(|(i, _)| i).collect(); + for i in dirty { + self.session.set_active(i); + self.save(); + if self.session.documents().get(i).is_some_and(|d| d.is_dirty()) { + return false; + } + } + true + } + + pub fn save(&mut self) { + if self.session.active().and_then(|d| d.path.clone()).is_some() { + match self.session.execute("file.save", &json!({})) { + Ok(_) => self.set_status("Saved"), + Err(e) => self.set_status(e.to_string()), + } + } else { + self.save_as_dialog("deckcraft"); + } + } + + /// Act on requests commands left for the UI. + pub(super) fn drain_requests(&mut self) { + for r in std::mem::take(&mut self.session.ui_requests) { + match r { + UiRequest::Dialog { id, params } => { + let mut d = dialogs::Dialog::new(&id); + d.params = params; + self.dialog = Some(d); + } + UiRequest::Pane { id } => self.ui.pane = Some(id), + UiRequest::StartShow { from } => self.start_show(from, false), + UiRequest::Message { text } => self.set_status(text), + UiRequest::PickFile { purpose } => { + if purpose == "saveAs" { + self.save_as_dialog("deckcraft"); + } + } + UiRequest::Media { action, shape, ms } => { + let owner = if self.show.is_some() { media::Owner::Show } else { media::Owner::Editor }; + // The command resolved `shape` on the editor's slide, also while a show runs. + if let Some((uid, doc, slide)) = self.session.active().map(|d| (d.uid, d.doc.clone(), d.selection.slide)) { + self.media.request(uid, &doc, slide, &action, shape, ms, owner); + } + } + } + } + } + + pub(crate) fn drain_inbox(&mut self) { + let Some(inbox) = self.services.inbox.clone() else { return }; + let events = match inbox.lock() { + Ok(mut v) => std::mem::take(&mut *v), + Err(poisoned) => std::mem::take(&mut *poisoned.into_inner()), + }; + for event in events { + match event { + InboxEvent::Cancelled { id } => self.pending_files.retain(|r| r.id != id), + InboxEvent::File(file) => self.apply_external_file(file.id, &file.name, &file.bytes), + } + } + } + + /// Remember the open presentation, the slide (or master/layout) and the operation for a + /// file the host will read asynchronously. Returns the cancellation identity the host must + /// echo on the [`InboxEvent`]. Call this when the pick or the drop starts, not when the + /// bytes arrive. + pub fn begin_external_file(&mut self, purpose: &str) -> u64 { + let id = self.next_file_id; + self.next_file_id = self.next_file_id.wrapping_add(1).max(1); + self.pending_files.push(PendingFile { id, purpose: purpose_of(purpose), target: self.capture_file_target() }); + id + } + + /// A dropped or picked file read synchronously: presentations open, media is inserted on + /// the slide that is current now. + pub fn open_or_insert_bytes(&mut self, name: &str, bytes: &[u8]) { + let id = self.begin_external_file("drop"); + self.apply_external_file(id, name, bytes); + } + + fn capture_file_target(&self) -> FileTarget { + let Some(d) = self.session.active() else { return FileTarget::Absent }; + let place = match d.selection.target { + Target::Slides => match d.current_slide() { + Some(s) => FilePlace::Slide(s.id), + None => FilePlace::Unspecified, + }, + Target::Master { master } => match d.doc.masters.get(master) { + Some(m) => FilePlace::Master(m.id), + None => FilePlace::Unspecified, + }, + Target::Layout { master, layout } => match d.doc.masters.get(master) { + Some(m) => match m.layouts.get(layout) { + Some(l) => FilePlace::Layout { master: m.id, layout: l.id }, + None => FilePlace::Unspecified, + }, + None => FilePlace::Unspecified, + }, + t @ (Target::Handout | Target::NotesMaster) => FilePlace::NotesPage(t), + }; + FileTarget::Document { doc: d.uid, place } + } + + /// True when the user is still looking at the place this request captured. + fn viewing_target(&self, target: &FileTarget) -> bool { + let FileTarget::Document { doc, place } = target else { return false }; + let Some(d) = self.session.active() else { return false }; + if d.uid != *doc { + return false; + } + match place { + FilePlace::Slide(id) => d.selection.target == Target::Slides && d.current_slide().is_some_and(|s| s.id == *id), + FilePlace::Master(id) => { + matches!(d.selection.target, Target::Master { master } if d.doc.masters.get(master).is_some_and(|m| m.id == *id)) + } + FilePlace::Layout { master, layout } => { + matches!(d.selection.target, Target::Layout { master: mi, layout: li } + if d.doc.masters.get(mi).is_some_and(|m| m.id == *master && m.layouts.get(li).is_some_and(|l| l.id == *layout))) + } + FilePlace::NotesPage(t) => d.selection.target == *t, + FilePlace::Unspecified => false, + } + } + + fn apply_external_file(&mut self, id: u64, name: &str, bytes: &[u8]) { + let Some(pos) = self.pending_files.iter().position(|r| r.id == id) else { return }; + let req = self.pending_files.remove(pos); + let cmd = match command_for(req.purpose, name, bytes) { + Ok(cmd) => cmd, + Err(e) => { + self.set_status(e); + return; + } + }; + let params = if cmd == "slide.fromOutline" { + json!({"text": deckcraft_engine::cmd::file::outline_text(bytes), "markdown": name.to_ascii_lowercase().ends_with(".md")}) + } else { + json!({"name": name, "data": deckcraft_engine::cmd::base64_encode(bytes)}) + }; + let result = if cmd == "file.openBytes" || self.viewing_target(&req.target) { + self.session.execute(cmd, ¶ms).map(|_| ()).map_err(|e| e.to_string()) + } else { + self.execute_on_target(&req.target, name, cmd, ¶ms) + }; + match result { + Err(e) if cmd == "file.openBytes" => self.notify(e), + Err(e) => self.set_status(e), + Ok(()) => {} + } + self.drain_requests(); + } + + /// Run `cmd` on the document and slide captured for the request, then put the view back + /// if the user has since moved. A missing document or slide cancels the file. + fn execute_on_target(&mut self, target: &FileTarget, name: &str, cmd: &str, params: &Value) -> Result<(), String> { + let FileTarget::Document { doc, place } = target else { + self.session.execute("file.new", &json!({})).map_err(|e| e.to_string())?; + return self.session.execute(cmd, params).map(|_| ()).map_err(|e| e.to_string()); + }; + let Some(idx) = self.session.documents().iter().position(|d| d.uid == *doc) else { + return Err(format!("{name} was not applied: the presentation it was chosen for is closed")); + }; + let view = self.session.active_index(); + let saved = self.session.documents().get(idx).map(|d| d.selection.clone()); + self.session.set_active(idx); + let located = match self.locate_place(place) { + Ok(located) => located, + Err(e) => { + self.restore_file_view(view); + return Err(format!("{name} was not applied: {e}")); + } + }; + if let Some(st) = self.session.active_mut() { + st.selection.target = located.target; + if let Some(slide) = located.slide { + st.selection.slide = slide; + st.selection.slides.clear(); + } + st.selection.shapes.clear(); + st.selection.text = None; + st.selection.cells = None; + } + let result = self.session.execute(cmd, params).map(|_| ()).map_err(|e| e.to_string()); + if view == Some(idx) { + if let (Some(sel), Some(st)) = (saved, self.session.active_mut()) { + st.selection = sel; + } + } else { + self.restore_file_view(view); + } + result + } + + fn restore_file_view(&mut self, view: Option) { + if let Some(i) = view + && i < self.session.documents().len() + { + self.session.set_active(i); + } + } + + fn locate_place(&self, place: &FilePlace) -> Result { + let d = self.session.doc().map_err(|e| e.to_string())?; + match place { + FilePlace::Slide(id) => { + let slide = d.doc.slide_index(*id).ok_or_else(|| "the slide it was chosen for was deleted".to_string())?; + Ok(LocatedPlace { target: Target::Slides, slide: Some(slide) }) + } + FilePlace::Master(id) => { + let master = + d.doc.masters.iter().position(|m| m.id == *id).ok_or_else(|| "the slide master it was chosen for was deleted".to_string())?; + Ok(LocatedPlace { target: Target::Master { master }, slide: None }) + } + FilePlace::Layout { master, layout } => { + let mi = + d.doc.masters.iter().position(|m| m.id == *master).ok_or_else(|| "the slide master it was chosen for was deleted".to_string())?; + let li = d + .doc + .masters + .get(mi) + .and_then(|m| m.layouts.iter().position(|l| l.id == *layout)) + .ok_or_else(|| "the layout it was chosen for was deleted".to_string())?; + Ok(LocatedPlace { target: Target::Layout { master: mi, layout: li }, slide: None }) + } + FilePlace::NotesPage(t) => Ok(LocatedPlace { target: *t, slide: None }), + FilePlace::Unspecified => Err("there was no slide to put it on".into()), + } + } +} + +/// Images for the browser download: the current slide, or every slide (named `1.png`, +/// `2.png`... like the native export) when `all`. `transparent` leaves the slide +/// background out (PNG only, #784). +pub(crate) fn web_image_files( + doc: &deckcraft_model::Presentation, + current: usize, + all: bool, + name: &str, + ext: &str, + transparent: bool, +) -> Vec<(String, Vec)> { + let slides: Vec = if all { (0..doc.slides.len()).collect() } else { vec![current] }; + let single = slides.len() == 1; + let scale = 2.0; + let dpi = deckcraft_render::dpi_for_scale(scale); + slides + .into_iter() + .enumerate() + .map(|(k, i)| { + let opts = deckcraft_render::RenderOpts { scale, transparent_background: transparent && ext == "png", ..Default::default() }; + let img = deckcraft_render::render_slide(doc, i, &opts); + let bytes = if ext == "png" { img.to_png_at(dpi) } else { img.to_jpeg_at(92, Some(dpi)) }; + let file = if single { format!("{name}.{ext}") } else { format!("{name}{}.{ext}", k + 1) }; + (file, bytes) + }) + .collect() +} diff --git a/crates/ui-egui/src/app/frame.rs b/crates/ui-egui/src/app/frame.rs new file mode 100644 index 00000000..7a458810 --- /dev/null +++ b/crates/ui-egui/src/app/frame.rs @@ -0,0 +1,108 @@ +use super::*; + +impl SlideApp { + /// Style, fonts and zoom setup. Runs in a real UI pass, not from `run_logic`, because + /// `set_fonts`/`set_visuals` only take effect for the pass that lays out the UI. + pub(super) fn ui_setup(&mut self, ctx: &egui::Context) -> bool { + let scale = self.ui.ui_scale.clamp(0.5, 3.0); + if (ctx.zoom_factor() - scale).abs() > 1e-3 { + ctx.set_zoom_factor(scale); + } + if !self.styled { + theme::install_fonts_with(ctx, &self.ui_fonts.faces); + self.styled = true; + self.restyle = true; + // Font definitions take effect at the next pass boundary. Do not paint a language + // or symbol with the old font set while the new set is still pending. + ctx.request_discard("install interface fonts before layout"); + return false; + } + if self.restyle { + theme::apply(ctx, &theme::Tokens::for_brightness(self.ui.brightness)); + self.restyle = false; + } + self.fonts_ready = true; + true + } + + /// AutoRecover on a monotonic deadline, even when nothing is painted. + pub(super) fn autosave_if_due(&mut self, ctx: &egui::Context) { + if self.session.recovery_dir.is_none() { + return; + } + let every = (self.session.prefs.recovery_minutes * 60.0).max(10.0); + let now = monotonic_secs(); + if now - self.last_recovery > every { + self.last_recovery = now; + if self.session.documents().iter().any(|d| d.is_dirty()) + && let Err(e) = self.session.execute("file.recovery.save", &json!({})) + { + self.set_status(format!("Couldn't save AutoRecover information: {e}")); + } + } + if self.session.documents().iter().any(|d| d.is_dirty()) { + let wait = (every - (monotonic_secs() - self.last_recovery)).clamp(0.5, every); + ctx.request_repaint_after(std::time::Duration::from_secs_f64(wait)); + } + } + + /// Issue due screenshots and finish pass-local UI bookkeeping. Call once at the end of a real + /// pass after layout; the outer [`Self::ui`] wrapper observes IME output after this returns. + pub(super) fn ui_end(&mut self, ctx: &egui::Context) { + self.ime_frame_viewport = None; + self.capture.observe_ui(ctx); + if !ctx.will_discard() { + let doc = self.session.active().map(|d| d.uid); + self.capture.issue(ctx, doc, web_time::Instant::now()); + } + if self.fonts_ready && !ctx.will_discard() { + self.synthetic_pending = false; + self.synthetic_injected.clear(); + self.synthetic_injected_at = None; + self.synthetic_injected_time = None; + self.synthetic_focus = None; + if self.synthetic.is_empty() { + self.synthetic_target = None; + } + self.promote_next_synthetic(); + } + if self.fonts_ready && !ctx.will_discard() { + let now = ctx.input(|i| i.time); + let dt = now - self.last_time; + if dt > 0.0 { + self.perf.fps = self.perf.fps * 0.9 + (1.0 / dt).min(240.0) * 0.1; + } + self.last_time = now; + } + } + + /// Media clocks. Voices belong to the open presentation: closing the last one, or switching + /// decks, stops them. The editor's media also stops when its slide is left. This runs from + /// `logic` on every call so a minimized window keeps playing; texture upload stays in `ui`. + pub(super) fn tick_media_clock(&mut self, ctx: &egui::Context) { + let uid = self.session.active().map(|d| d.uid); + self.media.set_document(uid); + if !self.media.any_active() { + self.session.media_status.clear(); + return; + } + if self.show.is_none() + && let Some(d) = self.session.active() + { + let here: Vec = if d.selection.target == deckcraft_engine::Target::Slides { + media::slide_media(&d.doc, d.selection.slide).into_iter().map(|m| m.0).collect() + } else { + vec![] + }; + let gone: Vec<_> = + self.media.ids().into_iter().filter(|id| !here.contains(id) && self.media.owner(*id) == Some(media::Owner::Editor)).collect(); + for id in gone { + self.media.stop(id); + } + } + let playing = self.media.tick_clock(monotonic_secs(), &mut self.session); + if playing { + ctx.request_repaint_after(std::time::Duration::from_millis(15)); + } + } +} diff --git a/crates/ui-egui/src/app/ime.rs b/crates/ui-egui/src/app/ime.rs new file mode 100644 index 00000000..a4bbfee0 --- /dev/null +++ b/crates/ui-egui/src/app/ime.rs @@ -0,0 +1,703 @@ +use super::*; + +impl SlideApp { + /// Borrow faces for interface text in scripts the interface fonts lack (#101): the open + /// presentations' names and text, recent files, and what is typed or composed this frame. + pub(super) fn note_ui_text(&mut self, ctx: &egui::Context) { + let typed: String = ctx.input(|i| { + i.events + .iter() + .filter_map(|e| match e { + egui::Event::Text(t) | egui::Event::Paste(t) => Some(t.as_str()), + egui::Event::Ime(egui::ImeEvent::Preedit { text, .. } | egui::ImeEvent::Commit(text)) => Some(text.as_str()), + _ => None, + }) + .collect() + }); + self.ui_fonts.note(ctx, &typed); + for d in self.session.documents() { + self.ui_fonts.note(ctx, &d.title()); + } + for r in &self.ui.recent { + self.ui_fonts.note(ctx, r); + } + if let Some(st) = self.session.active() { + self.ui_fonts.note_doc(ctx, st); + } + self.ui_fonts.apply(ctx); + } + + /// Stable IME owner for the canvas text target: document, view, shape and table cell. + /// + /// The caret, selection, text, revision and geometry are deliberately absent, so ordinary + /// edits and caret movement keep the same owner. A new document, shape or cell is a new + /// owner; [`egui::ime_context::ImeTarget::replacement_epoch`] stays zero because that key + /// itself changes on replacement. `file.revert` keeps the ids, so a composition it settles is + /// covered by the settle interrupt and by [`Self::ime_event`]'s staleness check instead. + pub(crate) fn ime_target(&self) -> Option { + let st = self.session.active()?; + let (shape, cell, notes) = match &st.selection.text { + Some(t) => (t.shape, t.cell, t.notes), + None => (*st.selection.shapes.first()?, None, false), + }; + if st.selection.text.is_none() && st.selection.shapes.len() != 1 { + return None; + } + let target = match st.selection.target { + Target::Slides => (0u8, 0usize, 0usize), + Target::Master { master } => (1, master, 0), + Target::Layout { master, layout } => (2, master, layout), + Target::Handout => (3, 0, 0), + Target::NotesMaster => (4, 0, 0), + }; + let owner = egui::Id::unique(("deckcraft-ime", st.uid, target, shape.0, cell, notes)); + Some(egui::ime_context::ImeTarget::new(owner, 0)) + } + + /// The committed surrounding snapshot for the current edit target, or `Unavailable` when + /// there is an owner but no committed text to describe. + /// + /// An unavailable stamp carries the document revision. The tracker requires a strictly + /// increasing revision on both sides of a receipt that involves `Unavailable`; without it, a + /// wide selection or a commit that starts from a selected shape would poison the whole frame. + fn ime_receipt_stamp(&self) -> Option { + let st = self.session.active()?; + self.ime_target()?; + Some(st.selection.text.as_ref().and_then(|t| deckcraft_engine::cmd::text::ime_surrounding(st, t)).map_or_else( + || egui::ime_context::ImeMutationStamp::unavailable_at_revision(st.revision), + |s| egui::ime_context::ImeMutationStamp::from_committed(&s.text, s.cursor, s.anchor, None), + )) + } + + /// Report an application-origin edit in this frame. The tracker lets `Other` dominate any + /// earlier or later IME receipt in the same frame. + pub(super) fn note_application_edit(&mut self, ctx: &egui::Context) { + let Some(viewport) = self.ime_frame_viewport else { return }; + if let Some(target) = self.ime_target() { + let frame = ctx.cumulative_frame_nr_for(viewport); + self.ime_tracker.note_application_change(viewport, frame, target); + } + } + + /// Record the expected selection after an accepted IME event. Its own deletion may move + /// the caret/revision; that is not an application interruption of the following commit. + fn refresh_ime_terminal_selection(&mut self) { + let state = self.session.active().and_then(|st| st.selection.text.as_ref().map(|t| (st.revision, t.anchor, t.caret))); + if let (Some(pending), Some((revision, anchor, caret))) = (&mut self.ime_terminal_pending, state) { + pending.revision = revision; + pending.anchor = anchor; + pending.caret = caret; + } + } + + /// The current live destination for an IME reset fence. + fn ime_destination(&self) -> Option { + let st = self.session.active()?; + let target = self.ime_target()?; + let (anchor, caret) = st.selection.text.as_ref().map_or((None, None), |t| (Some(t.anchor), Some(t.caret))); + Some(ImeDestination { owner: target.owner, revision: st.revision, anchor, caret }) + } + + /// Use the recorded IME viewport only while it matches the current context viewport. + pub(super) fn ime_viewport_or(&self, ctx: &egui::Context) -> egui::ViewportId { + self.ime_frame_viewport.filter(|v| *v == ctx.viewport_id()).unwrap_or_else(|| ctx.viewport_id()) + } + + /// The reset guard belonging to exactly this viewport. There is deliberately no fallback to + /// another viewport's phase. + pub(crate) fn ime_reset_for(&self, viewport: egui::ViewportId) -> ImeResetPhase { + self.ime_reset.get(&viewport).copied().unwrap_or(ImeResetPhase::Clear) + } + + /// Store a non-clear reset phase for one viewport, or remove the entry when the phase is `Clear`. + pub(crate) fn set_ime_reset(&mut self, viewport: egui::ViewportId, phase: ImeResetPhase) { + if matches!(phase, ImeResetPhase::Clear) { + let _ = self.ime_reset.remove(&viewport); + } else { + self.ime_reset.insert(viewport, phase); + } + } + + /// The live stamp used to compare against the origin/interrupted terminal destination. + fn ime_terminal_stamp(&self) -> Option { + let st = self.session.active()?; + let (anchor, caret) = st.selection.text.as_ref().map_or((None, None), |t| (Some(t.anchor), Some(t.caret))); + Some(ImeTerminalStamp { owner: self.ime_target().map(|t| t.owner), revision: st.revision, anchor, caret }) + } + + /// Positive route predicate for the canvas IME producer: this pass must be one that can + /// actually paint `canvas::show` for the active document, with no modal, stock field or + /// overlay owning input. + /// + /// Sorter and Notes Page are editor views without a canvas producer, so they are named here + /// even though the retained selection still yields an `ime_target`. A hidden, unfocused or + /// closing window, a missing document/target, a focused stock field, modal dialog, palette, + /// Backstage, slide show and opening overlay lose admission too. The finalizer remains the + /// authority for this pass's actual descriptor; keeping this predicate positive means a view + /// added later is not admitted by omission simply because it does not call `canvas::show`. + pub(crate) fn ime_custom_canvas_route(&self, ctx: &egui::Context) -> bool { + matches!(self.ui.view, ViewMode::Normal | ViewMode::Outline | ViewMode::Reading) + && self.session.active().is_some() + && self.ime_target().is_some() + && ctx.input(|i| i.viewport().focused.unwrap_or(true)) + && ctx.input(|i| i.viewport().visible() != Some(false)) + && !ctx.input(|i| i.viewport().close_requested()) + && ctx.memory(|m| m.focused().is_none()) + && self.dialog.as_ref().is_none_or(|d| !d.modal()) + && self.palette.is_none() + && self.backstage.is_none() + && self.show.is_none() + && self.opening.is_none() + } + + /// The singleton app's session, composition tracker and terminal guard belong to its owning + /// viewport. View › New Window and Presenter View render through their own immediate + /// viewports and never call `logic`/`ui`; admitting such a callback would let a render-only + /// viewport mutate root composition state while only the reset map is per-viewport. Reject it + /// before any state changes instead of pretending to support multi-view editing. + pub(super) fn root_viewport_admitted(ctx: &egui::Context) -> bool { + ctx.viewport_id() == egui::ViewportId::ROOT + } + + /// Report whether the current pass is a full UI pass for the IME-owning viewport rather than + /// standalone logic. + pub(super) fn ime_in_ui_pass(&self, ctx: &egui::Context) -> bool { + let key = ime_in_ui_pass_key(self.ime_viewport_or(ctx)); + ctx.data(|d| d.get_temp::(key).unwrap_or(false)) + } + + /// A new application-origin interruption supersedes any previous pending fence. + /// + /// This runs from the logic half of a full pass and from raw input handling, before the + /// finalizer has seen this pass's actual custom descriptor. No request is allocated here: + /// putting a token into egui `Memory` now would let egui attach it to a later stock + /// descriptor, or to a canvas this route does not paint. Retire the superseded id + /// immediately and store an unbound `Suspended { request: None }`; only the finalizer may + /// allocate its one replacement after confirming an eligible custom output. + pub(crate) fn note_ime_interruption(&mut self, ctx: &egui::Context, fresh: bool) { + let viewport = self.ime_viewport_or(ctx); + if !fresh + && matches!( + self.ime_reset_for(viewport), + ImeResetPhase::Awaiting { viewport: guard_viewport, .. } + | ImeResetPhase::Suspended { viewport: guard_viewport, .. } + if guard_viewport == viewport + ) + { + // This viewport already has a live or suspended guard. The finalizer replaces it + // once if the actual destination changed; do not churn a request per event. + return; + } + self.set_ime_reset(viewport, ImeResetPhase::Suspended { viewport, needs_rearm: true, request: None }); + } + + /// Custom admission was lost on this route. Retire the old id without inventing an + /// acknowledgement and block until a later eligible pass can request a replacement. + pub(super) fn suspend_ime_reset(&mut self, viewport: egui::ViewportId) { + if self.ime_reset_for(viewport).blocks_custom_input() { + self.set_ime_reset(viewport, ImeResetPhase::Suspended { viewport, needs_rearm: true, request: None }); + } + } + + /// An ordered completion for the exact newest request. Wrong/foreign/obsolete markers are + /// inert; this never mutates text, caret or undo history. + pub(super) fn ime_reset_completed(&mut self, request_id: egui::ImeResetId, ctx: &egui::Context) { + let viewport = self.ime_viewport_or(ctx); + if request_id.viewport_id() != viewport { + return; + } + if let ImeResetPhase::Awaiting { id, viewport: waiting_viewport, .. } = self.ime_reset_for(viewport) + && id == request_id + && waiting_viewport == viewport + { + self.set_ime_reset(viewport, ImeResetPhase::Clear); + // The exact ordered completion is the platform-reset boundary. It also releases a + // cleared-preedit terminal guard: the app no longer has to wait for the old + // Commit/Enabled/Disabled before a fresh composition may start. Events ordered + // before this marker remain rejected; a late old terminal after the reset has the + // same documented residual as any other event crossing a platform reset. + if self.ime_terminal_pending.as_ref().is_some_and(|p| p.cancelled) { + self.ime_terminal_pending = None; + } + if self.ime_composition.is_some_and(|c| c.settled) { + self.ime_composition = None; + } + self.ime_cancelled = None; + self.ime_interrupt_pending = false; + } + } + + /// Controls-only routing for a route where the custom editor cannot mutate. Markers are + /// visited in raw order; stock text/key events stay untouched for the stock widgets. + pub(super) fn route_ime_controls_only(&mut self, ctx: &egui::Context, events: &[egui::Event]) { + for e in events { + if let egui::Event::ImeResetCompleted { request_id } = e { + self.ime_reset_completed(*request_id, ctx); + } + } + } + + /// Bind a suspended reset to the actual custom descriptor, or retire it on loss. + pub(crate) fn finalize_ime_reset(&mut self, ctx: &egui::Context, custom: Option) { + let viewport = self.ime_viewport_or(ctx); + let phase = self.ime_reset_for(viewport); + let actual = ctx.output(|o| o.ime.as_ref().map(|i| ImeDescriptorKey::new(i.owner, i.context_generation, i.purpose))); + let destination = self.ime_destination(); + // The finalizer is the allocation authority, so it rechecks the positive canvas route + // and that this pass is not already known to be discarded. Sorter/Notes Page, a modal, + // palette, Backstage, show, opening overlay, hidden window, stock focus, font/layout + // discard or a missing target must suspend without putting a token into egui Memory. + let eligible = self.ime_custom_canvas_route(ctx) && !ctx.will_discard(); + let custom_output = match (eligible, custom, actual, destination) { + (true, Some(custom), Some(actual), Some(destination)) if custom == actual => Some((custom, destination)), + _ => None, + }; + let Some((output_context, destination)) = custom_output else { + match phase { + ImeResetPhase::Clear => {} + ImeResetPhase::Awaiting { .. } => { + self.set_ime_reset(viewport, ImeResetPhase::Suspended { viewport, needs_rearm: true, request: None }); + } + ImeResetPhase::Suspended { viewport: guard_viewport, request, .. } => { + // A request that was not bound to the actual custom output in this pass is + // retired locally; a later eligible activation obtains a fresh one. A guard + // stamped for another viewport (or a request issued for one) is never reused. + if guard_viewport != viewport || request.is_some() { + self.set_ime_reset(viewport, ImeResetPhase::Suspended { viewport, needs_rearm: true, request: None }); + } + } + ImeResetPhase::AllocationFailed { .. } => { + self.set_ime_reset(viewport, ImeResetPhase::Suspended { viewport, needs_rearm: true, request: None }); + } + } + return; + }; + match phase { + ImeResetPhase::Clear => {} + ImeResetPhase::Awaiting { viewport: old_viewport, destination: old_destination, output_context: old_context, .. } => { + if old_viewport != viewport || old_destination != destination || old_context != output_context { + self.request_ime_rearm(ctx, viewport, destination, output_context); + } + } + ImeResetPhase::Suspended { viewport: guard_viewport, request, needs_rearm } => { + let request = if needs_rearm && guard_viewport == viewport { + // Only a request issued for this exact viewport may be bound here. + request.filter(|id| id.viewport_id() == viewport) + } else { + None + }; + match request.or_else(|| ctx.request_ime_reset(viewport)) { + Some(id) => { + self.set_ime_reset(viewport, ImeResetPhase::Awaiting { id, viewport, destination, output_context }); + } + None => { + self.set_ime_reset(viewport, ImeResetPhase::AllocationFailed { viewport, destination, output_context }); + } + } + } + ImeResetPhase::AllocationFailed { viewport: old_viewport, destination: old_destination, output_context: old_context } => { + // Repeated idle output for the same destination does not retry allocation. + if old_viewport != viewport || old_destination != destination || old_context != output_context { + self.request_ime_rearm(ctx, viewport, destination, output_context); + } + } + } + } + + /// Request one replacement IME reset token and record `Awaiting` on success or + /// `AllocationFailed` when no token can be allocated. + fn request_ime_rearm(&mut self, ctx: &egui::Context, viewport: egui::ViewportId, destination: ImeDestination, output_context: ImeDescriptorKey) { + match ctx.request_ime_reset(viewport) { + Some(id) => self.set_ime_reset(viewport, ImeResetPhase::Awaiting { id, viewport, destination, output_context }), + None => self.set_ime_reset(viewport, ImeResetPhase::AllocationFailed { viewport, destination, output_context }), + } + } + + /// A standalone run_logic tick paints no custom geometry. Suspend once, without replaying + /// stale input; the next full UI pass can rearm from its actual descriptor. + pub(super) fn finalize_ime_logic(&mut self, ctx: &egui::Context) { + let viewport = self.ime_viewport_or(ctx); + if self.ime_reset_for(viewport).blocks_custom_input() { + // Only this viewport's guard is touched. A root/child logic tick must not suspend or + // rebind another viewport's independent reset lifetime. + self.set_ime_reset(viewport, ImeResetPhase::Suspended { viewport, needs_rearm: true, request: None }); + } + } + + /// Note compositions the application settled since the last check (a click, a key, another + /// command). Those are announced to the platform as an interrupt in [`Self::observe_ime`], + /// and a platform event for them is stale from that moment on, even in the same pass. + pub(crate) fn note_settled_composition(&mut self, ctx: &egui::Context) { + let settles = self.session.composition_settles(); + let current_stamp = self.ime_terminal_stamp(); + // `file.close` then `file.new` can replace the active uid before any pass observes the + // empty document. That is not a safe boundary for the old terminal: the platform may + // still deliver its suffix. Keep the guard bound to its retired owner and treat the new + // destination as a fresh interruption; only exact reset completion or an observed + // teardown may clear the terminal metadata. + let pending_interrupted = self.ime_terminal_pending.as_ref().is_some_and(|p| { + let reference = if p.interrupted { + p.interrupted_at + } else { + Some(ImeTerminalStamp { owner: Some(p.owner), revision: p.revision, anchor: Some(p.anchor), caret: Some(p.caret) }) + }; + reference != current_stamp + }); + if settles == self.ime_settles_seen && !pending_interrupted { + return; + } + let fresh = settles != self.ime_settles_seen; + self.ime_settles_seen = settles; + if let Some(pending) = &mut self.ime_terminal_pending { + pending.cancelled = true; + pending.interrupted = true; + // Compare later passes against the destination where interruption actually happened, + // not forever against the retired composition's origin. The destination may be absent + // (the interruption closed its target); `interrupted` keeps that distinct from "not + // interrupted yet". + pending.interrupted_at = current_stamp; + } + self.ime_interrupt_pending = true; + self.note_ime_interruption(ctx, fresh); + let cancelled = self.ime_composition.as_mut().map(|c| { + c.settled = true; + (c.owner, c.generation) + }); + if let Some((owner, generation)) = cancelled { + // Retain a tombstone beyond the first stale event: clearing the composition must + // not let the rest of this input batch commit into the next target. `observe_ime` + // retires it in the pass that publishes the matching interrupt. + self.ime_cancelled = Some(ImeCancelled { owner, generation }); + } + } + + /// An input method's event for the slide's text (#101): the preedit is shown in the text and + /// replaced on each update; the commit replaces it as one undo step. + /// + /// An event only ever changes the target its composition began in. The application settles a + /// composition itself on a click, a key, a command or a target change, and the platform may + /// still hold it: pre-edits and commits queued for that generation in the settling input batch + /// are dropped (with [`egui::Memory::interrupt_ime`]) instead of being typed into whatever is + /// selected now. After the interrupt has been published the platform is reset, so the next + /// batch is treated as new input; egui's event payload has no generation, so an OS-queued + /// event that crosses that reset cannot be distinguished from a legitimate one. + /// + /// A receipt is captured immediately before and after a successful commit or + /// delete-surrounding mutation. Preedit, rejected/failed events and no-ops never earn + /// [`egui::output::ImeChangeCause::InputMethod`]. + pub(crate) fn ime_event(&mut self, ctx: &egui::Context, e: &egui::ImeEvent) { + self.note_settled_composition(ctx); + let viewport = self.ime_frame_viewport.unwrap_or_else(|| ctx.viewport_id()); + let frame = ctx.cumulative_frame_nr_for(viewport); + let target = self.ime_target(); + let owner = target.map(|t| t.owner); + let accepted = + matches!(e, egui::ImeEvent::Commit(_) | egui::ImeEvent::DeleteSurrounding { .. } | egui::ImeEvent::DeleteSurroundingBytes { .. }); + if self.reject_stale_ime_event(ctx, e, owner) { + return; + } + // Until the exact newest reset request completes at its ordered stream position, custom + // mutations stay blocked. Platform enable/disable signals are not acknowledgements and do + // not release this guard; they still settle the old composition below. + #[allow(deprecated)] + if self.ime_reset_for(viewport).blocks_custom_input() && !matches!(e, egui::ImeEvent::Disabled | egui::ImeEvent::Enabled) { + return; + } + let before = if accepted { self.ime_receipt_stamp() } else { None }; + let r = self.apply_ime_event(e); + self.settle_ime_event_state(e, owner, r.is_ok()); + if r.is_ok() && accepted { + self.refresh_ime_terminal_selection(); + } + if let Err(e) = &r { + self.set_status(e.to_string()); + } else if accepted + && let (Some(target), Some(before), Some(after)) = (target, before, self.ime_receipt_stamp()) + && before != after + && r.as_ref().is_ok_and(|v| !v.is_null() && v.get("handled").and_then(Value::as_bool) != Some(false)) + { + // A no-op receipt (an empty commit, a zero-length delete, same text reinserted) is + // not a mutation and must not poison the frame for a real commit later in the batch. + self.ime_tracker.note_input_method_apply(viewport, frame, target, before, after); + } + } + + /// Drop an event that belongs to a composition the application already settled, or to another + /// owner; returns `true` when the event must not be applied. + /// + /// A commit or deletion belongs to the composition that began the pre-edit: another owner + /// (the user clicked elsewhere) or a composition the app already settled makes it stale. + /// While the reset is still pending, the tombstone rejects pre-edits too (the platform has + /// not yet been told to cancel its copy). `observe_ime` retires the tombstone in the pass + /// that publishes the interrupt (or in an accepted no-descriptor pass, which resets the + /// backend just the same), so a later commit-only is not lost forever: it is a + /// legitimate direct commit or a new platform composition. egui's events carry no + /// generation, so an event queued by the OS across that reset is indistinguishable from + /// one the user just typed; only events in the same input batch as the settle are rejected + /// deterministically. A cleared-preedit terminal is a distinct case: its pending owner + /// remains guarded across that output boundary until its actual commit/enable/disable + /// arrives. Publishing a reset is not evidence that the separately queued terminal was + /// delivered. With no incoming event epoch, the first ambiguous new composition after an + /// unobserved reset may be rejected; no timer guesses that it became safe. + /// The tombstone is not filtered by the *current* target: a click can change the owner in + /// the settling pass, and every remaining event of that batch still belongs to the + /// pre-reset composition. Filtering would let the event after the first dropped pre-edit + /// start composing in the new target before the platform is reset. + fn reject_stale_ime_event(&mut self, ctx: &egui::Context, e: &egui::ImeEvent, owner: Option) -> bool { + let cancelled = self.ime_cancelled; + let pending_cancelled = self.ime_terminal_pending.as_ref().is_some_and(|p| p.cancelled); + #[allow(deprecated)] + let stale = !matches!(e, egui::ImeEvent::Disabled | egui::ImeEvent::Enabled) + && (self.ime_composition.is_some_and(|c| c.settled || Some(c.owner) != owner) || cancelled.is_some() || pending_cancelled); + if !stale { + return false; + } + if pending_cancelled && matches!(e, egui::ImeEvent::Commit(_)) { + // The old terminal finally arrived: reject it and release its pending owner now, + // so a new preedit later in this same raw batch can belong to the current target. + self.ime_terminal_pending = None; + self.ime_composition = None; + self.ime_cancelled = None; + self.ime_interrupt_pending = false; + return true; + } + if pending_cancelled { + // note_settled_composition already queued the reset. Do not also latch the + // frame's memory interrupt: a terminal later in this batch can consume it and + // permit a fresh preedit without resetting that new composition at pass end. + self.ime_composition = None; + return true; + } + if let Some(c) = cancelled { + log::debug!("IGNORING IME event for settled composition owner {:?} generation {}", c.owner, c.generation); + } + self.ime_composition = None; + // Ask the backend to drop its copy, so this is one stale event and not a stream. + ctx.memory_mut(|m| m.interrupt_ime()); + true + } + + /// Apply the document mutation an IME event asks for, before any receipt or tracker state. + fn apply_ime_event(&mut self, e: &egui::ImeEvent) -> deckcraft_engine::Result { + match e { + egui::ImeEvent::Preedit { text, active_range_chars } => { + self.session.compose_text(text, active_range_chars.as_ref().map(|r| (r.start, r.end))) + } + // A commit only replaces text that is being edited. It must never start editing a + // merely selected shape: without a tracked pre-edit, a stale platform commit would + // replace that shape's whole text. + egui::ImeEvent::Commit(text) => { + if self.session.active().is_some_and(|d| d.selection.text.is_some()) { + self.session.commit_text(text) + } else { + Ok(Value::Null) + } + } + egui::ImeEvent::DeleteSurrounding { before_chars, after_chars } => { + if self.session.active().is_some_and(|d| d.selection.text.is_some()) { + self.session.delete_surrounding(*before_chars, *after_chars, false) + } else { + Ok(Value::Null) + } + } + egui::ImeEvent::DeleteSurroundingBytes { before_bytes, after_bytes } => { + if self.session.active().is_some_and(|d| d.selection.text.is_some()) { + self.session.delete_surrounding(*before_bytes, *after_bytes, true) + } else { + Ok(Value::Null) + } + } + #[allow(deprecated)] + egui::ImeEvent::Disabled | egui::ImeEvent::Enabled => { + // The platform ended (or is about to restart) its composition itself. + self.session.end_composition(); + Ok(Value::Null) + } + } + } + + /// Update the tracked composition after `e` has been applied; `applied` says whether the + /// document mutation succeeded. + fn settle_ime_event_state(&mut self, e: &egui::ImeEvent, owner: Option, applied: bool) { + match e { + // The platform ends its composition on a commit (and on Disabled/Enabled): nothing is + // left to interrupt. A pre-edit keeps (or starts) the tracked composition. + egui::ImeEvent::Commit(_) => { + self.ime_terminal_pending = None; + self.ime_composition = None; + self.ime_cancelled = None; + self.ime_interrupt_pending = false; + } + #[allow(deprecated)] + egui::ImeEvent::Disabled | egui::ImeEvent::Enabled => { + // A platform-side disable/enable already reset the composition. Do not carry an + // older app-settled interrupt into the next (possibly new) composition. + self.ime_terminal_pending = None; + self.ime_composition = None; + self.ime_cancelled = None; + self.ime_interrupt_pending = false; + } + egui::ImeEvent::Preedit { text, .. } => { + match (owner, applied, self.session.active().is_some_and(|d| d.composition.is_some())) { + (Some(owner), true, true) => { + // Updating the same composition keeps its generation. After a settle (or + // a first pre-edit) this is a new generation and clears the tombstone. + let generation = match self.ime_composition { + Some(c) if c.owner == owner && !c.settled => c.generation, + _ => { + self.ime_generation = self.ime_generation.wrapping_add(1).max(1); + self.ime_generation + } + }; + self.ime_composition = Some(ImeComposition { owner, generation, settled: false }); + self.ime_terminal_pending = None; + self.ime_cancelled = None; + } + (Some(owner), true, false) if text.is_empty() && self.ime_composition.is_some_and(|c| !c.settled && c.owner == owner) => { + // The model restored committed text, but no terminal was delivered. Keep + // this owner across passes; a later pointer/key/target change must reject + // its separately queued old delete/commit, even after an outgoing reset. + if let Some(st) = self.session.active() + && let Some(t) = &st.selection.text + { + self.ime_terminal_pending = Some(ImeTerminalPending { + owner, + revision: st.revision, + anchor: t.anchor, + caret: t.caret, + cancelled: false, + interrupted: false, + interrupted_at: None, + }); + } + } + _ => { + self.ime_composition = None; + self.ime_terminal_pending = None; + } + } + } + _ => {} + } + } + + /// Observe the canvas IME owner once per pass and publish its descriptor. + /// + /// No canvas target, a Sorter/Notes Page view, a focused egui field, a modal, the palette, + /// the Backstage view, a slide show, an opening overlay, or an unfocused/hidden window means + /// the custom owner is hidden: the tracker is told so and the next focus starts a new + /// generation. The descriptor is rebuilt from the current committed text every pass. + /// + /// A composition the application settled itself is announced once as + /// `should_interrupt_composition`, so the backend cancels the platform's copy of it. That + /// settled pass is also the active-composition tombstone's lifecycle end. A cleared-preedit + /// terminal retains its separate pending owner until the incoming terminal is observed. + pub(crate) fn observe_ime(&mut self, ctx: &egui::Context) -> Option { + let viewport = self.ime_frame_viewport.unwrap_or_else(|| ctx.viewport_id()); + self.note_settled_composition(ctx); + // The editing window going away really ends the viewport's context; a merely hidden + // owner (modal, palette, unfocused field) stays observed as unfocused instead. + if self.session.active().is_none() || ctx.input(|i| i.viewport().close_requested()) { + self.ime_interrupt_pending = false; + if let Some(old) = self.ime_viewport.take() { + self.ime_tracker.end_viewport(old); + // Explicit viewport destruction removes only that viewport's guard. + let _ = self.ime_reset.remove(&old); + } + let _ = self.ime_reset.remove(&viewport); + self.ime_composition = None; + self.ime_cancelled = None; + self.ime_terminal_pending = None; + return None; + } + if !ctx.input(|i| i.viewport().focused.unwrap_or(true)) { + // The native window itself lost focus: no custom editor is eligible this pass, even + // if the model still has a text selection and a last-pass geometry rect. + self.ime_tracker.observe(viewport, None); + return None; + } + let Some(rect) = self.ime_pending.take() else { + // No canvas editor was painted in this pass. A discarded font/layout pass publishes + // nothing the platform sees, so keep the interrupt and tombstone for a later pass. + // An accepted pass is a backend reset only when it publishes *no* IME descriptor at + // all: a focused stock `TextEdit` (notes pane, dialog, palette field) publishes its + // own `o.ime` while `ime_pending` is `None`, and that descriptor is not a reset. Gate + // retirement on the actual committed output, not on the absence of the canvas rect; + // otherwise the next legitimate commit in the focused field would be rejected. + let no_descriptor = ctx.output(|o| o.ime.is_none()); + if !ctx.will_discard() && no_descriptor { + self.ime_interrupt_pending = false; + self.ime_cancelled = None; + if self.ime_composition.is_some_and(|c| c.settled) { + self.ime_composition = None; + } + } + self.ime_tracker.observe(viewport, None); + return None; + }; + if !self.ime_custom_canvas_route(ctx) { + // The canvas can still paint behind an opening overlay or a Sorter/Notes Page + // reroute, and a forced hidden paint can run after the window lost focus or + // visibility. The positive route and the UI wrapper both reject those, so a pending + // rect must not republish the custom owner. + self.ime_tracker.observe(viewport, None); + return None; + } + let Some(st) = self.session.active() else { + self.ime_interrupt_pending = false; + self.ime_tracker.observe(viewport, None); + return None; + }; + let Some(target) = self.ime_target() else { + self.ime_interrupt_pending = false; + self.ime_cancelled = None; + self.ime_tracker.observe(viewport, None); + return None; + }; + if self.ime_viewport != Some(viewport) + && let Some(old) = self.ime_viewport.replace(viewport) + { + self.ime_tracker.end_viewport(old); + let _ = self.ime_reset.remove(&old); + } + let committed = st.selection.text.as_ref().and_then(|t| deckcraft_engine::cmd::text::ime_surrounding(st, t)); + let mut observation = + egui::ime_context::ImeObservation::new(ctx.cumulative_frame_nr_for(viewport), ctx.cumulative_pass_nr_for(viewport), target) + .purpose(egui::IMEPurpose::Normal) + .committed(committed.as_ref().map(|s| egui::ime_context::CommittedText::new(&s.text, s.cursor, s.anchor))); + if committed.is_none() { + observation = observation.unavailable_revision(st.revision); + } + let update = self.ime_tracker.observe(viewport, Some(observation))?; + // Clear the interrupt only once it is part of a settled pass's output descriptor. A + // discarded font/layout pass publishes nothing the platform sees, so keep it pending; + // that pass is not the boundary at which stale pre-edits become a new generation. + let interrupt = if ctx.will_discard() { false } else { std::mem::take(&mut self.ime_interrupt_pending) }; + if interrupt { + // This pass's output carries the backend reset. The tombstone did its job for the + // events already processed in this batch; from the next batch on every pre-edit or + // commit is a new platform generation. A late event the OS queued across the reset + // has no generation in its payload, so it cannot be rejected without also dropping + // legitimate direct commits (documented residual in `ime_event`). + self.ime_cancelled = None; + if self.ime_composition.is_some_and(|c| c.settled) { + self.ime_composition = None; + } + } + let key = ImeDescriptorKey::new(update.owner, update.context_generation, update.purpose); + ctx.output_mut(|o| { + o.ime = Some(egui::output::IMEOutput { + owner: update.owner, + context_generation: update.context_generation, + purpose: update.purpose, + rect, + cursor_rect: rect, + should_interrupt_composition: interrupt, + reset_request: None, + change_cause: update.change_cause, + surrounding_text: update.surrounding_text, + }); + }); + Some(key) + } +} diff --git a/crates/ui-egui/src/app/input.rs b/crates/ui-egui/src/app/input.rs new file mode 100644 index 00000000..463fbb8c --- /dev/null +++ b/crates/ui-egui/src/app/input.rs @@ -0,0 +1,270 @@ +use super::*; + +impl SlideApp { + /// What a queued synthetic event is meant for. The control protocol hands out window + /// coordinates, so a document, slide, view, zoom or canvas-layout change between queueing and + /// the next UI pass makes the event stale. + pub(crate) fn input_target(&self) -> InputTarget { + let bits = |v: f32| if v.is_finite() { v.to_bits() } else { 0 }; + InputTarget { + documents: self.session.documents().len(), + active: self.session.active().map(|d| d.uid), + slide: self.session.active().map(|d| d.selection.slide), + view: self.ui.view, + zoom: self.ui.zoom.map(bits), + canvas: self.canvas_rect.map(|r| [bits(r.min.x), bits(r.min.y), bits(r.max.x), bits(r.max.y)]), + canvas_scale: bits(self.canvas_scale), + dialog: self.dialog.as_ref().map(|d| egui::Id::unique(("dialog", d.id.as_str()))), + palette: self.palette.as_ref().map(|(q, sel)| egui::Id::unique(("palette", q.as_str(), *sel))), + backstage: self.backstage.as_ref().map(|b| egui::Id::unique(("backstage", b.page.id()))), + ribbon_tab: egui::Id::unique(("ribbon-tab", self.ui.tab.as_str())), + ribbon_collapsed: self.ui.ribbon_collapsed, + ribbon_scroll: self.ribbon_scroll.get(&self.ui.tab).copied().unwrap_or(0), + show: self.show.as_ref().map(show::Show::input_state), + opening: self.opening.as_ref().map(|o| (egui::Id::unique(("opening", o.path.as_str())), o.generation)), + } + } + + /// Queue one synthetic control-channel event. The target is recorded when the batch starts, + /// before any host command in the same logic pass can change the document. + pub fn queue_input(&mut self, event: egui::Event) { + let target = self.input_target(); + // A batch that was already injected keeps the target it was injected for until its UI + // pass consumes it or retracts it. New events queued meanwhile belong to the current + // target, so they wait in a separate batch instead of joining the old one and being + // cleared with it when that batch turns out to be stale. + if self.synthetic_pending { + if self.synthetic_next_target != Some(target) { + self.synthetic_next.clear(); + self.synthetic_next_target = Some(target); + } + self.synthetic_next.push(event); + return; + } + if self.synthetic.is_empty() { + self.synthetic_target = Some(target); + } else if self.synthetic_target != Some(target) { + // The unsent queue belongs to a target that has already changed; drop it (and any + // older batch still waiting behind it) rather than appending a click intended for the + // new target to a stale batch. + self.synthetic.clear(); + self.synthetic_next.clear(); + self.synthetic_next_target = None; + self.synthetic_target = Some(target); + } + self.synthetic.push(event); + } + + /// Events admitted by the control channel but not yet injected (including a batch waiting + /// behind the one currently in flight). + pub(crate) fn synthetic_queued_len(&self) -> usize { + self.synthetic.len() + self.synthetic_next.len() + } + + /// Forget the injected or queued synthetic batch and everything recorded about it. Events + /// waiting in `synthetic_next` are a separate, newer batch and are promoted by the caller. + fn clear_synthetic_batch(&mut self) { + self.synthetic.clear(); + self.synthetic_pending = false; + self.synthetic_injected.clear(); + self.synthetic_injected_at = None; + self.synthetic_injected_time = None; + self.synthetic_target = None; + self.synthetic_focus = None; + } + + /// Start the batch that was queued behind an older one once the older batch is gone. + pub(super) fn promote_next_synthetic(&mut self) { + if self.synthetic_pending || !self.synthetic.is_empty() || self.synthetic_next.is_empty() { + return; + } + self.synthetic = std::mem::take(&mut self.synthetic_next); + self.synthetic_target = self.synthetic_next_target.take(); + self.synthetic_focus = None; + } + + /// A font/layout pass will be discarded: egui already consumed the injected events but the + /// app deliberately lays nothing out, so put them back at the head of the queue. The next + /// frame injects them again after the new fonts are installed; otherwise a control click that + /// happened to arrive on the very first frame would be acknowledged without ever reaching the + /// UI. + pub(super) fn requeue_injected_synthetic(&mut self) { + if !self.synthetic_pending { + return; + } + let mut combined = std::mem::take(&mut self.synthetic_injected); + combined.append(&mut self.synthetic); + self.synthetic = combined; + self.synthetic_pending = false; + self.synthetic_injected_at = None; + self.synthetic_injected_time = None; + self.synthetic_focus = None; + if self.synthetic.is_empty() { + self.synthetic_target = None; + self.promote_next_synthetic(); + } + } + + /// Remove exactly the recorded synthetic events from `events`. The recorded index is tried + /// first so an identical host event earlier in the batch is never removed; the last exact + /// match is only a fallback for a raw input that was rewritten around the events. + fn drain_synthetic_from(&self, events: &mut Vec) -> bool { + let injected = &self.synthetic_injected; + let n = injected.len(); + if n == 0 { + return false; + } + let exact = self.synthetic_injected_at.filter(|start| events.get(*start..*start + n).is_some_and(|w| w == injected.as_slice())); + let at = exact.or_else(|| events.windows(n).rposition(|w| w == injected.as_slice())); + if let Some(at) = at { + events.drain(at..at + n); + true + } else { + false + } + } + + /// Drop an injected batch whose document, canvas, focus or modal owner changed during + /// `logic`. eframe calls `raw_input_hook` before `App::logic`, so a control request drained + /// there can still retarget the same `RawInput`. + /// + /// This must not infer the pass phase from `ViewportInfo::visible()`: a visible root can run + /// logic-only (an overdue redraw), and a hidden root can run a full pass for a screenshot or + /// `RequestPaintWhileHidden`. A logic-only callback leaves `InputState` from the *previous* + /// pass; remove the batch only when the live input carries exactly what this pass injected + /// (same `RawInput::time` and the exact recorded event window). Otherwise keep the pending + /// bookkeeping so the next `raw_input_hook` can retract the events from the carried + /// `RawInput` before eframe delivers them in a full pass. + pub(super) fn retract_synthetic_after_logic(&mut self, ctx: &egui::Context) { + if !self.synthetic_pending { + return; + } + let current = self.input_target(); + let focus = ctx.memory(|m| m.focused()); + if self.synthetic_target == Some(current) && focus == self.synthetic_focus { + return; + } + let injected_time = self.synthetic_injected_time; + let mut removed = false; + ctx.input_mut(|i| { + // A logic-only call keeps the previous pass's `InputState`; its `raw.time` is not the + // time of the RawInput this batch was injected into. Never drain an older batch that + // happens to look similar. + if i.raw.time != injected_time { + return; + } + let mut events = std::mem::take(&mut i.events); + let ok = self.drain_synthetic_from(&mut events); + i.events = events; + if ok { + // Keep `InputState::raw` consistent with what UI code reads as `events`. + let mut raw_events = std::mem::take(&mut i.raw.events); + let _ = self.drain_synthetic_from(&mut raw_events); + i.raw.events = raw_events; + removed = true; + } + }); + if removed { + self.clear_synthetic_batch(); + self.promote_next_synthetic(); + } else { + // Logic-only (or a pass whose input does not contain the batch): the events are in + // eframe's carried RawInput. Keep `synthetic_pending`, `synthetic_injected` and the + // stale target so the next hook retracts them there. Events queued after the batch + // belong to the same stale target as well. + self.synthetic.clear(); + } + } + + /// True when the pending synthetic batch is in this pass's live `InputState`, i.e. this is + /// the full pass the batch was admitted for and egui has already derived pointer/key state + /// from it. A logic-only callback leaves the previous pass's `InputState` (with another + /// `raw.time`) and must not be mistaken for the live pass, so control/background work is + /// still drained there and the next `raw_input_hook` retracts a batch that went stale. + pub(super) fn synthetic_batch_is_live(&self, ctx: &egui::Context) -> bool { + if !self.synthetic_pending || self.synthetic_injected.is_empty() { + return false; + } + let Some(injected_time) = self.synthetic_injected_time else { return false }; + let injected = &self.synthetic_injected; + ctx.input(|i| i.raw.time == Some(injected_time) && i.events.windows(injected.len()).any(|w| w == injected.as_slice())) + } + + /// Input that belongs to the pass about to run: native dropped files and one synthetic + /// control-channel batch. The synthetic batch is injected at most once; while it is + /// unacknowledged, later passes cannot replay it. A document, canvas, focus or modal change + /// drops the whole queued batch rather than typing into the wrong place. + /// + /// The hook always injects into the `RawInput`; it does not try to guess whether eframe will + /// run this as a full or a logic-only pass. `run_logic` does not consume events but eframe + /// carries the whole `RawInput` into the next full pass, so an already-injected batch is + /// retracted at the next hook if a control request changed the target, and otherwise is + /// delivered exactly once in the next real UI pass. `ViewportInfo::visible()` is not a phase + /// signal: a hidden root still gets a full pass for a screenshot or `RequestPaintWhileHidden`. + pub fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) { + #[cfg(not(target_arch = "wasm32"))] + for f in std::mem::take(&mut raw.dropped_files) { + let name = f.path().file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped file".into()); + match f.bytes() { + Ok(bytes) => self.open_or_insert_bytes(&name, &bytes), + Err(e) => self.set_status(e), + } + } + let focus = ctx.memory(|m| m.focused()); + if self.synthetic_pending { + let current = self.input_target(); + if self.synthetic_target != Some(current) || focus != self.synthetic_focus { + // eframe keeps the whole RawInput from a logic-only pass in `pending_raw_input` + // and appends the next host input to it. The events are already in `raw.events`; + // clearing only the unsent queue would still deliver the stale click or key into + // the new document on the next full pass. Retract exactly the batch we appended. + let _ = self.drain_synthetic_from(&mut raw.events); + self.clear_synthetic_batch(); + // New control input queued for the current target while the stale batch was in + // flight is a separate batch; keep it instead of clearing it with the old one. + self.promote_next_synthetic(); + } else { + // This RawInput is the carrier for the next pass, and eframe refreshes its time + // on every call. Record the current time so `retract_synthetic_after_logic` can + // tell this full pass's live `InputState` from a logic-only callback's stale one. + self.synthetic_injected_time = raw.time; + } + } + if self.synthetic_pending || self.synthetic.is_empty() { + return; + } + let current = self.input_target(); + if self.synthetic_target != Some(current) { + // The batch was queued for a document/slide/canvas that has since changed. + self.synthetic.clear(); + self.synthetic_target = None; + self.promote_next_synthetic(); + if self.synthetic.is_empty() { + return; + } + if self.synthetic_target != Some(current) { + // The newer batch is stale too: drop it rather than delivering it to this target. + self.synthetic.clear(); + self.synthetic_target = None; + self.synthetic_next.clear(); + self.synthetic_next_target = None; + return; + } + } + self.synthetic_generation = self.synthetic_generation.wrapping_add(1).max(1); + log::debug!("injecting synthetic input batch {}", self.synthetic_generation); + let n = match self.synthetic.first() { + Some(egui::Event::PointerMoved(_) | egui::Event::PointerButton { .. }) => 1, + _ => self.synthetic.iter().position(|e| matches!(e, egui::Event::Key { pressed: false, .. })).map_or(self.synthetic.len(), |i| i + 1), + }; + let n = n.min(self.synthetic.len()); + let batch: Vec = self.synthetic.drain(..n).collect(); + let at = raw.events.len(); + raw.events.extend(batch.iter().cloned()); + self.synthetic_injected = batch; + self.synthetic_injected_at = Some(at); + self.synthetic_injected_time = raw.time; + self.synthetic_pending = true; + self.synthetic_focus = focus; + } +} diff --git a/crates/ui-egui/src/app/keys.rs b/crates/ui-egui/src/app/keys.rs new file mode 100644 index 00000000..2d821856 --- /dev/null +++ b/crates/ui-egui/src/app/keys.rs @@ -0,0 +1,251 @@ +use super::clipboard::OBJECTS_MARKER; +use super::*; + +impl SlideApp { + /// The app shortcuts that still make sense on the empty File/start screen: create, open, + /// the command palette, preferences and the File tab itself. Document commands (save, find, + /// hyperlink, show, zoom) stay out; none of them can act on a presentation that is not open. + fn start_screen_shortcut(key: egui::Key, m: Mods) -> bool { + use egui::Key::*; + matches!( + (key, m.cmd, m.shift, m.alt), + (O, true, false, false) | (P, true, true, false) | (Comma, true, false, false) | (F, false, false, true) + ) + } + + /// File-tab navigation and document-free shortcuts, also used on the start screen. + fn route_backstage_keys(&mut self, events: Vec) { + if self.backstage.is_none() { + return; + } + // The File tab: its own keys (Esc goes back, arrows walk the side menu) and app + // shortcuts (New, Open, Command Palette…); typing, pasting or deleting would edit the + // hidden presentation. + let start_screen = self.session.active().is_none(); + for e in events { + if let egui::Event::Key { key, pressed: true, modifiers, .. } = e { + let mods = Mods { shift: modifiers.shift, alt: modifiers.alt, cmd: modifiers.command || modifiers.ctrl }; + let handled = backstage::key(self, key, mods) + // With no presentation the File home only owns the shortcuts that create or + // open one. Borrowing the whole document shortcut table here would also arm + // Save/Save As, hyperlink, find/replace, show and zoom against the empty + // start screen, which is a scope expansion no editing route needs. + || (mods.cmd || mods.alt) + && (!start_screen || Self::start_screen_shortcut(key, mods)) + && menus::ui_shortcut(self, key, mods); + if handled { + continue; + } + // Engine shortcuts that don't touch the hidden slide (Ctrl+N). + if mods.cmd + && matches!(key, egui::Key::N) + && let Err(e) = self.session.key(key_name(key), mods) + { + self.set_status(e.to_string()); + } + } + } + } + + /// Keyboard and clipboard for the slide (when no text field has focus). + pub(super) fn keyboard(&mut self, ctx: &egui::Context) { + let events = ctx.input(|i| i.events.clone()); + // Ctrl+V with only an image on the clipboard: egui-winit sends no Paste event and swallows the + // key press, so the release of a V press never seen is the paste. Tracked even while a text + // field has focus, so that a V typed there is not taken for one. + let mut image_paste = false; + let mut pasted_image_event = false; + for e in &events { + match e { + egui::Event::PasteImage(_) => { + pasted_image_event = true; + self.v_seen = true; + } + egui::Event::Paste(_) | egui::Event::Key { key: egui::Key::V, pressed: true, .. } => self.v_seen = true, + egui::Event::Key { key: egui::Key::V, pressed: false, modifiers, .. } => { + // Only a release with Command/Ctrl held is a paste: a bare V release is not. + let seen = std::mem::take(&mut self.v_seen); + image_paste = !seen && (modifiers.command || modifiers.ctrl); + } + _ => {} + } + } + let viewport = self.ime_viewport_or(ctx); + if self.session.active().is_none() { + // No document: keep slide-editing input suspended, while the start screen's + // File-tab navigation and document-free shortcuts remain available. + self.suspend_ime_reset(viewport); + self.route_ime_controls_only(ctx, &events); + if self.palette.is_none() && self.dialog.as_ref().is_none_or(|d| !d.modal()) && ctx.memory(|m| m.focused().is_none()) { + self.route_backstage_keys(events); + } + self.drain_requests(); + return; + } + let modal = self.dialog.as_ref().is_some_and(|d| d.modal()) || self.palette.is_some(); + // While slide text is being edited, the text owns the keyboard (#93). Tab and the arrow + // keys move egui's keyboard focus onto ordinary widgets (the window's drag handle, ribbon + // buttons), and that stray focus would swallow every later key and end text entry. Let it + // go; a real text field or an open menu keeps its keys. + if !modal + && self.session.active().is_some_and(|d| d.selection.text.is_some()) + && !ctx.text_edit_focused() + && !ctx.any_popup_open() + && let Some(id) = ctx.memory(|m| m.focused()) + { + ctx.memory_mut(|m| m.surrender_focus(id)); + } + if ctx.memory(|m| m.focused().is_some()) || modal { + // A stock field/modal/palette owns input now. Retire the custom fence, then visit + // controls in raw order; stock text/key events are left for the stock widgets. + self.suspend_ime_reset(viewport); + self.route_ime_controls_only(ctx, &events); + return; + } + if self.backstage.is_some() { + self.suspend_ime_reset(viewport); + self.route_ime_controls_only(ctx, &events); + self.route_backstage_keys(events); + self.drain_requests(); + return; + } + let img = if image_paste { self.services.clipboard_image.as_mut().and_then(|f| f()) } else { None }; + if let Some(png) = img { + let data = deckcraft_engine::cmd::base64_encode(&png); + if let Err(e) = self.session.execute("insert.picture", &json!({"name": "Pasted image.png", "data": data})) { + self.set_status(e.to_string()); + } + } + for e in events { + // An earlier command in this raw batch may have settled the composition. Request the + // replacement at this ordered boundary, before the current event is considered. + self.note_settled_composition(ctx); + match e { + egui::Event::ImeResetCompleted { request_id } => self.ime_reset_completed(request_id, ctx), + egui::Event::PasteImage(image) => match color_image_png(&image) { + Ok(png) => { + let params = json!({"name": "Pasted image.png", "data": deckcraft_engine::cmd::base64_encode(&png)}); + if let Err(e) = self.session.execute("insert.picture", ¶ms) { + self.set_status(e.to_string()); + } + } + Err(e) => self.set_status(e), + }, + egui::Event::Text(t) => { + let revision = self.session.active().map(|d| d.revision); + if self.session.active().is_some_and(|d| d.selection.text.is_some() || d.selection.shapes.len() == 1) + && let Err(e) = self.session.type_text(&t) + { + self.set_status(e.to_string()); + } + if self.session.active().map(|d| d.revision) != revision { + self.note_application_edit(ctx); + } + } + egui::Event::Ime(ime) => self.ime_event(ctx, &ime), + egui::Event::Copy => self.publish_clipboard(ctx, false), + egui::Event::Cut => self.publish_clipboard(ctx, true), + egui::Event::Paste(text) => { + // Our own marker for objects without text: paste what the session copied. + let text = if text == OBJECTS_MARKER { String::new() } else { text }; + // A picture (TIFF from Keynote, a screenshot) lands when the paste has no text. + // Text keeps its HTML (bold, italic, underline) when the host can read it. + let html = self.services.clipboard_html.as_mut().and_then(|f| f()).filter(|h| !h.is_empty()); + // egui 0.37 sends `PasteImage` directly; the services fallback stays for + // integrations that still send an empty `Paste`. Never insert twice. + let img = if text.is_empty() && !pasted_image_event { self.services.clipboard_image.as_mut().and_then(|f| f()) } else { None }; + let revision = self.session.active().map(|d| d.revision); + let r = match img { + Some(png) => self + .session + .execute("insert.picture", &json!({"name": "Pasted image.png", "data": deckcraft_engine::cmd::base64_encode(&png)})), + None => { + let mut params = json!({"text": text}); + if let Some(h) = html { + params["html"] = Value::String(h); + } + self.session.execute("edit.paste", ¶ms) + } + }; + if let Err(e) = r { + self.set_status(e.to_string()); + } + if self.session.active().map(|d| d.revision) != revision { + self.note_application_edit(ctx); + } + } + egui::Event::Key { key, pressed: true, modifiers, .. } => { + if key == egui::Key::Tab { + // The slide has the keyboard, so Tab is ours (indent, tab character, next + // shape). Left to egui it would also focus the first ribbon widget, and + // every key after it would go there instead of the slide (#78). + ctx.memory_mut(|m| m.move_focus(egui::FocusDirection::None)); + } + if key == egui::Key::Escape && self.anim_preview.is_some() && !modifiers.command && !modifiers.ctrl && !modifiers.alt { + self.anim_preview = None; + continue; + } + let mods = Mods { shift: modifiers.shift, alt: modifiers.alt, cmd: modifiers.command || modifiers.ctrl }; + if !self.session.tool.drawing_freeform() && menus::ui_shortcut(self, key, mods) { + continue; + } + let name = key_name(key); + let revision = self.session.active().map(|d| d.revision); + match self.session.key(name, mods) { + Ok(_) => {} + Err(e) => self.set_status(e.to_string()), + } + if self.session.active().map(|d| d.revision) != revision { + self.note_application_edit(ctx); + } + } + _ => {} + } + } + self.drain_requests(); + } +} + +/// Engine key name for an egui key. +pub fn key_name(key: egui::Key) -> &'static str { + use egui::Key::*; + match key { + Enter => "Enter", + Escape => "Escape", + Backspace => "Backspace", + Delete => "Delete", + Tab => "Tab", + ArrowLeft => "Left", + ArrowRight => "Right", + ArrowUp => "Up", + ArrowDown => "Down", + Home => "Home", + End => "End", + PageUp => "PageUp", + PageDown => "PageDown", + Space => "Space", + F2 => "F2", + F5 => "F5", + F7 => "F7", + other => other.name(), + } +} + +/// Largest clipboard image accepted into the document: 16 megapixels, the same bound as +/// background removal. The image was already allocated by the integration; this keeps the +/// RGBA copy, PNG and base64 from multiplying an arbitrarily large paste before `insert.picture`. +const MAX_PASTE_IMAGE_PIXELS: usize = 16_000_000; + +/// Encode an egui color image as the PNG bytes `insert.picture` stores. +fn color_image_png(image: &egui::ColorImage) -> Result, String> { + let [w, h] = image.size; + let pixels = w.checked_mul(h).ok_or_else(|| "the pasted image is too large".to_string())?; + if pixels == 0 { + return Err("the pasted image is empty".into()); + } + if pixels > MAX_PASTE_IMAGE_PIXELS { + return Err(format!("the pasted image is {w}×{h} pixels; the limit is 16 megapixels")); + } + let rgba: Vec = image.pixels.iter().flat_map(|c| c.to_array()).collect(); + Ok(deckcraft_render::Image { width: w as u32, height: h as u32, pixels: rgba }.to_png()) +} diff --git a/crates/ui-egui/src/app/playback.rs b/crates/ui-egui/src/app/playback.rs new file mode 100644 index 00000000..33b173d3 --- /dev/null +++ b/crates/ui-egui/src/app/playback.rs @@ -0,0 +1,171 @@ +use super::*; + +impl SlideApp { + /// Whether ⌘A should act on the slide list: always in the sorter, in Normal view once the + /// thumbnail pane has been clicked. + pub(crate) fn slides_list_focused(&self) -> bool { + self.ui.view == ViewMode::Sorter || (self.ui.view == ViewMode::Normal && self.slides_focused) + } + + /// First slide Play from Start should open: the custom show or range, skipping hidden slides. + pub fn show_start_index(&self) -> usize { + self.session.active().and_then(|d| deckcraft_anim::show_order(&d.doc).first().copied()).unwrap_or(0) + } + + /// The slide `show.start {from}` names: `Ok(None)` when absent, `Err` when it is not a slide of + /// the open deck (negative, non-integer, past the end), so a bad index never plays slide 0 or + /// opens on the end screen. Hidden slides are allowed, as in "from current slide". + pub(crate) fn show_from(&self, p: &Value) -> Result, String> { + let from = match p.get("from") { + None | Some(Value::Null) => return Ok(None), + Some(v) => v.as_u64().and_then(|v| usize::try_from(v).ok()).ok_or_else(|| format!("`from` is not a valid index: {v}"))?, + }; + let count = self.session.active().map_or(0, |d| d.doc.slides.len()); + if from >= count { + return Err(format!("no slide {from}")); + } + Ok(Some(from)) + } + + /// Leave animation preview, stop editor-owned media, and create a show at the requested slide + /// with the current reading/presenter configuration; an out-of-range slide starts an ended show. + pub fn start_show(&mut self, from: usize, reading: bool) { + self.anim_preview = None; + self.media.stop_owned(media::Owner::Editor); + if let Some(d) = self.session.active() { + self.show_instance = self.show_instance.wrapping_add(1).max(1); + let mut show = show::Show::new(&d.doc, from, reading, self.ui.presenter_view && !reading); + show.instance = self.show_instance; + self.show = Some(show); + } + } + + /// Play custom show `name` from its first slide. Set Up Slide Show is left as it was. + pub fn start_custom_show(&mut self, name: &str, reading: bool) { + let state = self.session.active().and_then(|d| deckcraft_anim::ShowState::play_custom(&d.doc, name)); + let Some(state) = state else { return }; + let from = state.slide; + self.start_show(from, reading); + if let Some(show) = self.show.as_mut() { + show.state = state; + } + } + + /// The current slide's animations are playing on the editor canvas. + pub fn anim_preview_active(&self) -> bool { + self.anim_preview.is_some() + } + + /// `(slide, seconds played, sequence length)` while a preview is running. + pub fn anim_preview_report(&self) -> Option<(usize, f64, f64)> { + self.anim_preview.as_ref().map(|p| (p.slide, p.elapsed, p.timeline.total_duration())) + } + + pub fn stop_anim_preview(&mut self) { + self.anim_preview = None; + } + + /// Timeline and playhead for this frame's canvas raster. `None` when the editor should draw the slide at rest. + pub fn anim_preview_draw(&self) -> Option<(deckcraft_anim::Timeline, f64)> { + self.anim_preview.as_ref().map(|p| (p.timeline.clone(), p.elapsed)) + } + + /// Advance the preview clock. Stops when the slide changes, the slide is edited, or the sequence + /// (plus a short hold) has finished. + pub fn tick_anim_preview(&mut self, now: f64) -> bool { + let Some(p) = self.anim_preview.as_ref() else { return false }; + let (uid, slide, ptr) = (p.uid, p.slide, p.slide_ptr); + let alive = self.show.is_none() + && self.session.active().is_some_and(|d| { + d.uid == uid + && d.selection.target == deckcraft_engine::Target::Slides + && d.selection.slide == slide + && d.doc.slides.get(slide).is_some_and(|s| std::sync::Arc::as_ptr(s) as usize == ptr) + }); + if !alive { + self.anim_preview = None; + return false; + } + let Some(p) = self.anim_preview.as_mut() else { return false }; + let now = if now.is_finite() { now } else { p.started.unwrap_or(0.0) }; + let start = *p.started.get_or_insert(now); + let elapsed = p.from + (now - start).max(0.0); + if !elapsed.is_finite() || elapsed > p.timeline.total_duration() + preview::HOLD_SECS { + self.anim_preview = None; + return false; + } + p.elapsed = elapsed; + true + } + + /// Play the current slide's animations on the canvas, from effect `from` (index into the slide's + /// animations) or from the start. + pub(super) fn start_anim_preview(&mut self, from: Option) -> Result { + let (uid, slide, ptr, timeline) = { + let Some(d) = self.session.active() else { + return Err("no presentation is open".into()); + }; + if d.selection.target != deckcraft_engine::Target::Slides { + return Err("animation preview plays the current slide".into()); + } + let slide = d.selection.slide; + let Some(slide_arc) = d.doc.slides.get(slide) else { + return Err("no slide to preview".into()); + }; + let ptr = std::sync::Arc::as_ptr(slide_arc) as usize; + let timeline = deckcraft_anim::ShowState::timeline(&d.doc, slide); + (d.uid, slide, ptr, timeline) + }; + if timeline.is_empty() { + self.anim_preview = None; + self.set_status("This slide has no animations"); + return Ok(json!({"playing": false})); + } + let from_secs = match from { + Some(i) => timeline.start_of(i).ok_or_else(|| format!("no effect {i} in this slide's click sequence"))?, + None => 0.0, + }; + if matches!(self.ui.view, ViewMode::Sorter | ViewMode::NotesPage) { + self.ui.view = ViewMode::Normal; + } + self.close_backstage(); + let duration = timeline.total_duration(); + self.anim_preview = Some(AnimPreview { uid, slide, slide_ptr: ptr, started: None, from: from_secs, elapsed: from_secs, timeline }); + self.set_status("Previewing animations"); + Ok(json!({"playing": true, "slide": slide, "duration": duration, "from": from_secs})) + } + + /// Keep a hidden show playing from logic alone. `run_logic` does not refresh + /// `InputState::time`, so the show anchors its last visible time to this monotonic instant + /// and advances from there. A forced hidden paint draws the current state but does not + /// advance it a second time, and no paused-show product policy is invented. + pub(crate) fn tick_hidden_show(&mut self, ctx: &egui::Context, now: web_time::Instant) { + let visible = show::viewport_visible(ctx); + let Some(doc) = self.session.active().map(|d| d.doc.clone()) else { return }; + let Some(show) = self.show.as_mut() else { return }; + if visible { + // A visible logic-only pass (`run_logic`) carries the last full frame's input time. + // Anchoring that stale time to `now` would move the hidden clock backwards if the + // window hides before the next full pass. `show::ui` already records the anchor on + // every visible full pass, where `InputState::time` is current. + return; + } + if !show.hidden_started() { + return; + } + let input_time = ctx.input(|i| i.time); + let show_time = show.hidden_logic_time(input_time, now); + let keep = show.advance_hidden(&doc, show_time); + let time_driven = show.hidden_time_driven(&doc); + if !keep { + self.show = None; + return; + } + if time_driven { + // A hidden event loop has no draw to piggyback on. A delayed repaint keeps the + // logic clock ticking; with no paint request pending eframe services it as a + // logic-only pass, not a visible frame. + ctx.request_repaint_after_for(std::time::Duration::from_millis(100), ctx.viewport_id()); + } + } +}