mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-27 03:56:50 -06:00
refactor(ui-egui): move SlideApp method groups to app/{commands,files,ime,input,keys,playback,clipboard,frame,control}.rs
This commit is contained in:
+14
-2489
File diff suppressed because it is too large
Load Diff
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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<Option<bool>, 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<Value, String> {
|
||||
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<Value, String> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Vec<u8>>) -> 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<usize> = 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<usize>) {
|
||||
if let Some(i) = view
|
||||
&& i < self.session.documents().len()
|
||||
{
|
||||
self.session.set_active(i);
|
||||
}
|
||||
}
|
||||
|
||||
fn locate_place(&self, place: &FilePlace) -> Result<LocatedPlace, String> {
|
||||
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 `<name>1.png`,
|
||||
/// `<name>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<u8>)> {
|
||||
let slides: Vec<usize> = 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()
|
||||
}
|
||||
@@ -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<deckcraft_model::ShapeId> = 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<egui::ime_context::ImeTarget> {
|
||||
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<egui::ime_context::ImeMutationStamp> {
|
||||
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<ImeDestination> {
|
||||
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<ImeTerminalStamp> {
|
||||
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::<bool>(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<ImeDescriptorKey>) {
|
||||
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<egui::Id>) -> 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<Value> {
|
||||
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<egui::Id>, 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<ImeDescriptorKey> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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<egui::Event>) -> 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<egui::Event> = 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;
|
||||
}
|
||||
}
|
||||
@@ -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<egui::Event>) {
|
||||
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<Vec<u8>, 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<u8> = 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())
|
||||
}
|
||||
@@ -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<Option<usize>, 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<usize>) -> Result<Value, String> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user