From 8c6c72e39ff244c8c26f7e608d08e0b1b8a0ef57 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 28 Oct 2026 06:02:27 +0000 Subject: [PATCH] Refactor ui-egui for strict Clippy checks --- crates/ui-egui/src/app.rs | 8 +- crates/ui-egui/src/app/clipboard.rs | 2 +- crates/ui-egui/src/app/commands.rs | 6 +- crates/ui-egui/src/app/control.rs | 2 +- crates/ui-egui/src/app/files.rs | 7 +- crates/ui-egui/src/app/frame.rs | 2 +- crates/ui-egui/src/app/ime.rs | 5 +- crates/ui-egui/src/app/input.rs | 2 +- crates/ui-egui/src/app/keys.rs | 2 +- crates/ui-egui/src/app/playback.rs | 2 +- crates/ui-egui/src/backstage/files.rs | 5 +- crates/ui-egui/src/backstage/info.rs | 2 +- crates/ui-egui/src/backstage/print.rs | 5 +- crates/ui-egui/src/canvas.rs | 112 +- crates/ui-egui/src/canvas/context_menu.rs | 496 ++-- crates/ui-egui/src/canvas/guides.rs | 2 +- crates/ui-egui/src/canvas/ime.rs | 2 +- crates/ui-egui/src/canvas/input.rs | 2 +- crates/ui-egui/src/canvas/media.rs | 2 +- crates/ui-egui/src/canvas/overlays.rs | 32 +- crates/ui-egui/src/canvas/rulers.rs | 2 +- crates/ui-egui/src/canvas/scroll.rs | 2 +- crates/ui-egui/src/dialogs/about.rs | 5 +- crates/ui-egui/src/dialogs/body.rs | 2654 ++++++++++--------- crates/ui-egui/src/dialogs/columns.rs | 2 +- crates/ui-egui/src/dialogs/date_time.rs | 2 +- crates/ui-egui/src/dialogs/export.rs | 2 +- crates/ui-egui/src/dialogs/find.rs | 2 +- crates/ui-egui/src/dialogs/font.rs | 2 +- crates/ui-egui/src/dialogs/grid_options.rs | 2 +- crates/ui-egui/src/dialogs/hyperlink.rs | 2 +- crates/ui-egui/src/dialogs/language.rs | 2 +- crates/ui-egui/src/dialogs/paragraph.rs | 2 +- crates/ui-egui/src/dialogs/photo_album.rs | 2 +- crates/ui-egui/src/dialogs/preferences.rs | 2 +- crates/ui-egui/src/dialogs/size_position.rs | 2 +- crates/ui-egui/src/dialogs/slide_size.rs | 2 +- crates/ui-egui/src/dialogs/tabs.rs | 2 +- crates/ui-egui/src/dialogs/theme_colors.rs | 2 +- crates/ui-egui/src/dialogs/theme_fonts.rs | 2 +- crates/ui-egui/src/dialogs/zoom.rs | 2 +- crates/ui-egui/src/doc_window.rs | 21 +- crates/ui-egui/src/icons/draw.rs | 395 +-- crates/ui-egui/src/icons/geometry.rs | 31 +- crates/ui-egui/src/media.rs | 2 +- crates/ui-egui/src/panels/animation.rs | 5 +- crates/ui-egui/src/panels/background.rs | 2 +- crates/ui-egui/src/panels/comments.rs | 2 +- crates/ui-egui/src/panels/designer.rs | 2 +- crates/ui-egui/src/panels/format_shape.rs | 207 +- crates/ui-egui/src/panels/selpane.rs | 63 +- crates/ui-egui/src/panels/smartart.rs | 2 +- crates/ui-egui/src/panels/thesaurus.rs | 2 +- crates/ui-egui/src/ribbon/animations.rs | 5 +- crates/ui-egui/src/ribbon/arrange.rs | 4 +- crates/ui-egui/src/ribbon/chart.rs | 2 +- crates/ui-egui/src/ribbon/design.rs | 5 +- crates/ui-egui/src/ribbon/draw.rs | 2 +- crates/ui-egui/src/ribbon/galleries.rs | 9 +- crates/ui-egui/src/ribbon/home.rs | 7 +- crates/ui-egui/src/ribbon/insert.rs | 5 +- crates/ui-egui/src/ribbon/masters.rs | 2 +- crates/ui-egui/src/ribbon/picture_format.rs | 5 +- crates/ui-egui/src/ribbon/playback.rs | 2 +- crates/ui-egui/src/ribbon/review.rs | 2 +- crates/ui-egui/src/ribbon/shape_format.rs | 5 +- crates/ui-egui/src/ribbon/slide_show.rs | 2 +- crates/ui-egui/src/ribbon/smartart.rs | 4 +- crates/ui-egui/src/ribbon/table.rs | 5 +- crates/ui-egui/src/ribbon/title_bar.rs | 2 +- crates/ui-egui/src/ribbon/transitions.rs | 5 +- crates/ui-egui/src/ribbon/view.rs | 2 +- crates/ui-egui/src/show/keys.rs | 2 +- crates/ui-egui/src/show/media.rs | 2 +- crates/ui-egui/src/show/presenter.rs | 2 +- crates/ui-egui/src/show/render.rs | 5 +- crates/ui-egui/src/show/ui.rs | 740 +++--- crates/ui-egui/src/show/zoom.rs | 4 +- crates/ui-egui/src/state.rs | 2 +- crates/ui-egui/src/textures.rs | 6 +- 80 files changed, 2649 insertions(+), 2318 deletions(-) diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index c4b618d4..8496d0ed 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -2,7 +2,13 @@ use super::clock::{monotonic_secs, now_ms}; use super::ime_hooks::ime_in_ui_pass_key; use super::services::{InboxEvent, Services}; use super::state::{FilePlace, FilePurpose, ImeDescriptorKey, ImeDestination, ImeTerminalStamp, LocatedPlace, RECENT_MAX, UiState, ViewMode}; -use super::*; +#[cfg(not(target_arch = "wasm32"))] +use super::updates_ui; +use super::{ + AnimPreview, ControlRequest, FileTarget, HashMap, ImeCancelled, ImeComposition, ImeResetPhase, ImeTerminalPending, InputTarget, Mods, + PendingFile, Perf, Receiver, Session, SlideApp, Target, UI_COMMANDS, UiRequest, Value, backstage, canvas, capture, dialogs, doc_window, + eyedropper, json, media, menus, notes, panes, preview, qat, ribbon, selpane, show, sorter, status, textures, theme, thumbs, toast, uifonts, +}; mod clipboard; mod commands; diff --git a/crates/ui-egui/src/app/clipboard.rs b/crates/ui-egui/src/app/clipboard.rs index 10778afc..19fd48bf 100644 --- a/crates/ui-egui/src/app/clipboard.rs +++ b/crates/ui-egui/src/app/clipboard.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{SlideApp, Value, json}; /// 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 diff --git a/crates/ui-egui/src/app/commands.rs b/crates/ui-egui/src/app/commands.rs index 8f0e462f..216dd30d 100644 --- a/crates/ui-egui/src/app/commands.rs +++ b/crates/ui-egui/src/app/commands.rs @@ -1,4 +1,6 @@ -use super::*; +#[cfg(not(target_arch = "wasm32"))] +use super::updates_ui; +use super::{SlideApp, UI_COMMANDS, Value, ViewMode, backstage, dialogs, doc_window, json, media, qat, theme}; fn grid_options_touched(p: &Value) -> bool { ["spacing", "snap", "smartGuides", "gridlines", "guides"].iter().any(|k| p.get(*k).is_some()) @@ -266,7 +268,7 @@ impl SlideApp { self.dialog = Some(dialogs::Dialog::new(d)); } "app.links" => { - use deckcraft_engine::links::*; + use deckcraft_engine::links::{APP_PAGE, APPS, DISCORD, GITHUB, WEBSITE}; return Ok(json!({"discord": DISCORD, "website": WEBSITE, "apps": APPS, "appPage": APP_PAGE, "github": GITHUB})); } _ => return Err(format!("unknown UI command `{id}`")), diff --git a/crates/ui-egui/src/app/control.rs b/crates/ui-egui/src/app/control.rs index f6d73e23..81e6c479 100644 --- a/crates/ui-egui/src/app/control.rs +++ b/crates/ui-egui/src/app/control.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{SlideApp, json}; use crate::control; impl SlideApp { diff --git a/crates/ui-egui/src/app/files.rs b/crates/ui-egui/src/app/files.rs index c035fcbf..0f3c944f 100644 --- a/crates/ui-egui/src/app/files.rs +++ b/crates/ui-egui/src/app/files.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + FilePlace, FilePurpose, FileTarget, InboxEvent, LocatedPlace, PendingFile, RECENT_MAX, SlideApp, Target, UiRequest, Value, ViewMode, dialogs, + json, media, +}; fn purpose_of(purpose: &str) -> FilePurpose { match purpose { @@ -446,7 +449,7 @@ impl SlideApp { 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); + self.media.request(uid, &doc, slide, &action, (shape, ms), owner); } } } diff --git a/crates/ui-egui/src/app/frame.rs b/crates/ui-egui/src/app/frame.rs index 7a458810..0da3896b 100644 --- a/crates/ui-egui/src/app/frame.rs +++ b/crates/ui-egui/src/app/frame.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{SlideApp, json, media, monotonic_secs, theme}; impl SlideApp { /// Style, fonts and zoom setup. Runs in a real UI pass, not from `run_logic`, because diff --git a/crates/ui-egui/src/app/ime.rs b/crates/ui-egui/src/app/ime.rs index a4bbfee0..3632b368 100644 --- a/crates/ui-egui/src/app/ime.rs +++ b/crates/ui-egui/src/app/ime.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + ImeCancelled, ImeComposition, ImeDescriptorKey, ImeDestination, ImeResetPhase, ImeTerminalPending, ImeTerminalStamp, SlideApp, Target, Value, + ViewMode, ime_in_ui_pass_key, +}; impl SlideApp { /// Borrow faces for interface text in scripts the interface fonts lack (#101): the open diff --git a/crates/ui-egui/src/app/input.rs b/crates/ui-egui/src/app/input.rs index 463fbb8c..c5060171 100644 --- a/crates/ui-egui/src/app/input.rs +++ b/crates/ui-egui/src/app/input.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{InputTarget, SlideApp, show}; impl SlideApp { /// What a queued synthetic event is meant for. The control protocol hands out window diff --git a/crates/ui-egui/src/app/keys.rs b/crates/ui-egui/src/app/keys.rs index 2d821856..d6903e82 100644 --- a/crates/ui-egui/src/app/keys.rs +++ b/crates/ui-egui/src/app/keys.rs @@ -1,5 +1,5 @@ use super::clipboard::OBJECTS_MARKER; -use super::*; +use super::{Mods, SlideApp, Value, backstage, json, menus}; impl SlideApp { /// The app shortcuts that still make sense on the empty File/start screen: create, open, diff --git a/crates/ui-egui/src/app/playback.rs b/crates/ui-egui/src/app/playback.rs index 33b173d3..1b4f1d25 100644 --- a/crates/ui-egui/src/app/playback.rs +++ b/crates/ui-egui/src/app/playback.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{AnimPreview, SlideApp, Value, ViewMode, json, media, preview, show}; impl SlideApp { /// Whether ⌘A should act on the slide list: always in the sorter, in Normal view once the diff --git a/crates/ui-egui/src/backstage/files.rs b/crates/ui-egui/src/backstage/files.rs index 58bdf94b..4b7cec66 100644 --- a/crates/ui-egui/src/backstage/files.rs +++ b/crates/ui-egui/src/backstage/files.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + Align2, Backstage, CONTENT_W, Color32, CornerRadius, Icon, Outcome, Page, Rect, RichText, Sense, SlideApp, Stroke, StrokeKind, Tokens, Ui, Value, + arrow_link, dim, heading, icons, json, pos2, search, theme, vec2, +}; /// A new-presentation tile: a theme preview drawn in code (or a blank slide) over its name. fn tile(ui: &mut Ui, label: &str, theme: Option<&deckcraft_model::Theme>, big: bool) -> egui::Response { diff --git a/crates/ui-egui/src/backstage/info.rs b/crates/ui-egui/src/backstage/info.rs index e00acdd6..690e5524 100644 --- a/crates/ui-egui/src/backstage/info.rs +++ b/crates/ui-egui/src/backstage/info.rs @@ -1,5 +1,5 @@ use super::files::task; -use super::*; +use super::{Backstage, Icon, Outcome, Page, RichText, Sense, SlideApp, Tokens, Ui, Value, dim, heading, json, theme, vec2}; /// Save As and Change File Type: (id for [`SlideApp::export_as`], label, detail). pub const SAVE_TYPES: [(&str, &str, &str); 7] = [ diff --git a/crates/ui-egui/src/backstage/print.rs b/crates/ui-egui/src/backstage/print.rs index 1e997049..612ecaf7 100644 --- a/crates/ui-egui/src/backstage/print.rs +++ b/crates/ui-egui/src/backstage/print.rs @@ -1,6 +1,9 @@ use super::files::{place_row, square_button}; use super::info::SAVE_TYPES; -use super::*; +use super::{ + Align2, Backstage, Color32, CornerRadius, ExportTask, Icon, PrintScope, Rect, RichText, Sense, SlideApp, Stroke, StrokeKind, Tokens, Ui, Value, + bullets, dim, heading, icon_button, json, pos2, theme, vec2, +}; /// The slides the Print page prints, in order (0-based). pub fn print_slides(doc: &deckcraft_model::Presentation, bs: &Backstage, current: usize) -> Result, String> { diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 97b17388..17ff3d44 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -83,6 +83,69 @@ pub fn layout(app: &SlideApp, avail: Rect) -> (Rect, f32) { (r, scale) } +/// The mouse wheel or a trackpad flick turns slides when the canvas has nothing to scroll. +fn wheel_navigation(app: &mut SlideApp, ui: &mut Ui, carry_id: egui::Id, doc_uid: u64) { + if app.anim_preview_active() || app.session.tool.marquee.is_some() || app.session.tool.dragging() { + return; + } + let (_, mut carry): (u64, f32) = ui.data_mut(|d| d.get_temp(carry_id).unwrap_or_default()); + let wheel_step = app.session.active().and_then(|d| { + let current = d.selection.slide; + ui.input(|i| wheel_slide_target(&i.events, current, d.doc.slides.len(), &mut carry)).map(|target| (current, target)) + }); + ui.data_mut(|d| d.insert_temp(carry_id, (doc_uid, carry))); + if let Some((current, index)) = wheel_step { + if index != current { + let _ = app.run("slide.go", json!({"index": index})); + } + } else if let Some(next) = page_flick(ui.input(|i| trackpad_scroll(&i.events))) { + // Point-based trackpad gestures keep their cooldown. Raw input avoids reading + // the smoothed tail of a discrete mouse notch as another page turn. + let gate = ui.scope_id().with("wheel_gate"); + let last: f64 = ui.data_mut(|d| d.get_temp(gate).unwrap_or(f64::NEG_INFINITY)); + let now = ui.input(|i| i.time); + if now - last > 0.35 { + ui.data_mut(|d| d.insert_temp(gate, now)); + let _ = app.run(if next { "slide.next" } else { "slide.previous" }, json!({})); + } + } +} + +fn empty_deck_prompt(app: &mut SlideApp, ui: &mut Ui, avail: Rect, t: &Tokens) { + let resp = ui.allocate_rect(avail, Sense::click()); + ui.painter().text(avail.center(), Align2::CENTER_CENTER, "Click to add first slide", theme::font(22.0), t.text_dim); + if resp.clicked() { + let _ = app.run("slide.new", json!({"layout": "title"})); + } +} + +fn reset_wheel_carry(ui: &Ui, carry_id: egui::Id, doc_uid: u64) { + ui.data_mut(|d| { + if d.get_temp::<(u64, f32)>(carry_id).is_none_or(|(uid, _)| uid != doc_uid) { + d.insert_temp(carry_id, (doc_uid, 0.0_f32)); + } + }); +} + +/// The scroll offset that keeps the slide point under `pointer` (or the canvas middle) fixed +/// when the scale changed since the last frame. +fn anchored_offset(last: Option<(Rect, f32)>, content_rect: Rect, scale: f32, pointer: Option, avail: Rect) -> Option { + let (last_rect, last_scale) = last?; + if (scale - last_scale).abs() <= 1e-6 { + return None; + } + let anchor = pointer.unwrap_or(avail.center()); + let at = (anchor - last_rect.min) / last_scale; + Some(content_rect.min + at * scale - anchor) +} + +fn slide_gifs(app: &mut SlideApp, now: f64) -> Vec { + match app.session.active() { + Some(d) if d.selection.target == Target::Slides => app.media.gifs(d.uid, &d.doc, d.selection.slide, now, &app.session.gif_paused), + _ => vec![], + } +} + /// Paint the editing canvas, including rulers, selection handles, overlays, and current /// zoom/scroll state. pub fn show(app: &mut SlideApp, ui: &mut Ui) { @@ -94,17 +157,9 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { // one is drawn here, hovered or not, so switching away and back starts from zero. let carry_id = ui.scope_id().with("wheel_carry"); let doc_uid = st.uid; - ui.data_mut(|d| { - if d.get_temp::<(u64, f32)>(carry_id).is_none_or(|(uid, _)| uid != doc_uid) { - d.insert_temp(carry_id, (doc_uid, 0.0_f32)); - } - }); + reset_wheel_carry(ui, carry_id, doc_uid); if st.doc.slides.is_empty() && st.selection.target == Target::Slides { - let resp = ui.allocate_rect(avail, Sense::click()); - ui.painter().text(avail.center(), Align2::CENTER_CENTER, "Click to add first slide", theme::font(22.0), t.text_dim); - if resp.clicked() { - let _ = app.run("slide.new", json!({"layout": "title"})); - } + empty_deck_prompt(app, ui, avail, &t); return; } // Scroll when zoomed beyond the window. @@ -122,12 +177,8 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { let (content_rect, scale) = layout(app, avail); // A new zoom keeps the slide point under the pointer (wheel, pinch) or under the middle of the // canvas (Zoom In/Out, the slider) where it was, instead of growing from the corner (#106). - if let Some((last_rect, last_scale)) = last - && (scale - last_scale).abs() > 1e-6 - { - let anchor = if zooming { hover.unwrap_or(avail.center()) } else { avail.center() }; - let at = (anchor - last_rect.min) / last_scale; - offset = content_rect.min + at * scale - anchor; + if let Some(o) = anchored_offset(last, content_rect, scale, zooming.then_some(hover).flatten(), avail) { + offset = o; } let overflow = vec2((content_rect.width() + 56.0 - avail.width()).max(0.0), (content_rect.height() + 56.0 - avail.height()).max(0.0)); // Scroll bars on the axes that overflow (#1157). They sit over the canvas edge and take their @@ -175,28 +226,8 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { if (resp.hovered() || on_bar) && !zooming { if overflow != egui::Vec2::ZERO { offset -= wheel; - } else if !app.anim_preview_active() && app.session.tool.marquee.is_none() && !app.session.tool.dragging() { - let (_, mut carry): (u64, f32) = ui.data_mut(|d| d.get_temp(carry_id).unwrap_or_default()); - let wheel_step = app.session.active().and_then(|d| { - let current = d.selection.slide; - ui.input(|i| wheel_slide_target(&i.events, current, d.doc.slides.len(), &mut carry)).map(|target| (current, target)) - }); - ui.data_mut(|d| d.insert_temp(carry_id, (doc_uid, carry))); - if let Some((current, index)) = wheel_step { - if index != current { - let _ = app.run("slide.go", json!({"index": index})); - } - } else if let Some(next) = page_flick(ui.input(|i| trackpad_scroll(&i.events))) { - // Point-based trackpad gestures keep their cooldown. Raw input avoids reading - // the smoothed tail of a discrete mouse notch as another page turn. - let gate = ui.scope_id().with("wheel_gate"); - let last: f64 = ui.data_mut(|d| d.get_temp(gate).unwrap_or(f64::NEG_INFINITY)); - let now = ui.input(|i| i.time); - if now - last > 0.35 { - ui.data_mut(|d| d.insert_temp(gate, now)); - let _ = app.run(if next { "slide.next" } else { "slide.previous" }, json!({})); - } - } + } else { + wheel_navigation(app, ui, carry_id, doc_uid); } } offset.x = offset.x.clamp(0.0, overflow.x); @@ -218,10 +249,7 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { painter.rect_filled(slide_rect.translate(vec2(0.0, 1.5)).expand(1.0), CornerRadius::same(2), t.shadow); let px = ((slide_rect.width() * ppp).round().max(1.0) as u32, (slide_rect.height() * ppp).round().max(1.0) as u32); let now = ui.input(|i| i.time); - let gifs = match app.session.active() { - Some(d) if d.selection.target == Target::Slides => app.media.gifs(d.uid, &d.doc, d.selection.slide, now, &app.session.gif_paused), - _ => vec![], - }; + let gifs = slide_gifs(app, now); crate::media::gif_repaint(ui.ctx(), &gifs); let times: Vec<_> = gifs.iter().map(|g| (g.id, g.t)).collect(); let anim = app.anim_preview_draw(); diff --git a/crates/ui-egui/src/canvas/context_menu.rs b/crates/ui-egui/src/canvas/context_menu.rs index 260838eb..1aa1c928 100644 --- a/crates/ui-egui/src/canvas/context_menu.rs +++ b/crates/ui-egui/src/canvas/context_menu.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Hit, SlideApp, json}; pub(super) fn context_menu(app: &mut SlideApp, resp: &egui::Response) { // Right-clicking a vertex in Edit Points picks it for the point menu. @@ -10,138 +10,12 @@ pub(super) fn context_menu(app: &mut SlideApp, resp: &egui::Response) { resp.context_menu(|ui| { let editing = app.session.active().and_then(|d| app.session.tool.editing_points(d).and_then(|id| d.shape(id).cloned())); if let Some(sh) = editing { - if let Some((path, index)) = app.session.tool.edit_segment { - let cur = deckcraft_engine::cmd::points::segment_kind_of(&sh, path, index); - for (l, k) in [("Straight Segment", "straight"), ("Curved Segment", "curved")] { - if ui.radio(cur == Some(k), l).clicked() { - let _ = app.run("shape.segmentType", json!({"path": path, "segment": index, "type": k})); - ui.close(); - } - } - // Each of these renumbers the points, so the picked segment is dropped. - if ui.button("Add Point").clicked() { - let t = app.session.tool.edit_segment_t.unwrap_or(0.5); - let _ = app.run("shape.addPoint", json!({"path": path, "segment": index, "t": t})); - app.session.tool.edit_segment = None; - ui.close(); - } - let closed = deckcraft_engine::cmd::points::closed_at(&sh, path, index); - if ui.add_enabled(closed == Some(true), egui::Button::new("Delete Segment")).clicked() { - let _ = app.run("shape.deleteSegment", json!({"path": path, "segment": index})); - app.session.tool.edit_segment = None; - ui.close(); - } - // Open Path opens the outline at the segment's start point. - let (label, id, at) = match closed { - Some(false) => ("Close Path", "shape.closePath", index), - _ => ("Open Path", "shape.openPath", deckcraft_engine::cmd::points::segment_start_of(&sh, path, index).unwrap_or(index)), - }; - if ui.button(label).clicked() { - let _ = app.run(id, json!({"path": path, "index": at})); - app.session.tool.edit_segment = None; - ui.close(); - } - ui.separator(); - } else if let Some((path, index)) = app.session.tool.edit_vertex { - let cur = deckcraft_engine::cmd::points::kind_of(&sh, path, index); - for (l, k) in [("Smooth Point", "smooth"), ("Straight Point", "straight"), ("Corner Point", "corner")] { - if ui.radio(cur == Some(k), l).clicked() { - let _ = app.run("shape.pointType", json!({"path": path, "index": index, "type": k})); - ui.close(); - } - } - if ui.button("Delete Point").clicked() { - let _ = app.run("shape.deletePoint", json!({"path": path, "index": index})); - app.session.tool.edit_vertex = None; - ui.close(); - } - let (label, id) = match deckcraft_engine::cmd::points::closed_at(&sh, path, index) { - Some(false) => ("Close Path", "shape.closePath"), - _ => ("Open Path", "shape.openPath"), - }; - if ui.button(label).clicked() { - let _ = app.run(id, json!({"path": path, "index": index})); - // The points are numbered from the new start now. - app.session.tool.edit_vertex = None; - ui.close(); - } - ui.separator(); - } - if ui.button("Exit Edit Points").clicked() { - let _ = app.run("shape.editPoints", json!({"on": false})); - ui.close(); - } + edit_points_menu(app, ui, &sh); return; } // A misspelt word under the pointer: its suggestions, then Ignore All and Add to Dictionary. if let Some(w) = app.squiggles.menu_word.clone() { - if let Some(from) = w.delete_from { - if ui.button(egui::RichText::new("Delete Repeated Word").strong()).clicked() { - let cut: String = format!("{}{}", " ".repeat(w.start - from), w.word); - // The gap is spaces; take it from the text so tabs or no-break spaces match. - let gap = app - .session - .active() - .and_then(|st| match w.tag { - crate::squiggles::Tag::Shape { id, para } => st.shape(id)?.text.as_ref()?.paragraphs.get(para).map(|p| p.text()), - crate::squiggles::Tag::Cell { id, row, col, para } => match &st.shape(id)?.kind { - deckcraft_model::ShapeKind::Table(tb) => tb.cell(row, col)?.text.paragraphs.get(para).map(|p| p.text()), - _ => None, - }, - crate::squiggles::Tag::Notes { .. } => None, - }) - .map(|t| t.chars().skip(from).take(w.start + w.word.chars().count() - from).collect::()) - .unwrap_or(cut); - let mut p = json!({"para": 0, "start": from, "word": gap, "to": ""}); - if let Some(o) = p.as_object_mut() { - match w.tag { - crate::squiggles::Tag::Shape { id, para } => { - o.insert("shape".into(), json!(id)); - o.insert("para".into(), json!(para)); - } - crate::squiggles::Tag::Cell { id, row, col, para } => { - o.insert("shape".into(), json!(id)); - o.insert("row".into(), json!(row)); - o.insert("col".into(), json!(col)); - o.insert("para".into(), json!(para)); - } - crate::squiggles::Tag::Notes { .. } => {} - } - } - let _ = app.run("review.spellingChange", p); - app.squiggles.menu_word = None; - ui.close(); - } - } else if w.suggestions.is_empty() { - ui.label(egui::RichText::new("(no spelling suggestions)").weak()); - } - for to in &w.suggestions { - if ui.button(egui::RichText::new(to).strong()).clicked() { - let p = match w.tag { - crate::squiggles::Tag::Shape { id, para } => json!({"shape": id, "para": para, "start": w.start, "word": w.word, "to": to}), - crate::squiggles::Tag::Cell { id, row, col, para } => { - json!({"shape": id, "row": row, "col": col, "para": para, "start": w.start, "word": w.word, "to": to}) - } - crate::squiggles::Tag::Notes { .. } => json!({}), - }; - let _ = app.run("review.spellingChange", p); - app.squiggles.menu_word = None; - ui.close(); - } - } - if ui.button("Ignore All").clicked() { - app.squiggles.ignore(&w.word); - app.squiggles.menu_word = None; - ui.close(); - } - if ui.button("Add to Dictionary").clicked() { - if let Err(e) = app.session.execute("review.addToDictionary", &json!({"word": w.word})) { - app.set_status(e.to_string()); - } - app.squiggles.menu_word = None; - ui.close(); - } - ui.separator(); + spelling_menu(app, ui, &w); } let has_sel = app.session.active().is_some_and(|d| !d.selection.shapes.is_empty()); for (l, id) in [("Cut", "edit.cut"), ("Copy", "edit.copy"), ("Paste", "edit.paste")] { @@ -154,129 +28,273 @@ pub(super) fn context_menu(app: &mut SlideApp, resp: &egui::Response) { // Right-clicking the text being edited keeps it (`PointerKind::ContextClick`): text items. let editing = app.session.active().is_some_and(|d| d.selection.text.as_ref().is_some_and(|t| !t.notes)); if editing { - if ui.button("Paragraph…").clicked() { - app.dialog = Some(crate::dialogs::Dialog::new("paragraph")); - ui.close(); - } - for (l, id) in [("Bullets", "format.bullets"), ("Numbering", "format.numbering")] { - if ui.add_enabled(crate::ribbon::enabled(app, id), egui::Button::new(l)).clicked() { - let _ = app.run(id, json!({})); - ui.close(); - } - } - if ui.button("Link…").clicked() { - app.dialog = Some(crate::dialogs::Dialog::new("hyperlink")); - ui.close(); - } - ui.separator(); - if ui.button("Format Shape…").clicked() { - let _ = app.run("view.pane", json!({"pane": "format"})); - ui.close(); - } + text_items(app, ui); } else if has_sel { - if ui.button("Edit Text").clicked() { - let _ = app.run("text.edit", json!({})); + shape_items(app, ui); + } else { + slide_items(app, ui); + } + }); +} + +fn edit_points_menu(app: &mut SlideApp, ui: &mut egui::Ui, sh: &deckcraft_model::Shape) { + if let Some((path, index)) = app.session.tool.edit_segment { + let cur = deckcraft_engine::cmd::points::segment_kind_of(sh, path, index); + for (l, k) in [("Straight Segment", "straight"), ("Curved Segment", "curved")] { + if ui.radio(cur == Some(k), l).clicked() { + let _ = app.run("shape.segmentType", json!({"path": path, "segment": index, "type": k})); ui.close(); } - if crate::ribbon::enabled(app, "shape.editPoints") - && app.session.active().is_some_and(|d| d.selection.shapes.len() == 1 && d.selected_shapes().iter().all(|s| !s.is_line())) - && ui.button("Edit Points").clicked() - { - let _ = app.run("shape.editPoints", json!({"on": true})); + } + // Each of these renumbers the points, so the picked segment is dropped. + if ui.button("Add Point").clicked() { + let t = app.session.tool.edit_segment_t.unwrap_or(0.5); + let _ = app.run("shape.addPoint", json!({"path": path, "segment": index, "t": t})); + app.session.tool.edit_segment = None; + ui.close(); + } + let closed = deckcraft_engine::cmd::points::closed_at(sh, path, index); + if ui.add_enabled(closed == Some(true), egui::Button::new("Delete Segment")).clicked() { + let _ = app.run("shape.deleteSegment", json!({"path": path, "segment": index})); + app.session.tool.edit_segment = None; + ui.close(); + } + // Open Path opens the outline at the segment's start point. + let (label, id, at) = match closed { + Some(false) => ("Close Path", "shape.closePath", index), + _ => ("Open Path", "shape.openPath", deckcraft_engine::cmd::points::segment_start_of(sh, path, index).unwrap_or(index)), + }; + if ui.button(label).clicked() { + let _ = app.run(id, json!({"path": path, "index": at})); + app.session.tool.edit_segment = None; + ui.close(); + } + ui.separator(); + } else if let Some((path, index)) = app.session.tool.edit_vertex { + let cur = deckcraft_engine::cmd::points::kind_of(sh, path, index); + for (l, k) in [("Smooth Point", "smooth"), ("Straight Point", "straight"), ("Corner Point", "corner")] { + if ui.radio(cur == Some(k), l).clicked() { + let _ = app.run("shape.pointType", json!({"path": path, "index": index, "type": k})); ui.close(); } - if ui.button("Font…").clicked() { - let _ = app.run("format.fontDialog", json!({})); - ui.close(); - } - if ui.button("Paragraph…").clicked() { - let _ = app.run("format.paragraphDialog", json!({})); - ui.close(); - } - ui.menu_button("Bring to Front", |ui| { - crate::ribbon::menu_item(app, ui, crate::icons::Icon::BringToFront, "Bring to Front", "arrange.bringToFront", json!({})); - crate::ribbon::menu_item(app, ui, crate::icons::Icon::BringForward, "Bring Forward", "arrange.bringForward", json!({})); - }); - ui.menu_button("Send to Back", |ui| { - crate::ribbon::menu_item(app, ui, crate::icons::Icon::SendToBack, "Send to Back", "arrange.sendToBack", json!({})); - crate::ribbon::menu_item(app, ui, crate::icons::Icon::SendBackward, "Send Backward", "arrange.sendBackward", json!({})); - }); - ui.menu_button("Group", |ui| { - crate::ribbon::menu_item(app, ui, crate::icons::Icon::Group, "Group", "arrange.group", json!({})); - crate::ribbon::menu_item(app, ui, crate::icons::Icon::Ungroup, "Ungroup", "arrange.ungroup", json!({})); - }); - let locked = app.session.active().is_some_and(|d| d.selected_shapes().iter().all(|s| s.locked)); - if ui.button(if locked { "Unlock" } else { "Lock" }).clicked() { - let _ = app.run("shape.lock", json!({"locked": !locked})); - ui.close(); - } - let picture = app + } + if ui.button("Delete Point").clicked() { + let _ = app.run("shape.deletePoint", json!({"path": path, "index": index})); + app.session.tool.edit_vertex = None; + ui.close(); + } + let (label, id) = match deckcraft_engine::cmd::points::closed_at(sh, path, index) { + Some(false) => ("Close Path", "shape.closePath"), + _ => ("Open Path", "shape.openPath"), + }; + if ui.button(label).clicked() { + let _ = app.run(id, json!({"path": path, "index": index})); + // The points are numbered from the new start now. + app.session.tool.edit_vertex = None; + ui.close(); + } + ui.separator(); + } + if ui.button("Exit Edit Points").clicked() { + let _ = app.run("shape.editPoints", json!({"on": false})); + ui.close(); + } +} + +fn spelling_menu(app: &mut SlideApp, ui: &mut egui::Ui, w: &crate::squiggles::MenuWord) { + if let Some(from) = w.delete_from { + if ui.button(egui::RichText::new("Delete Repeated Word").strong()).clicked() { + let cut: String = format!("{}{}", " ".repeat(w.start - from), w.word); + // The gap is spaces; take it from the text so tabs or no-break spaces match. + let gap = app .session .active() - .is_some_and(|d| d.selected_shapes().iter().any(|s| matches!(s.kind, deckcraft_model::ShapeKind::Picture { .. }))); - if picture && ui.button("Change Picture…").clicked() { - let _ = app.run("app.changePictureDialog", json!({})); - ui.close(); - } - if ui.button("Link…").clicked() { - app.dialog = Some(crate::dialogs::Dialog::new("hyperlink")); - ui.close(); - } - if ui.button("Edit Alt Text…").clicked() { - app.dialog = Some(crate::dialogs::Dialog::new("altText")); - ui.close(); - } - if ui.button("Set as Default Shape").clicked() { - let _ = app.run("shape.setDefault", json!({})); - ui.close(); - } - if ui.button("Size and Position…").clicked() { - let _ = app.run("shape.sizePosition", json!({})); - ui.close(); - } - if ui.button("Format Shape…").clicked() { - let _ = app.run("view.pane", json!({"pane": "format"})); - ui.close(); - } - } else { - ui.menu_button("Layout", |ui| { - let layouts: Vec<(String, u32)> = app - .session - .active() - .and_then(|d| d.doc.masters.first().map(|m| m.layouts.iter().map(|l| (l.name.clone(), l.id.0)).collect())) - .unwrap_or_default(); - for (n, id) in layouts { - if ui.button(n).clicked() { - let _ = app.run("slide.layout", json!({"layout": id})); - ui.close(); + .and_then(|st| match w.tag { + crate::squiggles::Tag::Shape { id, para } => st.shape(id)?.text.as_ref()?.paragraphs.get(para).map(|p| p.text()), + crate::squiggles::Tag::Cell { id, row, col, para } => match &st.shape(id)?.kind { + deckcraft_model::ShapeKind::Table(tb) => tb.cell(row, col)?.text.paragraphs.get(para).map(|p| p.text()), + _ => None, + }, + crate::squiggles::Tag::Notes { .. } => None, + }) + .map(|t| t.chars().skip(from).take(w.start + w.word.chars().count() - from).collect::()) + .unwrap_or(cut); + let mut p = json!({"para": 0, "start": from, "word": gap, "to": ""}); + if let Some(o) = p.as_object_mut() { + match w.tag { + crate::squiggles::Tag::Shape { id, para } => { + o.insert("shape".into(), json!(id)); + o.insert("para".into(), json!(para)); } - } - }); - ui.menu_button("Grid and Guides", |ui| { - let mut on = app.ui.guides; - if ui.checkbox(&mut on, "Guides").clicked() { - let _ = app.run("view.guides", json!({"on": on})); - } - for (label, vertical) in [("Add Vertical Guide", true), ("Add Horizontal Guide", false)] { - if ui.button(label).clicked() { - let _ = app.run("guide.add", json!({"vertical": vertical})); - let _ = app.run("view.guides", json!({"on": true})); - ui.close(); + crate::squiggles::Tag::Cell { id, row, col, para } => { + o.insert("shape".into(), json!(id)); + o.insert("row".into(), json!(row)); + o.insert("col".into(), json!(col)); + o.insert("para".into(), json!(para)); } + crate::squiggles::Tag::Notes { .. } => {} } - }); - if ui.button("Reset Slide").clicked() { - let _ = app.run("slide.reset", json!({})); - ui.close(); } - if ui.button("Format Background…").clicked() { - let _ = app.run("view.pane", json!({"pane": "background"})); - ui.close(); - } - if ui.button("New Comment").clicked() { - app.dialog = Some(crate::dialogs::Dialog::new("comment")); + let _ = app.run("review.spellingChange", p); + app.squiggles.menu_word = None; + ui.close(); + } + } else if w.suggestions.is_empty() { + ui.label(egui::RichText::new("(no spelling suggestions)").weak()); + } + for to in &w.suggestions { + if ui.button(egui::RichText::new(to).strong()).clicked() { + let p = match w.tag { + crate::squiggles::Tag::Shape { id, para } => json!({"shape": id, "para": para, "start": w.start, "word": w.word, "to": to}), + crate::squiggles::Tag::Cell { id, row, col, para } => { + json!({"shape": id, "row": row, "col": col, "para": para, "start": w.start, "word": w.word, "to": to}) + } + crate::squiggles::Tag::Notes { .. } => json!({}), + }; + let _ = app.run("review.spellingChange", p); + app.squiggles.menu_word = None; + ui.close(); + } + } + if ui.button("Ignore All").clicked() { + app.squiggles.ignore(&w.word); + app.squiggles.menu_word = None; + ui.close(); + } + if ui.button("Add to Dictionary").clicked() { + if let Err(e) = app.session.execute("review.addToDictionary", &json!({"word": w.word})) { + app.set_status(e.to_string()); + } + app.squiggles.menu_word = None; + ui.close(); + } + ui.separator(); +} + +fn text_items(app: &mut SlideApp, ui: &mut egui::Ui) { + if ui.button("Paragraph…").clicked() { + app.dialog = Some(crate::dialogs::Dialog::new("paragraph")); + ui.close(); + } + for (l, id) in [("Bullets", "format.bullets"), ("Numbering", "format.numbering")] { + if ui.add_enabled(crate::ribbon::enabled(app, id), egui::Button::new(l)).clicked() { + let _ = app.run(id, json!({})); + ui.close(); + } + } + if ui.button("Link…").clicked() { + app.dialog = Some(crate::dialogs::Dialog::new("hyperlink")); + ui.close(); + } + ui.separator(); + if ui.button("Format Shape…").clicked() { + let _ = app.run("view.pane", json!({"pane": "format"})); + ui.close(); + } +} + +fn shape_items(app: &mut SlideApp, ui: &mut egui::Ui) { + if ui.button("Edit Text").clicked() { + let _ = app.run("text.edit", json!({})); + ui.close(); + } + if crate::ribbon::enabled(app, "shape.editPoints") + && app.session.active().is_some_and(|d| d.selection.shapes.len() == 1 && d.selected_shapes().iter().all(|s| !s.is_line())) + && ui.button("Edit Points").clicked() + { + let _ = app.run("shape.editPoints", json!({"on": true})); + ui.close(); + } + if ui.button("Font…").clicked() { + let _ = app.run("format.fontDialog", json!({})); + ui.close(); + } + if ui.button("Paragraph…").clicked() { + let _ = app.run("format.paragraphDialog", json!({})); + ui.close(); + } + ui.menu_button("Bring to Front", |ui| { + crate::ribbon::menu_item(app, ui, crate::icons::Icon::BringToFront, "Bring to Front", "arrange.bringToFront", json!({})); + crate::ribbon::menu_item(app, ui, crate::icons::Icon::BringForward, "Bring Forward", "arrange.bringForward", json!({})); + }); + ui.menu_button("Send to Back", |ui| { + crate::ribbon::menu_item(app, ui, crate::icons::Icon::SendToBack, "Send to Back", "arrange.sendToBack", json!({})); + crate::ribbon::menu_item(app, ui, crate::icons::Icon::SendBackward, "Send Backward", "arrange.sendBackward", json!({})); + }); + ui.menu_button("Group", |ui| { + crate::ribbon::menu_item(app, ui, crate::icons::Icon::Group, "Group", "arrange.group", json!({})); + crate::ribbon::menu_item(app, ui, crate::icons::Icon::Ungroup, "Ungroup", "arrange.ungroup", json!({})); + }); + let locked = app.session.active().is_some_and(|d| d.selected_shapes().iter().all(|s| s.locked)); + if ui.button(if locked { "Unlock" } else { "Lock" }).clicked() { + let _ = app.run("shape.lock", json!({"locked": !locked})); + ui.close(); + } + let picture = + app.session.active().is_some_and(|d| d.selected_shapes().iter().any(|s| matches!(s.kind, deckcraft_model::ShapeKind::Picture { .. }))); + if picture && ui.button("Change Picture…").clicked() { + let _ = app.run("app.changePictureDialog", json!({})); + ui.close(); + } + if ui.button("Link…").clicked() { + app.dialog = Some(crate::dialogs::Dialog::new("hyperlink")); + ui.close(); + } + if ui.button("Edit Alt Text…").clicked() { + app.dialog = Some(crate::dialogs::Dialog::new("altText")); + ui.close(); + } + if ui.button("Set as Default Shape").clicked() { + let _ = app.run("shape.setDefault", json!({})); + ui.close(); + } + if ui.button("Size and Position…").clicked() { + let _ = app.run("shape.sizePosition", json!({})); + ui.close(); + } + if ui.button("Format Shape…").clicked() { + let _ = app.run("view.pane", json!({"pane": "format"})); + ui.close(); + } +} + +fn slide_items(app: &mut SlideApp, ui: &mut egui::Ui) { + ui.menu_button("Layout", |ui| { + let layouts: Vec<(String, u32)> = app + .session + .active() + .and_then(|d| d.doc.masters.first().map(|m| m.layouts.iter().map(|l| (l.name.clone(), l.id.0)).collect())) + .unwrap_or_default(); + for (n, id) in layouts { + if ui.button(n).clicked() { + let _ = app.run("slide.layout", json!({"layout": id})); ui.close(); } } }); + ui.menu_button("Grid and Guides", |ui| { + let mut on = app.ui.guides; + if ui.checkbox(&mut on, "Guides").clicked() { + let _ = app.run("view.guides", json!({"on": on})); + } + for (label, vertical) in [("Add Vertical Guide", true), ("Add Horizontal Guide", false)] { + if ui.button(label).clicked() { + let _ = app.run("guide.add", json!({"vertical": vertical})); + let _ = app.run("view.guides", json!({"on": true})); + ui.close(); + } + } + }); + if ui.button("Reset Slide").clicked() { + let _ = app.run("slide.reset", json!({})); + ui.close(); + } + if ui.button("Format Background…").clicked() { + let _ = app.run("view.pane", json!({"pane": "background"})); + ui.close(); + } + if ui.button("New Comment").clicked() { + app.dialog = Some(crate::dialogs::Dialog::new("comment")); + ui.close(); + } } diff --git a/crates/ui-egui/src/canvas/guides.rs b/crates/ui-egui/src/canvas/guides.rs index be23d2ee..50899615 100644 --- a/crates/ui-egui/src/canvas/guides.rs +++ b/crates/ui-egui/src/canvas/guides.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Color32, CornerRadius, Point, Rect, SlideApp, Stroke, Tokens, Xf, pos2, theme}; /// The presentation's drawing guides; the one being dragged at its new place, labelled with its /// distance from the slide's centre in points. diff --git a/crates/ui-egui/src/canvas/ime.rs b/crates/ui-egui/src/canvas/ime.rs index ef752efa..61f47bc1 100644 --- a/crates/ui-egui/src/canvas/ime.rs +++ b/crates/ui-egui/src/canvas/ime.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Rect, SlideApp, Ui, Xf, screen_rect, vec2}; /// Where an input method composes for the slide (#101), on screen: at the insertion point while /// editing text, or over the one selected shape (typing on it starts editing). `None` when there is diff --git a/crates/ui-egui/src/canvas/input.rs b/crates/ui-egui/src/canvas/input.rs index 4cfd6218..c5e7e8ee 100644 --- a/crates/ui-egui/src/canvas/input.rs +++ b/crates/ui-egui/src/canvas/input.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Hit, Point, PointerEvent, PointerKind, SlideApp, ToolKind, Ui, Xf, json, mods}; /// A two-finger twist on the trackpad. macOS delivers it as [`egui::Event::Rotate`]; /// other platforms leave the delta at zero. diff --git a/crates/ui-egui/src/canvas/media.rs b/crates/ui-egui/src/canvas/media.rs index a859beb8..5beb44b1 100644 --- a/crates/ui-egui/src/canvas/media.rs +++ b/crates/ui-egui/src/canvas/media.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Rect, Sense, SlideApp, Target, Ui, Xf, json, pos2, screen_rect, vec2}; /// Play/pause on an animated GIF under the pointer or selected on its own. pub(super) fn gif_buttons(app: &mut SlideApp, ui: &mut Ui, xf: Xf, gifs: &[crate::media::Gif]) { diff --git a/crates/ui-egui/src/canvas/overlays.rs b/crates/ui-egui/src/canvas/overlays.rs index 94c731b8..ff59a8e3 100644 --- a/crates/ui-egui/src/canvas/overlays.rs +++ b/crates/ui-egui/src/canvas/overlays.rs @@ -1,4 +1,5 @@ -use super::*; +use super::{Color32, CornerRadius, Point, Pos2, Rect, SlideApp, Stroke, Tokens, ToolKind, Ui, Xf, Xfrm, box_handles, pos2, theme}; +use deckcraft_engine::DocState; fn poly(p: &egui::Painter, pts: [Pos2; 4], stroke: Stroke) { for i in 0..4 { @@ -6,16 +7,7 @@ fn poly(p: &egui::Painter, pts: [Pos2; 4], stroke: Stroke) { } } -pub(super) fn overlays(app: &mut SlideApp, ui: &Ui, painter: &egui::Painter, xf: Xf, t: &Tokens) { - // Spelling squiggles under misspelt words (checked in the background). - if let Some(st) = app.session.active() { - let lines = app.squiggles.update(st, Some(ui.ctx())); - let scale = (xf.to_screen(Point::new(1.0, 0.0)) - xf.to_screen(Point::new(0.0, 0.0))).length(); - for (a, b) in lines { - crate::squiggles::paint(painter, xf.to_screen(a), xf.to_screen(b), scale); - } - } - let Some(st) = app.session.active() else { return }; +fn selection_handles(app: &SlideApp, st: &DocState, painter: &egui::Painter, xf: Xf, t: &Tokens) { let sel_stroke = Stroke::new(1.0, t.selection); let editing = st.selection.text.clone(); for id in &st.selection.shapes { @@ -104,6 +96,10 @@ pub(super) fn overlays(app: &mut SlideApp, ui: &Ui, painter: &egui::Painter, xf: } } } +} + +fn text_overlays(st: &DocState, ui: &Ui, painter: &egui::Painter, xf: Xf, t: &Tokens) { + let editing = st.selection.text.clone(); // Caret / text selection, in a shape or a table cell. if let Some(ts) = &editing { for quad in text_marks(st, ts).iter().flat_map(|m| &m.selection) { @@ -148,6 +144,20 @@ pub(super) fn overlays(app: &mut SlideApp, ui: &Ui, painter: &egui::Painter, xf: painter.add(egui::Shape::convex_polygon(pts.iter().map(|q| xf.to_screen(f.affine * *q)).collect(), t.text_selection, Stroke::NONE)); } } +} + +pub(super) fn overlays(app: &mut SlideApp, ui: &Ui, painter: &egui::Painter, xf: Xf, t: &Tokens) { + // Spelling squiggles under misspelt words (checked in the background). + if let Some(st) = app.session.active() { + let lines = app.squiggles.update(st, Some(ui.ctx())); + let scale = (xf.to_screen(Point::new(1.0, 0.0)) - xf.to_screen(Point::new(0.0, 0.0))).length(); + for (a, b) in lines { + crate::squiggles::paint(painter, xf.to_screen(a), xf.to_screen(b), scale); + } + } + let Some(st) = app.session.active() else { return }; + selection_handles(app, st, painter, xf, t); + text_overlays(st, ui, painter, xf, t); // Smart guides, marquee, previews. for g in &app.session.tool.guides { let (a, b) = diff --git a/crates/ui-egui/src/canvas/rulers.rs b/crates/ui-egui/src/canvas/rulers.rs index bb8cb71a..db714991 100644 --- a/crates/ui-egui/src/canvas/rulers.rs +++ b/crates/ui-egui/src/canvas/rulers.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Align2, Color32, CornerRadius, Point, Pos2, Rect, Sense, SlideApp, Stroke, Tokens, Ui, Xf, json, pos2, theme, vec2}; /// The ruler's numbered step in points and the ticks per step: quarter inches, or half /// centimetres. Any other unit (only a hand-edited `prefs.json` gives one) keeps inches, since a diff --git a/crates/ui-egui/src/canvas/scroll.rs b/crates/ui-egui/src/canvas/scroll.rs index ff3b072c..19ba9c6d 100644 --- a/crates/ui-egui/src/canvas/scroll.rs +++ b/crates/ui-egui/src/canvas/scroll.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Rect, pos2}; /// Thickness of the canvas scroll bars, in points. const SCROLL_BAR: f32 = 10.0; diff --git a/crates/ui-egui/src/dialogs/about.rs b/crates/ui-egui/src/dialogs/about.rs index a5f80aec..03c6629b 100644 --- a/crates/ui-egui/src/dialogs/about.rs +++ b/crates/ui-egui/src/dialogs/about.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Color32, CornerRadius, Rect, Sense, SlideApp, Stroke, Tokens, Ui, pos2, theme, vec2}; /// Room the About credits need for their table; the About tab keeps its compact size. pub(super) const CREDITS_SIZE: egui::Vec2 = vec2(640.0, 380.0); @@ -70,7 +70,8 @@ mod about_credits_tests { use egui_kittest::Harness; use egui_kittest::kittest::Queryable; - use super::{CREDITS_SIZE, Dialog, credits_block}; + use super::{CREDITS_SIZE, credits_block}; + use crate::dialogs::Dialog; use crate::{Services, SlideApp}; fn tab_of(h: &Harness<'_, SlideApp>) -> String { diff --git a/crates/ui-egui/src/dialogs/body.rs b/crates/ui-egui/src/dialogs/body.rs index ae19ac67..d458c263 100644 --- a/crates/ui-egui/src/dialogs/body.rs +++ b/crates/ui-egui/src/dialogs/body.rs @@ -19,1320 +19,48 @@ use super::theme_colors::theme_colors_dialog; use super::theme_fonts::theme_fonts_dialog; use super::trim::{seconds_text_to_ms, trim_seed}; use super::zoom::zoom_dialog; -use super::*; +use super::{Dialog, SlideApp, Tokens, Ui, Value, buttons, check, field, field_of, json, num, theme, vec2}; /// Returns true when the dialog should close. pub(super) fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { - let run = |app: &mut SlideApp, id: &str, p: Value| { - let _ = app.run(id, p); - }; match d.id.as_str() { - "table" => { - field(ui, d, "Number of columns:", "cols", "5"); - field(ui, d, "Number of rows:", "rows", "2"); - let (ok, cancel) = buttons(ui, "Insert"); - if ok { - let (r, c) = (num(d, "rows", 2.0) as usize, num(d, "cols", 5.0) as usize); - run(app, "insert.table", json!({"rows": r, "cols": c})); - } - ok || cancel - } - "slideSize" => { - let cur = app.session.active().map(|s| s.doc.slide_size).unwrap_or(deckcraft_model::defaults::WIDE); - let len = app.session.prefs.units; - let ppu = len.points_per_unit(); - // Each field keeps its exact size in points (`wPt`) beside the two decimals shown, so - // a preset, Portrait or an untouched field is not nudged off by the rounding (13.33", - // 33.87 cm read back would make the slide 959.76 or 960.09 pt wide). - if !d.fields.contains_key("wPt") { - put_slide_dim(d, "w", cur.width, ppu); - put_slide_dim(d, "h", cur.height, ppu); - } - let label = ui.label("Slides sized for:"); - let preset = d.get("preset", ""); - let combo = - craft_ui::ComboBox::from_id_salt("sizes").selected_text(if preset.is_empty() { "Custom" } else { &preset }).show_ui(ui, |ui| { - for (label, w, h) in deckcraft_model::defaults::SLIDE_SIZES { - if ui.selectable_label(preset == *label, *label).clicked() { - d.fields.insert("preset".into(), label.to_string()); - put_slide_dim(d, "w", *w, ppu); - put_slide_dim(d, "h", *h, ppu); - } - } - }); - let _ = combo.response.labelled_by(label.id); - let abbr = unit_abbr(len); - let (w_seed, h_seed) = (field_of(d, "w"), field_of(d, "h")); - field(ui, d, &format!("Width ({abbr}):"), "w", &w_seed); - field(ui, d, &format!("Height ({abbr}):"), "h", &h_seed); - let (w, h) = (slide_dim_points(d, "w", ppu, cur.width), slide_dim_points(d, "h", ppu, cur.height)); - ui.horizontal(|ui| { - ui.label("Orientation:"); - let portrait = ui.radio(h > w, "Portrait").clicked(); - let landscape = ui.radio(w >= h, "Landscape").clicked(); - // Picking the other orientation swaps the width and height fields. - if portrait && w > h || landscape && h > w { - put_slide_dim(d, "w", h, ppu); - put_slide_dim(d, "h", w, ppu); - } - }); - let maximize = check(ui, d, "Maximize content (else ensure fit)", "max", false); - let (ok, cancel) = buttons(ui, "OK"); - if ok { - let (w, h) = (slide_dim_points(d, "w", ppu, cur.width), slide_dim_points(d, "h", ppu, cur.height)); - run(app, "design.slideSize", json!({"w": w, "h": h, "scale": if maximize { "maximize" } else { "ensureFit" }})); - } - ok || cancel - } + "table" => dlg_table(app, ui, d), + "slideSize" => dlg_slide_size(app, ui, d), "sizePosition" => size_position_dialog(app, ui, d), - "headerFooter" => { - let hf = app.session.active().map(|s| s.doc.header_footer.clone()).unwrap_or_default(); - let date = check(ui, d, "Date and time", "date", hf.date); - field(ui, d, " Fixed date (empty = update automatically):", "dateText", &hf.date_text); - // The format applies to an automatic date; a fixed one is the text as typed. - let automatic = d.get("dateText", &hf.date_text).is_empty(); - let fmt_now = if hf.date_format.is_empty() { "datetime1".to_string() } else { hf.date_format.clone() }; - // Not `d.get`: that stores the default as if the user had picked it. - let fmt = d.fields.get("dateFormat").cloned().unwrap_or_else(|| fmt_now.clone()); - let rows = date_rows("en-US"); - let label = rows.iter().find(|(k, _)| *k == fmt).map(|(_, s)| s.clone()).unwrap_or_else(|| fmt.clone()); - ui.add_enabled_ui(date && automatic, |ui| { - ui.horizontal(|ui| { - ui.label(" Format:"); - craft_ui::ComboBox::from_id_salt("hf-date-format").selected_text(label).width(200.0).show_ui(ui, |ui| { - for (kind, sample) in &rows { - if ui.selectable_label(fmt == *kind, sample).clicked() { - d.fields.insert("dateFormat".into(), (*kind).to_string()); - } - } - }); - }); - }); - let num_on = check(ui, d, "Slide number", "num", hf.slide_number); - let foot = check(ui, d, "Footer", "footer", hf.footer); - field(ui, d, " Footer text:", "footerText", &hf.footer_text); - let hide = check(ui, d, "Don't show on title slide", "hide", hf.hide_on_title); - ui.add_space(6.0); - let mut apply_all = false; - let mut apply = false; - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - apply_all = ui.button("Apply to All").clicked(); - apply = ui.button("Apply").clicked(); - if ui.button("Cancel").clicked() { - d.fields.insert("cancel".into(), "1".into()); - } - }); - if apply || apply_all { - let dt = d.get("dateText", ""); - let ft = d.get("footerText", ""); - let mut params = serde_json::Map::new(); - params.insert("date".into(), json!(date)); - params.insert("dateText".into(), json!(dt)); - // Only a format the user picked: sending the default would rewrite every slide's - // existing date field (an imported datetime4, say) to datetime1. - if d.fields.contains_key("dateFormat") { - params.insert("dateFormat".into(), json!(d.get("dateFormat", &fmt_now))); - } - params.insert("slideNumber".into(), json!(num_on)); - params.insert("footer".into(), json!(foot)); - params.insert("footerText".into(), json!(ft)); - params.insert("hideOnTitle".into(), json!(hide)); - params.insert("all".into(), json!(apply_all)); - run(app, "design.headerFooter", serde_json::Value::Object(params)); - } - apply || apply_all || d.fields.contains_key("cancel") - } - "hyperlink" => { - let seed = hyperlink_seed_of(app.session.active()); - let slide_default = seed.slide.to_string(); - let mode = d.get("mode", seed.mode); - ui.horizontal(|ui| { - if ui.selectable_label(mode == "url", "Web Page or File").clicked() { - d.fields.insert("mode".into(), "url".into()); - } - if ui.selectable_label(mode == "slide", "This Document").clicked() { - d.fields.insert("mode".into(), "slide".into()); - } - if ui.selectable_label(mode == "email", "Email Address").clicked() { - d.fields.insert("mode".into(), "email".into()); - } - }); - match mode.as_str() { - "slide" => { - let titles: Vec = app - .session - .active() - .map(|s| s.doc.slides.iter().enumerate().map(|(i, sl)| format!("{}. {}", i + 1, sl.title())).collect()) - .unwrap_or_default(); - let sel = d.get("slide", &slide_default).parse::().unwrap_or(0); - egui::ScrollArea::vertical().max_height(180.0).show(ui, |ui| { - for (i, t) in titles.iter().enumerate() { - if ui.selectable_label(sel == i, t).clicked() { - d.fields.insert("slide".into(), i.to_string()); - } - } - }); - } - "email" => field(ui, d, "Email address:", "email", &seed.email), - _ => field(ui, d, "Address:", "url", &seed.url), - } - field(ui, d, "ScreenTip:", "tip", &seed.tip); - let (ok, cancel) = buttons(ui, "OK"); - if ok { - let mode_now = d.get("mode", seed.mode); - let url = d.get("url", &seed.url); - let email = d.get("email", &seed.email); - let slide = d.get("slide", &slide_default).parse::().unwrap_or(0); - let tip = d.get("tip", &seed.tip); - if hyperlink_should_apply(&seed, &mode_now, &url, &email, slide) { - run(app, "insert.hyperlink", hyperlink_command(&mode_now, &url, &email, slide, &tip, seed.highlight)); - } - } - ok || cancel - } - "setupShow" => { - let sh = app.session.active().map(|s| s.doc.show.clone()).unwrap_or_default(); - let ty = d.get("type", &sh.show_type); - ui.label("Show type"); - for (l, v) in [ - ("Presented by a speaker (full screen)", "speaker"), - ("Browsed by an individual (window)", "browsed"), - ("Browsed at a kiosk (full screen)", "kiosk"), - ] { - if ui.radio(ty == v, l).clicked() { - d.fields.insert("type".into(), v.into()); - } - } - let lp = check(ui, d, "Loop continuously until 'Esc'", "loop", sh.loop_until_esc); - let nn = check(ui, d, "Show without narration", "nn", sh.without_narration); - let na = check(ui, d, "Show without animation", "na", sh.without_animation); - let ut = check(ui, d, "Use timings, if present", "ut", sh.use_timings); - let names: Vec = app.session.active().map(|s| s.doc.custom_shows.iter().map(|c| c.name.clone()).collect()).unwrap_or_default(); - let chosen = d.fields.entry("custom".into()).or_insert_with(|| sh.custom_show.clone().unwrap_or_default()).clone(); - let field = ui.label("Slides"); - let label = if chosen.is_empty() { "All slides".to_string() } else { chosen.clone() }; - let combo = craft_ui::ComboBox::from_id_salt("setup_custom").selected_text(label).show_ui(ui, |ui| { - if ui.selectable_label(chosen.is_empty(), "All slides").clicked() { - d.fields.insert("custom".into(), String::new()); - } - for n in &names { - if ui.selectable_label(&chosen == n, n).clicked() { - d.fields.insert("custom".into(), n.clone()); - } - } - }); - let _ = combo.response.labelled_by(field.id); - let (ok, cancel) = buttons(ui, "OK"); - if ok { - let ty = d.get("type", "speaker"); - let custom = d.fields.get("custom").cloned().unwrap_or_default(); - run(app, "show.setup", json!({"type": ty, "loop": lp, "noNarration": nn, "noAnimation": na, "useTimings": ut, "custom": custom})); - } - ok || cancel - } - "quit" => { - let names: Vec = app.session.documents().iter().filter(|d| d.is_dirty()).map(|d| d.title()).collect(); - if names.is_empty() { - app.quit_confirmed = true; - ui.ctx().send_viewport_cmd(egui::ViewportCommand::Close); - return true; - } - let what = if names.len() == 1 { format!("“{}”", names[0]) } else { format!("{} presentations", names.len()) }; - ui.label(egui::RichText::new(format!("Do you want to save the changes you made to {what}?")).strong()); - ui.label("Your changes will be lost if you don't save them."); - ui.add_space(8.0); - let mut done = false; - ui.horizontal(|ui| { - let t = Tokens::get(ui.ctx()); - if ui.add(egui::Button::new("Don't Save").min_size(vec2(84.0, 24.0))).clicked() { - let _ = app.session.execute("file.recovery.discard", &json!({})); - app.quit_confirmed = true; - ui.ctx().send_viewport_cmd(egui::ViewportCommand::Close); - done = true; - } - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - let save = ui.add(egui::Button::new(egui::RichText::new("Save").color(t.accent_text)).fill(t.accent).min_size(vec2(72.0, 24.0))); - if save.clicked() || ui.input(|i| i.key_pressed(egui::Key::Enter)) { - if app.save_all() { - app.quit_confirmed = true; - ui.ctx().send_viewport_cmd(egui::ViewportCommand::Close); - } - done = true; - } - if ui.add(egui::Button::new("Cancel").min_size(vec2(72.0, 24.0))).clicked() { - done = true; - } - }); - }); - done - } - "close" => { - // File › Close and Ctrl+W on a presentation with unsaved changes. - let Some(name) = app.session.active().map(|d| d.title()) else { return true }; - ui.label(egui::RichText::new(format!("Do you want to save the changes you made to “{name}”?")).strong()); - ui.label("Your changes will be lost if you don't save them."); - ui.add_space(8.0); - let mut done = false; - ui.horizontal(|ui| { - let t = Tokens::get(ui.ctx()); - if ui.add(egui::Button::new("Don't Save").min_size(vec2(84.0, 24.0))).clicked() { - run(app, "file.close", json!({})); - done = true; - } - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - let save = ui.add(egui::Button::new(egui::RichText::new("Save").color(t.accent_text)).fill(t.accent).min_size(vec2(72.0, 24.0))); - if save.clicked() || ui.input(|i| i.key_pressed(egui::Key::Enter)) { - app.save(); - // In the browser a save is a download: the presentation stays "unsaved". - let web = app.services.pick_save.is_none() && app.services.download.is_some(); - if web || app.session.active().is_some_and(|d| !d.is_dirty()) { - run(app, "file.close", json!({})); - } - done = true; - } - if ui.add(egui::Button::new("Cancel").min_size(vec2(72.0, 24.0))).clicked() { - done = true; - } - }); - }); - done - } + "headerFooter" => dlg_header_footer(app, ui, d), + "hyperlink" => dlg_hyperlink(app, ui, d), + "setupShow" => dlg_setup_show(app, ui, d), + "quit" => dlg_quit(app, ui, d), + "close" => dlg_close(app, ui, d), "zoom" => zoom_dialog(app, ui, d), - "comment" => { - let mut v = d.get("text", ""); - ui.add(egui::TextEdit::multiline(&mut v).hint_text("Comment").desired_rows(4).desired_width(320.0)); - d.fields.insert("text".into(), v.clone()); - let (ok, cancel) = buttons(ui, "Post"); - if ok && !v.trim().is_empty() { - run(app, "comment.add", json!({"text": v})); - app.ui.pane = Some("comments".into()); - } - ok || cancel - } - "bullets" => { - // The current paragraph's list, as the dialog's starting point. - let cur = app.session.execute("format.state", &json!({})).ok().unwrap_or(Value::Null); - let numbered = cur.get("numbering").and_then(Value::as_bool).unwrap_or(false); - let tab = d.get("tab", if numbered { "numbered" } else { "bulleted" }); - ui.horizontal(|ui| { - for (k, l) in [("bulleted", "Bulleted"), ("numbered", "Numbered")] { - if ui.selectable_label(tab == k, l).clicked() { - d.fields.insert("tab".into(), k.into()); - } - } - }); - ui.separator(); - let style_key = if tab == "numbered" { "scheme" } else { "char" }; - let current = d.get(style_key, if tab == "numbered" { "arabicPeriod" } else { "•" }); - let styles: &[(&str, &str)] = if tab == "numbered" { - &[ - ("arabicPeriod", "1. 2. 3."), - ("arabicParenR", "1) 2) 3)"), - ("romanUcPeriod", "I. II. III."), - ("romanLcPeriod", "i. ii. iii."), - ("alphaUcPeriod", "A. B. C."), - ("alphaLcParenR", "a) b) c)"), - ("alphaLcPeriod", "a. b. c."), - ] - } else { - &[("•", "•"), ("○", "○"), ("■", "■"), ("□", "□"), ("◆", "◆"), ("➢", "➢"), ("✓", "✓"), ("–", "–"), ("★", "★")] - }; - egui::Grid::new("bullet-styles").show(ui, |ui| { - for (i, (k, l)) in styles.iter().enumerate() { - let r = ui.add_sized( - vec2(if tab == "numbered" { 88.0 } else { 44.0 }, 40.0), - egui::Button::selectable(current == *k, egui::RichText::new(*l).size(16.0)), - ); - if r.clicked() { - d.fields.insert(style_key.into(), (*k).into()); - } - if (i + 1) % if tab == "numbered" { 4 } else { 5 } == 0 { - ui.end_row(); - } - } - }); - if tab == "bulleted" { - field(ui, d, "Custom character:", "custom", ""); - } else { - field(ui, d, "Start at:", "start", "1"); - } - field(ui, d, "Size (% of text):", "size", "100"); - let colors = [ - ("", "Automatic"), - ("tx1", "Text"), - ("accent1", "Accent 1"), - ("accent2", "Accent 2"), - ("accent3", "Accent 3"), - ("accent4", "Accent 4"), - ("accent5", "Accent 5"), - ("accent6", "Accent 6"), - ]; - let color = d.get("color", ""); - ui.horizontal(|ui| { - let field = ui.add_sized(vec2(120.0, 20.0), egui::Label::new("Color:")); - let combo = craft_ui::ComboBox::from_id_salt("bullet-color") - .selected_text(colors.iter().find(|c| c.0 == color).map_or("Automatic", |c| c.1)) - .show_ui(ui, |ui| { - for (k, l) in colors { - if ui.selectable_label(color == k, l).clicked() { - d.fields.insert("color".into(), k.into()); - } - } - }); - let _ = combo.response.labelled_by(field.id); - }); - ui.horizontal(|ui| { - if ui.button("None").on_hover_text("Remove bullets and numbering").clicked() { - d.fields.insert("none".into(), "1".into()); - } - }); - let (ok, cancel) = buttons(ui, "OK"); - let none = d.fields.contains_key("none"); - if none { - run(app, if tab == "numbered" { "format.numbering" } else { "format.bullets" }, json!({"on": false})); - } else if ok { - let mut p = json!({"on": true}); - if let Some(size) = d.get("size", "100").trim().trim_end_matches('%').parse::().ok().filter(|v| v.is_finite()) { - p["size"] = json!(size.clamp(25.0, 400.0)); - } - if !color.is_empty() { - p["color"] = json!(color); - } - if tab == "numbered" { - p["scheme"] = json!(d.get("scheme", "arabicPeriod")); - p["start"] = json!(d.get("start", "1").trim().parse::().unwrap_or(1).clamp(1, 32767)); - run(app, "format.numbering", p); - } else { - let custom = d.get("custom", ""); - let ch = custom.chars().find(|c| !c.is_whitespace()).map(String::from).unwrap_or_else(|| d.get("char", "•")); - p["char"] = json!(ch); - run(app, "format.bullets", p); - } - } - ok || cancel || none - } - "action" => { - use deckcraft_model::text::Action; - let tab = d.get("tab", "click"); - // Start from the selected shape's current setting for this tab. - let (cur, slides, shows) = app - .session - .active() - .map(|st| { - let sh = st.selected_shapes().first().map(|x| if tab == "hover" { x.hover.clone() } else { x.click.clone() }).unwrap_or(None); - let slides: Vec<(deckcraft_model::SlideId, String)> = - st.doc.slides.iter().enumerate().map(|(i, sl)| (sl.id, format!("{}. {}", i + 1, sl.title()))).collect(); - let shows: Vec = st.doc.custom_shows.iter().map(|c| c.name.clone()).collect(); - (sh, slides, shows) - }) - .unwrap_or_default(); - let (cur_kind, cur_arg) = match cur.as_ref().map(|h| &h.action) { - None => ("none", String::new()), - Some(Action::NextSlide) => ("next", String::new()), - Some(Action::PreviousSlide) => ("previous", String::new()), - Some(Action::FirstSlide) => ("first", String::new()), - Some(Action::LastSlide) => ("last", String::new()), - Some(Action::LastViewed) => ("lastViewed", String::new()), - Some(Action::EndShow) => ("endShow", String::new()), - Some(Action::PlayMedia) => ("playMedia", String::new()), - Some(Action::Url { url }) => ("url", url.clone()), - Some(Action::CustomShow { name, .. }) => ("customShow", name.clone()), - Some(Action::Program { path }) => ("program", path.clone()), - Some(Action::Slide { slide }) => ("slide", slides.iter().position(|(id, _)| id == slide).unwrap_or(0).to_string()), - }; - ui.horizontal(|ui| { - for (k, l) in [("click", "Mouse Click"), ("hover", "Mouse Over")] { - if ui.selectable_label(tab == k, l).clicked() && tab != k { - d.fields.clear(); - d.fields.insert("tab".into(), k.into()); - } - } - }); - ui.separator(); - ui.label(if tab == "hover" { "Action on mouse over" } else { "Action on click" }); - let kind = d.get("kind", cur_kind); - let hyperlink = !matches!(kind.as_str(), "none" | "program" | "playMedia"); - let pick = |d: &mut Dialog, ui: &mut Ui, k: &str, l: &str| { - if ui.radio(d.get("kind", cur_kind) == k, l).clicked() { - d.fields.insert("kind".into(), k.into()); - } - }; - pick(d, ui, "none", "None"); - ui.horizontal(|ui| { - let field = ui.radio(hyperlink, "Hyperlink to:"); - if field.clicked() && !hyperlink { - d.fields.insert("kind".into(), "next".into()); - } - let targets = [ - ("next", "Next Slide"), - ("previous", "Previous Slide"), - ("first", "First Slide"), - ("last", "Last Slide"), - ("lastViewed", "Last Slide Viewed"), - ("endShow", "End Show"), - ("customShow", "Custom Show…"), - ("slide", "Slide…"), - ("url", "URL…"), - ]; - let shown = targets.iter().find(|t| t.0 == kind).map_or("Next Slide", |t| t.1); - ui.add_enabled_ui(hyperlink, |ui| { - let combo = craft_ui::ComboBox::from_id_salt("action-target").selected_text(shown).show_ui(ui, |ui| { - for (k, l) in targets { - if ui.selectable_label(kind == k, l).clicked() { - d.fields.insert("kind".into(), k.into()); - } - } - }); - let _ = combo.response.labelled_by(field.id); - }); - }); - let kind = d.get("kind", cur_kind); - match kind.as_str() { - "url" => field(ui, d, "Address:", "arg", if cur_kind == "url" { &cur_arg } else { "https://" }), - "slide" => { - let sel = d.get("arg", if cur_kind == "slide" { &cur_arg } else { "0" }).parse::().unwrap_or(0); - egui::ScrollArea::vertical().max_height(140.0).show(ui, |ui| { - for (i, (_, t)) in slides.iter().enumerate() { - if ui.selectable_label(sel == i, t).clicked() { - d.fields.insert("arg".into(), i.to_string()); - } - } - }); - } - "customShow" => { - if shows.is_empty() { - ui.label("This presentation has no custom shows (Slide Show ▸ Custom Slide Show)."); - } - let sel = d.get("arg", if cur_kind == "customShow" { &cur_arg } else { shows.first().map_or("", String::as_str) }); - for n in &shows { - if ui.selectable_label(&sel == n, n).clicked() { - d.fields.insert("arg".into(), n.clone()); - } - } - } - _ => {} - } - pick(d, ui, "program", "Run program:"); - if d.get("kind", cur_kind) == "program" { - field(ui, d, "Program:", "arg", if cur_kind == "program" { &cur_arg } else { "" }); - } - pick(d, ui, "playMedia", "Play the shape's media"); - let hl = check(ui, d, "Highlight click", "highlight", cur.as_ref().is_none_or(|h| h.highlight_click)); - let (ok, cancel) = buttons(ui, "OK"); - if ok { - let kind = d.get("kind", cur_kind); - let arg = d.get("arg", &cur_arg); - let mut p = json!({"on": tab, "action": kind, "highlight": hl}); - match kind.as_str() { - "url" => p["url"] = json!(arg), - "slide" => p["slide"] = json!(arg.parse::().unwrap_or(0)), - "customShow" => p["show"] = json!(arg), - "program" => p["path"] = json!(arg), - _ => {} - } - run(app, "shape.action", p); - } - ok || cancel - } - "altText" => { - let (cur, deco_now) = app - .session - .active() - .map(|s| { - let shapes = s.selected_shapes(); - let cur = shapes.first().map(|x| x.descr.clone()).unwrap_or_default(); - let deco = alt_text_starts_decorative(shapes.iter().map(|x| x.decorative)); - (cur, deco) - }) - .unwrap_or_default(); - ui.label("How would you describe this object and its context to someone who is blind or has low vision?"); - let mut v = d.get("text", &cur); - ui.add(egui::TextEdit::multiline(&mut v).hint_text("Alt text").desired_rows(4).desired_width(340.0)); - d.fields.insert("text".into(), v.clone()); - let deco = check(ui, d, "Mark as decorative", "deco", deco_now); - let (ok, cancel) = buttons(ui, "OK"); - if ok { - run(app, "shape.altText", json!({"text": v, "decorative": deco})); - } - ok || cancel - } - "outline" => { - ui.label("Paste or type an outline. Unindented lines become slide titles; tab-indented lines become bullets."); - let mut v = d.get("text", ""); - ui.add(egui::TextEdit::multiline(&mut v).hint_text("Outline").desired_rows(10).desired_width(380.0).code_editor()); - d.fields.insert("text".into(), v.clone()); - let (ok, cancel) = buttons(ui, "Insert"); - if ok { - run(app, "slide.fromOutline", json!({"text": v})); - } - ok || cancel - } - "reuseSlides" => { - let path = d.params.get("path").and_then(Value::as_str).unwrap_or("").to_string(); - let slides: Vec<(usize, String)> = d - .params - .get("slides") - .and_then(Value::as_array) - .map(|a| { - a.iter() - .filter_map(|x| { - Some((usize::try_from(x.get("index")?.as_u64()?).ok()?, x.get("title").and_then(Value::as_str).unwrap_or("").to_string())) - }) - .collect() - }) - .unwrap_or_default(); - let name = std::path::Path::new(&path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or(path.clone()); - ui.label(format!("Insert slides from {name} after the current slide:")); - // Unticked slides are remembered by index; everything starts ticked. - let off = |d: &Dialog, i: usize| d.fields.get(&format!("off{i}")).is_some_and(|v| v == "1"); - ui.horizontal(|ui| { - if ui.small_button("Select All").clicked() { - for (i, _) in &slides { - d.fields.remove(&format!("off{i}")); - } - } - if ui.small_button("Select None").clicked() { - for (i, _) in &slides { - d.fields.insert(format!("off{i}"), "1".into()); - } - } - }); - egui::ScrollArea::vertical().max_height(260.0).show(ui, |ui| { - for (i, title) in &slides { - let mut on = !off(d, *i); - let label = if title.trim().is_empty() { format!("{} (no title)", i + 1) } else { format!("{} {}", i + 1, title.trim()) }; - if ui.checkbox(&mut on, label).changed() { - if on { - d.fields.remove(&format!("off{i}")); - } else { - d.fields.insert(format!("off{i}"), "1".into()); - } - } - } - }); - let mut keep = d.params.get("keep").and_then(Value::as_bool).unwrap_or(false); - if ui.checkbox(&mut keep, "Keep source formatting").changed() - && let Some(o) = d.params.as_object_mut() - { - o.insert("keep".into(), json!(keep)); - } - let picked: Vec = slides.iter().map(|(i, _)| *i).filter(|i| !off(d, *i)).collect(); - let (ok, cancel) = buttons(ui, "Insert"); - if ok { - if picked.is_empty() { - app.set_status("Tick at least one slide to insert".to_string()); - return false; - } - // A failed insert keeps the dialog (and the ticked slides) open; the error is in the status bar. - let source = d.params.get("source").cloned().unwrap_or_default(); - return app.reuse_slides(&source, keep, &picked); - } - cancel - } - "renameSection" | "renameShape" | "renameLayout" => { - let cur = d.params.get("name").and_then(Value::as_str).unwrap_or("").to_string(); - field(ui, d, "Name:", "name", &cur); - let (ok, cancel) = buttons(ui, "Rename"); - if ok { - let name = d.get("name", ""); - match d.id.as_str() { - "renameSection" => run(app, "section.rename", json!({"index": d.params.get("index").cloned().unwrap_or_default(), "name": name})), - "renameShape" => run(app, "shape.rename", json!({"id": d.params.get("id").cloned().unwrap_or_default(), "name": name})), - _ => run(app, "master.renameLayout", json!({"name": name})), - } - } - ok || cancel - } + "comment" => dlg_comment(app, ui, d), + "bullets" => dlg_bullets(app, ui, d), + "action" => dlg_action(app, ui, d), + "altText" => dlg_alt_text(app, ui, d), + "outline" => dlg_outline(app, ui, d), + "reuseSlides" => dlg_reuse_slides(app, ui, d), + "renameSection" | "renameShape" | "renameLayout" => dlg_rename_section(app, ui, d), "font" => font_dialog(app, ui, d), "paragraph" => paragraph_dialog(app, ui, d), "tabs" => tabs_dialog(app, ui, d), - "export" => { - ui.label("Export the presentation as:"); - let fmt = d.get("fmt", "pdf"); - for (l, f) in [ - ("PDF document (.pdf)", "pdf"), - ("PNG images (current slide)", "png"), - ("PNG images (all slides)", "pngAll"), - ("JPEG image (current slide)", "jpeg"), - ("PowerPoint Presentation (.pptx)", "pptx"), - ("Outline (.txt)", "outline"), - ("Animated GIF (.gif)", "gif"), - ("Outline/RTF (.rtf)", "rtf"), - ] { - if ui.radio(fmt == f, l).clicked() { - d.fields.insert("fmt".into(), f.into()); - } - } - let pdf_layout = d.get("pdfLayout", "slides"); - if fmt == "pdf" { - ui.add_space(4.0); - ui.horizontal(|ui| { - let field = ui.label("Print layout:"); - let combo = craft_ui::ComboBox::from_id_salt("pdf_layout") - .selected_text(match pdf_layout.as_str() { - "notes" => "Notes Pages", - "h1" => "Handouts (1 slide per page)", - "h2" => "Handouts (2 slides per page)", - "h3" => "Handouts (3 slides per page)", - "h4" => "Handouts (4 slides per page)", - "h6" => "Handouts (6 slides per page)", - "h9" => "Handouts (9 slides per page)", - _ => "Full Page Slides", - }) - .show_ui(ui, |ui| { - for (k, l) in [ - ("slides", "Full Page Slides"), - ("notes", "Notes Pages"), - ("h1", "Handouts (1 slide per page)"), - ("h2", "Handouts (2 slides per page)"), - ("h3", "Handouts (3 slides per page)"), - ("h4", "Handouts (4 slides per page)"), - ("h6", "Handouts (6 slides per page)"), - ("h9", "Handouts (9 slides per page)"), - ] { - if ui.selectable_label(pdf_layout == k, l).clicked() { - d.fields.insert("pdfLayout".into(), k.into()); - } - } - }); - let _ = combo.response.labelled_by(field.id); - }); - let mut hidden = d.get("pdfHidden", "false") == "true"; - if ui.checkbox(&mut hidden, "Include hidden slides").changed() { - d.fields.insert("pdfHidden".into(), hidden.to_string()); - } - } - let png = matches!(fmt.as_str(), "png" | "pngAll"); - if png { - ui.add_space(4.0); - let mut clear = d.get("pngTransparent", "false") == "true"; - if ui - .checkbox(&mut clear, "Transparent background") - .on_hover_text("Leave the slide background out: only what is on the slide is drawn") - .changed() - { - d.fields.insert("pngTransparent".into(), clear.to_string()); - } - } - let gif_params = if fmt == "gif" { gif_options(app, ui, d) } else { Ok(Value::Null) }; - if let Err(e) = &gif_params { - ui.label(egui::RichText::new(e).color(Tokens::get(ui.ctx()).text_dim)); - } - let pdf_params = { - let (layout, per) = match pdf_layout.as_str() { - "notes" => ("notes", 0), - l if l.starts_with('h') => ("handouts", l[1..].parse().unwrap_or(6)), - _ => ("slides", 0), - }; - json!({"layout": layout, "perPage": per, "includeHidden": d.get("pdfHidden", "false") == "true"}) - }; - let (ok, cancel) = buttons(ui, "Export"); - if ok { - let gif_params = match gif_params { - Ok(v) => v, - // Keep the dialog open so the range or width can be corrected. - Err(e) => { - app.set_status(e); - return false; - } - }; - let extra = match fmt.as_str() { - "pdf" => pdf_params, - "gif" => gif_params, - _ if png && d.get("pngTransparent", "false") == "true" => json!({"transparentBackground": true}), - _ => json!({}), - }; - app.export_as(&fmt, extra); - } - ok || cancel - } - "replaceFonts" => { - let used: Vec = - app.session.execute("font.used", &json!({})).ok().and_then(|v| serde_json::from_value(v).ok()).unwrap_or_default(); - let mut from = d.get("from", used.first().map(String::as_str).unwrap_or("")); - let mut to = d.get("to", ""); - egui::Grid::new("replace_fonts").num_columns(2).spacing(vec2(10.0, 8.0)).show(ui, |ui| { - let field = ui.label("Replace:"); - let combo = craft_ui::ComboBox::from_id_salt("rf_from").width(240.0).selected_text(from.clone()).show_ui(ui, |ui| { - for f in &used { - ui.selectable_value(&mut from, f.clone(), f); - } - }); - let _ = combo.response.labelled_by(field.id); - ui.end_row(); - ui.label("With:"); - ui.vertical(|ui| { - ui.add(egui::TextEdit::singleline(&mut to).desired_width(240.0).hint_text("Font name")); - let needle = to.trim().to_lowercase(); - egui::ScrollArea::vertical().max_height(140.0).id_salt("rf_list").show(ui, |ui| { - for f in - crate::ribbon::font_families(app).iter().filter(|f| needle.is_empty() || f.to_lowercase().contains(&needle)).take(200) - { - if ui.selectable_label(f.eq_ignore_ascii_case(to.trim()), f).clicked() { - to = f.clone(); - } - } - }); - }); - ui.end_row(); - }); - if let Some(msg) = d.fields.get("result") { - ui.label(egui::RichText::new(msg.clone()).color(Tokens::get(ui.ctx()).text_dim)); - } - d.fields.insert("from".into(), from.clone()); - d.fields.insert("to".into(), to.clone()); - let (ok, cancel) = buttons(ui, "Replace"); - if ok && !from.is_empty() && !to.trim().is_empty() { - let n = app.run("font.replace", json!({"from": from, "to": to.trim()})).ok().and_then(|v| v["replaced"].as_u64()).unwrap_or(0); - let msg = - if n == 0 { format!("“{from}” isn't used explicitly.") } else { format!("Replaced “{from}” with “{}”.", to.trim()) }; - d.fields.insert("result".into(), msg); - d.fields.remove("from"); - // Stay open, like PowerPoint, so several fonts can be replaced in a row. - return cancel; - } - cancel - } - "symbol" => { - ui.label("Click a symbol to insert it at the insertion point."); - let groups: [(&str, &str); 6] = [ - ("Punctuation", "–—‘’“”…•·§¶†‡‰′″‹›«»¡¿"), - ("Currency", "$€£¥¢₹₩₽₺₿"), - ("Math", "±×÷≠≈≤≥∞√∑∏∫∂∆∇∈∉∩∪⊂⊃∀∃¬∧∨⇒⇔°′"), - ("Greek", "αβγδεζηθικλμνξοπρστυφχψωΓΔΘΛΞΠΣΦΨΩ"), - ("Arrows", "←↑→↓↔↕⇐⇑⇒⇓↖↗↘↙⟵⟶"), - ("Shapes", "■□▪▫▲△▶▷▼▽◀◁◆◇○●◎★☆✓✔✗✘♠♣♥♦"), - ]; - for (g, chars) in groups { - ui.label(egui::RichText::new(g).font(theme::bold(12.0))); - ui.horizontal_wrapped(|ui| { - for c in chars.chars() { - if ui.add(egui::Button::new(egui::RichText::new(c.to_string()).size(16.0)).min_size(vec2(26.0, 26.0))).clicked() { - if app.session.active().is_some_and(|s| s.selection.text.is_none()) { - let _ = app.run("text.edit", json!({})); - } - let _ = app.run("insert.symbol", json!({"text": c.to_string()})); - } - } - }); - } - let (ok, cancel) = buttons(ui, "Close"); - ok || cancel - } - "equation" => { - ui.label("Type an equation in linear form (e.g. a^2+b^2=c^2, x=(-b±√(b^2-4ac))/2a)."); - let mut v = d.get("eq", ""); - ui.add(egui::TextEdit::singleline(&mut v).hint_text("Equation").desired_width(340.0).font(theme::font(15.0))); - d.fields.insert("eq".into(), v.clone()); - let (ok, cancel) = buttons(ui, "Insert"); - if ok && !v.is_empty() { - let pretty = pretty_equation(&v); - let size = app.session.active().map(|s| s.doc.slide_size).unwrap_or(deckcraft_model::defaults::WIDE); - run(app, "insert.textBox", json!({"rect": [size.width / 2.0 - 150.0, size.height / 2.0 - 25.0, 300, 50], "text": pretty})); - run(app, "text.exit", json!({})); - run(app, "format.font", json!({"family": "Liberation Serif"})); - run(app, "format.italic", json!({"on": true})); - run(app, "format.size", json!({"size": 28})); - } - ok || cancel - } - "pasteSpecial" => { - const OPTS: [(&str, &str); 4] = [ - ("destinationTheme", "Use Destination Theme"), - ("keepSource", "Keep Source Formatting"), - ("picture", "Picture (PNG)"), - ("text", "Keep Text Only"), - ]; - let has_shapes = !app.session.clipboard.shapes.is_empty(); - let mut cur = d.get("as", "destinationTheme"); - ui.label("As:"); - for (id, label) in OPTS { - let ok = has_shapes || matches!(id, "destinationTheme" | "text"); - ui.add_enabled_ui(ok, |ui| ui.radio_value(&mut cur, id.to_string(), label)); - } - d.fields.insert("as".into(), cur.clone()); - let (ok, cancel) = buttons(ui, "OK"); - if ok { - run(app, "edit.pasteSpecial", json!({"as": cur})); - } - ok || cancel - } + "export" => dlg_export(app, ui, d), + "replaceFonts" => dlg_replace_fonts(app, ui, d), + "symbol" => dlg_symbol(app, ui, d), + "equation" => dlg_equation(app, ui, d), + "pasteSpecial" => dlg_paste_special(app, ui, d), "find" => find_dialog(app, ui, d), - "spelling" => { - // Words skipped with Ignore All while the dialog is open, one per line. - let ignored = d.get("ignore", ""); - let ignore: Vec<&str> = ignored.lines().filter(|l| !l.is_empty()).collect(); - let v = app.session.execute("review.spelling", &json!({"ignore": ignore})).unwrap_or_default(); - let list = v.as_array().cloned().unwrap_or_default(); - let info = app.session.execute("review.dictionary", &json!({})).unwrap_or_default(); - let available = info.get("available").and_then(Value::as_bool).unwrap_or(false); - match info.get("found").and_then(Value::as_str) { - Some(f) if available => { - ui.label(egui::RichText::new(format!("Dictionary: {}", f.rsplit(['/', '\\']).next().unwrap_or(f))).small().weak()) - } - _ if available => { - ui.label(egui::RichText::new(format!("Dictionary: {}", info.get("lang").and_then(Value::as_str).unwrap_or(""))).small().weak()) - } - _ => ui.label( - egui::RichText::new(format!( - "No {} dictionary is installed, so only common misspellings are found. Put a Hunspell .aff/.dic pair in {}.", - info.get("lang").and_then(Value::as_str).unwrap_or("en-US"), - info.get("folders") - .and_then(Value::as_array) - .and_then(|a| a.get(1).or(a.first())) - .and_then(Value::as_str) - .unwrap_or("a dictionaries folder") - )) - .small() - .weak(), - ), - }; - if list.is_empty() { - ui.label("The spelling check is complete. No issues found."); - } - let mut add_ignore = None; - egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| { - for item in &list { - let w = item.get("word").and_then(Value::as_str).unwrap_or(""); - let slide = item.get("slide").cloned().unwrap_or_default(); - let shape = item.get("shape").cloned().unwrap_or_default(); - ui.horizontal(|ui| { - ui.label(format!("Slide {}: “{w}”", slide.as_u64().unwrap_or(0) + 1)); - if ui.small_button("Go to").clicked() { - let _ = app.run("slide.go", json!({"index": slide})); - let _ = app.run("edit.select", json!({"ids": [shape]})); - } - if ui.small_button("Ignore All").clicked() { - add_ignore = Some(w.to_string()); - } - if ui.small_button("Add to Dictionary").clicked() - && let Err(e) = app.session.execute("review.addToDictionary", &json!({"word": w})) - { - app.set_status(e.to_string()); - } - }); - let sug: Vec = item - .get("suggestions") - .and_then(Value::as_array) - .map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()) - .unwrap_or_default(); - if item.get("repeated").and_then(Value::as_bool).unwrap_or(false) && !item.get("notes").and_then(Value::as_bool).unwrap_or(false) - { - ui.horizontal(|ui| { - ui.add_space(16.0); - ui.label(egui::RichText::new("Repeated word").weak()); - if ui.small_button("Delete Repeated Word").clicked() { - let from = item.get("deleteFrom").and_then(Value::as_u64).unwrap_or(0) as usize; - let end = item.get("end").and_then(Value::as_u64).unwrap_or(0) as usize; - let para = item.get("para").and_then(Value::as_u64).unwrap_or(0) as usize; - let text = app.session.active().and_then(|st| { - let sh = st.shape(deckcraft_model::ShapeId(shape.as_u64()? as u32))?; - let pp = match (item.get("row").and_then(Value::as_u64), item.get("col").and_then(Value::as_u64), &sh.kind) { - (Some(r), Some(c), deckcraft_model::ShapeKind::Table(tb)) => { - tb.cell(r as usize, c as usize)?.text.paragraphs.get(para)?.text() - } - _ => sh.text.as_ref()?.paragraphs.get(para)?.text(), - }; - Some(pp.chars().skip(from).take(end.saturating_sub(from)).collect::()) - }); - if let Some(gap) = text { - let mut p = json!({"shape": shape, "para": para, "start": from, "word": gap, "to": ""}); - if let (Some(o), Some(r), Some(c)) = (p.as_object_mut(), item.get("row"), item.get("col")) { - o.insert("row".into(), r.clone()); - o.insert("col".into(), c.clone()); - } - run(app, "review.spellingChange", p); - } - } - }); - } - if !sug.is_empty() { - ui.horizontal_wrapped(|ui| { - ui.add_space(16.0); - ui.label(egui::RichText::new("Change to:").weak()); - for to in &sug { - let r = ui.small_button(to.as_str()).on_hover_text("Click to change this one; Shift-click to change all"); - if r.clicked() { - let all = ui.input(|i| i.modifiers.shift); - if item.get("notes").and_then(Value::as_bool).unwrap_or(false) { - // A word in the notes: change that line of the slide's notes. - let si = slide.as_u64().unwrap_or(0) as usize; - let notes = - app.session.active().and_then(|d| d.doc.slides.get(si)).map(|s| s.notes_text()).unwrap_or_default(); - let (pi, st) = ( - item.get("para").and_then(Value::as_u64).unwrap_or(0) as usize, - item.get("start").and_then(Value::as_u64).unwrap_or(0) as usize, - ); - let lines: Vec = notes - .split('\n') - .enumerate() - .map(|(i, l)| { - if i != pi { - return l.to_string(); - } - let c: Vec = l.chars().collect(); - let n = w.chars().count(); - if c.get(st..st + n).is_some_and(|x| x.iter().collect::() == w) { - c[..st].iter().chain(to.chars().collect::>().iter()).chain(c[st + n..].iter()).collect() - } else { - l.to_string() - } - }) - .collect(); - run(app, "slide.notes", json!({"text": lines.join("\n"), "index": si})); - } else { - let mut p = if all { - json!({"word": w, "to": to, "all": true}) - } else { - json!({"shape": shape, "word": w, "to": to, "para": item.get("para"), "start": item.get("start")}) - }; - if !all && let (Some(o), Some(r), Some(c)) = (p.as_object_mut(), item.get("row"), item.get("col")) { - o.insert("row".into(), r.clone()); - o.insert("col".into(), c.clone()); - } - run(app, "review.spellingChange", p); - } - } - } - }); - } - } - }); - if let Some(w) = add_ignore { - d.fields.insert("ignore".into(), format!("{ignored}{w}\n")); - } - let (ok, cancel) = buttons(ui, "Close"); - ok || cancel - } - "accessibility" => { - let v = app.session.execute("review.accessibility", &json!({})).unwrap_or_default(); - let list = v.as_array().cloned().unwrap_or_default(); - if list.is_empty() { - ui.label("No accessibility issues found. People with disabilities should not have difficulty reading this document."); - } - egui::ScrollArea::vertical().max_height(300.0).show(ui, |ui| { - for item in &list { - ui.horizontal(|ui| { - ui.label(format!( - "Slide {}: {}{}", - item.get("slide").and_then(Value::as_u64).unwrap_or(0) + 1, - item.get("issue").and_then(Value::as_str).unwrap_or(""), - item.get("name").and_then(Value::as_str).map(|n| format!(" — {n}")).unwrap_or_default() - )); - if ui.small_button("Go to").clicked() { - let _ = app.run("slide.go", json!({"index": item.get("slide").cloned().unwrap_or_default()})); - if let Some(id) = item.get("shape") { - let _ = app.run("edit.select", json!({"ids": [id]})); - } - } - }); - } - }); - let (ok, cancel) = buttons(ui, "Close"); - ok || cancel - } - "language" => { - // Starts from the selected text's language and Do Not Check Spelling. - let (cur, np) = selected_language(app); - let lang = d.get("lang", &cur); - let mut no_proof = d.get("noProof", if np { "1" } else { "" }) == "1"; - // The selected text, or every text in the presentation (all there is with nothing selected). - let has_sel = deckcraft_engine::cmd::has_text_or_shapes(&app.session).is_ok(); - let mut all = !has_sel || d.get("scope", "") == "all"; - ui.horizontal(|ui| { - ui.label("Apply to:"); - if ui.add_enabled(has_sel, egui::RadioButton::new(!all, "Selected text")).clicked() { - d.fields.insert("scope".into(), "sel".into()); - all = false; - } - if ui.radio(all, "Whole presentation").clicked() { - d.fields.insert("scope".into(), "all".into()); - all = true; - } - }); - ui.label(if all { "Mark all text in the presentation as:" } else { "Mark selected text as:" }); - egui::ScrollArea::vertical().max_height(220.0).show(ui, |ui| { - // The selection's own language first when the list doesn't have it. - let own = (!crate::squiggles::LANGS.iter().any(|(_, t)| t.eq_ignore_ascii_case(&cur))).then(|| (cur.clone(), cur.clone())); - let all = own.into_iter().chain(crate::squiggles::LANGS.iter().map(|(n, t)| (n.to_string(), t.to_string()))); - for (_native, tag) in all { - // Native names are shown when the interface fonts can draw them; otherwise - // the tag is the explicit fallback (egui 0.37 aborts on a missing glyph). - let name = crate::squiggles::display_lang_name(ui.ctx(), &tag); - let installed = deckcraft_engine::spell::dictionary(&tag).is_some(); - let label = if installed { format!("{name} (dictionary)") } else { name }; - if ui.selectable_label(lang.eq_ignore_ascii_case(&tag), label).clicked() { - d.fields.insert("lang".into(), tag); - } - } - }); - ui.add_space(4.0); - ui.label(egui::RichText::new("Spelling is checked for languages marked (dictionary).").small().weak()); - if ui.checkbox(&mut no_proof, "Do not check spelling or grammar").changed() { - d.fields.insert("noProof".into(), if no_proof { "1".into() } else { String::new() }); - } - let (ok, cancel) = buttons(ui, "OK"); - if ok { - let lang = d.get("lang", &cur); - run(app, if all { "format.languageAll" } else { "format.language" }, json!({"lang": lang, "noProof": no_proof})); - } - ok || cancel - } - "customShow" => { - let shows = app.session.active().map(|s| s.doc.custom_shows.clone()).unwrap_or_default(); - let mut started = false; - for s in &shows { - ui.horizontal(|ui| { - ui.label(format!("{} ({} slides)", s.name, s.slides.len())); - if ui.small_button("Show").clicked() { - run(app, "show.start", json!({"custom": s.name})); - started = true; - } - if ui.small_button("Delete").clicked() { - let _ = app.run("show.customShow", json!({"name": s.name, "delete": true})); - } - }); - } - if started { - return true; - } - field(ui, d, "New show name:", "name", "Custom Show 1"); - field(ui, d, "Slides (e.g. 1,3,5):", "slides", "1"); - let (ok, cancel) = buttons(ui, "Create"); - if ok { - let idx: Vec = - d.get("slides", "").split(',').filter_map(|s| s.trim().parse::().ok()).filter(|v| *v > 0).map(|v| v - 1).collect(); - run(app, "show.customShow", json!({"name": d.get("name", "Custom Show"), "slides": idx})); - } - ok || cancel - } - "chartData" => { - let ch = app.session.active().and_then(|s| { - s.selected_shapes() - .into_iter() - .find_map(|x| if let deckcraft_model::ShapeKind::Chart(c) = &x.kind { Some(((**c).clone(), x.id)) } else { None }) - }); - let Some((c, id)) = ch else { - ui.label("Select a chart first."); - let (ok, cancel) = buttons(ui, "Close"); - return ok || cancel; - }; - ui.label("Edit the data: first row = series names, first column = categories."); - let mut grid: Vec> = d.params.get("grid").and_then(|g| serde_json::from_value(g.clone()).ok()).unwrap_or_else(|| { - let mut g = vec![std::iter::once(String::new()).chain(c.series.iter().map(|s| s.name.clone())).collect::>()]; - for (i, cat) in c.categories.iter().enumerate() { - let mut row = vec![cat.clone()]; - for s in &c.series { - row.push(s.values.get(i).copied().flatten().map(|v| v.to_string()).unwrap_or_default()); - } - g.push(row); - } - g - }); - egui::Grid::new("chartdata").striped(true).show(ui, |ui| { - for row in grid.iter_mut() { - for cell in row.iter_mut() { - ui.add(egui::TextEdit::singleline(cell).hint_text("Chart cell").desired_width(70.0)); - } - ui.end_row(); - } - }); - ui.horizontal(|ui| { - if ui.button("+ Row").clicked() { - let n = grid.first().map(Vec::len).unwrap_or(2); - grid.push(vec![String::new(); n]); - } - if ui.button("+ Series").clicked() { - for r in grid.iter_mut() { - r.push(String::new()); - } - } - }); - d.params = json!({"grid": grid}); - let (ok, cancel) = buttons(ui, "OK"); - if ok { - let names: Vec = grid.first().map(|r| r.iter().skip(1).cloned().collect()).unwrap_or_default(); - let cats: Vec = grid.iter().skip(1).map(|r| r.first().cloned().unwrap_or_default()).collect(); - let series: Vec = names.iter().enumerate().map(|(k, n)| json!({"name": n, "values": grid.iter().skip(1).map(|r| r.get(k + 1).and_then(|v| v.trim().parse::().ok())).collect::>()})).collect(); - run(app, "chart.data", json!({"id": id.0, "categories": cats, "series": series})); - } - ok || cancel - } - "trim" => { - let seed = trim_seed(app.session.active()); - field(ui, d, "Start time (s):", "start", &seed.start); - field(ui, d, "End trim (s):", "end", &seed.end); - field(ui, d, "Fade in (s):", "fin", &seed.fade_in); - field(ui, d, "Fade out (s):", "fout", &seed.fade_out); - let (ok, cancel) = buttons(ui, "OK"); - if ok { - run( - app, - "media.options", - json!({ - "trimStart": seconds_text_to_ms(&d.get("start", &seed.start)), - "trimEnd": seconds_text_to_ms(&d.get("end", &seed.end)), - "fadeIn": seconds_text_to_ms(&d.get("fin", &seed.fade_in)), - "fadeOut": seconds_text_to_ms(&d.get("fout", &seed.fade_out)), - }), - ); - } - ok || cancel - } - "smartart" => { - ui.label("Pick a graphic; type one item per line."); - let kind = d.get("kind", "process"); - ui.horizontal_wrapped(|ui| { - for (k, l) in [ - ("list", "Basic List"), - ("process", "Basic Process"), - ("cycle", "Basic Cycle"), - ("hierarchy", "Hierarchy"), - ("pyramid", "Pyramid"), - ("matrix", "Matrix"), - ] { - if ui.selectable_label(kind == k, l).clicked() { - d.fields.insert("kind".into(), k.into()); - } - } - }); - let mut v = d.get("text", "Plan\nBuild\nLaunch"); - ui.add(egui::TextEdit::multiline(&mut v).hint_text("SmartArt items").desired_rows(5).desired_width(320.0)); - d.fields.insert("text".into(), v.clone()); - let (ok, cancel) = buttons(ui, "Insert"); - if ok { - run(app, "insert.smartArt", json!({"kind": kind, "items": v.lines().filter(|l| !l.trim().is_empty()).collect::>()})); - app.ui.pane = Some("smartart".into()); - } - ok || cancel - } - "about" => { - let tab = d.get("tab", "about"); - ui.horizontal(|ui| { - for (k, l) in [("about", "About"), ("contributors", "Contributors"), ("models", "Models")] { - if ui.selectable_label(tab == k, l).clicked() { - d.fields.insert("tab".into(), k.into()); - } - } - }); - ui.separator(); - match tab.as_str() { - "contributors" | "models" => { - credits_block(ui, tab == "models"); - } - _ => about(app, ui), - } - let (ok, cancel) = buttons(ui, "Close"); - ok || cancel - } - "autocorrect" => { - let pr = &mut app.session.prefs; - ui.checkbox(&mut pr.ac_two_initials, "Correct TWo INitial CApitals"); - ui.checkbox(&mut pr.ac_sentence_caps, "Capitalize first letter of sentences"); - ui.checkbox(&mut pr.ac_days, "Capitalize names of days"); - ui.checkbox(&mut pr.ac_caps_lock, "Correct accidental use of cAPS LOCK key"); - ui.checkbox(&mut pr.ac_fractions, "Replace fractions (1/2) with fraction characters (½)"); - ui.checkbox(&mut pr.ac_ordinals, "Ordinals (1st) with superscript"); - ui.checkbox(&mut pr.ac_lists, "Automatic bulleted and numbered lists"); - ui.checkbox(&mut pr.autocorrect, "Replace text as you type"); - ui.add_space(4.0); - let mut from = d.get("from", ""); - let mut to = d.get("to", ""); - ui.horizontal(|ui| { - let l = ui.label("Replace"); - ui.add(egui::TextEdit::singleline(&mut from).desired_width(90.0)).labelled_by(l.id); - let l = ui.label("With"); - ui.add(egui::TextEdit::singleline(&mut to).desired_width(130.0)).labelled_by(l.id); - if ui.add_enabled(!from.trim().is_empty(), egui::Button::new("Add")).clicked() { - run(app, "text.autocorrect", json!({"add": [from.trim(), to]})); - from.clear(); - to.clear(); - } - }); - d.fields.insert("from".into(), from.clone()); - d.fields.insert("to".into(), to); - let list = app.session.execute("text.autocorrect", &json!({})).ok().and_then(|v| v.get("entries").cloned()); - let entries: Vec<(String, String)> = list.and_then(|v| serde_json::from_value(v).ok()).unwrap_or_default(); - let q = from.trim().to_lowercase(); - egui::ScrollArea::vertical().max_height(220.0).show(ui, |ui| { - egui::Grid::new("ac-list").striped(true).show(ui, |ui| { - for (k, v) in entries.iter().filter(|(k, _)| q.is_empty() || k.to_lowercase().starts_with(&q)) { - ui.label(k); - ui.label(v); - if ui.small_button("Delete").clicked() { - run(app, "text.autocorrect", json!({"remove": k})); - } - ui.end_row(); - } - }); - }); - let (ok, cancel) = buttons(ui, "OK"); - ok || cancel - } + "spelling" => dlg_spelling(app, ui, d), + "accessibility" => dlg_accessibility(app, ui, d), + "language" => dlg_language(app, ui, d), + "customShow" => dlg_custom_show(app, ui, d), + "chartData" => dlg_chart_data(app, ui, d), + "trim" => dlg_trim(app, ui, d), + "smartart" => dlg_smartart(app, ui, d), + "about" => dlg_about(app, ui, d), + "autocorrect" => dlg_autocorrect(app, ui, d), "gridOptions" => grid_options(app, ui, d), "columns" => columns_dialog(app, ui, d), - "preferences" => { - // The controls apply as they change, so the dialog remembers what it opened with and - // Cancel (or Esc, or the close button: see `show`) puts that back. - if d.params.get("before").is_none() { - d.params = json!({ "before": preferences_snapshot(app) }); - } - ui.label(egui::RichText::new("General").font(theme::bold(13.0))); - let mut dark = app.ui.brightness == theme::Brightness::Dark; - if ui.checkbox(&mut dark, "Dark appearance").changed() { - let _ = app.run("view.dark", json!({"on": dark})); - } - // While the pointer holds the slider its value only waits in the dialog: applying it - // would rescale the slider under the pointer and run it to an end (#767). It applies - // on release, and at once from the keyboard. - let pending = d.fields.get("uiScale").and_then(|v| v.parse::().ok()); - let mut scale = pending.unwrap_or(app.ui.ui_scale); - let r = ui.add(egui::Slider::new(&mut scale, 0.75..=1.75)); - // No visible label here (the row is laid out to fit); the accessible name only. - let enabled = r.enabled(); - r.widget_info(|| egui::WidgetInfo::slider(enabled, f64::from(scale), "Interface size")); - if r.is_pointer_button_down_on() { - if r.changed() || pending.is_some() { - d.fields.insert("uiScale".into(), scale.to_string()); - } - } else { - if r.changed() || pending.is_some() { - app.ui.ui_scale = scale; - } - d.fields.remove("uiScale"); - } - // PowerPoint takes the unit from the system's measurement setting; DeckCraft asks here. - ui.horizontal(|ui| { - ui.label("Measurement units:"); - let units = &mut app.session.prefs.units; - ui.radio_value(units, deckcraft_geom::Unit::Inches, "Inches"); - ui.radio_value(units, deckcraft_geom::Unit::Centimeters, "Centimeters"); - }); - ui.label(egui::RichText::new("Editing").font(theme::bold(13.0))); - ui.checkbox(&mut app.session.prefs.smart_guides, "Show smart guides"); - ui.checkbox(&mut app.session.prefs.snap_to_grid, "Snap objects to grid"); - ui.checkbox(&mut app.session.prefs.smart_quotes, "Replace straight quotes with smart quotes"); - ui.horizontal(|ui| { - ui.checkbox(&mut app.session.prefs.autocorrect, "AutoCorrect as you type"); - if ui.button("AutoCorrect Options…").clicked() { - *d = Dialog::new("autocorrect"); - } - }); - if d.id != "preferences" { - return false; - } - ui.horizontal(|ui| { - let label = ui.label("Your name (comments):"); - // Name the field for assistive technology; a bare text edit next to a label is an - // unnamed input in egui 0.37's accessibility tree. - let _ = ui.add(egui::TextEdit::singleline(&mut app.session.prefs.author)).labelled_by(label.id); - }); - let (ok, cancel) = buttons(ui, "Done"); - if cancel { - restore_preferences(app, d); - } - ok || cancel - } + "preferences" => dlg_preferences(app, ui, d), "photoAlbum" => photo_album_dialog(app, ui, d), "themeColors" => theme_colors_dialog(app, ui, d), "themeFonts" => theme_fonts_dialog(app, ui, d), @@ -1344,3 +72,1325 @@ pub(super) fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { } } } + +fn run(app: &mut SlideApp, id: &str, p: Value) { + let _ = app.run(id, p); +} + +fn dlg_table(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { + field(ui, d, "Number of columns:", "cols", "5"); + field(ui, d, "Number of rows:", "rows", "2"); + let (ok, cancel) = buttons(ui, "Insert"); + if ok { + let (r, c) = (num(d, "rows", 2.0) as usize, num(d, "cols", 5.0) as usize); + run(app, "insert.table", json!({"rows": r, "cols": c})); + } + ok || cancel +} + +fn dlg_slide_size(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { + let cur = app.session.active().map(|s| s.doc.slide_size).unwrap_or(deckcraft_model::defaults::WIDE); + let len = app.session.prefs.units; + let ppu = len.points_per_unit(); + // Each field keeps its exact size in points (`wPt`) beside the two decimals shown, so + // a preset, Portrait or an untouched field is not nudged off by the rounding (13.33", + // 33.87 cm read back would make the slide 959.76 or 960.09 pt wide). + if !d.fields.contains_key("wPt") { + put_slide_dim(d, "w", cur.width, ppu); + put_slide_dim(d, "h", cur.height, ppu); + } + let label = ui.label("Slides sized for:"); + let preset = d.get("preset", ""); + let combo = craft_ui::ComboBox::from_id_salt("sizes").selected_text(if preset.is_empty() { "Custom" } else { &preset }).show_ui(ui, |ui| { + for (label, w, h) in deckcraft_model::defaults::SLIDE_SIZES { + if ui.selectable_label(preset == *label, *label).clicked() { + d.fields.insert("preset".into(), label.to_string()); + put_slide_dim(d, "w", *w, ppu); + put_slide_dim(d, "h", *h, ppu); + } + } + }); + let _ = combo.response.labelled_by(label.id); + let abbr = unit_abbr(len); + let (w_seed, h_seed) = (field_of(d, "w"), field_of(d, "h")); + field(ui, d, &format!("Width ({abbr}):"), "w", &w_seed); + field(ui, d, &format!("Height ({abbr}):"), "h", &h_seed); + let (w, h) = (slide_dim_points(d, "w", ppu, cur.width), slide_dim_points(d, "h", ppu, cur.height)); + ui.horizontal(|ui| { + ui.label("Orientation:"); + let portrait = ui.radio(h > w, "Portrait").clicked(); + let landscape = ui.radio(w >= h, "Landscape").clicked(); + // Picking the other orientation swaps the width and height fields. + if portrait && w > h || landscape && h > w { + put_slide_dim(d, "w", h, ppu); + put_slide_dim(d, "h", w, ppu); + } + }); + let maximize = check(ui, d, "Maximize content (else ensure fit)", "max", false); + let (ok, cancel) = buttons(ui, "OK"); + if ok { + let (w, h) = (slide_dim_points(d, "w", ppu, cur.width), slide_dim_points(d, "h", ppu, cur.height)); + run(app, "design.slideSize", json!({"w": w, "h": h, "scale": if maximize { "maximize" } else { "ensureFit" }})); + } + ok || cancel +} + +fn dlg_header_footer(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { + let hf = app.session.active().map(|s| s.doc.header_footer.clone()).unwrap_or_default(); + let date = check(ui, d, "Date and time", "date", hf.date); + field(ui, d, " Fixed date (empty = update automatically):", "dateText", &hf.date_text); + // The format applies to an automatic date; a fixed one is the text as typed. + let automatic = d.get("dateText", &hf.date_text).is_empty(); + let fmt_now = if hf.date_format.is_empty() { "datetime1".to_string() } else { hf.date_format.clone() }; + // Not `d.get`: that stores the default as if the user had picked it. + let fmt = d.fields.get("dateFormat").cloned().unwrap_or_else(|| fmt_now.clone()); + let rows = date_rows("en-US"); + let label = rows.iter().find(|(k, _)| *k == fmt).map(|(_, s)| s.clone()).unwrap_or_else(|| fmt.clone()); + ui.add_enabled_ui(date && automatic, |ui| { + ui.horizontal(|ui| { + ui.label(" Format:"); + craft_ui::ComboBox::from_id_salt("hf-date-format").selected_text(label).width(200.0).show_ui(ui, |ui| { + for (kind, sample) in &rows { + if ui.selectable_label(fmt == *kind, sample).clicked() { + d.fields.insert("dateFormat".into(), (*kind).to_string()); + } + } + }); + }); + }); + let num_on = check(ui, d, "Slide number", "num", hf.slide_number); + let foot = check(ui, d, "Footer", "footer", hf.footer); + field(ui, d, " Footer text:", "footerText", &hf.footer_text); + let hide = check(ui, d, "Don't show on title slide", "hide", hf.hide_on_title); + ui.add_space(6.0); + let mut apply_all = false; + let mut apply = false; + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + apply_all = ui.button("Apply to All").clicked(); + apply = ui.button("Apply").clicked(); + if ui.button("Cancel").clicked() { + d.fields.insert("cancel".into(), "1".into()); + } + }); + if apply || apply_all { + let dt = d.get("dateText", ""); + let ft = d.get("footerText", ""); + let mut params = serde_json::Map::new(); + params.insert("date".into(), json!(date)); + params.insert("dateText".into(), json!(dt)); + // Only a format the user picked: sending the default would rewrite every slide's + // existing date field (an imported datetime4, say) to datetime1. + if d.fields.contains_key("dateFormat") { + params.insert("dateFormat".into(), json!(d.get("dateFormat", &fmt_now))); + } + params.insert("slideNumber".into(), json!(num_on)); + params.insert("footer".into(), json!(foot)); + params.insert("footerText".into(), json!(ft)); + params.insert("hideOnTitle".into(), json!(hide)); + params.insert("all".into(), json!(apply_all)); + run(app, "design.headerFooter", serde_json::Value::Object(params)); + } + apply || apply_all || d.fields.contains_key("cancel") +} + +fn dlg_hyperlink(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { + let seed = hyperlink_seed_of(app.session.active()); + let slide_default = seed.slide.to_string(); + let mode = d.get("mode", seed.mode); + ui.horizontal(|ui| { + if ui.selectable_label(mode == "url", "Web Page or File").clicked() { + d.fields.insert("mode".into(), "url".into()); + } + if ui.selectable_label(mode == "slide", "This Document").clicked() { + d.fields.insert("mode".into(), "slide".into()); + } + if ui.selectable_label(mode == "email", "Email Address").clicked() { + d.fields.insert("mode".into(), "email".into()); + } + }); + match mode.as_str() { + "slide" => { + let titles: Vec = app + .session + .active() + .map(|s| s.doc.slides.iter().enumerate().map(|(i, sl)| format!("{}. {}", i + 1, sl.title())).collect()) + .unwrap_or_default(); + let sel = d.get("slide", &slide_default).parse::().unwrap_or(0); + egui::ScrollArea::vertical().max_height(180.0).show(ui, |ui| { + for (i, t) in titles.iter().enumerate() { + if ui.selectable_label(sel == i, t).clicked() { + d.fields.insert("slide".into(), i.to_string()); + } + } + }); + } + "email" => field(ui, d, "Email address:", "email", &seed.email), + _ => field(ui, d, "Address:", "url", &seed.url), + } + field(ui, d, "ScreenTip:", "tip", &seed.tip); + let (ok, cancel) = buttons(ui, "OK"); + if ok { + let mode_now = d.get("mode", seed.mode); + let url = d.get("url", &seed.url); + let email = d.get("email", &seed.email); + let slide = d.get("slide", &slide_default).parse::().unwrap_or(0); + let tip = d.get("tip", &seed.tip); + if hyperlink_should_apply(&seed, &mode_now, &url, &email, slide) { + run(app, "insert.hyperlink", hyperlink_command(&mode_now, &url, &email, slide, &tip, seed.highlight)); + } + } + ok || cancel +} + +fn dlg_setup_show(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { + let sh = app.session.active().map(|s| s.doc.show.clone()).unwrap_or_default(); + let ty = d.get("type", &sh.show_type); + ui.label("Show type"); + for (l, v) in [ + ("Presented by a speaker (full screen)", "speaker"), + ("Browsed by an individual (window)", "browsed"), + ("Browsed at a kiosk (full screen)", "kiosk"), + ] { + if ui.radio(ty == v, l).clicked() { + d.fields.insert("type".into(), v.into()); + } + } + let lp = check(ui, d, "Loop continuously until 'Esc'", "loop", sh.loop_until_esc); + let nn = check(ui, d, "Show without narration", "nn", sh.without_narration); + let na = check(ui, d, "Show without animation", "na", sh.without_animation); + let ut = check(ui, d, "Use timings, if present", "ut", sh.use_timings); + let names: Vec = app.session.active().map(|s| s.doc.custom_shows.iter().map(|c| c.name.clone()).collect()).unwrap_or_default(); + let chosen = d.fields.entry("custom".into()).or_insert_with(|| sh.custom_show.clone().unwrap_or_default()).clone(); + let field = ui.label("Slides"); + let label = if chosen.is_empty() { "All slides".to_string() } else { chosen.clone() }; + let combo = craft_ui::ComboBox::from_id_salt("setup_custom").selected_text(label).show_ui(ui, |ui| { + if ui.selectable_label(chosen.is_empty(), "All slides").clicked() { + d.fields.insert("custom".into(), String::new()); + } + for n in &names { + if ui.selectable_label(&chosen == n, n).clicked() { + d.fields.insert("custom".into(), n.clone()); + } + } + }); + let _ = combo.response.labelled_by(field.id); + let (ok, cancel) = buttons(ui, "OK"); + if ok { + let ty = d.get("type", "speaker"); + let custom = d.fields.get("custom").cloned().unwrap_or_default(); + run(app, "show.setup", json!({"type": ty, "loop": lp, "noNarration": nn, "noAnimation": na, "useTimings": ut, "custom": custom})); + } + ok || cancel +} + +fn dlg_quit(app: &mut SlideApp, ui: &mut Ui, _d: &mut Dialog) -> bool { + let names: Vec = app.session.documents().iter().filter(|d| d.is_dirty()).map(|d| d.title()).collect(); + if names.is_empty() { + app.quit_confirmed = true; + ui.ctx().send_viewport_cmd(egui::ViewportCommand::Close); + return true; + } + let what = if names.len() == 1 { format!("“{}”", names[0]) } else { format!("{} presentations", names.len()) }; + ui.label(egui::RichText::new(format!("Do you want to save the changes you made to {what}?")).strong()); + ui.label("Your changes will be lost if you don't save them."); + ui.add_space(8.0); + let mut done = false; + ui.horizontal(|ui| { + let t = Tokens::get(ui.ctx()); + if ui.add(egui::Button::new("Don't Save").min_size(vec2(84.0, 24.0))).clicked() { + let _ = app.session.execute("file.recovery.discard", &json!({})); + app.quit_confirmed = true; + ui.ctx().send_viewport_cmd(egui::ViewportCommand::Close); + done = true; + } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let save = ui.add(egui::Button::new(egui::RichText::new("Save").color(t.accent_text)).fill(t.accent).min_size(vec2(72.0, 24.0))); + if save.clicked() || ui.input(|i| i.key_pressed(egui::Key::Enter)) { + if app.save_all() { + app.quit_confirmed = true; + ui.ctx().send_viewport_cmd(egui::ViewportCommand::Close); + } + done = true; + } + if ui.add(egui::Button::new("Cancel").min_size(vec2(72.0, 24.0))).clicked() { + done = true; + } + }); + }); + done +} + +fn dlg_close(app: &mut SlideApp, ui: &mut Ui, _d: &mut Dialog) -> bool { + // File › Close and Ctrl+W on a presentation with unsaved changes. + let Some(name) = app.session.active().map(|d| d.title()) else { return true }; + ui.label(egui::RichText::new(format!("Do you want to save the changes you made to “{name}”?")).strong()); + ui.label("Your changes will be lost if you don't save them."); + ui.add_space(8.0); + let mut done = false; + ui.horizontal(|ui| { + let t = Tokens::get(ui.ctx()); + if ui.add(egui::Button::new("Don't Save").min_size(vec2(84.0, 24.0))).clicked() { + run(app, "file.close", json!({})); + done = true; + } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let save = ui.add(egui::Button::new(egui::RichText::new("Save").color(t.accent_text)).fill(t.accent).min_size(vec2(72.0, 24.0))); + if save.clicked() || ui.input(|i| i.key_pressed(egui::Key::Enter)) { + app.save(); + // In the browser a save is a download: the presentation stays "unsaved". + let web = app.services.pick_save.is_none() && app.services.download.is_some(); + if web || app.session.active().is_some_and(|d| !d.is_dirty()) { + run(app, "file.close", json!({})); + } + done = true; + } + if ui.add(egui::Button::new("Cancel").min_size(vec2(72.0, 24.0))).clicked() { + done = true; + } + }); + }); + done +} + +fn dlg_comment(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { + let mut v = d.get("text", ""); + ui.add(egui::TextEdit::multiline(&mut v).hint_text("Comment").desired_rows(4).desired_width(320.0)); + d.fields.insert("text".into(), v.clone()); + let (ok, cancel) = buttons(ui, "Post"); + if ok && !v.trim().is_empty() { + run(app, "comment.add", json!({"text": v})); + app.ui.pane = Some("comments".into()); + } + ok || cancel +} + +fn dlg_bullets(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { + // The current paragraph's list, as the dialog's starting point. + let cur = app.session.execute("format.state", &json!({})).ok().unwrap_or(Value::Null); + let numbered = cur.get("numbering").and_then(Value::as_bool).unwrap_or(false); + let tab = d.get("tab", if numbered { "numbered" } else { "bulleted" }); + ui.horizontal(|ui| { + for (k, l) in [("bulleted", "Bulleted"), ("numbered", "Numbered")] { + if ui.selectable_label(tab == k, l).clicked() { + d.fields.insert("tab".into(), k.into()); + } + } + }); + ui.separator(); + let style_key = if tab == "numbered" { "scheme" } else { "char" }; + let current = d.get(style_key, if tab == "numbered" { "arabicPeriod" } else { "•" }); + let styles: &[(&str, &str)] = if tab == "numbered" { + &[ + ("arabicPeriod", "1. 2. 3."), + ("arabicParenR", "1) 2) 3)"), + ("romanUcPeriod", "I. II. III."), + ("romanLcPeriod", "i. ii. iii."), + ("alphaUcPeriod", "A. B. C."), + ("alphaLcParenR", "a) b) c)"), + ("alphaLcPeriod", "a. b. c."), + ] + } else { + &[("•", "•"), ("○", "○"), ("■", "■"), ("□", "□"), ("◆", "◆"), ("➢", "➢"), ("✓", "✓"), ("–", "–"), ("★", "★")] + }; + egui::Grid::new("bullet-styles").show(ui, |ui| { + for (i, (k, l)) in styles.iter().enumerate() { + let r = ui.add_sized( + vec2(if tab == "numbered" { 88.0 } else { 44.0 }, 40.0), + egui::Button::selectable(current == *k, egui::RichText::new(*l).size(16.0)), + ); + if r.clicked() { + d.fields.insert(style_key.into(), (*k).into()); + } + if (i + 1) % if tab == "numbered" { 4 } else { 5 } == 0 { + ui.end_row(); + } + } + }); + if tab == "bulleted" { + field(ui, d, "Custom character:", "custom", ""); + } else { + field(ui, d, "Start at:", "start", "1"); + } + field(ui, d, "Size (% of text):", "size", "100"); + let colors = [ + ("", "Automatic"), + ("tx1", "Text"), + ("accent1", "Accent 1"), + ("accent2", "Accent 2"), + ("accent3", "Accent 3"), + ("accent4", "Accent 4"), + ("accent5", "Accent 5"), + ("accent6", "Accent 6"), + ]; + let color = d.get("color", ""); + ui.horizontal(|ui| { + let field = ui.add_sized(vec2(120.0, 20.0), egui::Label::new("Color:")); + let combo = craft_ui::ComboBox::from_id_salt("bullet-color") + .selected_text(colors.iter().find(|c| c.0 == color).map_or("Automatic", |c| c.1)) + .show_ui(ui, |ui| { + for (k, l) in colors { + if ui.selectable_label(color == k, l).clicked() { + d.fields.insert("color".into(), k.into()); + } + } + }); + let _ = combo.response.labelled_by(field.id); + }); + ui.horizontal(|ui| { + if ui.button("None").on_hover_text("Remove bullets and numbering").clicked() { + d.fields.insert("none".into(), "1".into()); + } + }); + let (ok, cancel) = buttons(ui, "OK"); + let none = d.fields.contains_key("none"); + if none { + run(app, if tab == "numbered" { "format.numbering" } else { "format.bullets" }, json!({"on": false})); + } else if ok { + let mut p = json!({"on": true}); + if let Some(size) = d.get("size", "100").trim().trim_end_matches('%').parse::().ok().filter(|v| v.is_finite()) { + p["size"] = json!(size.clamp(25.0, 400.0)); + } + if !color.is_empty() { + p["color"] = json!(color); + } + if tab == "numbered" { + p["scheme"] = json!(d.get("scheme", "arabicPeriod")); + p["start"] = json!(d.get("start", "1").trim().parse::().unwrap_or(1).clamp(1, 32767)); + run(app, "format.numbering", p); + } else { + let custom = d.get("custom", ""); + let ch = custom.chars().find(|c| !c.is_whitespace()).map(String::from).unwrap_or_else(|| d.get("char", "•")); + p["char"] = json!(ch); + run(app, "format.bullets", p); + } + } + ok || cancel || none +} + +fn dlg_action(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { + use deckcraft_model::text::Action; + let tab = d.get("tab", "click"); + // Start from the selected shape's current setting for this tab. + let (cur, slides, shows) = app + .session + .active() + .map(|st| { + let sh = st.selected_shapes().first().map(|x| if tab == "hover" { x.hover.clone() } else { x.click.clone() }).unwrap_or(None); + let slides: Vec<(deckcraft_model::SlideId, String)> = + st.doc.slides.iter().enumerate().map(|(i, sl)| (sl.id, format!("{}. {}", i + 1, sl.title()))).collect(); + let shows: Vec = st.doc.custom_shows.iter().map(|c| c.name.clone()).collect(); + (sh, slides, shows) + }) + .unwrap_or_default(); + let (cur_kind, cur_arg) = match cur.as_ref().map(|h| &h.action) { + None => ("none", String::new()), + Some(Action::NextSlide) => ("next", String::new()), + Some(Action::PreviousSlide) => ("previous", String::new()), + Some(Action::FirstSlide) => ("first", String::new()), + Some(Action::LastSlide) => ("last", String::new()), + Some(Action::LastViewed) => ("lastViewed", String::new()), + Some(Action::EndShow) => ("endShow", String::new()), + Some(Action::PlayMedia) => ("playMedia", String::new()), + Some(Action::Url { url }) => ("url", url.clone()), + Some(Action::CustomShow { name, .. }) => ("customShow", name.clone()), + Some(Action::Program { path }) => ("program", path.clone()), + Some(Action::Slide { slide }) => ("slide", slides.iter().position(|(id, _)| id == slide).unwrap_or(0).to_string()), + }; + ui.horizontal(|ui| { + for (k, l) in [("click", "Mouse Click"), ("hover", "Mouse Over")] { + if ui.selectable_label(tab == k, l).clicked() && tab != k { + d.fields.clear(); + d.fields.insert("tab".into(), k.into()); + } + } + }); + ui.separator(); + ui.label(if tab == "hover" { "Action on mouse over" } else { "Action on click" }); + let kind = d.get("kind", cur_kind); + let hyperlink = !matches!(kind.as_str(), "none" | "program" | "playMedia"); + let pick = |d: &mut Dialog, ui: &mut Ui, k: &str, l: &str| { + if ui.radio(d.get("kind", cur_kind) == k, l).clicked() { + d.fields.insert("kind".into(), k.into()); + } + }; + pick(d, ui, "none", "None"); + ui.horizontal(|ui| { + let field = ui.radio(hyperlink, "Hyperlink to:"); + if field.clicked() && !hyperlink { + d.fields.insert("kind".into(), "next".into()); + } + let targets = [ + ("next", "Next Slide"), + ("previous", "Previous Slide"), + ("first", "First Slide"), + ("last", "Last Slide"), + ("lastViewed", "Last Slide Viewed"), + ("endShow", "End Show"), + ("customShow", "Custom Show…"), + ("slide", "Slide…"), + ("url", "URL…"), + ]; + let shown = targets.iter().find(|t| t.0 == kind).map_or("Next Slide", |t| t.1); + ui.add_enabled_ui(hyperlink, |ui| { + let combo = craft_ui::ComboBox::from_id_salt("action-target").selected_text(shown).show_ui(ui, |ui| { + for (k, l) in targets { + if ui.selectable_label(kind == k, l).clicked() { + d.fields.insert("kind".into(), k.into()); + } + } + }); + let _ = combo.response.labelled_by(field.id); + }); + }); + let kind = d.get("kind", cur_kind); + match kind.as_str() { + "url" => field(ui, d, "Address:", "arg", if cur_kind == "url" { &cur_arg } else { "https://" }), + "slide" => { + let sel = d.get("arg", if cur_kind == "slide" { &cur_arg } else { "0" }).parse::().unwrap_or(0); + egui::ScrollArea::vertical().max_height(140.0).show(ui, |ui| { + for (i, (_, t)) in slides.iter().enumerate() { + if ui.selectable_label(sel == i, t).clicked() { + d.fields.insert("arg".into(), i.to_string()); + } + } + }); + } + "customShow" => { + if shows.is_empty() { + ui.label("This presentation has no custom shows (Slide Show ▸ Custom Slide Show)."); + } + let sel = d.get("arg", if cur_kind == "customShow" { &cur_arg } else { shows.first().map_or("", String::as_str) }); + for n in &shows { + if ui.selectable_label(&sel == n, n).clicked() { + d.fields.insert("arg".into(), n.clone()); + } + } + } + _ => {} + } + pick(d, ui, "program", "Run program:"); + if d.get("kind", cur_kind) == "program" { + field(ui, d, "Program:", "arg", if cur_kind == "program" { &cur_arg } else { "" }); + } + pick(d, ui, "playMedia", "Play the shape's media"); + let hl = check(ui, d, "Highlight click", "highlight", cur.as_ref().is_none_or(|h| h.highlight_click)); + let (ok, cancel) = buttons(ui, "OK"); + if ok { + let kind = d.get("kind", cur_kind); + let arg = d.get("arg", &cur_arg); + let mut p = json!({"on": tab, "action": kind, "highlight": hl}); + match kind.as_str() { + "url" => p["url"] = json!(arg), + "slide" => p["slide"] = json!(arg.parse::().unwrap_or(0)), + "customShow" => p["show"] = json!(arg), + "program" => p["path"] = json!(arg), + _ => {} + } + run(app, "shape.action", p); + } + ok || cancel +} + +fn dlg_alt_text(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { + let (cur, deco_now) = app + .session + .active() + .map(|s| { + let shapes = s.selected_shapes(); + let cur = shapes.first().map(|x| x.descr.clone()).unwrap_or_default(); + let deco = alt_text_starts_decorative(shapes.iter().map(|x| x.decorative)); + (cur, deco) + }) + .unwrap_or_default(); + ui.label("How would you describe this object and its context to someone who is blind or has low vision?"); + let mut v = d.get("text", &cur); + ui.add(egui::TextEdit::multiline(&mut v).hint_text("Alt text").desired_rows(4).desired_width(340.0)); + d.fields.insert("text".into(), v.clone()); + let deco = check(ui, d, "Mark as decorative", "deco", deco_now); + let (ok, cancel) = buttons(ui, "OK"); + if ok { + run(app, "shape.altText", json!({"text": v, "decorative": deco})); + } + ok || cancel +} + +fn dlg_outline(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { + ui.label("Paste or type an outline. Unindented lines become slide titles; tab-indented lines become bullets."); + let mut v = d.get("text", ""); + ui.add(egui::TextEdit::multiline(&mut v).hint_text("Outline").desired_rows(10).desired_width(380.0).code_editor()); + d.fields.insert("text".into(), v.clone()); + let (ok, cancel) = buttons(ui, "Insert"); + if ok { + run(app, "slide.fromOutline", json!({"text": v})); + } + ok || cancel +} + +fn dlg_reuse_slides(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { + let path = d.params.get("path").and_then(Value::as_str).unwrap_or("").to_string(); + let slides: Vec<(usize, String)> = d + .params + .get("slides") + .and_then(Value::as_array) + .map(|a| { + a.iter() + .filter_map(|x| { + Some((usize::try_from(x.get("index")?.as_u64()?).ok()?, x.get("title").and_then(Value::as_str).unwrap_or("").to_string())) + }) + .collect() + }) + .unwrap_or_default(); + let name = std::path::Path::new(&path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or(path.clone()); + ui.label(format!("Insert slides from {name} after the current slide:")); + // Unticked slides are remembered by index; everything starts ticked. + let off = |d: &Dialog, i: usize| d.fields.get(&format!("off{i}")).is_some_and(|v| v == "1"); + ui.horizontal(|ui| { + if ui.small_button("Select All").clicked() { + for (i, _) in &slides { + d.fields.remove(&format!("off{i}")); + } + } + if ui.small_button("Select None").clicked() { + for (i, _) in &slides { + d.fields.insert(format!("off{i}"), "1".into()); + } + } + }); + egui::ScrollArea::vertical().max_height(260.0).show(ui, |ui| { + for (i, title) in &slides { + let mut on = !off(d, *i); + let label = if title.trim().is_empty() { format!("{} (no title)", i + 1) } else { format!("{} {}", i + 1, title.trim()) }; + if ui.checkbox(&mut on, label).changed() { + if on { + d.fields.remove(&format!("off{i}")); + } else { + d.fields.insert(format!("off{i}"), "1".into()); + } + } + } + }); + let mut keep = d.params.get("keep").and_then(Value::as_bool).unwrap_or(false); + if ui.checkbox(&mut keep, "Keep source formatting").changed() + && let Some(o) = d.params.as_object_mut() + { + o.insert("keep".into(), json!(keep)); + } + let picked: Vec = slides.iter().map(|(i, _)| *i).filter(|i| !off(d, *i)).collect(); + let (ok, cancel) = buttons(ui, "Insert"); + if ok { + if picked.is_empty() { + app.set_status("Tick at least one slide to insert".to_string()); + return false; + } + // A failed insert keeps the dialog (and the ticked slides) open; the error is in the status bar. + let source = d.params.get("source").cloned().unwrap_or_default(); + return app.reuse_slides(&source, keep, &picked); + } + cancel +} + +fn dlg_rename_section(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { + let cur = d.params.get("name").and_then(Value::as_str).unwrap_or("").to_string(); + field(ui, d, "Name:", "name", &cur); + let (ok, cancel) = buttons(ui, "Rename"); + if ok { + let name = d.get("name", ""); + match d.id.as_str() { + "renameSection" => run(app, "section.rename", json!({"index": d.params.get("index").cloned().unwrap_or_default(), "name": name})), + "renameShape" => run(app, "shape.rename", json!({"id": d.params.get("id").cloned().unwrap_or_default(), "name": name})), + _ => run(app, "master.renameLayout", json!({"name": name})), + } + } + ok || cancel +} + +fn dlg_export(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { + ui.label("Export the presentation as:"); + let fmt = d.get("fmt", "pdf"); + for (l, f) in [ + ("PDF document (.pdf)", "pdf"), + ("PNG images (current slide)", "png"), + ("PNG images (all slides)", "pngAll"), + ("JPEG image (current slide)", "jpeg"), + ("PowerPoint Presentation (.pptx)", "pptx"), + ("Outline (.txt)", "outline"), + ("Animated GIF (.gif)", "gif"), + ("Outline/RTF (.rtf)", "rtf"), + ] { + if ui.radio(fmt == f, l).clicked() { + d.fields.insert("fmt".into(), f.into()); + } + } + let pdf_layout = d.get("pdfLayout", "slides"); + if fmt == "pdf" { + ui.add_space(4.0); + ui.horizontal(|ui| { + let field = ui.label("Print layout:"); + let combo = craft_ui::ComboBox::from_id_salt("pdf_layout") + .selected_text(match pdf_layout.as_str() { + "notes" => "Notes Pages", + "h1" => "Handouts (1 slide per page)", + "h2" => "Handouts (2 slides per page)", + "h3" => "Handouts (3 slides per page)", + "h4" => "Handouts (4 slides per page)", + "h6" => "Handouts (6 slides per page)", + "h9" => "Handouts (9 slides per page)", + _ => "Full Page Slides", + }) + .show_ui(ui, |ui| { + for (k, l) in [ + ("slides", "Full Page Slides"), + ("notes", "Notes Pages"), + ("h1", "Handouts (1 slide per page)"), + ("h2", "Handouts (2 slides per page)"), + ("h3", "Handouts (3 slides per page)"), + ("h4", "Handouts (4 slides per page)"), + ("h6", "Handouts (6 slides per page)"), + ("h9", "Handouts (9 slides per page)"), + ] { + if ui.selectable_label(pdf_layout == k, l).clicked() { + d.fields.insert("pdfLayout".into(), k.into()); + } + } + }); + let _ = combo.response.labelled_by(field.id); + }); + let mut hidden = d.get("pdfHidden", "false") == "true"; + if ui.checkbox(&mut hidden, "Include hidden slides").changed() { + d.fields.insert("pdfHidden".into(), hidden.to_string()); + } + } + let png = matches!(fmt.as_str(), "png" | "pngAll"); + if png { + ui.add_space(4.0); + let mut clear = d.get("pngTransparent", "false") == "true"; + if ui + .checkbox(&mut clear, "Transparent background") + .on_hover_text("Leave the slide background out: only what is on the slide is drawn") + .changed() + { + d.fields.insert("pngTransparent".into(), clear.to_string()); + } + } + let gif_params = if fmt == "gif" { gif_options(app, ui, d) } else { Ok(Value::Null) }; + if let Err(e) = &gif_params { + ui.label(egui::RichText::new(e).color(Tokens::get(ui.ctx()).text_dim)); + } + let pdf_params = { + let (layout, per) = match pdf_layout.as_str() { + "notes" => ("notes", 0), + l if l.starts_with('h') => ("handouts", l[1..].parse().unwrap_or(6)), + _ => ("slides", 0), + }; + json!({"layout": layout, "perPage": per, "includeHidden": d.get("pdfHidden", "false") == "true"}) + }; + let (ok, cancel) = buttons(ui, "Export"); + if ok { + let gif_params = match gif_params { + Ok(v) => v, + // Keep the dialog open so the range or width can be corrected. + Err(e) => { + app.set_status(e); + return false; + } + }; + let extra = match fmt.as_str() { + "pdf" => pdf_params, + "gif" => gif_params, + _ if png && d.get("pngTransparent", "false") == "true" => json!({"transparentBackground": true}), + _ => json!({}), + }; + app.export_as(&fmt, extra); + } + ok || cancel +} + +fn dlg_replace_fonts(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { + let used: Vec = app.session.execute("font.used", &json!({})).ok().and_then(|v| serde_json::from_value(v).ok()).unwrap_or_default(); + let mut from = d.get("from", used.first().map(String::as_str).unwrap_or("")); + let mut to = d.get("to", ""); + egui::Grid::new("replace_fonts").num_columns(2).spacing(vec2(10.0, 8.0)).show(ui, |ui| { + let field = ui.label("Replace:"); + let combo = craft_ui::ComboBox::from_id_salt("rf_from").width(240.0).selected_text(from.clone()).show_ui(ui, |ui| { + for f in &used { + ui.selectable_value(&mut from, f.clone(), f); + } + }); + let _ = combo.response.labelled_by(field.id); + ui.end_row(); + ui.label("With:"); + ui.vertical(|ui| { + ui.add(egui::TextEdit::singleline(&mut to).desired_width(240.0).hint_text("Font name")); + let needle = to.trim().to_lowercase(); + egui::ScrollArea::vertical().max_height(140.0).id_salt("rf_list").show(ui, |ui| { + for f in crate::ribbon::font_families(app).iter().filter(|f| needle.is_empty() || f.to_lowercase().contains(&needle)).take(200) { + if ui.selectable_label(f.eq_ignore_ascii_case(to.trim()), f).clicked() { + to = f.clone(); + } + } + }); + }); + ui.end_row(); + }); + if let Some(msg) = d.fields.get("result") { + ui.label(egui::RichText::new(msg.clone()).color(Tokens::get(ui.ctx()).text_dim)); + } + d.fields.insert("from".into(), from.clone()); + d.fields.insert("to".into(), to.clone()); + let (ok, cancel) = buttons(ui, "Replace"); + if ok && !from.is_empty() && !to.trim().is_empty() { + let n = app.run("font.replace", json!({"from": from, "to": to.trim()})).ok().and_then(|v| v["replaced"].as_u64()).unwrap_or(0); + let msg = if n == 0 { format!("“{from}” isn't used explicitly.") } else { format!("Replaced “{from}” with “{}”.", to.trim()) }; + d.fields.insert("result".into(), msg); + d.fields.remove("from"); + // Stay open, like PowerPoint, so several fonts can be replaced in a row. + return cancel; + } + cancel +} + +fn dlg_symbol(app: &mut SlideApp, ui: &mut Ui, _d: &mut Dialog) -> bool { + ui.label("Click a symbol to insert it at the insertion point."); + let groups: [(&str, &str); 6] = [ + ("Punctuation", "–—‘’“”…•·§¶†‡‰′″‹›«»¡¿"), + ("Currency", "$€£¥¢₹₩₽₺₿"), + ("Math", "±×÷≠≈≤≥∞√∑∏∫∂∆∇∈∉∩∪⊂⊃∀∃¬∧∨⇒⇔°′"), + ("Greek", "αβγδεζηθικλμνξοπρστυφχψωΓΔΘΛΞΠΣΦΨΩ"), + ("Arrows", "←↑→↓↔↕⇐⇑⇒⇓↖↗↘↙⟵⟶"), + ("Shapes", "■□▪▫▲△▶▷▼▽◀◁◆◇○●◎★☆✓✔✗✘♠♣♥♦"), + ]; + for (g, chars) in groups { + ui.label(egui::RichText::new(g).font(theme::bold(12.0))); + ui.horizontal_wrapped(|ui| { + for c in chars.chars() { + if ui.add(egui::Button::new(egui::RichText::new(c.to_string()).size(16.0)).min_size(vec2(26.0, 26.0))).clicked() { + if app.session.active().is_some_and(|s| s.selection.text.is_none()) { + let _ = app.run("text.edit", json!({})); + } + let _ = app.run("insert.symbol", json!({"text": c.to_string()})); + } + } + }); + } + let (ok, cancel) = buttons(ui, "Close"); + ok || cancel +} + +fn dlg_equation(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { + ui.label("Type an equation in linear form (e.g. a^2+b^2=c^2, x=(-b±√(b^2-4ac))/2a)."); + let mut v = d.get("eq", ""); + ui.add(egui::TextEdit::singleline(&mut v).hint_text("Equation").desired_width(340.0).font(theme::font(15.0))); + d.fields.insert("eq".into(), v.clone()); + let (ok, cancel) = buttons(ui, "Insert"); + if ok && !v.is_empty() { + let pretty = pretty_equation(&v); + let size = app.session.active().map(|s| s.doc.slide_size).unwrap_or(deckcraft_model::defaults::WIDE); + run(app, "insert.textBox", json!({"rect": [size.width / 2.0 - 150.0, size.height / 2.0 - 25.0, 300, 50], "text": pretty})); + run(app, "text.exit", json!({})); + run(app, "format.font", json!({"family": "Liberation Serif"})); + run(app, "format.italic", json!({"on": true})); + run(app, "format.size", json!({"size": 28})); + } + ok || cancel +} + +fn dlg_paste_special(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { + const OPTS: [(&str, &str); 4] = [ + ("destinationTheme", "Use Destination Theme"), + ("keepSource", "Keep Source Formatting"), + ("picture", "Picture (PNG)"), + ("text", "Keep Text Only"), + ]; + let has_shapes = !app.session.clipboard.shapes.is_empty(); + let mut cur = d.get("as", "destinationTheme"); + ui.label("As:"); + for (id, label) in OPTS { + let ok = has_shapes || matches!(id, "destinationTheme" | "text"); + ui.add_enabled_ui(ok, |ui| ui.radio_value(&mut cur, id.to_string(), label)); + } + d.fields.insert("as".into(), cur.clone()); + let (ok, cancel) = buttons(ui, "OK"); + if ok { + run(app, "edit.pasteSpecial", json!({"as": cur})); + } + ok || cancel +} + +fn dlg_spelling(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { + // Words skipped with Ignore All while the dialog is open, one per line. + let ignored = d.get("ignore", ""); + let ignore: Vec<&str> = ignored.lines().filter(|l| !l.is_empty()).collect(); + let v = app.session.execute("review.spelling", &json!({"ignore": ignore})).unwrap_or_default(); + let list = v.as_array().cloned().unwrap_or_default(); + let info = app.session.execute("review.dictionary", &json!({})).unwrap_or_default(); + let available = info.get("available").and_then(Value::as_bool).unwrap_or(false); + match info.get("found").and_then(Value::as_str) { + Some(f) if available => ui.label(egui::RichText::new(format!("Dictionary: {}", f.rsplit(['/', '\\']).next().unwrap_or(f))).small().weak()), + _ if available => { + ui.label(egui::RichText::new(format!("Dictionary: {}", info.get("lang").and_then(Value::as_str).unwrap_or(""))).small().weak()) + } + _ => ui.label( + egui::RichText::new(format!( + "No {} dictionary is installed, so only common misspellings are found. Put a Hunspell .aff/.dic pair in {}.", + info.get("lang").and_then(Value::as_str).unwrap_or("en-US"), + info.get("folders") + .and_then(Value::as_array) + .and_then(|a| a.get(1).or(a.first())) + .and_then(Value::as_str) + .unwrap_or("a dictionaries folder") + )) + .small() + .weak(), + ), + }; + if list.is_empty() { + ui.label("The spelling check is complete. No issues found."); + } + let mut add_ignore = None; + egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| { + for item in &list { + let w = item.get("word").and_then(Value::as_str).unwrap_or(""); + let slide = item.get("slide").cloned().unwrap_or_default(); + let shape = item.get("shape").cloned().unwrap_or_default(); + ui.horizontal(|ui| { + ui.label(format!("Slide {}: “{w}”", slide.as_u64().unwrap_or(0) + 1)); + if ui.small_button("Go to").clicked() { + let _ = app.run("slide.go", json!({"index": slide})); + let _ = app.run("edit.select", json!({"ids": [shape]})); + } + if ui.small_button("Ignore All").clicked() { + add_ignore = Some(w.to_string()); + } + if ui.small_button("Add to Dictionary").clicked() + && let Err(e) = app.session.execute("review.addToDictionary", &json!({"word": w})) + { + app.set_status(e.to_string()); + } + }); + let sug: Vec = item + .get("suggestions") + .and_then(Value::as_array) + .map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()) + .unwrap_or_default(); + if item.get("repeated").and_then(Value::as_bool).unwrap_or(false) && !item.get("notes").and_then(Value::as_bool).unwrap_or(false) { + ui.horizontal(|ui| { + ui.add_space(16.0); + ui.label(egui::RichText::new("Repeated word").weak()); + if ui.small_button("Delete Repeated Word").clicked() { + let from = item.get("deleteFrom").and_then(Value::as_u64).unwrap_or(0) as usize; + let end = item.get("end").and_then(Value::as_u64).unwrap_or(0) as usize; + let para = item.get("para").and_then(Value::as_u64).unwrap_or(0) as usize; + let text = app.session.active().and_then(|st| { + let sh = st.shape(deckcraft_model::ShapeId(shape.as_u64()? as u32))?; + let pp = match (item.get("row").and_then(Value::as_u64), item.get("col").and_then(Value::as_u64), &sh.kind) { + (Some(r), Some(c), deckcraft_model::ShapeKind::Table(tb)) => { + tb.cell(r as usize, c as usize)?.text.paragraphs.get(para)?.text() + } + _ => sh.text.as_ref()?.paragraphs.get(para)?.text(), + }; + Some(pp.chars().skip(from).take(end.saturating_sub(from)).collect::()) + }); + if let Some(gap) = text { + let mut p = json!({"shape": shape, "para": para, "start": from, "word": gap, "to": ""}); + if let (Some(o), Some(r), Some(c)) = (p.as_object_mut(), item.get("row"), item.get("col")) { + o.insert("row".into(), r.clone()); + o.insert("col".into(), c.clone()); + } + run(app, "review.spellingChange", p); + } + } + }); + } + if !sug.is_empty() { + ui.horizontal_wrapped(|ui| { + ui.add_space(16.0); + ui.label(egui::RichText::new("Change to:").weak()); + for to in &sug { + let r = ui.small_button(to.as_str()).on_hover_text("Click to change this one; Shift-click to change all"); + if r.clicked() { + let all = ui.input(|i| i.modifiers.shift); + if item.get("notes").and_then(Value::as_bool).unwrap_or(false) { + // A word in the notes: change that line of the slide's notes. + let si = slide.as_u64().unwrap_or(0) as usize; + let notes = app.session.active().and_then(|d| d.doc.slides.get(si)).map(|s| s.notes_text()).unwrap_or_default(); + let (pi, st) = ( + item.get("para").and_then(Value::as_u64).unwrap_or(0) as usize, + item.get("start").and_then(Value::as_u64).unwrap_or(0) as usize, + ); + let lines: Vec = notes + .split('\n') + .enumerate() + .map(|(i, l)| { + if i != pi { + return l.to_string(); + } + let c: Vec = l.chars().collect(); + let n = w.chars().count(); + if c.get(st..st + n).is_some_and(|x| x.iter().collect::() == w) { + c[..st].iter().chain(to.chars().collect::>().iter()).chain(c[st + n..].iter()).collect() + } else { + l.to_string() + } + }) + .collect(); + run(app, "slide.notes", json!({"text": lines.join("\n"), "index": si})); + } else { + let mut p = if all { + json!({"word": w, "to": to, "all": true}) + } else { + json!({"shape": shape, "word": w, "to": to, "para": item.get("para"), "start": item.get("start")}) + }; + if !all && let (Some(o), Some(r), Some(c)) = (p.as_object_mut(), item.get("row"), item.get("col")) { + o.insert("row".into(), r.clone()); + o.insert("col".into(), c.clone()); + } + run(app, "review.spellingChange", p); + } + } + } + }); + } + } + }); + if let Some(w) = add_ignore { + d.fields.insert("ignore".into(), format!("{ignored}{w}\n")); + } + let (ok, cancel) = buttons(ui, "Close"); + ok || cancel +} + +fn dlg_accessibility(app: &mut SlideApp, ui: &mut Ui, _d: &mut Dialog) -> bool { + let v = app.session.execute("review.accessibility", &json!({})).unwrap_or_default(); + let list = v.as_array().cloned().unwrap_or_default(); + if list.is_empty() { + ui.label("No accessibility issues found. People with disabilities should not have difficulty reading this document."); + } + egui::ScrollArea::vertical().max_height(300.0).show(ui, |ui| { + for item in &list { + ui.horizontal(|ui| { + ui.label(format!( + "Slide {}: {}{}", + item.get("slide").and_then(Value::as_u64).unwrap_or(0) + 1, + item.get("issue").and_then(Value::as_str).unwrap_or(""), + item.get("name").and_then(Value::as_str).map(|n| format!(" — {n}")).unwrap_or_default() + )); + if ui.small_button("Go to").clicked() { + let _ = app.run("slide.go", json!({"index": item.get("slide").cloned().unwrap_or_default()})); + if let Some(id) = item.get("shape") { + let _ = app.run("edit.select", json!({"ids": [id]})); + } + } + }); + } + }); + let (ok, cancel) = buttons(ui, "Close"); + ok || cancel +} + +fn dlg_language(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { + // Starts from the selected text's language and Do Not Check Spelling. + let (cur, np) = selected_language(app); + let lang = d.get("lang", &cur); + let mut no_proof = d.get("noProof", if np { "1" } else { "" }) == "1"; + // The selected text, or every text in the presentation (all there is with nothing selected). + let has_sel = deckcraft_engine::cmd::has_text_or_shapes(&app.session).is_ok(); + let mut all = !has_sel || d.get("scope", "") == "all"; + ui.horizontal(|ui| { + ui.label("Apply to:"); + if ui.add_enabled(has_sel, egui::RadioButton::new(!all, "Selected text")).clicked() { + d.fields.insert("scope".into(), "sel".into()); + all = false; + } + if ui.radio(all, "Whole presentation").clicked() { + d.fields.insert("scope".into(), "all".into()); + all = true; + } + }); + ui.label(if all { "Mark all text in the presentation as:" } else { "Mark selected text as:" }); + egui::ScrollArea::vertical().max_height(220.0).show(ui, |ui| { + // The selection's own language first when the list doesn't have it. + let own = (!crate::squiggles::LANGS.iter().any(|(_, t)| t.eq_ignore_ascii_case(&cur))).then(|| (cur.clone(), cur.clone())); + let all = own.into_iter().chain(crate::squiggles::LANGS.iter().map(|(n, t)| (n.to_string(), t.to_string()))); + for (_native, tag) in all { + // Native names are shown when the interface fonts can draw them; otherwise + // the tag is the explicit fallback (egui 0.37 aborts on a missing glyph). + let name = crate::squiggles::display_lang_name(ui.ctx(), &tag); + let installed = deckcraft_engine::spell::dictionary(&tag).is_some(); + let label = if installed { format!("{name} (dictionary)") } else { name }; + if ui.selectable_label(lang.eq_ignore_ascii_case(&tag), label).clicked() { + d.fields.insert("lang".into(), tag); + } + } + }); + ui.add_space(4.0); + ui.label(egui::RichText::new("Spelling is checked for languages marked (dictionary).").small().weak()); + if ui.checkbox(&mut no_proof, "Do not check spelling or grammar").changed() { + d.fields.insert("noProof".into(), if no_proof { "1".into() } else { String::new() }); + } + let (ok, cancel) = buttons(ui, "OK"); + if ok { + let lang = d.get("lang", &cur); + run(app, if all { "format.languageAll" } else { "format.language" }, json!({"lang": lang, "noProof": no_proof})); + } + ok || cancel +} + +fn dlg_custom_show(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { + let shows = app.session.active().map(|s| s.doc.custom_shows.clone()).unwrap_or_default(); + let mut started = false; + for s in &shows { + ui.horizontal(|ui| { + ui.label(format!("{} ({} slides)", s.name, s.slides.len())); + if ui.small_button("Show").clicked() { + run(app, "show.start", json!({"custom": s.name})); + started = true; + } + if ui.small_button("Delete").clicked() { + let _ = app.run("show.customShow", json!({"name": s.name, "delete": true})); + } + }); + } + if started { + return true; + } + field(ui, d, "New show name:", "name", "Custom Show 1"); + field(ui, d, "Slides (e.g. 1,3,5):", "slides", "1"); + let (ok, cancel) = buttons(ui, "Create"); + if ok { + let idx: Vec = + d.get("slides", "").split(',').filter_map(|s| s.trim().parse::().ok()).filter(|v| *v > 0).map(|v| v - 1).collect(); + run(app, "show.customShow", json!({"name": d.get("name", "Custom Show"), "slides": idx})); + } + ok || cancel +} + +fn dlg_chart_data(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { + let ch = app.session.active().and_then(|s| { + s.selected_shapes() + .into_iter() + .find_map(|x| if let deckcraft_model::ShapeKind::Chart(c) = &x.kind { Some(((**c).clone(), x.id)) } else { None }) + }); + let Some((c, id)) = ch else { + ui.label("Select a chart first."); + let (ok, cancel) = buttons(ui, "Close"); + return ok || cancel; + }; + ui.label("Edit the data: first row = series names, first column = categories."); + let mut grid: Vec> = d.params.get("grid").and_then(|g| serde_json::from_value(g.clone()).ok()).unwrap_or_else(|| { + let mut g = vec![std::iter::once(String::new()).chain(c.series.iter().map(|s| s.name.clone())).collect::>()]; + for (i, cat) in c.categories.iter().enumerate() { + let mut row = vec![cat.clone()]; + for s in &c.series { + row.push(s.values.get(i).copied().flatten().map(|v| v.to_string()).unwrap_or_default()); + } + g.push(row); + } + g + }); + egui::Grid::new("chartdata").striped(true).show(ui, |ui| { + for row in grid.iter_mut() { + for cell in row.iter_mut() { + ui.add(egui::TextEdit::singleline(cell).hint_text("Chart cell").desired_width(70.0)); + } + ui.end_row(); + } + }); + ui.horizontal(|ui| { + if ui.button("+ Row").clicked() { + let n = grid.first().map(Vec::len).unwrap_or(2); + grid.push(vec![String::new(); n]); + } + if ui.button("+ Series").clicked() { + for r in grid.iter_mut() { + r.push(String::new()); + } + } + }); + d.params = json!({"grid": grid}); + let (ok, cancel) = buttons(ui, "OK"); + if ok { + let names: Vec = grid.first().map(|r| r.iter().skip(1).cloned().collect()).unwrap_or_default(); + let cats: Vec = grid.iter().skip(1).map(|r| r.first().cloned().unwrap_or_default()).collect(); + let series: Vec = names.iter().enumerate().map(|(k, n)| json!({"name": n, "values": grid.iter().skip(1).map(|r| r.get(k + 1).and_then(|v| v.trim().parse::().ok())).collect::>()})).collect(); + run(app, "chart.data", json!({"id": id.0, "categories": cats, "series": series})); + } + ok || cancel +} + +fn dlg_trim(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { + let seed = trim_seed(app.session.active()); + field(ui, d, "Start time (s):", "start", &seed.start); + field(ui, d, "End trim (s):", "end", &seed.end); + field(ui, d, "Fade in (s):", "fin", &seed.fade_in); + field(ui, d, "Fade out (s):", "fout", &seed.fade_out); + let (ok, cancel) = buttons(ui, "OK"); + if ok { + run( + app, + "media.options", + json!({ + "trimStart": seconds_text_to_ms(&d.get("start", &seed.start)), + "trimEnd": seconds_text_to_ms(&d.get("end", &seed.end)), + "fadeIn": seconds_text_to_ms(&d.get("fin", &seed.fade_in)), + "fadeOut": seconds_text_to_ms(&d.get("fout", &seed.fade_out)), + }), + ); + } + ok || cancel +} + +fn dlg_smartart(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { + ui.label("Pick a graphic; type one item per line."); + let kind = d.get("kind", "process"); + ui.horizontal_wrapped(|ui| { + for (k, l) in [ + ("list", "Basic List"), + ("process", "Basic Process"), + ("cycle", "Basic Cycle"), + ("hierarchy", "Hierarchy"), + ("pyramid", "Pyramid"), + ("matrix", "Matrix"), + ] { + if ui.selectable_label(kind == k, l).clicked() { + d.fields.insert("kind".into(), k.into()); + } + } + }); + let mut v = d.get("text", "Plan\nBuild\nLaunch"); + ui.add(egui::TextEdit::multiline(&mut v).hint_text("SmartArt items").desired_rows(5).desired_width(320.0)); + d.fields.insert("text".into(), v.clone()); + let (ok, cancel) = buttons(ui, "Insert"); + if ok { + run(app, "insert.smartArt", json!({"kind": kind, "items": v.lines().filter(|l| !l.trim().is_empty()).collect::>()})); + app.ui.pane = Some("smartart".into()); + } + ok || cancel +} + +fn dlg_about(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { + let tab = d.get("tab", "about"); + ui.horizontal(|ui| { + for (k, l) in [("about", "About"), ("contributors", "Contributors"), ("models", "Models")] { + if ui.selectable_label(tab == k, l).clicked() { + d.fields.insert("tab".into(), k.into()); + } + } + }); + ui.separator(); + match tab.as_str() { + "contributors" | "models" => { + credits_block(ui, tab == "models"); + } + _ => about(app, ui), + } + let (ok, cancel) = buttons(ui, "Close"); + ok || cancel +} + +fn dlg_autocorrect(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { + let pr = &mut app.session.prefs; + ui.checkbox(&mut pr.ac_two_initials, "Correct TWo INitial CApitals"); + ui.checkbox(&mut pr.ac_sentence_caps, "Capitalize first letter of sentences"); + ui.checkbox(&mut pr.ac_days, "Capitalize names of days"); + ui.checkbox(&mut pr.ac_caps_lock, "Correct accidental use of cAPS LOCK key"); + ui.checkbox(&mut pr.ac_fractions, "Replace fractions (1/2) with fraction characters (½)"); + ui.checkbox(&mut pr.ac_ordinals, "Ordinals (1st) with superscript"); + ui.checkbox(&mut pr.ac_lists, "Automatic bulleted and numbered lists"); + ui.checkbox(&mut pr.autocorrect, "Replace text as you type"); + ui.add_space(4.0); + let mut from = d.get("from", ""); + let mut to = d.get("to", ""); + ui.horizontal(|ui| { + let l = ui.label("Replace"); + ui.add(egui::TextEdit::singleline(&mut from).desired_width(90.0)).labelled_by(l.id); + let l = ui.label("With"); + ui.add(egui::TextEdit::singleline(&mut to).desired_width(130.0)).labelled_by(l.id); + if ui.add_enabled(!from.trim().is_empty(), egui::Button::new("Add")).clicked() { + run(app, "text.autocorrect", json!({"add": [from.trim(), to]})); + from.clear(); + to.clear(); + } + }); + d.fields.insert("from".into(), from.clone()); + d.fields.insert("to".into(), to); + let list = app.session.execute("text.autocorrect", &json!({})).ok().and_then(|v| v.get("entries").cloned()); + let entries: Vec<(String, String)> = list.and_then(|v| serde_json::from_value(v).ok()).unwrap_or_default(); + let q = from.trim().to_lowercase(); + egui::ScrollArea::vertical().max_height(220.0).show(ui, |ui| { + egui::Grid::new("ac-list").striped(true).show(ui, |ui| { + for (k, v) in entries.iter().filter(|(k, _)| q.is_empty() || k.to_lowercase().starts_with(&q)) { + ui.label(k); + ui.label(v); + if ui.small_button("Delete").clicked() { + run(app, "text.autocorrect", json!({"remove": k})); + } + ui.end_row(); + } + }); + }); + let (ok, cancel) = buttons(ui, "OK"); + ok || cancel +} + +fn dlg_preferences(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { + // The controls apply as they change, so the dialog remembers what it opened with and + // Cancel (or Esc, or the close button: see `show`) puts that back. + if d.params.get("before").is_none() { + d.params = json!({ "before": preferences_snapshot(app) }); + } + ui.label(egui::RichText::new("General").font(theme::bold(13.0))); + let mut dark = app.ui.brightness == theme::Brightness::Dark; + if ui.checkbox(&mut dark, "Dark appearance").changed() { + let _ = app.run("view.dark", json!({"on": dark})); + } + // While the pointer holds the slider its value only waits in the dialog: applying it + // would rescale the slider under the pointer and run it to an end (#767). It applies + // on release, and at once from the keyboard. + let pending = d.fields.get("uiScale").and_then(|v| v.parse::().ok()); + let mut scale = pending.unwrap_or(app.ui.ui_scale); + let r = ui.add(egui::Slider::new(&mut scale, 0.75..=1.75)); + // No visible label here (the row is laid out to fit); the accessible name only. + let enabled = r.enabled(); + r.widget_info(|| egui::WidgetInfo::slider(enabled, f64::from(scale), "Interface size")); + if r.is_pointer_button_down_on() { + if r.changed() || pending.is_some() { + d.fields.insert("uiScale".into(), scale.to_string()); + } + } else { + if r.changed() || pending.is_some() { + app.ui.ui_scale = scale; + } + d.fields.remove("uiScale"); + } + // PowerPoint takes the unit from the system's measurement setting; DeckCraft asks here. + ui.horizontal(|ui| { + ui.label("Measurement units:"); + let units = &mut app.session.prefs.units; + ui.radio_value(units, deckcraft_geom::Unit::Inches, "Inches"); + ui.radio_value(units, deckcraft_geom::Unit::Centimeters, "Centimeters"); + }); + ui.label(egui::RichText::new("Editing").font(theme::bold(13.0))); + ui.checkbox(&mut app.session.prefs.smart_guides, "Show smart guides"); + ui.checkbox(&mut app.session.prefs.snap_to_grid, "Snap objects to grid"); + ui.checkbox(&mut app.session.prefs.smart_quotes, "Replace straight quotes with smart quotes"); + ui.horizontal(|ui| { + ui.checkbox(&mut app.session.prefs.autocorrect, "AutoCorrect as you type"); + if ui.button("AutoCorrect Options…").clicked() { + *d = Dialog::new("autocorrect"); + } + }); + if d.id != "preferences" { + return false; + } + ui.horizontal(|ui| { + let label = ui.label("Your name (comments):"); + // Name the field for assistive technology; a bare text edit next to a label is an + // unnamed input in egui 0.37's accessibility tree. + let _ = ui.add(egui::TextEdit::singleline(&mut app.session.prefs.author)).labelled_by(label.id); + }); + let (ok, cancel) = buttons(ui, "Done"); + if cancel { + restore_preferences(app, d); + } + ok || cancel +} diff --git a/crates/ui-egui/src/dialogs/columns.rs b/crates/ui-egui/src/dialogs/columns.rs index 3e40916d..f436fce6 100644 --- a/crates/ui-egui/src/dialogs/columns.rs +++ b/crates/ui-egui/src/dialogs/columns.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Color32, Dialog, SlideApp, Ui, Value, buttons, field, field_of, json, num_text}; /// The Columns dialog's changed fields as `format.columns` params: Number and Spacing (inches, /// sent in points). A field left as the dialog showed it is left out, so each selected text box diff --git a/crates/ui-egui/src/dialogs/date_time.rs b/crates/ui-egui/src/dialogs/date_time.rs index 89ba6a9d..3ba1c3d7 100644 --- a/crates/ui-egui/src/dialogs/date_time.rs +++ b/crates/ui-egui/src/dialogs/date_time.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{DATE_KINDS, Dialog, SlideApp, Tokens, Ui, buttons, check, json}; const DATE_LANGS: &[(&str, &str)] = &[ ("en-US", "English (United States)"), diff --git a/crates/ui-egui/src/dialogs/export.rs b/crates/ui-egui/src/dialogs/export.rs index 27e2e5ef..432ac58e 100644 --- a/crates/ui-egui/src/dialogs/export.rs +++ b/crates/ui-egui/src/dialogs/export.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Dialog, SlideApp, Ui, Value, check, field, json, num, vec2}; /// The export dialog's Animated GIF options (size, seconds per slide, loop, slide range, hidden /// slides) as `file.export` params; a slide range that doesn't fit the deck is an error message. diff --git a/crates/ui-egui/src/dialogs/find.rs b/crates/ui-egui/src/dialogs/find.rs index c9a04f66..c702db92 100644 --- a/crates/ui-egui/src/dialogs/find.rs +++ b/crates/ui-egui/src/dialogs/find.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Dialog, SlideApp, Ui, Value, check, field, json, vec2}; /// Home › Find / Replace: step through matches (slide text, table cells, notes) or replace them. pub(super) fn find_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { diff --git a/crates/ui-egui/src/dialogs/font.rs b/crates/ui-egui/src/dialogs/font.rs index f8841404..a163e7bc 100644 --- a/crates/ui-egui/src/dialogs/font.rs +++ b/crates/ui-egui/src/dialogs/font.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Dialog, SlideApp, Ui, buttons, check, choice, color_label, finite_pt, json, json_text, labeled_text, seed_once, vec2}; fn seed_font_fields(d: &mut Dialog) { seed_once(d, "family", json_text(d.params.get("family"), "")); diff --git a/crates/ui-egui/src/dialogs/grid_options.rs b/crates/ui-egui/src/dialogs/grid_options.rs index f90442f3..0e7f9c97 100644 --- a/crates/ui-egui/src/dialogs/grid_options.rs +++ b/crates/ui-egui/src/dialogs/grid_options.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Color32, Dialog, SlideApp, Ui, buttons, check, field, json, num, theme}; fn format_spacing(v: f64) -> String { if !v.is_finite() { diff --git a/crates/ui-egui/src/dialogs/hyperlink.rs b/crates/ui-egui/src/dialogs/hyperlink.rs index dd9be289..a2882600 100644 --- a/crates/ui-egui/src/dialogs/hyperlink.rs +++ b/crates/ui-egui/src/dialogs/hyperlink.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Value, json}; /// What Insert Hyperlink would edit on the current selection. #[derive(Clone, Debug, PartialEq)] diff --git a/crates/ui-egui/src/dialogs/language.rs b/crates/ui-egui/src/dialogs/language.rs index 57536fb7..11b941c5 100644 --- a/crates/ui-egui/src/dialogs/language.rs +++ b/crates/ui-egui/src/dialogs/language.rs @@ -1,4 +1,4 @@ -use super::*; +use super::SlideApp; /// The selection's proofing language and whether it's marked Do Not Check Spelling: the run at /// the caret, else the first run of the first selected shape; US English when none says. diff --git a/crates/ui-egui/src/dialogs/paragraph.rs b/crates/ui-egui/src/dialogs/paragraph.rs index 3dcdfdec..217076c3 100644 --- a/crates/ui-egui/src/dialogs/paragraph.rs +++ b/crates/ui-egui/src/dialogs/paragraph.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Dialog, SlideApp, Ui, Value, buttons, choice, finite_pt, inches_text, json, json_text, labeled_text, pt_text, seed_once, vec2}; fn seed_paragraph_fields(d: &mut Dialog) { seed_once(d, "align", json_text(d.params.get("align"), "left")); diff --git a/crates/ui-egui/src/dialogs/photo_album.rs b/crates/ui-egui/src/dialogs/photo_album.rs index 236fef33..340e5832 100644 --- a/crates/ui-egui/src/dialogs/photo_album.rs +++ b/crates/ui-egui/src/dialogs/photo_album.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Dialog, SlideApp, Tokens, Ui, Value, buttons, check, field, json}; fn album_items(d: &Dialog) -> Option> { let raw = d.fields.get("items").map(String::as_str).unwrap_or("[]"); diff --git a/crates/ui-egui/src/dialogs/preferences.rs b/crates/ui-egui/src/dialogs/preferences.rs index 13e96203..b0b98fe5 100644 --- a/crates/ui-egui/src/dialogs/preferences.rs +++ b/crates/ui-egui/src/dialogs/preferences.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Dialog, SlideApp, Value, json, theme}; /// What the Preferences dialog can change, as it was when the dialog opened. pub(super) fn preferences_snapshot(app: &SlideApp) -> Value { diff --git a/crates/ui-egui/src/dialogs/size_position.rs b/crates/ui-egui/src/dialogs/size_position.rs index 8745203f..73e6274f 100644 --- a/crates/ui-egui/src/dialogs/size_position.rs +++ b/crates/ui-egui/src/dialogs/size_position.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Dialog, SlideApp, Ui, Value, buttons, check, edited, exact_of, field_of, json, num_text, parse_shown, text_row}; fn put_dim(d: &mut Dialog, key: &str, exact: f64, shown: String) { d.fields.insert(key.into(), shown.clone()); diff --git a/crates/ui-egui/src/dialogs/slide_size.rs b/crates/ui-egui/src/dialogs/slide_size.rs index 93e1e3bf..a69a478e 100644 --- a/crates/ui-egui/src/dialogs/slide_size.rs +++ b/crates/ui-egui/src/dialogs/slide_size.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Dialog, exact_of, field_of}; /// A Slide Size field set to `pt`: two decimals of the unit shown, the exact points kept beside. pub(super) fn put_slide_dim(d: &mut Dialog, key: &str, pt: f64, ppu: f64) { diff --git a/crates/ui-egui/src/dialogs/tabs.rs b/crates/ui-egui/src/dialogs/tabs.rs index c0fa1478..a74760c5 100644 --- a/crates/ui-egui/src/dialogs/tabs.rs +++ b/crates/ui-egui/src/dialogs/tabs.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Dialog, SlideApp, Ui, Value, buttons, field, json, num, vec2}; const TAB_KINDS: [(&str, &str); 4] = [("l", "Left"), ("ctr", "Center"), ("r", "Right"), ("dec", "Decimal")]; diff --git a/crates/ui-egui/src/dialogs/theme_colors.rs b/crates/ui-egui/src/dialogs/theme_colors.rs index fb00f510..250dfea4 100644 --- a/crates/ui-egui/src/dialogs/theme_colors.rs +++ b/crates/ui-egui/src/dialogs/theme_colors.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Color32, CornerRadius, Dialog, SchemeSlot, Sense, SlideApp, Stroke, Tokens, Ui, Value, buttons, json, theme, vec2}; fn seed_theme_colors(d: &mut Dialog) { if d.fields.contains_key("seeded") { diff --git a/crates/ui-egui/src/dialogs/theme_fonts.rs b/crates/ui-egui/src/dialogs/theme_fonts.rs index 14b88eb8..0adcd337 100644 --- a/crates/ui-egui/src/dialogs/theme_fonts.rs +++ b/crates/ui-egui/src/dialogs/theme_fonts.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Dialog, SlideApp, Tokens, Ui, Value, buttons, json, vec2}; fn seed_theme_fonts(d: &mut Dialog) { if d.fields.contains_key("seeded") { diff --git a/crates/ui-egui/src/dialogs/zoom.rs b/crates/ui-egui/src/dialogs/zoom.rs index 9bf392bf..e257b9f9 100644 --- a/crates/ui-egui/src/dialogs/zoom.rs +++ b/crates/ui-egui/src/dialogs/zoom.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Dialog, SlideApp, Ui, buttons}; /// Preset percents in the Zoom dialog, largest first. const ZOOM_PRESETS: &[u32] = &[400, 200, 150, 100, 75, 66, 50, 33]; diff --git a/crates/ui-egui/src/doc_window.rs b/crates/ui-egui/src/doc_window.rs index dfff3d4b..44c739ae 100644 --- a/crates/ui-egui/src/doc_window.rs +++ b/crates/ui-egui/src/doc_window.rs @@ -14,7 +14,7 @@ use crate::SlideApp; pub struct DocWindow { pub uid: u64, pub slide: usize, - key: Option<(u64, u64, usize, u32, u32, bool)>, + key: Option, tex: Option, } @@ -108,7 +108,7 @@ pub fn show(app: &mut SlideApp, ctx: &egui::Context) { egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.canvas).inner_margin(egui::Margin::same(16))).show(ui, |ui| { let avail = ui.available_rect_before_wrap(); let (w, h) = pixel_size(&doc, avail.width()); - let tex = ensure_texture(app, ui.ctx(), &doc, uid, revision, slide, w, h, grayscale); + let tex = ensure_texture(app, ui.ctx(), &doc, (uid, revision, slide, w, h, grayscale)); if let Some(tex) = tex { let fitted = fit_rect(avail, w, h); ui.painter().rect_filled(fitted, CornerRadius::ZERO, Color32::WHITE); @@ -141,18 +141,11 @@ fn fit_rect(avail: Rect, w: u32, h: u32) -> Rect { Rect::from_center_size(avail.center(), vec2(width.max(1.0), height.max(1.0))) } -fn ensure_texture( - app: &mut SlideApp, - ctx: &egui::Context, - doc: &deckcraft_model::Presentation, - uid: u64, - revision: u64, - slide: usize, - w: u32, - h: u32, - grayscale: bool, -) -> Option { - let key = (uid, revision, slide, w, h, grayscale); +/// What a cached slide texture was drawn from: document, revision, slide, pixel size, grayscale. +type TexKey = (u64, u64, usize, u32, u32, bool); + +fn ensure_texture(app: &mut SlideApp, ctx: &egui::Context, doc: &deckcraft_model::Presentation, key: TexKey) -> Option { + let (_, _, slide, w, h, grayscale) = key; if let Some(win) = app.doc_window.as_ref() && win.key == Some(key) && let Some(tex) = win.tex.clone() diff --git a/crates/ui-egui/src/icons/draw.rs b/crates/ui-egui/src/icons/draw.rs index 19a0a584..e4479547 100644 --- a/crates/ui-egui/src/icons/draw.rs +++ b/crates/ui-egui/src/icons/draw.rs @@ -1,19 +1,26 @@ use super::geometry::{B, C, D, G, Glyph, K, N, O, P2, PU, Pen, R, W, Y, arc_pts, cubic_pts, quad_pts, rad, round_pts, rrect_pts, t, tf}; -use super::*; +use super::{Icon, PI}; // --------------------------------------------------------------------------------------------- // The icons // --------------------------------------------------------------------------------------------- -#[expect(clippy::too_many_lines)] pub(super) fn draw(p: &Pen<'_>, icon: Icon) { + let _ = draw_clipboard_to_drawing(p, icon) + || draw_insert_to_design(p, icon) + || draw_motion_to_view(p, icon) + || draw_arrange_to_media(p, icon) + || draw_chrome_and_status(p, icon); +} + +fn draw_clipboard_to_drawing(p: &Pen<'_>, icon: Icon) -> bool { use Icon as I; match icon { // ----- Clipboard & slides ----- I::Paste => { p.rline(&[(10.5, 21.5), (3.5, 21.5), (3.5, 4.5), (17.0, 4.5), (17.0, 9.0)], 2.0, K); - p.rect(7.0, 2.5, 13.5, 6.5, 1.2, Y, K); - p.rect(10.5, 10.0, 21.0, 22.0, 1.2, t(B), B); + p.rect((7.0, 2.5, 13.5, 6.5), 1.2, Y, K); + p.rect((10.5, 10.0, 21.0, 22.0), 1.2, t(B), B); p.seg((13.0, 14.0), (18.5, 14.0), B); p.seg((13.0, 18.0), (17.0, 18.0), B); } @@ -26,14 +33,14 @@ pub(super) fn draw(p: &Pen<'_>, icon: Icon) { } I::Copy => { p.rline(&[(8.0, 16.0), (3.0, 16.0), (3.0, 2.5), (14.0, 2.5), (14.0, 7.0)], 1.5, K); - p.rect(8.0, 7.5, 20.5, 21.5, 1.5, t(B), B); + p.rect((8.0, 7.5, 20.5, 21.5), 1.5, t(B), B); p.seg((11.0, 12.5), (17.5, 12.5), B); p.seg((11.0, 16.5), (16.0, 16.5), B); } I::FormatPainter => { - p.rect(2.5, 3.0, 17.0, 9.0, 1.5, Y, K); + p.rect((2.5, 3.0, 17.0, 9.0), 1.5, Y, K); p.rline(&[(17.0, 6.0), (20.5, 6.0), (20.5, 11.5), (11.0, 11.5), (11.0, 14.0)], 1.2, K); - p.rect(9.0, 14.0, 13.0, 21.5, 1.0, K, K); + p.rect((9.0, 14.0, 13.0, 21.5), 1.0, K, K); } I::NewSlide => { p.frame(2.0, 4.0, 19.5, 16.5); @@ -43,9 +50,9 @@ pub(super) fn draw(p: &Pen<'_>, icon: Icon) { } I::Layout => { p.frame(2.0, 3.5, 22.0, 20.5); - p.rect(5.0, 6.5, 19.0, 9.0, 0.8, B, N); - p.rect(5.0, 11.5, 11.0, 17.5, 0.8, t(G), G); - p.rect(13.0, 11.5, 19.0, 17.5, 0.8, t(O), O); + p.rect((5.0, 6.5, 19.0, 9.0), 0.8, B, N); + p.rect((5.0, 11.5, 11.0, 17.5), 0.8, t(G), G); + p.rect((13.0, 11.5, 19.0, 17.5), 0.8, t(O), O); } I::ResetSlide => { p.frame(2.0, 3.5, 22.0, 20.5); @@ -54,12 +61,12 @@ pub(super) fn draw(p: &Pen<'_>, icon: Icon) { I::Section => { p.rshape(&[(2.5, 2.5), (13.0, 2.5), (16.5, 5.75), (13.0, 9.0), (2.5, 9.0)], 0.6, PU, N); p.rline(&[(5.0, 9.0), (5.0, 16.0), (7.5, 16.0)], 1.0, K); - p.rect(7.5, 12.0, 21.5, 20.5, 1.2, N, K); + p.rect((7.5, 12.0, 21.5, 20.5), 1.2, N, K); p.seg((10.0, 15.5), (16.0, 15.5), K.a(0.5)); } I::DuplicateSlide => { p.rline(&[(6.5, 15.5), (2.5, 15.5), (2.5, 3.5), (16.5, 3.5), (16.5, 8.0)], 1.5, K); - p.rect(6.5, 8.0, 21.5, 20.5, 1.5, t(B), B); + p.rect((6.5, 8.0, 21.5, 20.5), 1.5, t(B), B); p.seg((14.0, 11.5), (14.0, 17.0), B); p.seg((11.25, 14.25), (16.75, 14.25), B); } @@ -70,14 +77,14 @@ pub(super) fn draw(p: &Pen<'_>, icon: Icon) { p.badge((18.0, 17.5), 5.0, R, Glyph::Cross); } I::HideSlide => { - p.rect(2.0, 4.0, 22.0, 20.0, 1.5, N, K.a(0.55)); + p.rect((2.0, 4.0, 22.0, 20.0), 1.5, N, K.a(0.55)); p.seg((5.0, 8.0), (13.0, 8.0), K.a(0.55)); p.seg((3.0, 21.0), (21.0, 3.0), R); } I::Slide => { p.frame(2.0, 4.5, 22.0, 19.5); p.seg((5.0, 8.5), (15.0, 8.5), B); - p.rect(5.0, 11.5, 19.0, 16.5, 0.8, t(B), N); + p.rect((5.0, 11.5, 19.0, 16.5), 0.8, t(B), N); } // ----- Font ----- @@ -136,11 +143,11 @@ pub(super) fn draw(p: &Pen<'_>, icon: Icon) { let m = |pts: &[P2]| tf((8.0, 15.0), -45.0, pts); p.poly(&m(&[(0.0, -1.2), (3.0, -3.0), (3.0, 3.0), (0.0, 1.4)]), Y, K); p.poly(&m(&[(3.0, -3.0), (13.5, -3.0), (13.5, 3.0), (3.0, 3.0)]), N, K); - p.rect(2.5, 19.5, 21.5, 22.5, 0.8, Y, N); + p.rect((2.5, 19.5, 21.5, 22.5), 0.8, Y, N); } I::FontColor => { p.letter_a(4.5, 2.5, 19.5, 17.0, K, 1.1); - p.rect(2.5, 19.5, 21.5, 22.5, 0.8, R, N); + p.rect((2.5, 19.5, 21.5, 22.5), 0.8, R, N); } I::TextShadow => { p.text("S", (14.0, 13.8), 22.0, K.a(0.3)); @@ -201,7 +208,7 @@ pub(super) fn draw(p: &Pen<'_>, icon: Icon) { p.arrow((21.0, 3.5), (21.0, 20.5), B); } I::AlignText => { - p.rect(2.5, 3.0, 15.0, 21.0, 1.2, N, K); + p.rect((2.5, 3.0, 15.0, 21.0), 1.2, N, K); p.seg((5.5, 10.0), (12.0, 10.0), K); p.seg((5.5, 14.0), (10.5, 14.0), K); p.arrow((19.5, 2.5), (19.5, 9.5), B); @@ -212,27 +219,27 @@ pub(super) fn draw(p: &Pen<'_>, icon: Icon) { p.seg((2.5, y), (x1, y), K); } p.path_arrow(&[(14.0, 6.0), (19.0, 6.0), (19.0, 12.5)], B); - p.rect(2.5, 15.5, 7.5, 21.5, 1.0, B, N); - p.rect(9.5, 15.5, 14.5, 21.5, 1.0, O, N); - p.rect(16.5, 15.5, 21.5, 21.5, 1.0, G, N); + p.rect((2.5, 15.5, 7.5, 21.5), 1.0, B, N); + p.rect((9.5, 15.5, 14.5, 21.5), 1.0, O, N); + p.rect((16.5, 15.5, 21.5, 21.5), 1.0, G, N); } // ----- Drawing ----- I::Shapes => { - p.rect(11.0, 2.5, 21.5, 13.0, 1.0, t(G), G); + p.rect((11.0, 2.5, 21.5, 13.0), 1.0, t(G), G); p.rshape(&[(15.25, 11.0), (21.5, 21.5), (9.0, 21.5)], 0.8, t(O), O); p.circle((8.0, 9.0), 5.5, t(B), B); } I::Arrange => { - p.rect(2.5, 2.5, 11.5, 11.5, 1.0, N, K); - p.rect(7.5, 7.5, 16.5, 16.5, 1.0, t(B), B); - p.rect(12.5, 12.5, 21.5, 21.5, 1.0, B, B); + p.rect((2.5, 2.5, 11.5, 11.5), 1.0, N, K); + p.rect((7.5, 7.5, 16.5, 16.5), 1.0, t(B), B); + p.rect((12.5, 12.5, 21.5, 21.5), 1.0, B, B); } I::QuickStyles => { - p.rect(3.0, 3.0, 11.0, 11.0, 1.5, B, N); - p.rect(13.0, 3.0, 21.0, 11.0, 1.5, N, O); - p.rect(3.0, 13.0, 11.0, 21.0, 1.5, t(G), G); - p.rect(13.0, 13.0, 21.0, 21.0, 1.5, PU, N); + p.rect((3.0, 3.0, 11.0, 11.0), 1.5, B, N); + p.rect((13.0, 3.0, 21.0, 11.0), 1.5, N, O); + p.rect((3.0, 13.0, 11.0, 21.0), 1.5, t(G), G); + p.rect((13.0, 13.0, 21.0, 21.0), 1.5, PU, N); } I::ShapeFill => { let m = |pts: &[P2]| tf((10.0, 10.0), 45.0, pts); @@ -241,26 +248,26 @@ pub(super) fn draw(p: &Pen<'_>, icon: Icon) { p.line(&quad_pts((5.2, 6.4), (5.5, 1.0), (10.0, 3.0), 10), K); p.poly(&[(18.5, 8.5), (20.6, 12.6), (16.4, 12.6)], B, N); p.dot((18.5, 13.4), 2.1, B); - p.rect(2.5, 19.5, 21.5, 22.5, 0.8, B, N); + p.rect((2.5, 19.5, 21.5, 22.5), 0.8, B, N); } I::ShapeOutline => { - p.tool((4.5, 15.5), -45.0, 17.0, 2.4, 4.2, N, K, 0.0, N); - p.rect(2.5, 19.5, 21.5, 22.5, 0.8, O, N); + p.tool(((4.5, 15.5), -45.0), (17.0, 2.4, 4.2), N, K, (0.0, N)); + p.rect((2.5, 19.5, 21.5, 22.5), 0.8, O, N); } I::ShapeEffects => { - p.rect(6.0, 8.0, 18.5, 20.5, 2.0, K.a(0.25), N); - p.rect(3.0, 5.0, 15.5, 17.5, 2.0, B, N); + p.rect((6.0, 8.0, 18.5, 20.5), 2.0, K.a(0.25), N); + p.rect((3.0, 5.0, 15.5, 17.5), 2.0, B, N); p.sparkle((19.0, 5.0), 4.0, Y); } I::TextFill => { p.letter_a(4.5, 2.5, 19.5, 17.0, B, 1.5); - p.rect(2.5, 19.5, 21.5, 22.5, 0.8, B, N); + p.rect((2.5, 19.5, 21.5, 22.5), 0.8, B, N); } I::TextOutline => { let outer = [(3.5, 17.0), (9.6, 2.5), (14.4, 2.5), (20.5, 17.0), (16.6, 17.0), (15.2, 13.2), (8.8, 13.2), (7.4, 17.0)]; p.shape_w(&outer, N, K, 0.8); p.poly_w(&[(10.0, 10.0), (12.0, 5.0), (14.0, 10.0)], N, K, 0.8); - p.rect(2.5, 19.5, 21.5, 22.5, 0.8, O, N); + p.rect((2.5, 19.5, 21.5, 22.5), 0.8, O, N); } I::TextEffects => { p.letter_a(3.0, 5.0, 17.0, 21.0, PU, 1.3); @@ -275,7 +282,7 @@ pub(super) fn draw(p: &Pen<'_>, icon: Icon) { pts.extend(cubic_pts(c, (15.0, 22.0), (9.0, 17.0), a, 16)); p.shape(&pts, t(B), K); for q in [a, b, c] { - p.rect(q.0 - 1.6, q.1 - 1.6, q.0 + 1.6, q.1 + 1.6, 0.3, B, N); + p.rect((q.0 - 1.6, q.1 - 1.6, q.0 + 1.6, q.1 + 1.6), 0.3, B, N); } } I::MergeShapes => { @@ -286,14 +293,22 @@ pub(super) fn draw(p: &Pen<'_>, icon: Icon) { p.circle((15.0, 12.0), 6.5, N, K); } + _ => return false, + } + true +} + +fn draw_insert_to_design(p: &Pen<'_>, icon: Icon) -> bool { + use Icon as I; + match icon { // ----- Insert ----- - I::Table => p.table(2.5, 4.0, 21.5, 20.0, 3, 3, B), + I::Table => p.table((2.5, 4.0, 21.5, 20.0), 3, 3, B), I::Picture => p.picture(2.0, 4.0, 22.0, 20.0), I::Screenshot => { for (cx, cy, sx, sy) in [(2.5, 3.5, 1.0, 1.0), (21.5, 3.5, -1.0, 1.0), (2.5, 20.5, 1.0, -1.0), (21.5, 20.5, -1.0, -1.0)] { p.rline(&[(cx, cy + 4.0 * sy), (cx, cy), (cx + 4.0 * sx, cy)], 1.0, K); } - p.rect(6.5, 7.5, 17.5, 16.5, 1.0, t(B), B); + p.rect((6.5, 7.5, 17.5, 16.5), 1.0, t(B), B); p.seg((6.5, 10.0), (17.5, 10.0), B); } I::Icons => { @@ -320,11 +335,11 @@ pub(super) fn draw(p: &Pen<'_>, icon: Icon) { p.seg(mid, bot, K); } I::SmartArt => { - p.rect(8.5, 2.5, 15.5, 8.5, 1.0, G, N); + p.rect((8.5, 2.5, 15.5, 8.5), 1.0, G, N); p.seg((12.0, 8.5), (12.0, 11.5), K); p.rline(&[(5.5, 14.5), (5.5, 11.5), (18.5, 11.5), (18.5, 14.5)], 1.0, K); - p.rect(2.0, 14.5, 9.0, 20.5, 1.0, B, N); - p.rect(15.0, 14.5, 22.0, 20.5, 1.0, O, N); + p.rect((2.0, 14.5, 9.0, 20.5), 1.0, B, N); + p.rect((15.0, 14.5, 22.0, 20.5), 1.0, O, N); } I::Chart => p.bars(2.5, 4.0, 21.5, 20.5), I::Zoom => { @@ -341,7 +356,7 @@ pub(super) fn draw(p: &Pen<'_>, icon: Icon) { link((15.5, 8.5), B); } I::Action => { - p.rect(2.5, 3.5, 18.0, 12.5, 2.0, t(B), B); + p.rect((2.5, 3.5, 18.0, 12.5), 2.0, t(B), B); p.cursor(11.5, 8.5, 0.82); } I::Comment => { @@ -350,17 +365,17 @@ pub(super) fn draw(p: &Pen<'_>, icon: Icon) { p.seg((6.5, 12.0), (14.0, 12.0), K); } I::TextBox => { - p.rect(3.5, 4.5, 20.5, 19.5, 0.5, N, K); + p.rect((3.5, 4.5, 20.5, 19.5), 0.5, N, K); for (x, y) in [(3.5, 4.5), (20.5, 4.5), (3.5, 19.5), (20.5, 19.5)] { - p.rect(x - 1.5, y - 1.5, x + 1.5, y + 1.5, 0.3, B, N); + p.rect((x - 1.5, y - 1.5, x + 1.5, y + 1.5), 0.3, B, N); } p.seg((7.5, 9.5), (16.5, 9.5), K); p.seg((7.5, 14.5), (13.5, 14.5), K); } I::HeaderFooter => { - p.rect(4.0, 2.5, 20.0, 21.5, 1.2, N, K); - p.rect(6.5, 5.0, 17.5, 7.5, 0.6, B, N); - p.rect(6.5, 16.5, 17.5, 19.0, 0.6, B, N); + p.rect((4.0, 2.5, 20.0, 21.5), 1.2, N, K); + p.rect((6.5, 5.0, 17.5, 7.5), 0.6, B, N); + p.rect((6.5, 16.5, 17.5, 19.0), 0.6, B, N); p.seg_w((7.0, 10.5), (17.0, 10.5), K.a(0.45), 0.8); p.seg_w((7.0, 13.2), (14.5, 13.2), K.a(0.45), 0.8); } @@ -370,9 +385,9 @@ pub(super) fn draw(p: &Pen<'_>, icon: Icon) { p.text_rot("A", (11.3, 11.5), 22.0, O, -10.0); } I::DateTime => { - p.rect(3.0, 5.0, 21.0, 21.0, 1.6, N, K); - p.rect(3.0, 5.0, 21.0, 9.5, 1.6, R, N); - p.rect(3.0, 5.0, 21.0, 21.0, 1.6, N, K); + p.rect((3.0, 5.0, 21.0, 21.0), 1.6, N, K); + p.rect((3.0, 5.0, 21.0, 9.5), 1.6, R, N); + p.rect((3.0, 5.0, 21.0, 21.0), 1.6, N, K); p.seg((8.0, 2.8), (8.0, 6.5), K); p.seg((16.0, 2.8), (16.0, 6.5), K); for (i, (x, y)) in [(8.0, 13.5), (12.0, 13.5), (16.0, 13.5), (8.0, 17.5), (12.0, 17.5), (16.0, 17.5)].into_iter().enumerate() { @@ -388,15 +403,15 @@ pub(super) fn draw(p: &Pen<'_>, icon: Icon) { } I::Object => { p.page(3.0, 2.0, 16.0, 19.0, N, K); - p.rect(11.5, 11.5, 21.5, 21.5, 1.6, B, N); - p.rect(14.2, 14.2, 18.8, 18.8, 0.6, N, W); + p.rect((11.5, 11.5, 21.5, 21.5), 1.6, B, N); + p.rect((14.2, 14.2, 18.8, 18.8), 0.6, N, W); } I::Equation => { p.line_w(&[(18.5, 6.5), (18.0, 4.0), (6.0, 4.0), (12.5, 12.0), (6.0, 20.0), (18.0, 20.0), (18.5, 17.5)], B, 1.15); } I::Symbol => p.text("Ω", (12.0, 12.0), 22.0, K), I::Video => { - p.rect(2.0, 6.0, 15.5, 18.0, 2.0, t(B), K); + p.rect((2.0, 6.0, 15.5, 18.0), 2.0, t(B), K); p.rshape(&[(16.5, 10.5), (22.0, 7.0), (22.0, 17.0), (16.5, 13.5)], 0.6, N, K); p.dot((6.0, 10.0), 1.2, R); } @@ -418,16 +433,16 @@ pub(super) fn draw(p: &Pen<'_>, icon: Icon) { // ----- Draw tab ----- I::Pen => { - p.tool((3.5, 20.5), -45.0, 23.0, 2.2, 5.0, B, K, 3.5, K); + p.tool(((3.5, 20.5), -45.0), (23.0, 2.2, 5.0), B, K, (3.5, K)); } I::Pencil => { - p.tool((3.5, 20.5), -45.0, 22.0, 2.6, 5.0, Y, K, 3.5, R); + p.tool(((3.5, 20.5), -45.0), (22.0, 2.6, 5.0), Y, K, (3.5, R)); } I::HighlighterPen => { let m = |pts: &[P2]| tf((6.0, 15.0), -45.0, pts); p.poly(&m(&[(0.0, -1.0), (3.0, -3.2), (3.0, 3.2), (0.0, 2.2)]), Y, K); p.poly(&m(&[(3.0, -3.6), (14.5, -3.6), (14.5, 3.6), (3.0, 3.6)]), t(Y), K); - p.rect(2.0, 19.5, 13.0, 22.5, 0.8, Y, N); + p.rect((2.0, 19.5, 13.0, 22.5), 0.8, Y, N); } I::Eraser => { let m = |pts: &[P2]| tf((12.5, 11.0), -45.0, pts); @@ -465,21 +480,21 @@ pub(super) fn draw(p: &Pen<'_>, icon: Icon) { // ----- Design ----- I::Themes => { - p.rect(9.5, 2.5, 21.5, 14.5, 2.0, O, N); - p.rect(2.5, 8.5, 15.0, 21.5, 2.0, B, N); + p.rect((9.5, 2.5, 21.5, 14.5), 2.0, O, N); + p.rect((2.5, 8.5, 15.0, 21.5), 2.0, B, N); p.text("Aa", (8.75, 15.0), 10.0, W); } I::Variants => { - p.rect(8.0, 2.5, 21.5, 11.0, 1.2, t(PU), PU); - p.rect(5.0, 7.0, 18.5, 15.5, 1.2, t(G), G); - p.rect(2.5, 12.0, 16.0, 21.0, 1.2, B, B); + p.rect((8.0, 2.5, 21.5, 11.0), 1.2, t(PU), PU); + p.rect((5.0, 7.0, 18.5, 15.5), 1.2, t(G), G); + p.rect((2.5, 12.0, 16.0, 21.0), 1.2, B, B); } I::Colors => { for (i, c) in [R, Y, G, B].into_iter().enumerate() { let x = 3.0 + i as f32 * 4.5; - p.rect(x, 4.0, x + 4.5, 20.0, 0.0, c, N); + p.rect((x, 4.0, x + 4.5, 20.0), 0.0, c, N); } - p.rect(3.0, 4.0, 21.0, 20.0, 0.0, N, K); + p.rect((3.0, 4.0, 21.0, 20.0), 0.0, N, K); } I::Fonts => { p.text("A", (8.0, 11.0), 20.0, K); @@ -496,7 +511,7 @@ pub(super) fn draw(p: &Pen<'_>, icon: Icon) { } I::FormatBackground => { p.rline(&[(10.0, 17.0), (2.0, 17.0), (2.0, 4.0), (20.0, 4.0), (20.0, 9.0)], 1.5, K); - p.rect(4.5, 6.5, 13.5, 12.5, 0.6, t(PU), N); + p.rect((4.5, 6.5, 13.5, 12.5), 0.6, t(PU), N); p.brush((12.0, 20.5), -45.0, 13.0, Y); } I::DesignIdeas => { @@ -511,13 +526,21 @@ pub(super) fn draw(p: &Pen<'_>, icon: Icon) { } } + _ => return false, + } + true +} + +fn draw_motion_to_view(p: &Pen<'_>, icon: Icon) -> bool { + use Icon as I; + match icon { // ----- Transitions / animations ----- I::Preview => { p.circle((12.0, 12.0), 9.5, N, K); p.rshape(&[(9.5, 7.0), (17.0, 12.0), (9.5, 17.0)], 0.8, G, N); } I::EffectOptions => { - p.rect(2.5, 8.5, 15.5, 21.5, 1.5, t(B), K); + p.rect((2.5, 8.5, 15.5, 21.5), 1.5, t(B), K); p.arrow((9.0, 15.0), (21.5, 2.5), B); } I::Sound => { @@ -533,14 +556,14 @@ pub(super) fn draw(p: &Pen<'_>, icon: Icon) { } I::Duration => { p.circle((12.0, 13.5), 8.0, N, K); - p.rect(10.0, 2.0, 14.0, 4.0, 0.6, K, N); + p.rect((10.0, 2.0, 14.0, 4.0), 0.6, K, N); p.seg((12.0, 4.0), (12.0, 5.5), K); p.seg((12.0, 13.5), (15.8, 9.0), B); p.dot((12.0, 13.5), 1.3, B); } I::ApplyToAll => { p.rline(&[(6.0, 7.5), (6.0, 3.0), (21.5, 3.0), (21.5, 13.5), (17.5, 13.5)], 1.5, K); - p.rect(2.5, 7.5, 17.5, 18.0, 1.5, N, K); + p.rect((2.5, 7.5, 17.5, 18.0), 1.5, N, K); p.badge((18.0, 18.0), 4.8, G, Glyph::Check); } I::AnimEntrance => { @@ -563,9 +586,9 @@ pub(super) fn draw(p: &Pen<'_>, icon: Icon) { p.circle((4.5, 19.5), 2.4, G, N); } I::AnimationPane => { - p.rect(2.5, 3.0, 21.5, 21.0, 1.5, N, K); + p.rect((2.5, 3.0, 21.5, 21.0), 1.5, N, K); p.seg((11.0, 3.0), (11.0, 21.0), K); - p.rect(4.5, 7.0, 8.5, 12.0, 0.5, t(B), N); + p.rect((4.5, 7.0, 8.5, 12.0), 0.5, t(B), N); for (y, c) in [(7.5, G), (12.0, Y), (16.5, R)] { p.seg_w((13.5, y), (19.0, y), c, 1.4); } @@ -585,7 +608,7 @@ pub(super) fn draw(p: &Pen<'_>, icon: Icon) { // ----- Slide show ----- I::PlayFromStart => { p.frame(2.0, 4.0, 22.0, 20.0); - p.rect(7.0, 8.0, 9.2, 16.0, 0.5, G, N); + p.rect((7.0, 8.0, 9.2, 16.0), 0.5, G, N); p.rshape(&[(11.0, 8.0), (17.5, 12.0), (11.0, 16.0)], 0.6, G, N); } I::PlayFromCurrent => { @@ -594,15 +617,15 @@ pub(super) fn draw(p: &Pen<'_>, icon: Icon) { } I::PresenterView => { p.monitor(2.0, 3.0, 22.0, 16.5); - p.rect(4.5, 5.5, 13.0, 12.0, 0.6, t(B), B); + p.rect((4.5, 5.5, 13.0, 12.0), 0.6, t(B), B); p.seg_w((15.0, 6.5), (19.5, 6.5), K, 0.8); p.seg_w((15.0, 9.5), (19.5, 9.5), K, 0.8); p.seg_w((15.0, 12.5), (18.0, 12.5), K, 0.8); } I::CustomShow => { - p.rect(2.5, 2.5, 12.5, 8.0, 1.0, N, K); - p.rect(2.5, 9.5, 12.5, 15.0, 1.0, O, O); - p.rect(2.5, 16.5, 12.5, 22.0, 1.0, N, K); + p.rect((2.5, 2.5, 12.5, 8.0), 1.0, N, K); + p.rect((2.5, 9.5, 12.5, 15.0), 1.0, O, O); + p.rect((2.5, 16.5, 12.5, 22.0), 1.0, N, K); p.rshape(&[(15.5, 8.0), (21.5, 12.25), (15.5, 16.5)], 0.6, G, N); } I::SetUpShow => { @@ -620,7 +643,7 @@ pub(super) fn draw(p: &Pen<'_>, icon: Icon) { p.circle((12.0, 12.0), 5.5, R, N); } I::Subtitles => { - p.rect(2.0, 4.5, 22.0, 19.5, 2.0, N, K); + p.rect((2.0, 4.5, 22.0, 19.5), 2.0, N, K); p.seg((5.5, 12.0), (11.0, 12.0), B); p.seg((13.5, 12.0), (18.5, 12.0), B); p.seg((5.5, 16.0), (8.5, 16.0), B); @@ -639,8 +662,8 @@ pub(super) fn draw(p: &Pen<'_>, icon: Icon) { p.line_w(&[(6.0, 16.0), (10.0, 20.0), (19.5, 11.0)], G, 1.3); } I::Thesaurus => { - p.rect(8.0, 2.5, 19.5, 21.5, 1.2, t(B), N); - p.rect(4.5, 2.5, 19.5, 21.5, 1.5, N, K); + p.rect((8.0, 2.5, 19.5, 21.5), 1.2, t(B), N); + p.rect((4.5, 2.5, 19.5, 21.5), 1.5, N, K); p.seg((8.0, 2.5), (8.0, 21.5), K); p.poly(&[(13.5, 2.5), (17.0, 2.5), (17.0, 9.0), (15.25, 7.5), (13.5, 9.0)], R, N); } @@ -652,8 +675,8 @@ pub(super) fn draw(p: &Pen<'_>, icon: Icon) { p.line(&[(9.0, 18.5), (12.0, 13.8), (15.0, 18.5)], B); } I::Translate => { - p.rect(10.0, 9.0, 21.5, 20.5, 2.0, O, N); - p.rect(2.5, 3.5, 14.0, 15.0, 2.0, B, N); + p.rect((10.0, 9.0, 21.5, 20.5), 2.0, O, N); + p.rect((2.5, 3.5, 14.0, 15.0), 2.0, B, N); p.letter_a(5.5, 5.8, 11.0, 12.8, W, 0.9); p.seg_w((17.75, 10.8), (17.75, 11.9), W, 0.8); p.seg_w((15.2, 12.9), (20.3, 12.9), W, 0.8); @@ -690,8 +713,8 @@ pub(super) fn draw(p: &Pen<'_>, icon: Icon) { p.seg((5.5, 14.2), (11.0, 14.2), K); } I::Compare => { - p.rect(2.5, 2.5, 11.0, 15.5, 1.2, N, K); - p.rect(13.0, 8.5, 21.5, 21.5, 1.2, t(B), B); + p.rect((2.5, 2.5, 11.0, 15.5), 1.2, N, K); + p.rect((13.0, 8.5, 21.5, 21.5), 1.2, t(B), B); p.arrow((13.0, 4.0), (21.5, 4.0), K); p.arrow((11.0, 20.0), (2.5, 20.0), K); } @@ -702,37 +725,37 @@ pub(super) fn draw(p: &Pen<'_>, icon: Icon) { // ----- View ----- I::ViewNormal => { - p.rect(2.0, 3.5, 22.0, 20.5, 1.5, N, K); + p.rect((2.0, 3.5, 22.0, 20.5), 1.5, N, K); p.seg((8.0, 3.5), (8.0, 20.5), K); for y in [6.0, 10.5, 15.0] { - p.rect(3.9, y, 6.2, y + 2.6, 0.3, K.a(0.55), N); + p.rect((3.9, y, 6.2, y + 2.6), 0.3, K.a(0.55), N); } - p.rect(10.0, 6.0, 20.0, 15.0, 0.6, t(B), B); + p.rect((10.0, 6.0, 20.0, 15.0), 0.6, t(B), B); p.seg_w((10.0, 18.0), (17.0, 18.0), K.a(0.5), 0.8); } I::ViewOutline => { - p.rect(2.0, 3.5, 22.0, 20.5, 1.5, N, K); + p.rect((2.0, 3.5, 22.0, 20.5), 1.5, N, K); p.seg((11.0, 3.5), (11.0, 20.5), K); p.seg_w((4.0, 7.0), (9.0, 7.0), K, 0.8); p.seg_w((5.5, 10.0), (9.0, 10.0), K, 0.8); p.seg_w((5.5, 13.0), (9.0, 13.0), K, 0.8); p.seg_w((4.0, 16.0), (9.0, 16.0), K, 0.8); - p.rect(13.0, 7.0, 20.0, 13.0, 0.6, t(B), B); + p.rect((13.0, 7.0, 20.0, 13.0), 0.6, t(B), B); } I::ViewSorter => { for (i, (x0, x1)) in [(2.5, 11.0), (13.0, 21.5)].into_iter().enumerate() { for (j, (y0, y1)) in [(4.0, 10.5), (13.5, 20.0)].into_iter().enumerate() { if i == 0 && j == 0 { - p.rect(x0, y0, x1, y1, 0.8, t(B), B); + p.rect((x0, y0, x1, y1), 0.8, t(B), B); } else { - p.rect(x0, y0, x1, y1, 0.8, N, K); + p.rect((x0, y0, x1, y1), 0.8, N, K); } } } } I::ViewNotesPage => { - p.rect(5.0, 2.0, 19.0, 22.0, 1.2, N, K); - p.rect(7.5, 4.5, 16.5, 10.5, 0.6, t(B), B); + p.rect((5.0, 2.0, 19.0, 22.0), 1.2, N, K); + p.rect((7.5, 4.5, 16.5, 10.5), 0.6, t(B), B); p.seg_w((7.5, 14.0), (16.5, 14.0), K, 0.8); p.seg_w((7.5, 17.5), (14.0, 17.5), K, 0.8); } @@ -741,24 +764,24 @@ pub(super) fn draw(p: &Pen<'_>, icon: Icon) { p.seg((12.0, 7.0), (12.0, 20.5), K); } I::SlideMaster => { - p.rect(2.0, 2.5, 15.0, 10.5, 1.2, t(PU), PU); + p.rect((2.0, 2.5, 15.0, 10.5), 1.2, t(PU), PU); p.seg((4.5, 5.5), (11.0, 5.5), PU); p.rline(&[(5.0, 10.5), (5.0, 19.5), (9.0, 19.5)], 1.0, K); p.seg((5.0, 14.0), (9.0, 14.0), K); - p.rect(9.0, 12.0, 17.0, 16.0, 0.8, N, K); - p.rect(9.0, 17.5, 17.0, 21.5, 0.8, N, K); + p.rect((9.0, 12.0, 17.0, 16.0), 0.8, N, K); + p.rect((9.0, 17.5, 17.0, 21.5), 0.8, N, K); } I::HandoutMaster => { - p.rect(4.0, 2.0, 20.0, 22.0, 1.2, N, K); + p.rect((4.0, 2.0, 20.0, 22.0), 1.2, N, K); for (x, y) in [(6.5, 4.5), (12.75, 4.5), (6.5, 11.0), (12.75, 11.0)] { - p.rect(x, y, x + 4.75, y + 4.5, 0.4, t(PU), PU); + p.rect((x, y, x + 4.75, y + 4.5), 0.4, t(PU), PU); } p.seg_w((6.5, 19.0), (17.5, 19.0), K, 0.8); } I::NotesMaster => { p.rline(&[(8.0, 4.0), (8.0, 2.0), (21.0, 2.0), (21.0, 19.0), (16.0, 19.0)], 1.2, PU); - p.rect(3.0, 4.5, 16.0, 22.0, 1.2, N, K); - p.rect(5.5, 7.0, 13.5, 12.0, 0.5, t(PU), PU); + p.rect((3.0, 4.5, 16.0, 22.0), 1.2, N, K); + p.rect((5.5, 7.0, 13.5, 12.0), 0.5, t(PU), PU); p.seg_w((5.5, 15.5), (13.5, 15.5), K, 0.8); p.seg_w((5.5, 18.8), (11.5, 18.8), K, 0.8); } @@ -767,15 +790,15 @@ pub(super) fn draw(p: &Pen<'_>, icon: Icon) { p.seg_w((v, 3.0), (v, 21.0), K.a(0.6), 0.7); p.seg_w((3.0, v), (21.0, v), K.a(0.6), 0.7); } - p.rect(3.0, 3.0, 21.0, 21.0, 1.2, N, K); + p.rect((3.0, 3.0, 21.0, 21.0), 1.2, N, K); } I::Guides => { - p.rect(3.0, 3.0, 21.0, 21.0, 1.2, N, K); + p.rect((3.0, 3.0, 21.0, 21.0), 1.2, N, K); p.dashed(&[(12.0, 1.5), (12.0, 22.5)], B, 2.2, 1.6); p.dashed(&[(1.5, 12.0), (22.5, 12.0)], B, 2.2, 1.6); } I::Notes => { - p.rect(4.0, 4.0, 20.0, 21.5, 1.5, t(Y), K); + p.rect((4.0, 4.0, 20.0, 21.5), 1.5, t(Y), K); for x in [8.0, 12.0, 16.0] { p.seg((x, 2.3), (x, 5.8), K); } @@ -788,16 +811,16 @@ pub(super) fn draw(p: &Pen<'_>, icon: Icon) { for (cx, cy, sx, sy) in [(2.5, 2.5, 1.0, 1.0), (21.5, 2.5, -1.0, 1.0), (2.5, 21.5, 1.0, -1.0), (21.5, 21.5, -1.0, -1.0)] { p.rline(&[(cx, cy + 5.0 * sy), (cx, cy), (cx + 5.0 * sx, cy)], 0.8, K); } - p.rect(7.0, 8.0, 17.0, 16.0, 1.0, t(B), B); + p.rect((7.0, 8.0, 17.0, 16.0), 1.0, t(B), B); } I::Grayscale => { - p.rect(3.0, 3.0, 9.0, 21.0, 0.0, K.a(0.85), N); - p.rect(9.0, 3.0, 15.0, 21.0, 0.0, K.a(0.45), N); - p.rect(15.0, 3.0, 21.0, 21.0, 0.0, K.a(0.15), N); - p.rect(3.0, 3.0, 21.0, 21.0, 1.2, N, K); + p.rect((3.0, 3.0, 9.0, 21.0), 0.0, K.a(0.85), N); + p.rect((9.0, 3.0, 15.0, 21.0), 0.0, K.a(0.45), N); + p.rect((15.0, 3.0, 21.0, 21.0), 0.0, K.a(0.15), N); + p.rect((3.0, 3.0, 21.0, 21.0), 1.2, N, K); } I::NewWindow => { - p.rect(2.0, 3.5, 20.0, 17.0, 1.5, N, K); + p.rect((2.0, 3.5, 20.0, 17.0), 1.5, N, K); p.seg((2.0, 7.0), (20.0, 7.0), K); p.dot((4.5, 5.25), 0.6, K); p.badge((18.5, 18.0), 4.8, G, Glyph::Plus); @@ -809,6 +832,14 @@ pub(super) fn draw(p: &Pen<'_>, icon: Icon) { p.gear_badge((17.5, 17.5), 5.2, O); } + _ => return false, + } + true +} + +fn draw_arrange_to_media(p: &Pen<'_>, icon: Icon) -> bool { + use Icon as I; + match icon { // ----- Arrange ----- I::BringForward => { p.layer(10.0, 15.5, 8.0, 4.0, N, K); @@ -836,64 +867,64 @@ pub(super) fn draw(p: &Pen<'_>, icon: Icon) { } I::Group => { p.dashed(&[(2.5, 2.5), (21.5, 2.5), (21.5, 21.5), (2.5, 21.5), (2.5, 2.5)], B, 2.0, 1.6); - p.rect(5.5, 5.5, 13.0, 13.0, 0.8, t(G), G); + p.rect((5.5, 5.5, 13.0, 13.0), 0.8, t(G), G); p.circle((15.5, 15.5), 4.3, t(O), O); } I::Ungroup => { p.dashed_w(&[(2.0, 2.0), (12.0, 2.0), (12.0, 12.0), (2.0, 12.0), (2.0, 2.0)], B, 1.6, 1.3, 0.8); - p.rect(4.5, 4.5, 9.5, 9.5, 0.6, t(G), G); + p.rect((4.5, 4.5, 9.5, 9.5), 0.6, t(G), G); p.dashed_w(&[(12.0, 12.0), (22.0, 12.0), (22.0, 22.0), (12.0, 22.0), (12.0, 12.0)], B, 1.6, 1.3, 0.8); p.circle((17.0, 17.0), 2.9, t(O), O); } I::AlignObjects => { p.dashed(&[(2.0, 12.0), (22.0, 12.0)], K, 2.0, 1.5); - p.rect(3.5, 4.0, 8.0, 20.0, 0.8, B, N); - p.rect(10.0, 7.5, 14.0, 16.5, 0.8, O, N); - p.rect(16.0, 5.5, 20.5, 18.5, 0.8, G, N); + p.rect((3.5, 4.0, 8.0, 20.0), 0.8, B, N); + p.rect((10.0, 7.5, 14.0, 16.5), 0.8, O, N); + p.rect((16.0, 5.5, 20.5, 18.5), 0.8, G, N); } I::AlignObjLeft => { - p.rect(3.5, 5.0, 19.5, 10.5, 0.8, t(B), B); - p.rect(3.5, 13.5, 13.5, 19.0, 0.8, t(B), B); + p.rect((3.5, 5.0, 19.5, 10.5), 0.8, t(B), B); + p.rect((3.5, 13.5, 13.5, 19.0), 0.8, t(B), B); p.seg((3.5, 2.5), (3.5, 21.5), K); } I::AlignObjCenter => { - p.rect(4.0, 5.0, 20.0, 10.5, 0.8, t(B), B); - p.rect(7.0, 13.5, 17.0, 19.0, 0.8, t(B), B); + p.rect((4.0, 5.0, 20.0, 10.5), 0.8, t(B), B); + p.rect((7.0, 13.5, 17.0, 19.0), 0.8, t(B), B); p.seg((12.0, 2.5), (12.0, 21.5), K); } I::AlignObjRight => { - p.rect(4.5, 5.0, 20.5, 10.5, 0.8, t(B), B); - p.rect(10.5, 13.5, 20.5, 19.0, 0.8, t(B), B); + p.rect((4.5, 5.0, 20.5, 10.5), 0.8, t(B), B); + p.rect((10.5, 13.5, 20.5, 19.0), 0.8, t(B), B); p.seg((20.5, 2.5), (20.5, 21.5), K); } I::AlignObjTop => { - p.rect(5.0, 3.5, 10.5, 19.5, 0.8, t(B), B); - p.rect(13.5, 3.5, 19.0, 13.5, 0.8, t(B), B); + p.rect((5.0, 3.5, 10.5, 19.5), 0.8, t(B), B); + p.rect((13.5, 3.5, 19.0, 13.5), 0.8, t(B), B); p.seg((2.5, 3.5), (21.5, 3.5), K); } I::AlignObjMiddle => { - p.rect(5.0, 4.0, 10.5, 20.0, 0.8, t(B), B); - p.rect(13.5, 7.0, 19.0, 17.0, 0.8, t(B), B); + p.rect((5.0, 4.0, 10.5, 20.0), 0.8, t(B), B); + p.rect((13.5, 7.0, 19.0, 17.0), 0.8, t(B), B); p.seg((2.5, 12.0), (21.5, 12.0), K); } I::AlignObjBottom => { - p.rect(5.0, 4.5, 10.5, 20.5, 0.8, t(B), B); - p.rect(13.5, 10.5, 19.0, 20.5, 0.8, t(B), B); + p.rect((5.0, 4.5, 10.5, 20.5), 0.8, t(B), B); + p.rect((13.5, 10.5, 19.0, 20.5), 0.8, t(B), B); p.seg((2.5, 20.5), (21.5, 20.5), K); } I::DistributeH => { p.seg((2.5, 3.0), (2.5, 21.0), K); p.seg((21.5, 3.0), (21.5, 21.0), K); - p.rect(4.9, 7.0, 8.2, 17.0, 0.6, t(B), B); - p.rect(10.35, 5.0, 13.65, 19.0, 0.6, t(B), B); - p.rect(15.8, 8.5, 19.1, 15.5, 0.6, t(B), B); + p.rect((4.9, 7.0, 8.2, 17.0), 0.6, t(B), B); + p.rect((10.35, 5.0, 13.65, 19.0), 0.6, t(B), B); + p.rect((15.8, 8.5, 19.1, 15.5), 0.6, t(B), B); } I::DistributeV => { p.seg((3.0, 2.5), (21.0, 2.5), K); p.seg((3.0, 21.5), (21.0, 21.5), K); - p.rect(7.0, 4.9, 17.0, 8.2, 0.6, t(B), B); - p.rect(5.0, 10.35, 19.0, 13.65, 0.6, t(B), B); - p.rect(8.5, 15.8, 15.5, 19.1, 0.6, t(B), B); + p.rect((7.0, 4.9, 17.0, 8.2), 0.6, t(B), B); + p.rect((5.0, 10.35, 19.0, 13.65), 0.6, t(B), B); + p.rect((8.5, 15.8, 15.5, 19.1), 0.6, t(B), B); } I::RotateLeft => { p.poly(&[(3.0, 21.5), (3.0, 13.0), (12.0, 21.5)], t(B), B); @@ -914,15 +945,15 @@ pub(super) fn draw(p: &Pen<'_>, icon: Icon) { p.poly(&[(5.0, 14.5), (19.0, 21.5), (19.0, 14.5)], B, B); } I::SelectionPane => { - p.rect(3.0, 2.5, 21.0, 21.5, 1.5, N, K); - p.rect(4.6, 4.6, 19.4, 9.4, 0.8, t(B), N); + p.rect((3.0, 2.5, 21.0, 21.5), 1.5, N, K); + p.rect((4.6, 4.6, 19.4, 9.4), 0.8, t(B), N); for (y, c) in [(7.0, B), (12.0, O), (17.0, G)] { - p.rect(6.0, y - 1.3, 8.6, y + 1.3, 0.4, c, N); + p.rect((6.0, y - 1.3, 8.6, y + 1.3), 0.4, c, N); p.seg_w((10.5, y), (17.5, y), K, 0.85); } } I::Rotate => { - p.rect(9.0, 9.0, 15.0, 15.0, 0.8, t(B), B); + p.rect((9.0, 9.0, 15.0, 15.0), 0.8, t(B), B); p.arc_arrow((12.0, 12.0), 8.5, -70.0, 220.0, K); } @@ -945,7 +976,7 @@ pub(super) fn draw(p: &Pen<'_>, icon: Icon) { p.circle((15.7, 14.6), 5.6, B.a(0.6), N); } I::ArtisticEffects => { - p.rect(2.0, 4.0, 22.0, 20.0, 1.5, N, K); + p.rect((2.0, 4.0, 22.0, 20.0), 1.5, N, K); p.curve_w((5.5, 16.0), (8.0, 4.5), (14.0, 21.0), (18.5, 8.0), PU, 2.0); } I::Transparency => { @@ -954,12 +985,12 @@ pub(super) fn draw(p: &Pen<'_>, icon: Icon) { for j in 0..4 { if (i + j) % 2 == 0 { let (x, y) = (3.0 + i as f32 * cs, 3.0 + j as f32 * cs); - p.rect(x, y, x + cs, y + cs, 0.0, K.a(0.3), N); + p.rect((x, y, x + cs, y + cs), 0.0, K.a(0.3), N); } } } - p.rect(3.0, 3.0, 16.0, 16.0, 0.6, N, K.a(0.6)); - p.rect(9.0, 9.0, 21.0, 21.0, 1.2, B.a(0.55), B); + p.rect((3.0, 3.0, 16.0, 16.0), 0.6, N, K.a(0.6)); + p.rect((9.0, 9.0, 21.0, 21.0), 1.2, B.a(0.55), B); } I::CompressPictures => { p.picture(6.5, 6.0, 17.5, 18.0); @@ -983,10 +1014,10 @@ pub(super) fn draw(p: &Pen<'_>, icon: Icon) { p.arc_arrow((17.0, 17.5), 4.0, -10.0, -260.0, B); } I::RemoveBackground => { - p.rect(2.0, 4.0, 22.0, 20.0, 1.5, PU.a(0.55), N); + p.rect((2.0, 4.0, 22.0, 20.0), 1.5, PU.a(0.55), N); p.dot((12.0, 10.0), 3.0, B); p.poly(&arc_pts((12.0, 19.3), 6.0, 5.5, 180.0, 360.0), B, N); - p.rect(2.0, 4.0, 22.0, 20.0, 1.5, N, K); + p.rect((2.0, 4.0, 22.0, 20.0), 1.5, N, K); } I::AltText => { p.picture(2.0, 2.0, 15.5, 12.5); @@ -1002,24 +1033,24 @@ pub(super) fn draw(p: &Pen<'_>, icon: Icon) { I::InsertLeft => ((10.0, 3.0, 21.0, 21.0, 3, 2), (2.5, 3.0, 7.5, 21.0)), _ => ((3.0, 3.0, 14.0, 21.0, 3, 2), (16.5, 3.0, 21.5, 21.0)), }; - p.table(grid.0, grid.1, grid.2, grid.3, grid.4, grid.5, N); - p.rect(add.0, add.1, add.2, add.3, 1.0, t(G), G); + p.table((grid.0, grid.1, grid.2, grid.3), grid.4, grid.5, N); + p.rect((add.0, add.1, add.2, add.3), 1.0, t(G), G); let c = ((add.0 + add.2) * 0.5, (add.1 + add.3) * 0.5); p.seg_w((c.0 - 1.6, c.1), (c.0 + 1.6, c.1), G, 0.9); p.seg_w((c.0, c.1 - 1.6), (c.0, c.1 + 1.6), G, 0.9); } I::DeleteTable => { - p.table(2.5, 3.0, 19.5, 17.0, 3, 3, B); + p.table((2.5, 3.0, 19.5, 17.0), 3, 3, B); p.badge((18.0, 18.0), 5.0, R, Glyph::Cross); } I::MergeCells => { - p.rect(2.5, 6.0, 21.5, 18.0, 1.2, N, K); + p.rect((2.5, 6.0, 21.5, 18.0), 1.2, N, K); p.dashed(&[(12.0, 6.0), (12.0, 18.0)], K.a(0.6), 1.5, 1.2); p.arrow((4.5, 12.0), (10.2, 12.0), B); p.arrow((19.5, 12.0), (13.8, 12.0), B); } I::SplitCells => { - p.rect(2.5, 6.0, 21.5, 18.0, 1.2, N, K); + p.rect((2.5, 6.0, 21.5, 18.0), 1.2, N, K); p.seg((12.0, 6.0), (12.0, 18.0), K); p.arrow((10.0, 12.0), (4.5, 12.0), B); p.arrow((14.0, 12.0), (19.5, 12.0), B); @@ -1027,12 +1058,12 @@ pub(super) fn draw(p: &Pen<'_>, icon: Icon) { I::Borders => { p.dotted(&[(12.0, 5.0), (12.0, 19.0)], K, 2.2, 0.45); p.dotted(&[(5.0, 12.0), (19.0, 12.0)], K, 2.2, 0.45); - p.rect(3.0, 3.0, 21.0, 21.0, 0.6, N, B); + p.rect((3.0, 3.0, 21.0, 21.0), 0.6, N, B); } I::Shading => { - p.rect(3.0, 3.0, 12.0, 12.0, 0.0, Y.a(0.8), N); - p.rect(12.0, 12.0, 21.0, 21.0, 0.0, Y.a(0.8), N); - p.table(3.0, 3.0, 21.0, 21.0, 2, 2, N); + p.rect((3.0, 3.0, 12.0, 12.0), 0.0, Y.a(0.8), N); + p.rect((12.0, 12.0, 21.0, 21.0), 0.0, Y.a(0.8), N); + p.table((3.0, 3.0, 21.0, 21.0), 2, 2, N); } // ----- Chart ----- @@ -1045,40 +1076,40 @@ pub(super) fn draw(p: &Pen<'_>, icon: Icon) { p.brush((14.0, 10.0), -45.0, 12.0, PU); } I::SwitchRowCol => { - p.table(2.5, 2.5, 11.0, 11.0, 2, 2, B); - p.rect(13.0, 13.0, 17.25, 21.5, 0.6, O, N); - p.table(13.0, 13.0, 21.5, 21.5, 2, 2, N); + p.table((2.5, 2.5, 11.0, 11.0), 2, 2, B); + p.rect((13.0, 13.0, 17.25, 21.5), 0.6, O, N); + p.table((13.0, 13.0, 21.5, 21.5), 2, 2, N); p.line(&quad_pts((14.0, 4.0), (20.0, 4.0), (20.0, 9.5), 10), K); p.head((20.0, 11.0), 90.0, 3.6, K); p.line(&quad_pts((10.0, 20.0), (4.0, 20.0), (4.0, 14.5), 10), K); p.head((4.0, 13.0), -90.0, 3.6, K); } I::EditData => { - p.table(2.5, 3.0, 18.0, 16.0, 3, 3, B); - p.tool((11.0, 21.5), -45.0, 14.0, 2.2, 3.8, Y, K, 2.5, R); + p.table((2.5, 3.0, 18.0, 16.0), 3, 3, B); + p.tool(((11.0, 21.5), -45.0), (14.0, 2.2, 3.8), Y, K, (2.5, R)); } I::ChangeChartType => { p.circle((8.0, 8.0), 5.8, t(B), B); let mut wedge = vec![(8.0, 8.0)]; wedge.extend(arc_pts((8.0, 8.0), 5.8, 5.8, -90.0, 0.0)); p.poly(&wedge, O, N); - p.rect(13.0, 15.0, 15.8, 21.0, 0.4, G, N); - p.rect(17.5, 11.0, 20.3, 21.0, 0.4, B, N); + p.rect((13.0, 15.0, 15.8, 21.0), 0.4, G, N); + p.rect((17.5, 11.0, 20.3, 21.0), 0.4, B, N); p.seg((11.5, 21.5), (22.0, 21.5), K); } // ----- Media ----- I::Play => p.rshape(&[(6.5, 4.0), (19.5, 12.0), (6.5, 20.0)], 1.0, G, N), I::Pause => { - p.rect(6.0, 4.5, 10.0, 19.5, 1.0, K, N); - p.rect(14.0, 4.5, 18.0, 19.5, 1.0, K, N); + p.rect((6.0, 4.5, 10.0, 19.5), 1.0, K, N); + p.rect((14.0, 4.5, 18.0, 19.5), 1.0, K, N); } - I::Stop => p.rect(5.0, 5.0, 19.0, 19.0, 2.0, K, N), + I::Stop => p.rect((5.0, 5.0, 19.0, 19.0), 2.0, K, N), I::TrimMedia => { - p.rect(2.0, 8.0, 22.0, 16.0, 1.2, N, K); - p.rect(7.0, 8.0, 17.0, 16.0, 0.0, t(Y), N); - p.rect(5.8, 5.0, 8.2, 19.0, 0.8, Y, N); - p.rect(15.8, 5.0, 18.2, 19.0, 0.8, Y, N); + p.rect((2.0, 8.0, 22.0, 16.0), 1.2, N, K); + p.rect((7.0, 8.0, 17.0, 16.0), 0.0, t(Y), N); + p.rect((5.8, 5.0, 8.2, 19.0), 0.8, Y, N); + p.rect((15.8, 5.0, 18.2, 19.0), 0.8, Y, N); } I::Volume => { p.speaker(2.5, 12.0, N, K); @@ -1094,17 +1125,25 @@ pub(super) fn draw(p: &Pen<'_>, icon: Icon) { p.rshape(&[(6.0, 2.5), (18.0, 2.5), (18.0, 21.5), (12.0, 16.5), (6.0, 21.5)], 0.8, R, N); } + _ => return false, + } + true +} + +fn draw_chrome_and_status(p: &Pen<'_>, icon: Icon) -> bool { + use Icon as I; + match icon { // ----- Window chrome ----- I::Home => { - p.rect(10.0, 14.5, 14.0, 21.0, 0.8, B, N); + p.rect((10.0, 14.5, 14.0, 21.0), 0.8, B, N); p.line(&[(2.5, 11.5), (12.0, 3.0), (21.5, 11.5)], K); p.rline(&[(5.0, 9.5), (5.0, 21.0), (19.0, 21.0), (19.0, 9.5)], 1.2, K); } I::Save => { p.rshape(&[(3.0, 3.0), (17.5, 3.0), (21.0, 6.5), (21.0, 21.0), (3.0, 21.0)], 1.5, t(B), B); - p.rect(7.0, 3.0, 15.0, 8.5, 0.6, N, B); + p.rect((7.0, 3.0, 15.0, 8.5), 0.6, N, B); p.seg((12.5, 4.5), (12.5, 7.0), B); - p.rect(6.5, 13.0, 17.5, 21.0, 0.8, N, B); + p.rect((6.5, 13.0, 17.5, 21.0), 0.8, N, B); } I::Undo | I::Redo => { let mut pts = vec![(6.0, 8.5), (14.0, 8.5)]; @@ -1147,9 +1186,9 @@ pub(super) fn draw(p: &Pen<'_>, icon: Icon) { p.arrow((8.5, 12.0), (22.0, 12.0), B); } I::Print => { - p.rect(6.5, 2.5, 17.5, 9.0, 0.8, N, K); + p.rect((6.5, 2.5, 17.5, 9.0), 0.8, N, K); p.rline(&[(6.5, 17.5), (2.5, 17.5), (2.5, 9.0), (21.5, 9.0), (21.5, 17.5), (17.5, 17.5)], 1.8, K); - p.rect(6.5, 14.0, 17.5, 21.5, 0.8, N, K); + p.rect((6.5, 14.0, 17.5, 21.5), 0.8, N, K); p.dot((18.5, 12.0), 1.1, G); } I::Settings => { @@ -1180,7 +1219,7 @@ pub(super) fn draw(p: &Pen<'_>, icon: Icon) { sh.extend(arc_pts((12.0, 8.0), 4.5, 4.5, 180.0, 360.0)); sh.push((16.5, 10.5)); p.line(&sh, K); - p.rect(4.5, 10.5, 19.5, 21.5, 1.8, N, K); + p.rect((4.5, 10.5, 19.5, 21.5), 1.8, N, K); p.seg((12.0, 14.5), (12.0, 17.5), K); } I::Unlock => { @@ -1189,7 +1228,7 @@ pub(super) fn draw(p: &Pen<'_>, icon: Icon) { sh.extend(arc_pts((16.5, 6.0), 4.5, 4.5, 180.0, 360.0)); sh.push((21.0, 8.5)); p.line(&sh, K); - p.rect(3.0, 10.5, 16.5, 21.5, 1.8, N, K); + p.rect((3.0, 10.5, 16.5, 21.5), 1.8, N, K); p.seg((9.75, 14.5), (9.75, 17.5), K); } I::Eye | I::EyeOff => { @@ -1225,7 +1264,7 @@ pub(super) fn draw(p: &Pen<'_>, icon: Icon) { } I::Star => p.star((12.0, 12.8), 10.0, t(Y), Y), I::Image => { - p.rect(2.5, 4.0, 21.5, 20.0, 1.5, N, K); + p.rect((2.5, 4.0, 21.5, 20.0), 1.5, N, K); p.circle((16.0, 9.0), 1.8, N, K); p.line(&[(2.5, 17.5), (8.5, 11.0), (14.0, 16.5), (16.5, 14.0), (21.5, 18.5)], K); } @@ -1244,7 +1283,7 @@ pub(super) fn draw(p: &Pen<'_>, icon: Icon) { } I::Grid => { for (x, y) in [(3.0, 3.0), (13.0, 3.0), (3.0, 13.0), (13.0, 13.0)] { - p.rect(x, y, x + 8.0, y + 8.0, 1.5, N, K); + p.rect((x, y, x + 8.0, y + 8.0), 1.5, N, K); } } I::List => { @@ -1278,7 +1317,7 @@ pub(super) fn draw(p: &Pen<'_>, icon: Icon) { } I::SaveAs => { p.rline(&[(11.0, 20.5), (2.5, 20.5), (2.5, 3.0), (15.0, 3.0), (18.0, 6.0), (18.0, 10.5)], 1.2, K); - p.rect(6.0, 3.0, 13.0, 7.5, 0.6, N, K); + p.rect((6.0, 3.0, 13.0, 7.5), 0.6, N, K); p.seg_w((13.5, 21.0), (21.5, 13.0), O, 1.4); p.seg_w((12.5, 22.0), (13.5, 21.0), K, 1.0); } @@ -1300,18 +1339,18 @@ pub(super) fn draw(p: &Pen<'_>, icon: Icon) { // ----- Status bar (monochrome) ----- I::NotesSmall => { - p.rect(3.0, 4.0, 21.0, 20.0, 1.5, N, K); + p.rect((3.0, 4.0, 21.0, 20.0), 1.5, N, K); p.seg((3.0, 13.5), (21.0, 13.5), K); p.seg((6.5, 16.8), (15.5, 16.8), K); } I::CommentsSmall => p.bubble(3.0, 4.0, 21.0, 16.5, N, K), I::ViewNormalSmall => { - p.rect(2.5, 4.0, 21.5, 20.0, 1.5, N, K); + p.rect((2.5, 4.0, 21.5, 20.0), 1.5, N, K); p.seg((8.5, 4.0), (8.5, 20.0), K); } I::ViewSorterSmall => { for (x, y) in [(2.5, 4.5), (13.0, 4.5), (2.5, 13.5), (13.0, 13.5)] { - p.rect(x, y, x + 8.5, y + 6.0, 1.0, N, K); + p.rect((x, y, x + 8.5, y + 6.0), 1.0, N, K); } } I::ViewReadingSmall => { @@ -1320,7 +1359,7 @@ pub(super) fn draw(p: &Pen<'_>, icon: Icon) { } I::ViewShowSmall => { p.seg((2.0, 4.0), (22.0, 4.0), K); - p.rect(3.5, 4.0, 20.5, 16.0, 0.8, N, K); + p.rect((3.5, 4.0, 20.5, 16.0), 0.8, N, K); p.seg((12.0, 16.0), (12.0, 19.0), K); p.line(&[(8.5, 22.0), (12.0, 19.0), (15.5, 22.0)], K); } @@ -1328,7 +1367,7 @@ pub(super) fn draw(p: &Pen<'_>, icon: Icon) { for (cx, cy, sx, sy) in [(3.0, 3.0, 1.0, 1.0), (21.0, 3.0, -1.0, 1.0), (3.0, 21.0, 1.0, -1.0), (21.0, 21.0, -1.0, -1.0)] { p.rline(&[(cx, cy + 5.0 * sy), (cx, cy), (cx + 5.0 * sx, cy)], 0.8, K); } - p.rect(8.0, 8.5, 16.0, 15.5, 0.8, N, K); + p.rect((8.0, 8.5, 16.0, 15.5), 0.8, N, K); } I::ZoomIn | I::ZoomOut => { p.magnifier((10.0, 10.0), 7.0, N, K); @@ -1337,5 +1376,7 @@ pub(super) fn draw(p: &Pen<'_>, icon: Icon) { p.seg((10.0, 7.0), (10.0, 13.0), K); } } + _ => return false, } + true } diff --git a/crates/ui-egui/src/icons/geometry.rs b/crates/ui-egui/src/icons/geometry.rs index 93ac96c0..8c020a9e 100644 --- a/crates/ui-egui/src/icons/geometry.rs +++ b/crates/ui-egui/src/icons/geometry.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + Align2, BLUE, Color32, DARK, FontId, GOLD, GREEN, Mesh, ORANGE, PI, PURPLE, Painter, PathShape, Pos2, RED, Rect, Shape, Stroke, StrokeKind, + TextShape, pos2, vec2, +}; // --------------------------------------------------------------------------------------------- // Drawing toolkit @@ -49,6 +52,8 @@ pub(super) fn t(c: C) -> C { } pub(super) type P2 = (f32, f32); +/// A box as `(x0, y0, x1, y1)`. +pub(super) type Box4 = (f32, f32, f32, f32); #[derive(Clone, Copy)] pub(super) enum Glyph { @@ -363,13 +368,13 @@ impl Pen<'_> { self.p.add(PathShape::convex_polygon(v, self.col(fill), self.stroke(stroke, w))); } - fn rect_w(&self, x0: f32, y0: f32, x1: f32, y1: f32, r: f32, fill: C, stroke: C, w: f32) { + fn rect_w(&self, (x0, y0, x1, y1): Box4, r: f32, fill: C, stroke: C, w: f32) { let rect = Rect::from_min_max(self.pt((x0, y0)), self.pt((x1, y1))); self.p.rect(rect, r * self.k, self.col(fill), self.stroke(stroke, w), StrokeKind::Middle); } - pub(super) fn rect(&self, x0: f32, y0: f32, x1: f32, y1: f32, r: f32, fill: C, stroke: C) { - self.rect_w(x0, y0, x1, y1, r, fill, stroke, 1.0); + pub(super) fn rect(&self, b: Box4, r: f32, fill: C, stroke: C) { + self.rect_w(b, r, fill, stroke, 1.0); } fn circle_w(&self, c: P2, r: f32, fill: C, stroke: C, w: f32) { @@ -516,7 +521,7 @@ impl Pen<'_> { /// Landscape slide frame. pub(super) fn frame(&self, x0: f32, y0: f32, x1: f32, y1: f32) { - self.rect(x0, y0, x1, y1, 1.5, N, K); + self.rect((x0, y0, x1, y1), 1.5, N, K); } /// Document page with a folded top-right corner. @@ -530,7 +535,7 @@ impl Pen<'_> { pub(super) fn picture(&self, x0: f32, y0: f32, x1: f32, y1: f32) { let (w, h) = (x1 - x0, y1 - y0); let m = w.min(h); - self.rect(x0, y0, x1, y1, 1.5, N, K); + self.rect((x0, y0, x1, y1), 1.5, N, K); self.dot((x1 - w * 0.27, y0 + h * 0.3), m * 0.12, Y); let base = y1 - h * 0.14; self.poly(&[(x0 + w * 0.12, base), (x0 + w * 0.38, y0 + h * 0.36), (x0 + w * 0.64, base)], G, N); @@ -597,11 +602,11 @@ impl Pen<'_> { } /// Table grid; `header` fills the first row. - pub(super) fn table(&self, x0: f32, y0: f32, x1: f32, y1: f32, rows: usize, cols: usize, header: C) { + pub(super) fn table(&self, (x0, y0, x1, y1): Box4, rows: usize, cols: usize, header: C) { let rh = (y1 - y0) / rows as f32; let cw = (x1 - x0) / cols as f32; if !matches!(header, C::None) { - self.rect(x0, y0, x1, y0 + rh, 1.2, header, N); + self.rect((x0, y0, x1, y0 + rh), 1.2, header, N); } for i in 1..rows { let y = y0 + rh * i as f32; @@ -611,7 +616,7 @@ impl Pen<'_> { let x = x0 + cw * j as f32; self.seg_w((x, y0), (x, y1), K, 0.8); } - self.rect(x0, y0, x1, y1, 1.2, N, K); + self.rect((x0, y0, x1, y1), 1.2, N, K); } /// Bar chart in the box. @@ -620,13 +625,13 @@ impl Pen<'_> { let h = y1 - y0; for (i, (frac, c)) in [(0.5, B), (1.0, O), (0.72, G)].into_iter().enumerate() { let bx = x0 + w * i as f32; - self.rect(bx + w * 0.16, y1 - h * frac, bx + w * 0.84, y1, 0.6, c, N); + self.rect((bx + w * 0.16, y1 - h * frac, bx + w * 0.84, y1), 0.6, c, N); } self.seg((x0 - 0.5, y1 + 0.2), (x1 + 0.5, y1 + 0.2), K); } /// A writing tool lying along `ang` degrees with its point at `tip`. - pub(super) fn tool(&self, tip: P2, ang: f32, len: f32, hw: f32, cone: f32, body: C, lead: C, cap_len: f32, cap: C) { + pub(super) fn tool(&self, (tip, ang): (P2, f32), (len, hw, cone): (f32, f32, f32), body: C, lead: C, (cap_len, cap): (f32, C)) { let m = |pts: &[P2]| tf(tip, ang, pts); self.poly(&m(&[(0.0, 0.0), (cone, -hw), (cone, hw)]), N, K); self.poly(&m(&[(0.0, 0.0), (cone * 0.42, -hw * 0.42), (cone * 0.42, hw * 0.42)]), lead, N); @@ -714,7 +719,7 @@ impl Pen<'_> { } pub(super) fn monitor(&self, x0: f32, y0: f32, x1: f32, y1: f32) { - self.rect(x0, y0, x1, y1, 1.5, N, K); + self.rect((x0, y0, x1, y1), 1.5, N, K); let cx = (x0 + x1) * 0.5; self.seg((cx, y1), (cx, y1 + 3.2), K); self.seg((cx - 4.0, y1 + 3.6), (cx + 4.0, y1 + 3.6), K); @@ -749,7 +754,7 @@ impl Pen<'_> { sh.extend(arc_pts((cx, y0 - r * 0.7), r, r, 180.0, 360.0)); sh.push((cx + r, y0)); self.line(&sh, K); - self.rect(x0, y0, x1, y1, 1.2, Y, K); + self.rect((x0, y0, x1, y1), 1.2, Y, K); let ky = (y0 + y1) * 0.5; self.dot((cx, ky - 0.4), 1.1, D); self.seg_w((cx, ky), (cx, ky + 2.0), D, 0.9); diff --git a/crates/ui-egui/src/media.rs b/crates/ui-egui/src/media.rs index 956f2d9b..c44d3faf 100644 --- a/crates/ui-egui/src/media.rs +++ b/crates/ui-egui/src/media.rs @@ -348,7 +348,7 @@ impl MediaHost { /// Carry out a `media.*` request from the engine for presentation `doc_uid`. /// `slide` is the slide the request comes from: shape ids are only unique per slide. - pub fn request(&mut self, doc_uid: u64, doc: &Presentation, slide: usize, action: &str, id: ShapeId, ms: Option, owner: Owner) { + pub fn request(&mut self, doc_uid: u64, doc: &Presentation, slide: usize, action: &str, (id, ms): (ShapeId, Option), owner: Owner) { self.ensure_doc(doc_uid); let Some(clip) = find_clip(doc, id, Some(slide)) else { return }; let t = ms.map(|v| v as f64 / 1000.0); diff --git a/crates/ui-egui/src/panels/animation.rs b/crates/ui-egui/src/panels/animation.rs index 95eea93f..22d3c68e 100644 --- a/crates/ui-egui/src/panels/animation.rs +++ b/crates/ui-egui/src/panels/animation.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + Align2, AnimClass, AnimStart, Color32, CornerRadius, Icon, Rect, Sense, SlideApp, Stroke, Tokens, Ui, animtimeline, icons, json, paint_anim_tile, + pos2, run, theme, vec2, +}; /// Render the animation pane and route its controls through the engine's animation commands. pub(super) fn animation(app: &mut SlideApp, ui: &mut Ui) { diff --git a/crates/ui-egui/src/panels/background.rs b/crates/ui-egui/src/panels/background.rs index 05bc11be..906408a9 100644 --- a/crates/ui-egui/src/panels/background.rs +++ b/crates/ui-egui/src/panels/background.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Fill, SlideApp, Ui, Value, cref_param, json, run, scheme, theme, vec2, widgets}; pub(super) fn background(app: &mut SlideApp, ui: &mut Ui) { let sc = scheme(app); diff --git a/crates/ui-egui/src/panels/comments.rs b/crates/ui-egui/src/panels/comments.rs index 0b384579..edf0e8b8 100644 --- a/crates/ui-egui/src/panels/comments.rs +++ b/crates/ui-egui/src/panels/comments.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Align2, Color32, CornerRadius, Sense, SlideApp, Stroke, Tokens, Ui, Value, json, run, theme, vec2}; /// Review › Compare: the revised copy's slide-level changes; click to go there, Accept to take it, /// Reject to drop it. diff --git a/crates/ui-egui/src/panels/designer.rs b/crates/ui-egui/src/panels/designer.rs index 422e0085..16000b7c 100644 --- a/crates/ui-egui/src/panels/designer.rs +++ b/crates/ui-egui/src/panels/designer.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Color32, CornerRadius, Rect, Sense, SlideApp, Stroke, Tokens, Ui, json, pos2, run, vec2}; /// Design Ideas: offline suggestions — the current slide in each theme and layout variations. /// Rendered idea thumbnails, kept until the deck, slide or pane width changes (rendering eight diff --git a/crates/ui-egui/src/panels/format_shape.rs b/crates/ui-egui/src/panels/format_shape.rs index cb324721..8a4d2377 100644 --- a/crates/ui-egui/src/panels/format_shape.rs +++ b/crates/ui-egui/src/panels/format_shape.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Fill, Icon, ShapeKind, SlideApp, Tokens, Ui, color_button, cref_param, json, run, scheme, theme, vec2, widgets}; /// Percent transparency (0 = opaque) of a solid fill, the same number `shape.fill`'s /// `transparency` parameter writes as an alpha transform. Anything else (no fill, a @@ -23,16 +23,7 @@ pub(super) fn fill_transparency_to_apply(shown_percent: f32, seed_percent: f32) Some(f64::from(shown) / 100.0) } -/// Render the shape-format pane with fill/line, effects, size/properties, text, and picture tabs. -pub(super) fn format_shape(app: &mut SlideApp, ui: &mut Ui) { - let t = Tokens::get(ui.ctx()); - let Some(st) = app.session.active() else { return }; - let Some(sh) = st.selected_shapes().first().map(|s| (*s).clone()) else { - ui.label(egui::RichText::new("Select a shape to format it.").color(t.text_dim)); - return; - }; - let x = deckcraft_engine::cmd::xfrm_of(&st.doc, &st.selection, &sh); - let uid = st.uid; +fn format_tabs(app: &mut SlideApp, ui: &mut Ui, sh: &deckcraft_model::Shape) { // Tabs as icons. ui.horizontal(|ui| { for (tab, icon, tip) in [ @@ -52,100 +43,110 @@ pub(super) fn format_shape(app: &mut SlideApp, ui: &mut Ui) { app.ui.format_tab = "picture".into(); } }); - ui.separator(); - match app.ui.format_tab.as_str() { - "effects" => { - ui.label(egui::RichText::new("Shadow").font(theme::bold(13.0))); - ui.horizontal_wrapped(|ui| { - for (l, v) in [ - ("None", "none"), - ("Outer", "outer"), - ("Bottom", "bottom"), - ("Center", "center"), - ("Inner", "inner"), - ("Perspective", "perspective"), - ] { - if ui.button(l).clicked() { - run(app, "shape.effects", json!({"shadow": v})); - } - } - }); - let fx = sh.effects.clone().unwrap_or_default(); - let shadow = fx.outer_shadow.as_ref().or(fx.inner_shadow.as_ref()); - if let Some(sw) = shadow { - color_button(app, ui, "Color", None, |c| ("shape.effects".into(), json!({"shadowProps": {"color": c.as_ref().map(cref_param)}}))); - fx_slider( - app, - ui, - ("shadow", sh.id.0, "t"), - "Transparency", - (1.0 - sw.color.alpha()) * 100.0, - 0.0..=100.0, - "%", - "shadowProps", - "transparency", - 0.01, - ); - fx_slider(app, ui, ("shadow", sh.id.0, "b"), "Blur", sw.blur, 0.0..=100.0, " pt", "shadowProps", "blur", 1.0); - fx_slider(app, ui, ("shadow", sh.id.0, "a"), "Angle", sw.dir, 0.0..=359.0, "°", "shadowProps", "dir", 1.0); - fx_slider(app, ui, ("shadow", sh.id.0, "d"), "Distance", sw.dist, 0.0..=200.0, " pt", "shadowProps", "dist", 1.0); - } - ui.add_space(6.0); - ui.label(egui::RichText::new("Reflection").font(theme::bold(13.0))); - ui.horizontal_wrapped(|ui| { - for (l, v) in [("None", "none"), ("Tight", "tight"), ("Half", "half"), ("Full", "full")] { - if ui.button(l).clicked() { - run(app, "shape.effects", json!({"reflection": v})); - } - } - }); - if let Some(r) = &fx.reflection { - fx_slider( - app, - ui, - ("refl", sh.id.0, "t"), - "Transparency", - (1.0 - r.start_alpha) * 100.0, - 0.0..=100.0, - "%", - "reflectionProps", - "transparency", - 0.01, - ); - fx_slider(app, ui, ("refl", sh.id.0, "s"), "Size", r.end_pos * 100.0, 0.0..=100.0, "%", "reflectionProps", "size", 0.01); - fx_slider(app, ui, ("refl", sh.id.0, "d"), "Distance", r.dist, 0.0..=100.0, " pt", "reflectionProps", "dist", 1.0); - fx_slider(app, ui, ("refl", sh.id.0, "b"), "Blur", r.blur, 0.0..=100.0, " pt", "reflectionProps", "blur", 1.0); - } - ui.add_space(6.0); - ui.label(egui::RichText::new("Glow").font(theme::bold(13.0))); - let radius = fx.glow.as_ref().map_or(0.0, |g| g.radius); - fx_slider(app, ui, ("glow", sh.id.0, "r"), "Size", radius, 0.0..=150.0, " pt", "glowProps", "radius", 1.0); - if let Some(g) = &fx.glow { - color_button(app, ui, "Color", None, |c| ("shape.effects".into(), json!({"glowProps": {"color": c.as_ref().map(cref_param)}}))); - fx_slider( - app, - ui, - ("glow", sh.id.0, "t"), - "Transparency", - (1.0 - g.color.alpha()) * 100.0, - 0.0..=100.0, - "%", - "glowProps", - "transparency", - 0.01, - ); - } - ui.label(egui::RichText::new("Soft Edges").font(theme::bold(13.0))); - let mut s = sh.effects.as_ref().and_then(|e| e.soft_edge).unwrap_or(0.0); - if ui.add(egui::Slider::new(&mut s, 0.0..=50.0).text("size (pt)")).drag_stopped() { - run(app, "shape.effects", json!({"softEdges": s})); - } - let raw3d = sh.effects.as_ref().and_then(|e| e.raw3d.clone()); - three_d_options(app, ui, sh.id.0, raw3d.as_deref()); - if ui.button("Reset Effects").clicked() { - run(app, "shape.effects", json!({"reset": true})); +} + +fn effects_tab(app: &mut SlideApp, ui: &mut Ui, sh: &deckcraft_model::Shape) { + ui.label(egui::RichText::new("Shadow").font(theme::bold(13.0))); + ui.horizontal_wrapped(|ui| { + for (l, v) in + [("None", "none"), ("Outer", "outer"), ("Bottom", "bottom"), ("Center", "center"), ("Inner", "inner"), ("Perspective", "perspective")] + { + if ui.button(l).clicked() { + run(app, "shape.effects", json!({"shadow": v})); } } + }); + let fx = sh.effects.clone().unwrap_or_default(); + let shadow = fx.outer_shadow.as_ref().or(fx.inner_shadow.as_ref()); + if let Some(sw) = shadow { + color_button(app, ui, "Color", None, |c| ("shape.effects".into(), json!({"shadowProps": {"color": c.as_ref().map(cref_param)}}))); + fx_slider( + app, + ui, + ("shadow", sh.id.0, "t"), + "Transparency", + (1.0 - sw.color.alpha()) * 100.0, + 0.0..=100.0, + "%", + "shadowProps", + "transparency", + 0.01, + ); + fx_slider(app, ui, ("shadow", sh.id.0, "b"), "Blur", sw.blur, 0.0..=100.0, " pt", "shadowProps", "blur", 1.0); + fx_slider(app, ui, ("shadow", sh.id.0, "a"), "Angle", sw.dir, 0.0..=359.0, "°", "shadowProps", "dir", 1.0); + fx_slider(app, ui, ("shadow", sh.id.0, "d"), "Distance", sw.dist, 0.0..=200.0, " pt", "shadowProps", "dist", 1.0); + } + ui.add_space(6.0); + ui.label(egui::RichText::new("Reflection").font(theme::bold(13.0))); + ui.horizontal_wrapped(|ui| { + for (l, v) in [("None", "none"), ("Tight", "tight"), ("Half", "half"), ("Full", "full")] { + if ui.button(l).clicked() { + run(app, "shape.effects", json!({"reflection": v})); + } + } + }); + if let Some(r) = &fx.reflection { + fx_slider( + app, + ui, + ("refl", sh.id.0, "t"), + "Transparency", + (1.0 - r.start_alpha) * 100.0, + 0.0..=100.0, + "%", + "reflectionProps", + "transparency", + 0.01, + ); + fx_slider(app, ui, ("refl", sh.id.0, "s"), "Size", r.end_pos * 100.0, 0.0..=100.0, "%", "reflectionProps", "size", 0.01); + fx_slider(app, ui, ("refl", sh.id.0, "d"), "Distance", r.dist, 0.0..=100.0, " pt", "reflectionProps", "dist", 1.0); + fx_slider(app, ui, ("refl", sh.id.0, "b"), "Blur", r.blur, 0.0..=100.0, " pt", "reflectionProps", "blur", 1.0); + } + ui.add_space(6.0); + ui.label(egui::RichText::new("Glow").font(theme::bold(13.0))); + let radius = fx.glow.as_ref().map_or(0.0, |g| g.radius); + fx_slider(app, ui, ("glow", sh.id.0, "r"), "Size", radius, 0.0..=150.0, " pt", "glowProps", "radius", 1.0); + if let Some(g) = &fx.glow { + color_button(app, ui, "Color", None, |c| ("shape.effects".into(), json!({"glowProps": {"color": c.as_ref().map(cref_param)}}))); + fx_slider( + app, + ui, + ("glow", sh.id.0, "t"), + "Transparency", + (1.0 - g.color.alpha()) * 100.0, + 0.0..=100.0, + "%", + "glowProps", + "transparency", + 0.01, + ); + } + ui.label(egui::RichText::new("Soft Edges").font(theme::bold(13.0))); + let mut s = sh.effects.as_ref().and_then(|e| e.soft_edge).unwrap_or(0.0); + if ui.add(egui::Slider::new(&mut s, 0.0..=50.0).text("size (pt)")).drag_stopped() { + run(app, "shape.effects", json!({"softEdges": s})); + } + let raw3d = sh.effects.as_ref().and_then(|e| e.raw3d.clone()); + three_d_options(app, ui, sh.id.0, raw3d.as_deref()); + if ui.button("Reset Effects").clicked() { + run(app, "shape.effects", json!({"reset": true})); + } +} + +/// Render the shape-format pane with fill/line, effects, size/properties, text, and picture tabs. +pub(super) fn format_shape(app: &mut SlideApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + let Some(st) = app.session.active() else { return }; + let Some(sh) = st.selected_shapes().first().map(|s| (*s).clone()) else { + ui.label(egui::RichText::new("Select a shape to format it.").color(t.text_dim)); + return; + }; + let x = deckcraft_engine::cmd::xfrm_of(&st.doc, &st.selection, &sh); + let uid = st.uid; + format_tabs(app, ui, &sh); + ui.separator(); + match app.ui.format_tab.as_str() { + "effects" => effects_tab(app, ui, &sh), "size" => { let lock_id = egui::Id::unique("fmt-lock-aspect"); let mut lock: bool = ui.data_mut(|d| d.get_temp(lock_id).unwrap_or(false)); diff --git a/crates/ui-egui/src/panels/selpane.rs b/crates/ui-egui/src/panels/selpane.rs index 048eff3f..b9325548 100644 --- a/crates/ui-egui/src/panels/selpane.rs +++ b/crates/ui-egui/src/panels/selpane.rs @@ -118,16 +118,7 @@ fn small_icon_button(ui: &mut Ui, icon: Icon, label: &str, enabled: bool) -> egu resp.on_hover_text(label) } -/// Render the Selection Pane and route visibility, order, rename, and selection actions through -/// engine commands. -pub fn show(app: &mut SlideApp, ui: &mut Ui) { - let t = Tokens::get(ui.ctx()); - let Some(st) = app.session.active() else { return }; - app.selpane.bind(st.uid); - let rows = rows(st.shapes(), &app.selpane.collapsed); - let selected: Vec = st.selection.shapes.iter().map(|i| i.0).collect(); - let has_sel = !selected.is_empty(); - +fn toolbar(app: &mut SlideApp, ui: &mut Ui, t: &Tokens, has_sel: bool) { ui.horizontal(|ui| { if ui.add(egui::Button::new("Show All").stroke(Stroke::new(1.0, t.border))).clicked() { run(app, "selection.showAll", json!({})); @@ -144,15 +135,49 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { } }); }); - ui.add_space(6.0); +} - // The list has the keyboard while one of its rows has focus. +fn focus_lock(ui: &mut Ui, rows: &[Row]) -> Option { let focused_row = ui.memory(|m| m.focused()).and_then(|f| rows.iter().find(|r| row_id(r.id) == f).map(|r| r.id)); if let Some(f) = focused_row { ui.memory_mut(|m| { m.set_focus_lock_filter(row_id(f), egui::EventFilter { tab: false, horizontal_arrows: true, vertical_arrows: true, escape: true }) }); } + focused_row +} + +fn group_chevron(app: &mut SlideApp, ui: &mut Ui, row: &Row, painter: &egui::Painter, x0: f32, r: Rect, t: &Tokens) { + let open = !app.selpane.collapsed.contains(&row.id); + let cr = Rect::from_center_size(pos2(x0 + 6.0, r.center().y), vec2(14.0, 14.0)); + let cresp = ui.interact(cr, ui.scope_id().with(("fold", row.id)), Sense::click()); + let label = format!("{} {}", if open { "Collapse" } else { "Expand" }, row.name); + cresp.widget_info(|| WidgetInfo::labeled(Role::Button, true, &label)); + icons::paint(painter, cr, if open { Icon::ChevronDown } else { Icon::ChevronRight }, t.text_dim, false); + if cresp.clicked() { + if open { + app.selpane.collapsed.insert(row.id); + } else { + app.selpane.collapsed.remove(&row.id); + } + } +} + +/// Render the Selection Pane and route visibility, order, rename, and selection actions through +/// engine commands. +pub fn show(app: &mut SlideApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + let Some(st) = app.session.active() else { return }; + app.selpane.bind(st.uid); + let rows = rows(st.shapes(), &app.selpane.collapsed); + let selected: Vec = st.selection.shapes.iter().map(|i| i.0).collect(); + let has_sel = !selected.is_empty(); + + toolbar(app, ui, &t, has_sel); + ui.add_space(6.0); + + // The list has the keyboard while one of its rows has focus. + let focused_row = focus_lock(ui, &rows); let mut rects: Vec = Vec::with_capacity(rows.len()); let width = ui.available_width(); for (k, row) in rows.iter().enumerate() { @@ -171,19 +196,7 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { let x0 = r.min.x + 4.0 + INDENT * row.depth as f32; // Group chevron. if row.group { - let open = !app.selpane.collapsed.contains(&row.id); - let cr = Rect::from_center_size(pos2(x0 + 6.0, r.center().y), vec2(14.0, 14.0)); - let cresp = ui.interact(cr, ui.scope_id().with(("fold", row.id)), Sense::click()); - let label = format!("{} {}", if open { "Collapse" } else { "Expand" }, row.name); - cresp.widget_info(|| WidgetInfo::labeled(Role::Button, true, &label)); - icons::paint(&painter, cr, if open { Icon::ChevronDown } else { Icon::ChevronRight }, t.text_dim, false); - if cresp.clicked() { - if open { - app.selpane.collapsed.insert(row.id); - } else { - app.selpane.collapsed.remove(&row.id); - } - } + group_chevron(app, ui, row, &painter, x0, r, &t); } let name_x = x0 + 16.0; let eye = Rect::from_center_size(pos2(r.max.x - 34.0, r.center().y), vec2(16.0, 16.0)); diff --git a/crates/ui-egui/src/panels/smartart.rs b/crates/ui-egui/src/panels/smartart.rs index d21393e9..aa872d1f 100644 --- a/crates/ui-egui/src/panels/smartart.rs +++ b/crates/ui-egui/src/panels/smartart.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{SlideApp, Tokens, Ui, Value, json, run}; /// SmartArt Text Pane: one line per node, indented by level. Enter adds a line after, Tab and /// Shift+Tab demote and promote, Backspace in an empty line removes it. diff --git a/crates/ui-egui/src/panels/thesaurus.rs b/crates/ui-egui/src/panels/thesaurus.rs index b79e8ddb..ca4e5133 100644 --- a/crates/ui-egui/src/panels/thesaurus.rs +++ b/crates/ui-egui/src/panels/thesaurus.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{SlideApp, Tokens, Ui, json, run, theme}; /// Review › Thesaurus: look a word up; clicking a synonym puts it in place of the selected word /// when editing text, otherwise looks that synonym up in turn. diff --git a/crates/ui-egui/src/ribbon/animations.rs b/crates/ui-egui/src/ribbon/animations.rs index 0bdf1929..30dbb9e3 100644 --- a/crates/ui-egui/src/ribbon/animations.rs +++ b/crates/ui-egui/src/ribbon/animations.rs @@ -1,5 +1,8 @@ use super::transitions::option_label; -use super::*; +use super::{ + Align2, AnimClass, CornerRadius, Icon, Rect, Sense, SlideApp, Tokens, Ui, big, big_button, big_button_ex, dropdown, enabled, group, icons, json, + pos2, rows, run, theme, vec2, widgets, +}; // ---------- Animations ---------- diff --git a/crates/ui-egui/src/ribbon/arrange.rs b/crates/ui-egui/src/ribbon/arrange.rs index f5b517bf..0d340df4 100644 --- a/crates/ui-egui/src/ribbon/arrange.rs +++ b/crates/ui-egui/src/ribbon/arrange.rs @@ -1,4 +1,6 @@ -use super::*; +use super::{ + Icon, ShapeId, SlideApp, Ui, big, big_button, big_button_ex, enabled, group, json, menu_item, rows, run, small_button_ex, theme, widgets, +}; pub fn arrange_menu(app: &mut SlideApp, ui: &mut Ui) { ui.label(egui::RichText::new("Order Objects").font(theme::bold(12.0))); diff --git a/crates/ui-egui/src/ribbon/chart.rs b/crates/ui-egui/src/ribbon/chart.rs index 97431ab3..bf84c0f8 100644 --- a/crates/ui-egui/src/ribbon/chart.rs +++ b/crates/ui-egui/src/ribbon/chart.rs @@ -1,6 +1,6 @@ use super::arrange::arrange_group; use super::insert::chart_menu_types; -use super::*; +use super::{Icon, SlideApp, Ui, big, big_button, group, json}; pub(super) fn chart_design(app: &mut SlideApp, ui: &mut Ui) { group(ui, |ui| { diff --git a/crates/ui-egui/src/ribbon/design.rs b/crates/ui-egui/src/ribbon/design.rs index 18305914..7a81c98e 100644 --- a/crates/ui-egui/src/ribbon/design.rs +++ b/crates/ui-egui/src/ribbon/design.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + Align2, Color32, CornerRadius, Icon, Rect, Sense, SlideApp, Stroke, Tokens, Ui, big, big_button, enabled, group, json, pos2, run, scheme, + small_button, theme, vec2, widgets, +}; // ---------- Design ---------- diff --git a/crates/ui-egui/src/ribbon/draw.rs b/crates/ui-egui/src/ribbon/draw.rs index d1a38541..592390e1 100644 --- a/crates/ui-egui/src/ribbon/draw.rs +++ b/crates/ui-egui/src/ribbon/draw.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Icon, SlideApp, Ui, big_button, big_button_ex, group, json, run}; // ---------- Draw ---------- diff --git a/crates/ui-egui/src/ribbon/galleries.rs b/crates/ui-egui/src/ribbon/galleries.rs index 3feb1b74..41291062 100644 --- a/crates/ui-egui/src/ribbon/galleries.rs +++ b/crates/ui-egui/src/ribbon/galleries.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Align2, Color32, CornerRadius, Rect, Sense, SlideApp, Stroke, Tokens, Ui, Value, json, pos2, run, scheme, theme, vec2, widgets}; /// A ColorRef as a command colour parameter. pub fn cref_param(c: &deckcraft_model::ColorRef) -> Value { @@ -60,8 +60,7 @@ pub(super) fn smartart_gallery( cmd: &str, extra: Value, current: &str, - style: &str, - colors: &str, + (style, colors): (&str, &str), nodes: Option<&[deckcraft_model::DiagramNode]>, ) { use deckcraft_engine::cmd::smartart::{LAYOUTS, category}; @@ -86,7 +85,7 @@ pub(super) fn smartart_gallery( egui::Grid::new(("smartart_gallery", cat)).num_columns(4).spacing([6.0, 6.0]).show(ui, |ui| { for (i, (k, label)) in kinds.iter().enumerate() { let ctx = ui.ctx().clone(); - let thumb = app.session.active().and_then(|d| app.textures.smartart(&ctx, &d.doc, slide, k, style, colors, None, 192)); + let thumb = app.session.active().and_then(|d| app.textures.smartart(&ctx, &d.doc, slide, (k, style, colors), None, 192)); let r = ui .vertical_centered(|ui| { ui.set_width(100.0); @@ -102,7 +101,7 @@ pub(super) fn smartart_gallery( }) .inner; if r.hovered() { - let preview = app.session.active().and_then(|d| app.textures.smartart(&ctx, &d.doc, slide, k, style, colors, nodes, 480)); + let preview = app.session.active().and_then(|d| app.textures.smartart(&ctx, &d.doc, slide, (k, style, colors), nodes, 480)); if let Some(t) = preview { r.show_tooltip_ui(|ui| { ui.add(egui::Image::new(&t).fit_to_exact_size(egui::vec2(320.0, 200.0))); diff --git a/crates/ui-egui/src/ribbon/home.rs b/crates/ui-egui/src/ribbon/home.rs index 826fba98..1c27dc98 100644 --- a/crates/ui-egui/src/ribbon/home.rs +++ b/crates/ui-egui/src/ribbon/home.rs @@ -1,6 +1,9 @@ use super::arrange::arrange_menu; use super::galleries::{cref_param, effects_menu, outline_menu, quick_styles, shapes_gallery, smartart_gallery}; -use super::*; +use super::{ + Color32, CornerRadius, Icon, Rect, Sense, SlideApp, Stroke, Tokens, Ui, Value, big, big_button, big_button_ex, color_split, dropdown, enabled, + fmt_state, group, icon_toggle, json, pos2, rows, run, scheme, small, small_button, small_button_ex, small_button_named, toggle, vec2, widgets, +}; // ---------- Home ---------- @@ -382,7 +385,7 @@ fn paragraph_group(app: &mut SlideApp, ui: &mut Ui, st: &Value) { } }); let r = icon_toggle(ui, Icon::SmartArt, "Convert to SmartArt", enabled(app, "smartArt.fromText"), false); - egui::Popup::menu(&r).show(|ui| smartart_gallery(app, ui, "smartArt.fromText", json!({}), "", "", "", None)); + egui::Popup::menu(&r).show(|ui| smartart_gallery(app, ui, "smartArt.fromText", json!({}), "", ("", ""), None)); if ui.add_enabled(en, egui::Button::new("Paragraph…").min_size(vec2(88.0, 22.0))).on_hover_text("Paragraph").clicked() { run(app, "format.paragraphDialog", json!({})); } diff --git a/crates/ui-egui/src/ribbon/insert.rs b/crates/ui-egui/src/ribbon/insert.rs index da2b6bb4..fab6e317 100644 --- a/crates/ui-egui/src/ribbon/insert.rs +++ b/crates/ui-egui/src/ribbon/insert.rs @@ -1,7 +1,10 @@ use super::galleries::{Fl, kurbo_flatten, shapes_gallery}; use super::home::layout_menu; use super::view::zoom_menu; -use super::*; +use super::{ + Color32, CornerRadius, Icon, Rect, Sense, SlideApp, Stroke, Tokens, Ui, big, big_button, big_button_ex, enabled, group, json, menu_item, pos2, + run, tip, vec2, +}; /// Photo Album is a split button: the main click starts a new album, the arrow edits one. fn photo_album_button(app: &mut SlideApp, ui: &mut Ui) { diff --git a/crates/ui-egui/src/ribbon/masters.rs b/crates/ui-egui/src/ribbon/masters.rs index c39d2f15..ac33b352 100644 --- a/crates/ui-egui/src/ribbon/masters.rs +++ b/crates/ui-egui/src/ribbon/masters.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Icon, SlideApp, Target, Ui, big, big_button, group, json, rows, run}; /// Preserve, and whether the open master or layout has a title or any footer placeholder. fn master_checks(app: &SlideApp) -> (bool, bool, bool) { diff --git a/crates/ui-egui/src/ribbon/picture_format.rs b/crates/ui-egui/src/ribbon/picture_format.rs index 73c3bde6..e2704f5a 100644 --- a/crates/ui-egui/src/ribbon/picture_format.rs +++ b/crates/ui-egui/src/ribbon/picture_format.rs @@ -1,6 +1,9 @@ use super::arrange::{arrange_group, size_group}; use super::galleries::{cref_param, shapes_gallery}; -use super::*; +use super::{ + Color32, CornerRadius, Icon, Rect, SlideApp, Stroke, Ui, Value, big, big_button, color_split, enabled, group, json, pos2, rows, run, scheme, + small, small_button_ex, theme, vec2, widgets, +}; /// Render the Picture Format tab's corrections, colour, artistic effects, transparency, styles, /// border, and picture actions. diff --git a/crates/ui-egui/src/ribbon/playback.rs b/crates/ui-egui/src/ribbon/playback.rs index 9322c76d..1b76f13b 100644 --- a/crates/ui-egui/src/ribbon/playback.rs +++ b/crates/ui-egui/src/ribbon/playback.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Icon, ShapeKind, SlideApp, Ui, big, big_button, group, json, rows, run}; pub(super) fn playback(app: &mut SlideApp, ui: &mut Ui) { let m = app diff --git a/crates/ui-egui/src/ribbon/review.rs b/crates/ui-egui/src/ribbon/review.rs index 70bf04d6..7c5db24c 100644 --- a/crates/ui-egui/src/ribbon/review.rs +++ b/crates/ui-egui/src/ribbon/review.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Icon, SlideApp, Ui, Value, big, big_button, big_button_ex, enabled, group, json, rows, run, small_button, tip}; fn comment_nav(app: &mut SlideApp, ui: &mut Ui, forward: bool) { let (icon, label, id) = if forward { (Icon::NextComment, "Next", "comment.next") } else { (Icon::PrevComment, "Previous", "comment.prev") }; diff --git a/crates/ui-egui/src/ribbon/shape_format.rs b/crates/ui-egui/src/ribbon/shape_format.rs index c8033552..1f648bd1 100644 --- a/crates/ui-egui/src/ribbon/shape_format.rs +++ b/crates/ui-egui/src/ribbon/shape_format.rs @@ -1,6 +1,9 @@ use super::arrange::{arrange_group, size_group}; use super::galleries::{cref_param, effects_menu, outline_menu, quick_styles, shapes_gallery}; -use super::*; +use super::{ + Color32, CornerRadius, Icon, Rect, Sense, SlideApp, Stroke, Tokens, Ui, big, big_button, enabled, group, json, pos2, rows, run, scheme, + small_button, small_button_ex, theme, vec2, widgets, +}; // ---------- contextual tabs ---------- diff --git a/crates/ui-egui/src/ribbon/slide_show.rs b/crates/ui-egui/src/ribbon/slide_show.rs index 8542f547..89c78c11 100644 --- a/crates/ui-egui/src/ribbon/slide_show.rs +++ b/crates/ui-egui/src/ribbon/slide_show.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Icon, SlideApp, Ui, big, big_button, big_button_ex, group, json, rows, run}; // ---------- Slide Show / Record / Review / View ---------- diff --git a/crates/ui-egui/src/ribbon/smartart.rs b/crates/ui-egui/src/ribbon/smartart.rs index b8b38fbb..969cc614 100644 --- a/crates/ui-egui/src/ribbon/smartart.rs +++ b/crates/ui-egui/src/ribbon/smartart.rs @@ -1,6 +1,6 @@ use super::arrange::arrange_group; use super::galleries::smartart_gallery; -use super::*; +use super::{Icon, SlideApp, Ui, Value, big_button, group, json, rows, run, small_button, small_button_ex, widgets}; /// SmartArt Design: Create Graphic (Text Pane, Add Shape, Promote, Demote, Remove) and Layouts. pub(super) fn smartart_design(app: &mut SlideApp, ui: &mut Ui) { @@ -53,7 +53,7 @@ pub(super) fn smartart_design(app: &mut SlideApp, ui: &mut Ui) { let outline = sel.as_ref().map(|(_, d)| d.nodes.clone()); if let Some(arrow) = arrow { widgets::split_menu(&main, &arrow, |ui| { - smartart_gallery(app, ui, "smartArt.layout", json!({"id": gid}), &kind, &style, &colors, outline.as_deref()); + smartart_gallery(app, ui, "smartArt.layout", json!({"id": gid}), &kind, (&style, &colors), outline.as_deref()); }); } let (main, arrow) = small_button_ex(ui, Icon::Layout, "Convert", gid.is_some(), false, true); diff --git a/crates/ui-egui/src/ribbon/table.rs b/crates/ui-egui/src/ribbon/table.rs index b2c0fde8..8c49c354 100644 --- a/crates/ui-egui/src/ribbon/table.rs +++ b/crates/ui-egui/src/ribbon/table.rs @@ -1,6 +1,9 @@ use super::arrange::arrange_group; use super::galleries::cref_param; -use super::*; +use super::{ + Color32, CornerRadius, Icon, Rect, Sense, ShapeKind, SlideApp, Stroke, Ui, Value, big, big_button, big_button_ex, color_split, group, json, + menu_item, pos2, rows, run, scheme, small, theme, toggle, vec2, widgets, +}; pub(super) fn table_design(app: &mut SlideApp, ui: &mut Ui) { let tbl = app diff --git a/crates/ui-egui/src/ribbon/title_bar.rs b/crates/ui-egui/src/ribbon/title_bar.rs index c8bdd351..6c7e825e 100644 --- a/crates/ui-egui/src/ribbon/title_bar.rs +++ b/crates/ui-egui/src/ribbon/title_bar.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Align2, Icon, Rect, Sense, SlideApp, Tokens, Ui, icon_toggle, json, pos2, theme, vec2}; pub const TITLE_H: f32 = 38.0; diff --git a/crates/ui-egui/src/ribbon/transitions.rs b/crates/ui-egui/src/ribbon/transitions.rs index 0e6ad70d..3f43ebd9 100644 --- a/crates/ui-egui/src/ribbon/transitions.rs +++ b/crates/ui-egui/src/ribbon/transitions.rs @@ -1,5 +1,8 @@ use super::animations::marked_options; -use super::*; +use super::{ + Align2, Color32, CornerRadius, Icon, Rect, Sense, SlideApp, Stroke, Tokens, Ui, Value, big, big_button, group, json, pos2, rows, run, theme, + vec2, widgets, +}; // ---------- Transitions ---------- diff --git a/crates/ui-egui/src/ribbon/view.rs b/crates/ui-egui/src/ribbon/view.rs index 8231f9a6..c67f1c80 100644 --- a/crates/ui-egui/src/ribbon/view.rs +++ b/crates/ui-egui/src/ribbon/view.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Icon, SlideApp, Target, Ui, big_button, big_button_ex, group, json, rows, run, theme}; pub(super) fn view(app: &mut SlideApp, ui: &mut Ui) { use crate::ViewMode; diff --git a/crates/ui-egui/src/show/keys.rs b/crates/ui-egui/src/show/keys.rs index 71322923..c7da76e2 100644 --- a/crates/ui-egui/src/show/keys.rs +++ b/crates/ui-egui/src/show/keys.rs @@ -1,6 +1,6 @@ use super::presenter::NOTES_STEP; use super::zoom::{plain, toggle_blank}; -use super::*; +use super::{Color32, Presentation, Show, ShowAction}; /// Read the current viewport's show keys before its widgets can consume them. Both the /// audience window and Presenter View use this path, including animation steps and end-of-show. diff --git a/crates/ui-egui/src/show/media.rs b/crates/ui-egui/src/show/media.rs index 72838396..ee8b1dec 100644 --- a/crates/ui-egui/src/show/media.rs +++ b/crates/ui-egui/src/show/media.rs @@ -1,5 +1,5 @@ use super::render::fit; -use super::*; +use super::{Click, HitShape, Hyperlink, Presentation, Rect, ShapeId, Show, SlideApp, Ui, pos2, vec2}; /// The media-host voice for slide transition sounds (no shape has this id). pub(crate) const TRANSITION_SOUND: ShapeId = ShapeId(u32::MAX); diff --git a/crates/ui-egui/src/show/presenter.rs b/crates/ui-egui/src/show/presenter.rs index 17f78fa5..4b0c2529 100644 --- a/crates/ui-egui/src/show/presenter.rs +++ b/crates/ui-egui/src/show/presenter.rs @@ -1,7 +1,7 @@ use super::keys::keyboard; use super::render::{fit, next_rect}; use super::zoom::{Zoom, grid_indices, paint_grid, see_all_hit, slide_aspect, toggle_blank, zoom_frame}; -use super::*; +use super::{Align2, Color32, CornerRadius, Presentation, Rect, RenderOpts, Sense, Show, Stroke, Ui, pos2, theme, vec2}; pub(super) const NOTES_DEFAULT: f32 = 18.0; diff --git a/crates/ui-egui/src/show/render.rs b/crates/ui-egui/src/show/render.rs index bf278064..d6de7a9f 100644 --- a/crates/ui-egui/src/show/render.rs +++ b/crates/ui-egui/src/show/render.rs @@ -1,5 +1,8 @@ use super::zoom::GRID_CAP; -use super::*; +use super::{ + BackKey, Color32, Key, Layer, Mesh, Prefetch, Presentation, Rect, RenderOpts, ShapeId, Show, ShowState, StepStamp, TextureHandle, Timeline, + TriggerPlay, pos2, state_for, vec2, +}; pub(super) fn render_threads() -> u16 { if cfg!(target_arch = "wasm32") { 0 } else { std::thread::available_parallelism().map(|n| n.get().min(8) as u16).unwrap_or(0) } diff --git a/crates/ui-egui/src/show/ui.rs b/crates/ui-egui/src/show/ui.rs index e769d66f..7f850fac 100644 --- a/crates/ui-egui/src/show/ui.rs +++ b/crates/ui-egui/src/show/ui.rs @@ -8,7 +8,49 @@ use super::zoom::{ Zoom, grid_indices, norm_on_slide, paint_grid, see_all_hit, slide_aspect, slide_box, slide_to_screen, toggle_blank, tool_bar_rect, unit_uv, wheel_zoom_lines, }; -use super::*; +use super::{ + Align2, BackKey, Click, Color32, CornerRadius, Key, LinkJump, Pos2, Rect, RenderOpts, Sense, ShapeId, ShapeState, Show, ShowAction, SlideApp, + Source, Stroke, TextureHandle, Ui, json, pos2, show_fills_screen, state_for, theme, vec2, viewport_visible, +}; +use deckcraft_anim::{StepStamp, TriggerPlay}; +use deckcraft_model::Presentation; + +/// Per-frame values the show's draw steps share. +#[derive(Clone, Copy)] +struct Fr<'a> { + ctx: &'a egui::Context, + doc: &'a std::sync::Arc, + doc_uid: u64, + now: f64, + full: Rect, + srect: Rect, + clicked: bool, + secondary: bool, + pointer: Option, +} + +/// Whether the show goes on, and the steps input asked for. +struct Out { + keep: bool, + actions: Vec, +} + +/// What is drawn on the current slide this frame. +struct Sl { + idx: usize, + step: usize, + animating: bool, + t: f64, + plays: Vec, + stamps: Vec, + media: Vec<(ShapeId, deckcraft_geom::Xfrm, deckcraft_model::MediaClip)>, + hidden: Vec, + times: Vec<(ShapeId, f64)>, + gifs: Vec, + key: Key, + back_key: BackKey, + size: (u32, u32), +} /// Draw and advance the active show, handling hidden frames, timing, input, transitions, and /// presenter/reading presentation. @@ -24,20 +66,51 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) { // A forced hidden paint (for example a pending screenshot) draws the current frame; // the show clocks are advanced by logic, not by this draw, so it must not advance them a // second time. Keep drawing the last frame rather than returning a blank. - if show.started { - let full = ui.max_rect(); - let avail = if show.reading { Rect::from_min_max(full.min, pos2(full.max.x, full.max.y - 34.0)) } else { full }; - let srect = fit(avail, &doc); - let painter = ui.painter_at(full); - painter.rect_filled(full, CornerRadius::ZERO, Color32::BLACK); - if let Some((_, tex)) = &show.tex { - painter.image(tex.id(), srect, unit_uv(), Color32::WHITE); - } - } + paint_hidden(ui, &doc, &show); app.show = Some(show); return; } show.observe_visible_frame(now, web_time::Instant::now()); + begin_show(&ctx, &mut show, &doc, now); + let mut out = Out { keep: keyboard(&ctx, &mut show, &doc, now, false), actions: vec![] }; + let (clicked, secondary, moved) = ctx.input(|i| (i.pointer.primary_clicked(), i.pointer.secondary_clicked(), i.pointer.delta().length() > 0.0)); + if moved { + show.last_move = now; + } + let full = ui.max_rect(); + let avail = if show.reading { Rect::from_min_max(full.min, pos2(full.max.x, full.max.y - 34.0)) } else { full }; + let srect = fit(avail, &doc); + let pointer = ctx.input(|i| i.pointer.interact_pos()); + show.expire_goto(now); + // The clock of the triggered animations runs before anything reacts to the pointer. + show.tick_triggers(&doc, now); + let f = Fr { ctx: &ctx, doc: &doc, doc_uid, now, full, srect, clicked, secondary, pointer }; + let pointing = handle_clicks(app, ui, &mut show, &f, &mut out); + handle_wheel_and_swipe(&mut show, &f, &mut out); + advance(app, &mut show, &f, &mut out); + let painter = ui.painter_at(full); + painter.rect_filled(full, CornerRadius::ZERO, Color32::BLACK); + draw_slide(app, ui, &mut show, &f, &painter); + draw_pen_and_hints(ui, &mut show, &f, &painter, pointing); + draw_bars(ui, &mut show, &f, &painter, &mut out); + draw_extras(&mut show, &f, &painter, &mut out); + finish(app, show, &f, out.keep); +} + +fn paint_hidden(ui: &Ui, doc: &Presentation, show: &Show) { + if show.started { + let full = ui.max_rect(); + let avail = if show.reading { Rect::from_min_max(full.min, pos2(full.max.x, full.max.y - 34.0)) } else { full }; + let srect = fit(avail, doc); + let painter = ui.painter_at(full); + painter.rect_filled(full, CornerRadius::ZERO, Color32::BLACK); + if let Some((_, tex)) = &show.tex { + painter.image(tex.id(), srect, unit_uv(), Color32::WHITE); + } + } +} + +fn begin_show(ctx: &egui::Context, show: &mut Show, doc: &Presentation, now: f64) { if !show.started { show.started = true; show.slide_start = now; @@ -51,21 +124,12 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) { if show_fills_screen(show.reading, &doc.show.show_type) { ctx.send_viewport_cmd(egui::ViewportCommand::Fullscreen(true)); } - show.after_enter(&doc, now); + show.after_enter(doc, now); } - let mut keep = keyboard(&ctx, &mut show, &doc, now, false); - let (clicked, secondary, moved) = ctx.input(|i| (i.pointer.primary_clicked(), i.pointer.secondary_clicked(), i.pointer.delta().length() > 0.0)); - if moved { - show.last_move = now; - } - let mut actions: Vec = vec![]; - let full = ui.max_rect(); - let avail = if show.reading { Rect::from_min_max(full.min, pos2(full.max.x, full.max.y - 34.0)) } else { full }; - let srect = fit(avail, &doc); - let pointer = ctx.input(|i| i.pointer.interact_pos()); - show.expire_goto(now); - // The clock of the triggered animations runs before anything reacts to the pointer. - show.tick_triggers(&doc, now); +} + +fn handle_clicks(app: &mut SlideApp, ui: &mut Ui, show: &mut Show, f: &Fr, out: &mut Out) -> bool { + let Fr { ctx, doc, doc_uid, now, full, srect, clicked, secondary, pointer } = *f; let on_slide = !show.grid && !show.pen && !show.laser @@ -73,31 +137,28 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) { && show.blank.is_none() && show.zoom.is_none() && !show.zoom_arm - && !show.reading_bar_hit(&ctx) - && !pointer_on_media_bar(&ctx, ui, &doc, &show, now); + && !show.reading_bar_hit(ctx) + && !pointer_on_media_bar(ctx, ui, doc, show, now); // The button is drawn only while the pointer is awake; a hidden button takes no click. - let gif_click = if clicked && on_slide && doc.show.show_media_controls && show.pointer_awake(now) { - gif_hit(&ctx, ui, &show, doc_uid, &doc, app) - } else { - None - }; - let media_click = if clicked && on_slide && gif_click.is_none() { media_hit(&ctx, ui, &show, &doc) } else { None }; - let under = if on_slide && gif_click.is_none() && media_click.is_none() { shapes_under_pointer(&ctx, ui, &mut show, &doc, now) } else { None }; + let gif_click = + if clicked && on_slide && doc.show.show_media_controls && show.pointer_awake(now) { gif_hit(ctx, ui, show, doc_uid, doc, app) } else { None }; + let media_click = if clicked && on_slide && gif_click.is_none() { media_hit(ctx, ui, show, doc) } else { None }; + let under = if on_slide && gif_click.is_none() && media_click.is_none() { shapes_under_pointer(ctx, ui, show, doc, now) } else { None }; let pointing = under.as_ref().is_some_and(|u| u.pick.is_some()); if let Some(id) = gif_click { let _ = app.run("media.gifPlay", json!({"id": id})); } else if let Some((id, clip)) = media_click { - app.media.toggle(doc_uid, &doc, id, &clip, crate::media::Owner::Show); + app.media.toggle(doc_uid, doc, id, &clip, crate::media::Owner::Show); } else if clicked && let Some(Pick::Text(l)) = under.as_ref().and_then(|u| u.pick.clone()) { let last = show.last_viewed; - match show.state.follow_link(&doc, &l, last) { - LinkJump::Show(a) => actions.push(a), + match show.state.follow_link(doc, &l, last) { + LinkJump::Show(a) => out.actions.push(a), LinkJump::Open(url) => ctx.open_url(egui::OpenUrl::new_tab(url)), - LinkJump::Exit => keep = false, + LinkJump::Exit => out.keep = false, LinkJump::None => {} } } else if show.grid && clicked { - let aspect = slide_aspect(&doc); + let aspect = slide_aspect(doc); if let Some(p) = pointer && let Some(n) = see_all_hit(full, grid_indices(doc.slides.len()).len(), aspect, p) && let Some(&i) = grid_indices(doc.slides.len()).get(n) @@ -106,36 +167,41 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) { show.blank = None; show.zoom = None; show.zoom_arm = false; - actions.push(show.state.goto(&doc, i)); + out.actions.push(show.state.goto(doc, i)); } - } else if clicked && show.zoom_arm && !show.reading_bar_hit(&ctx) { + } else if clicked && show.zoom_arm && !show.reading_bar_hit(ctx) { if let Some(p) = pointer && let Some((nx, ny)) = norm_on_slide(srect, unit_uv(), p) { show.zoom = Some(Zoom::at(nx, ny)); show.zoom_arm = false; } - } else if clicked && show.zoom.is_some() && !show.pen && !show.reading_bar_hit(&ctx) { + } else if clicked && show.zoom.is_some() && !show.pen && !show.reading_bar_hit(ctx) { show.dismiss_zoom(); - } else if clicked && !show.pen && !show.reading_bar_hit(&ctx) && !pointer_on_media_bar(&ctx, ui, &doc, &show, now) { + } else if clicked && !show.pen && !show.reading_bar_hit(ctx) && !pointer_on_media_bar(ctx, ui, doc, show, now) { if show.ended { - keep = false; + out.keep = false; } else if show.blank.is_some() { show.blank = None; } else { // A shape with an action or a triggered animation, or the background. let none = Under::default(); - match show.click_slide(&doc, under.as_ref().unwrap_or(&none), now) { - Click::Show(a) => actions.push(a), + match show.click_slide(doc, under.as_ref().unwrap_or(&none), now) { + Click::Show(a) => out.actions.push(a), Click::Open(url) => ctx.open_url(egui::OpenUrl::new_tab(url)), - Click::Exit => keep = false, + Click::Exit => out.keep = false, Click::Animated | Click::None => {} } } } if secondary && !show.ended && !show.grid && show.zoom.is_none() && !show.zoom_arm { - actions.push(show.state.prev(&doc)); + out.actions.push(show.state.prev(doc)); } + pointing +} + +fn handle_wheel_and_swipe(show: &mut Show, f: &Fr, out: &mut Out) { + let Fr { ctx, doc, now, srect, pointer, .. } = *f; // Ctrl+wheel (Cmd+wheel on a Mac) zooms around the pointer, as in PowerPoint (#1286). egui // keeps it out of `smooth_scroll_delta`, so it does not also turn the page below. let line_points = ctx.options(|o| o.input_options.line_scroll_speed); @@ -156,45 +222,303 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) { show.last_swipe = now; if next { if show.ended { - keep = false; + out.keep = false; } else { - actions.push(show.state.next(&doc)); + out.actions.push(show.state.next(doc)); } } else { show.ended = false; - actions.push(show.state.prev(&doc)); + out.actions.push(show.state.prev(doc)); } } +} + +fn advance(app: &mut SlideApp, show: &mut Show, f: &Fr, out: &mut Out) { + let Fr { doc, doc_uid, now, .. } = *f; // Apply input first: a step started by this click must restart the clock before it is read. - for a in actions.drain(..) { - if !show.apply(a, &doc, now) { - keep = false; + for a in out.actions.drain(..) { + if !show.apply(a, doc, now) { + out.keep = false; } } // Step clock, shared with the hidden logic tick. - if !show.queue_clock_actions(&doc, now, &mut actions) { - keep = false; + if !show.queue_clock_actions(doc, now, &mut out.actions) { + out.keep = false; } - for a in actions { - if !show.apply(a, &doc, now) { - keep = false; + for a in std::mem::take(&mut out.actions) { + if !show.apply(a, doc, now) { + out.keep = false; } } if show.state.playing { let step = show.state.step; - show.cue_media(&doc, step, now - show.step_start); + show.cue_media(doc, step, now - show.step_start); } - show_media(app, &mut show, doc_uid, &doc); - run_media_cues(app, &mut show, doc_uid, &doc); - // Draw. - let painter = ui.painter_at(full); - painter.rect_filled(full, CornerRadius::ZERO, Color32::BLACK); + show_media(app, show, doc_uid, doc); + run_media_cues(app, show, doc_uid, doc); +} + +fn begin_slide(app: &mut SlideApp, show: &Show, f: &Fr, size: (u32, u32)) -> Sl { + let Fr { ctx, doc, doc_uid, now, .. } = *f; + let idx = show.state.slide; + let step = show.state.step; + // A main step or a triggered animation is running: the picture changes every frame. + let animating = show.state.playing || show.state.trigger_playing(); + let t = if show.state.playing { now - show.step_start } else { 0.0 }; + let plays = show.state.trigger_plays(); + let stamps = show.state.trigger_stamps(); + let media = crate::media::slide_media(doc, idx); + let hidden: Vec = media + .iter() + .filter(|(id, _, clip)| clip.hide_while_not_playing && !app.media.status(*id).is_some_and(|s| s.state != deckcraft_media::PlayState::Ended)) + .map(|m| m.0) + .collect(); + let gifs = app.media.gifs(doc_uid, doc, idx, now, &app.session.gif_paused); + let times: Vec<_> = gifs.iter().map(|g| (g.id, g.t)).collect(); + crate::media::gif_repaint(ctx, &gifs); + let gif = crate::media::gif_key(&gifs); + let done: Vec<(ShapeId, usize)> = plays.iter().map(|p| (p.trigger, p.step)).collect(); + let key = Key { slide: idx, step, size, doc: std::sync::Arc::as_ptr(doc) as usize, hidden: hidden.clone(), plays: done, gif }; + let back_key = BackKey { slide: idx, size, doc: key.doc }; + Sl { idx, step, animating, t, plays, stamps, media, hidden, times, gifs, key, back_key, size } +} + +fn frame_texture(show: &mut Show, f: &Fr, sl: &Sl) -> Option { + let Fr { ctx, doc, .. } = *f; + let Sl { idx, step, animating, size, .. } = *sl; + if !animating && show.tex.as_ref().is_some_and(|(k, _)| *k == sl.key) { + show.tex.as_ref().map(|(_, t)| t.clone()) + } else { + // The frame rendered ahead of time, when it is this one (waiting for it beats starting over). + let ready = match show.prefetch.take() { + Some(p) if p.key == sl.key && !animating => p.rx.recv().ok(), + _ => None, + }; + let ci = match ready { + Some(ci) => ci, + None => { + let tl = show.timeline(doc).clone(); + // Animating: the backdrop doesn't change, only the shapes over it are drawn. + let back = animating.then(|| show.backdrop(doc, sl.back_key)); + render_frame(doc, idx, tl, step, sl.t, size, sl.hidden.clone(), &sl.times, render_threads(), &sl.plays, &sl.stamps, back.as_deref()) + } + }; + let tex = match show.tex.take() { + Some((_, mut h)) => { + crate::textures::replace(ctx, &mut h, ci); + h + } + None => crate::textures::upload(ctx, "show", ci), + }; + show.tex = Some((sl.key.clone(), tex.clone())); + Some(tex) + } +} + +fn prefetch_next(app: &mut SlideApp, show: &mut Show, f: &Fr, sl: &Sl) { + let Fr { doc, doc_uid, now, .. } = *f; + let Sl { animating, size, .. } = *sl; + // While this slide rests, render the next one's first frame (not one with GIFs, whose + // frame depends on when it is entered). + // A slide with animations gets its backdrop ready while it rests, so they start smoothly. + if !animating && !show.timeline(doc).is_empty() { + show.prefetch_backdrop(doc, sl.back_key); + } + if !animating && show.trans.is_none() && !show.preview_only { + let next = show.state.peek_next(doc); + let wanted = next.filter(|n| app.media.gifs(doc_uid, doc, *n, now, &app.session.gif_paused).is_empty()); + if let Some(n) = wanted + && show.prefetch.as_ref().is_none_or(|p| p.key.slide != n || p.key.size != size || p.key.doc != std::sync::Arc::as_ptr(doc) as usize) + { + show.prefetch = start_prefetch(doc, n, size); + } + } +} + +fn draw_transition(show: &mut Show, f: &Fr, sl: &Sl, painter: &egui::Painter, tex: &Option) -> bool { + let Fr { ctx, doc, now, srect, .. } = *f; + let Sl { idx, size, .. } = *sl; + let mut drew = false; + let morphing = show.trans.as_ref().is_some_and(|tr| tr.kind == "morph" && tr.from.is_some() && now - tr.start < tr.dur); + let tl = if morphing { Some(show.timeline(doc).clone()) } else { None }; + let morph = match (&show.trans, tl) { + (Some(tr), Some(tl)) => { + let p = ((now - tr.start) / tr.dur.max(0.01)).clamp(0.0, 1.0); + let hidden_b = |id: ShapeId| { + let st = state_for(&tl, id, 0, 0.0, &[], &[]); + !st.visible || st.opacity <= 0.0 || sl.hidden.contains(&id) + }; + tr.from + .and_then(|i| doc.slides.get(i).map(|a| (i, a))) + .zip(doc.slides.get(idx)) + .map(|((i, a), b)| (i, deckcraft_anim::morph_frame(doc, a, b, p, &hidden_b))) + } + _ => None, + }; + if let (Some((from, m)), Some(tr)) = (morph, show.trans.as_mut()) { + let f = |id: ShapeId| ShapeState { opacity: m.opacity_of(id), ..Default::default() }; + let threads = if cfg!(target_arch = "wasm32") { 0 } else { std::thread::available_parallelism().map(|n| n.get().min(8) as u16).unwrap_or(0) }; + let opts = + RenderOpts { scale: size.0 as f64 / doc.slide_size.width.max(1.0), size: Some(size), state: Some(&f), threads, ..Default::default() }; + let text: Vec = + m.text.iter().map(|x| deckcraft_render::TextMorph { old: x.old, new: x.new, from: x.from, to: x.to, chars: x.chars, t: m.mix }).collect(); + let paths: Vec = m + .paths + .iter() + .map(|x| deckcraft_render::PathMorph { id: x.id, from: x.from.clone(), from_box: x.from_box, to_box: x.to_box, t: m.mix }) + .collect(); + let img = deckcraft_render::render_blend(doc, (from, idx), m.mix, &m.shapes, &text, &paths, &opts); + let ci = crate::textures::to_color_image(&img, false); + let frame = match tr.morph.take() { + Some(mut h) => { + crate::textures::replace(ctx, &mut h, ci); + h + } + None => crate::textures::upload(ctx, "morph", ci), + }; + painter.image(frame.id(), srect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); + tr.morph = Some(frame); + drew = true; + ctx.request_repaint(); + } else if let (Some(tr), Some(new)) = (show.trans.as_mut(), &tex) { + let p = ((now - tr.start) / tr.dur.max(0.01)).clamp(0.0, 1.0); + if p < 1.0 { + // Slides split into background and content are rendered once per transition; + // every frame only composites cached textures. + if tr.parts.is_none() && deckcraft_anim::uses_slide_parts(&tr.kind, &tr.option) { + tr.parts = Some(part_textures(ctx, doc, tr.from, idx, size)); + } + let aspect = doc.slide_size.width / doc.slide_size.height.max(1.0); + let part = |k: usize| tr.parts.as_ref().and_then(|p| p.get(k).cloned().flatten()); + let parts = [part(0), part(1), part(2), part(3)]; + for l in deckcraft_anim::transition_frame(&tr.kind, &tr.option, p, aspect) { + let old_or_black = |t: Option<&TextureHandle>| match t { + Some(o) => quad_mesh(Some(o.id()), srect, &l, Color32::WHITE), + None => quad_mesh(None, srect, &l, Color32::BLACK), + }; + let mesh = match l.source { + Source::Old => old_or_black(tr.old.as_ref()), + Source::New => quad_mesh(Some(new.id()), srect, &l, Color32::WHITE), + Source::Black => quad_mesh(None, srect, &l, Color32::BLACK), + Source::White => quad_mesh(None, srect, &l, Color32::WHITE), + Source::OldBack => old_or_black(parts[0].as_ref().or(tr.old.as_ref())), + Source::NewBack => quad_mesh(Some(parts[2].as_ref().unwrap_or(new).id()), srect, &l, Color32::WHITE), + Source::OldContent => match &parts[1] { + Some(t) => quad_mesh(Some(t.id()), srect, &l, Color32::WHITE), + None => continue, + }, + Source::NewContent => match &parts[3] { + Some(t) => quad_mesh(Some(t.id()), srect, &l, Color32::WHITE), + None => continue, + }, + }; + painter.add(egui::Shape::mesh(mesh)); + } + drew = true; + ctx.request_repaint(); + } + } + drew +} + +fn draw_videos(app: &mut SlideApp, show: &mut Show, f: &Fr, sl: &Sl, painter: &egui::Painter, tex: &Option) -> (Vec, bool) { + let Fr { doc, full, srect, .. } = *f; + if let Some(tex) = &tex { + painter.image(tex.id(), srect, show.zoom_uv(), Color32::WHITE); + } + // Playing video: the current frame in its box, or full screen. + let (sw, sh) = (doc.slide_size.width as f32, doc.slide_size.height as f32); + let uv = show.zoom_uv(); + let mut framed: Vec = vec![]; + let mut full_screen = false; + for (id, x, clip) in &sl.media { + if !clip.video || sl.hidden.contains(id) { + continue; + } + let r = if clip.full_screen && app.media.is_playing(*id) { + full_screen = true; + full + } else { + slide_box(srect, uv, (sw, sh), (x.x as f32, x.y as f32, x.w as f32, x.h as f32)) + }; + if crate::media::paint_frame(painter, &app.media, *id, r, clip.crop) { + framed.push(*id); + } + } + (framed, full_screen) +} + +fn draw_overlay(show: &mut Show, f: &Fr, sl: &Sl, painter: &egui::Painter, framed: &[ShapeId], full_screen: bool) { + let Fr { ctx, doc, srect, .. } = *f; + let Sl { idx, step, animating, size, .. } = *sl; + let uv = show.zoom_uv(); + // Shapes stacked above a playing video show over it, as in PowerPoint (#1005); a video + // playing full screen covers the whole slide. + let skip = if full_screen { vec![] } else { below_playing_video(doc, idx, framed) }; + if skip.is_empty() { + show.overlay = None; + } else { + let fresh = !animating && show.overlay.as_ref().is_some_and(|(k, s, _)| *k == sl.key && *s == skip); + if !fresh { + let tl = show.timeline(doc).clone(); + let ci = render_overlay(doc, idx, tl, step, sl.t, size, sl.hidden.clone(), &skip, &sl.times, render_threads(), &sl.plays, &sl.stamps); + let handle = match show.overlay.take() { + Some((_, _, mut h)) => { + crate::textures::replace(ctx, &mut h, ci); + h + } + None => crate::textures::upload(ctx, "show-overlay", ci), + }; + show.overlay = Some((sl.key.clone(), skip, handle)); + } + if let Some((_, _, h)) = &show.overlay { + painter.image(h.id(), srect, uv, Color32::WHITE); + } + } +} + +fn draw_media_controls(app: &mut SlideApp, ui: &mut Ui, show: &mut Show, f: &Fr, sl: &Sl, painter: &egui::Painter) { + let Fr { ctx, doc, doc_uid, now, srect, .. } = *f; + let sw = doc.slide_size.width as f32; + // Play/pause on the animated GIF under the pointer, and the audio/video bar. + // Slide Show ▸ Show Media Controls hides both (the click still sl.plays a clip). + // Not while zoomed: the controls sit where the whole slide would be. + if doc.show.show_media_controls && !show.zoom_arm && show.zoom.is_none() { + let k = srect.width() / sw.max(1.0); + if let Some(p) = ctx.input(|i| i.pointer.hover_pos()).filter(|_| !show.pen && !show.laser && show.pointer_awake(now)) { + for g in &sl.gifs { + let r = on_screen(srect, k, &g.rect); + if r.contains(p) { + let b = crate::media::gif_button_rect(r); + crate::media::paint_gif_button(painter, b, g.paused, b.contains(p)); + } + } + } + if let Some((id, clip, bar)) = media_bar_target(ctx, doc, show, srect).filter(|_| show.pointer_awake(now)) { + ctx.request_repaint_after(std::time::Duration::from_millis(500)); + let action = { + let status = app.media.status(id); + crate::media::control_bar(ui, bar, status.as_ref(), &clip) + }; + let pos = app.media.status(id).map(|s| s.position).unwrap_or(0.0); + match action { + Some(crate::media::BarAction::Toggle) => app.media.toggle(doc_uid, doc, id, &clip, crate::media::Owner::Show), + Some(crate::media::BarAction::Seek(t)) => app.media.seek(doc_uid, doc, id, &clip, t, crate::media::Owner::Show), + Some(crate::media::BarAction::Nudge(dt)) => app.media.seek(doc_uid, doc, id, &clip, (pos + dt).max(0.0), crate::media::Owner::Show), + None => {} + } + } + } +} + +fn draw_slide(app: &mut SlideApp, ui: &mut Ui, show: &mut Show, f: &Fr, painter: &egui::Painter) { + let Fr { ctx, full, srect, .. } = *f; let ppp = ctx.pixels_per_point(); // A huge screen or DPI mustn't ask for a picture the GPU can't hold: render at most its limit // (drawn stretched to the screen). let size = crate::textures::fit_size( ((srect.width() * ppp).round().clamp(1.0, u32::MAX as f32) as u32, (srect.height() * ppp).round().clamp(1.0, u32::MAX as f32) as u32), - crate::textures::max_side(&ctx), + crate::textures::max_side(ctx), ); if show.ended { painter.text( @@ -205,237 +529,23 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) { Color32::from_gray(200), ); } else { - let idx = show.state.slide; - let step = show.state.step; - // A main step or a triggered animation is running: the picture changes every frame. - let animating = show.state.playing || show.state.trigger_playing(); - let t = if show.state.playing { now - show.step_start } else { 0.0 }; - let plays = show.state.trigger_plays(); - let stamps = show.state.trigger_stamps(); - let media = crate::media::slide_media(&doc, idx); - let hidden: Vec = media - .iter() - .filter(|(id, _, clip)| { - clip.hide_while_not_playing && !app.media.status(*id).is_some_and(|s| s.state != deckcraft_media::PlayState::Ended) - }) - .map(|m| m.0) - .collect(); - let gifs = app.media.gifs(doc_uid, &doc, idx, now, &app.session.gif_paused); - let times: Vec<_> = gifs.iter().map(|g| (g.id, g.t)).collect(); - crate::media::gif_repaint(&ctx, &gifs); - let gif = crate::media::gif_key(&gifs); - let done: Vec<(ShapeId, usize)> = plays.iter().map(|p| (p.trigger, p.step)).collect(); - let key = Key { slide: idx, step, size, doc: std::sync::Arc::as_ptr(&doc) as usize, hidden: hidden.clone(), plays: done, gif }; - let back_key = BackKey { slide: idx, size, doc: key.doc }; - let tex = if !animating && show.tex.as_ref().is_some_and(|(k, _)| *k == key) { - show.tex.as_ref().map(|(_, t)| t.clone()) - } else { - // The frame rendered ahead of time, when it is this one (waiting for it beats starting over). - let ready = match show.prefetch.take() { - Some(p) if p.key == key && !animating => p.rx.recv().ok(), - _ => None, - }; - let ci = match ready { - Some(ci) => ci, - None => { - let tl = show.timeline(&doc).clone(); - // Animating: the backdrop doesn't change, only the shapes over it are drawn. - let back = animating.then(|| show.backdrop(&doc, back_key)); - render_frame(&doc, idx, tl, step, t, size, hidden.clone(), ×, render_threads(), &plays, &stamps, back.as_deref()) - } - }; - let tex = match show.tex.take() { - Some((_, mut h)) => { - crate::textures::replace(&ctx, &mut h, ci); - h - } - None => crate::textures::upload(&ctx, "show", ci), - }; - show.tex = Some((key.clone(), tex.clone())); - Some(tex) - }; - // While this slide rests, render the next one's first frame (not one with GIFs, whose - // frame depends on when it is entered). - // A slide with animations gets its backdrop ready while it rests, so they start smoothly. - if !animating && !show.timeline(&doc).is_empty() { - show.prefetch_backdrop(&doc, back_key); - } - if !animating && show.trans.is_none() && !show.preview_only { - let next = show.state.peek_next(&doc); - let wanted = next.filter(|n| app.media.gifs(doc_uid, &doc, *n, now, &app.session.gif_paused).is_empty()); - if let Some(n) = wanted - && show.prefetch.as_ref().is_none_or(|p| p.key.slide != n || p.key.size != size || p.key.doc != std::sync::Arc::as_ptr(&doc) as usize) - { - show.prefetch = start_prefetch(&doc, n, size); - } - } - let mut drew = false; - let morphing = show.trans.as_ref().is_some_and(|tr| tr.kind == "morph" && tr.from.is_some() && now - tr.start < tr.dur); - let tl = if morphing { Some(show.timeline(&doc).clone()) } else { None }; - let morph = match (&show.trans, tl) { - (Some(tr), Some(tl)) => { - let p = ((now - tr.start) / tr.dur.max(0.01)).clamp(0.0, 1.0); - let hidden_b = |id: ShapeId| { - let st = state_for(&tl, id, 0, 0.0, &[], &[]); - !st.visible || st.opacity <= 0.0 || hidden.contains(&id) - }; - tr.from - .and_then(|i| doc.slides.get(i).map(|a| (i, a))) - .zip(doc.slides.get(idx)) - .map(|((i, a), b)| (i, deckcraft_anim::morph_frame(&doc, a, b, p, &hidden_b))) - } - _ => None, - }; - if let (Some((from, m)), Some(tr)) = (morph, show.trans.as_mut()) { - let f = |id: ShapeId| ShapeState { opacity: m.opacity_of(id), ..Default::default() }; - let threads = - if cfg!(target_arch = "wasm32") { 0 } else { std::thread::available_parallelism().map(|n| n.get().min(8) as u16).unwrap_or(0) }; - let opts = - RenderOpts { scale: size.0 as f64 / doc.slide_size.width.max(1.0), size: Some(size), state: Some(&f), threads, ..Default::default() }; - let text: Vec = m - .text - .iter() - .map(|x| deckcraft_render::TextMorph { old: x.old, new: x.new, from: x.from, to: x.to, chars: x.chars, t: m.mix }) - .collect(); - let paths: Vec = m - .paths - .iter() - .map(|x| deckcraft_render::PathMorph { id: x.id, from: x.from.clone(), from_box: x.from_box, to_box: x.to_box, t: m.mix }) - .collect(); - let img = deckcraft_render::render_blend(&doc, from, idx, m.mix, &m.shapes, &text, &paths, &opts); - let ci = crate::textures::to_color_image(&img, false); - let frame = match tr.morph.take() { - Some(mut h) => { - crate::textures::replace(&ctx, &mut h, ci); - h - } - None => crate::textures::upload(&ctx, "morph", ci), - }; - painter.image(frame.id(), srect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); - tr.morph = Some(frame); - drew = true; - ctx.request_repaint(); - } else if let (Some(tr), Some(new)) = (show.trans.as_mut(), &tex) { - let p = ((now - tr.start) / tr.dur.max(0.01)).clamp(0.0, 1.0); - if p < 1.0 { - // Slides split into background and content are rendered once per transition; - // every frame only composites cached textures. - if tr.parts.is_none() && deckcraft_anim::uses_slide_parts(&tr.kind, &tr.option) { - tr.parts = Some(part_textures(&ctx, &doc, tr.from, idx, size)); - } - let aspect = doc.slide_size.width / doc.slide_size.height.max(1.0); - let part = |k: usize| tr.parts.as_ref().and_then(|p| p.get(k).cloned().flatten()); - let parts = [part(0), part(1), part(2), part(3)]; - for l in deckcraft_anim::transition_frame(&tr.kind, &tr.option, p, aspect) { - let old_or_black = |t: Option<&TextureHandle>| match t { - Some(o) => quad_mesh(Some(o.id()), srect, &l, Color32::WHITE), - None => quad_mesh(None, srect, &l, Color32::BLACK), - }; - let mesh = match l.source { - Source::Old => old_or_black(tr.old.as_ref()), - Source::New => quad_mesh(Some(new.id()), srect, &l, Color32::WHITE), - Source::Black => quad_mesh(None, srect, &l, Color32::BLACK), - Source::White => quad_mesh(None, srect, &l, Color32::WHITE), - Source::OldBack => old_or_black(parts[0].as_ref().or(tr.old.as_ref())), - Source::NewBack => quad_mesh(Some(parts[2].as_ref().unwrap_or(new).id()), srect, &l, Color32::WHITE), - Source::OldContent => match &parts[1] { - Some(t) => quad_mesh(Some(t.id()), srect, &l, Color32::WHITE), - None => continue, - }, - Source::NewContent => match &parts[3] { - Some(t) => quad_mesh(Some(t.id()), srect, &l, Color32::WHITE), - None => continue, - }, - }; - painter.add(egui::Shape::mesh(mesh)); - } - drew = true; - ctx.request_repaint(); - } - } - if !drew { + let sl = begin_slide(app, show, f, size); + let tex = frame_texture(show, f, &sl); + prefetch_next(app, show, f, &sl); + if !draw_transition(show, f, &sl, painter, &tex) { show.trans = None; - if let Some(tex) = &tex { - painter.image(tex.id(), srect, show.zoom_uv(), Color32::WHITE); - } - // Playing video: the current frame in its box, or full screen. - let (sw, sh) = (doc.slide_size.width as f32, doc.slide_size.height as f32); - let uv = show.zoom_uv(); - let mut framed: Vec = vec![]; - let mut full_screen = false; - for (id, x, clip) in &media { - if !clip.video || hidden.contains(id) { - continue; - } - let r = if clip.full_screen && app.media.is_playing(*id) { - full_screen = true; - full - } else { - slide_box(srect, uv, sw, sh, x.x as f32, x.y as f32, x.w as f32, x.h as f32) - }; - if crate::media::paint_frame(&painter, &app.media, *id, r, clip.crop) { - framed.push(*id); - } - } - // Shapes stacked above a playing video show over it, as in PowerPoint (#1005); a video - // playing full screen covers the whole slide. - let skip = if full_screen { vec![] } else { below_playing_video(&doc, idx, &framed) }; - if skip.is_empty() { - show.overlay = None; - } else { - let fresh = !animating && show.overlay.as_ref().is_some_and(|(k, s, _)| *k == key && *s == skip); - if !fresh { - let tl = show.timeline(&doc).clone(); - let ci = render_overlay(&doc, idx, tl, step, t, size, hidden.clone(), &skip, ×, render_threads(), &plays, &stamps); - let handle = match show.overlay.take() { - Some((_, _, mut h)) => { - crate::textures::replace(&ctx, &mut h, ci); - h - } - None => crate::textures::upload(&ctx, "show-overlay", ci), - }; - show.overlay = Some((key.clone(), skip, handle)); - } - if let Some((_, _, h)) = &show.overlay { - painter.image(h.id(), srect, uv, Color32::WHITE); - } - } - // Play/pause on the animated GIF under the pointer, and the audio/video bar. - // Slide Show ▸ Show Media Controls hides both (the click still plays a clip). - // Not while zoomed: the controls sit where the whole slide would be. - if doc.show.show_media_controls && !show.zoom_arm && show.zoom.is_none() { - let k = srect.width() / sw.max(1.0); - if let Some(p) = ctx.input(|i| i.pointer.hover_pos()).filter(|_| !show.pen && !show.laser && show.pointer_awake(now)) { - for g in &gifs { - let r = on_screen(srect, k, &g.rect); - if r.contains(p) { - let b = crate::media::gif_button_rect(r); - crate::media::paint_gif_button(&painter, b, g.paused, b.contains(p)); - } - } - } - if let Some((id, clip, bar)) = media_bar_target(&ctx, &doc, &show, srect).filter(|_| show.pointer_awake(now)) { - ctx.request_repaint_after(std::time::Duration::from_millis(500)); - let action = { - let status = app.media.status(id); - crate::media::control_bar(ui, bar, status.as_ref(), &clip) - }; - let pos = app.media.status(id).map(|s| s.position).unwrap_or(0.0); - match action { - Some(crate::media::BarAction::Toggle) => app.media.toggle(doc_uid, &doc, id, &clip, crate::media::Owner::Show), - Some(crate::media::BarAction::Seek(t)) => app.media.seek(doc_uid, &doc, id, &clip, t, crate::media::Owner::Show), - Some(crate::media::BarAction::Nudge(dt)) => { - app.media.seek(doc_uid, &doc, id, &clip, (pos + dt).max(0.0), crate::media::Owner::Show) - } - None => {} - } - } - } + let (framed, full_screen) = draw_videos(app, show, f, &sl, painter, &tex); + draw_overlay(show, f, &sl, painter, &framed, full_screen); + draw_media_controls(app, ui, show, f, &sl, painter); } - if animating { + if sl.animating { ctx.request_repaint(); } } +} + +fn draw_pen_and_hints(ui: &mut Ui, show: &mut Show, f: &Fr, painter: &egui::Painter, pointing: bool) { + let Fr { ctx, doc, now, full, srect, .. } = *f; if let Some(c) = show.blank { painter.rect_filled(full, CornerRadius::ZERO, c); } @@ -500,6 +610,10 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) { Color32::WHITE, ); } +} + +fn draw_bars(ui: &mut Ui, show: &mut Show, f: &Fr, painter: &egui::Painter, out: &mut Out) { + let Fr { ctx, doc, now, full, .. } = *f; // Reading view bar / on-screen controls. if show.reading { let bar = Rect::from_min_max(pos2(full.min.x, full.max.y - 34.0), full.max); @@ -517,17 +631,17 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) { .layout(egui::Layout::right_to_left(egui::Align::Center)), ); if child.button("Close").clicked() { - keep = false; + out.keep = false; } if child.button("▶").clicked() { - let a = show.state.next(&doc); - if !show.apply(a, &doc, now) { - keep = false; + let a = show.state.next(doc); + if !show.apply(a, doc, now) { + out.keep = false; } } if child.button("◀").clicked() { - let a = show.state.prev(&doc); - show.apply(a, &doc, now); + let a = show.state.prev(doc); + show.apply(a, doc, now); } } else if !show.grid && (show.pointer_awake(now) || show.zoom_arm) { let bar = tool_bar_rect(full); @@ -536,13 +650,13 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) { ui.new_child(egui::UiBuilder::new().max_rect(bar.shrink2(vec2(10.0, 4.0))).layout(egui::Layout::left_to_right(egui::Align::Center))); let b = |ui: &mut Ui, s: &str| ui.add(egui::Button::new(egui::RichText::new(s).color(Color32::WHITE).size(15.0)).frame(false)); if b(&mut child, "◀").clicked() { - let a = show.state.prev(&doc); - show.apply(a, &doc, now); + let a = show.state.prev(doc); + show.apply(a, doc, now); } if b(&mut child, "▶").clicked() { - let a = show.state.next(&doc); - if !show.apply(a, &doc, now) { - keep = false; + let a = show.state.next(doc); + if !show.apply(a, doc, now) { + out.keep = false; } } if b(&mut child, if show.pen { "✏ on" } else { "✏" }).clicked() { @@ -552,10 +666,10 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) { show.set_laser(!show.laser); } if b(&mut child, "Black").clicked() { - toggle_blank(&mut show, Color32::BLACK); + toggle_blank(show, Color32::BLACK); } if b(&mut child, "White").clicked() { - toggle_blank(&mut show, Color32::WHITE); + toggle_blank(show, Color32::WHITE); } if b(&mut child, "All").clicked() { show.grid = !show.grid; @@ -564,12 +678,16 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) { show.zoom_key(); } if b(&mut child, "End").clicked() { - keep = false; + out.keep = false; } ctx.request_repaint_after(std::time::Duration::from_millis(300)); } +} + +fn draw_extras(show: &mut Show, f: &Fr, painter: &egui::Painter, out: &mut Out) { + let Fr { ctx, doc, now, full, .. } = *f; if show.grid && !show.ended { - paint_grid(&painter, &ctx, &mut show, &doc, full); + paint_grid(painter, ctx, show, doc, full); } if show.rehearse { let t = now - show.slide_start; @@ -586,9 +704,13 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) { ctx.request_repaint_after(std::time::Duration::from_millis(100)); } // Presenter view in a second window. - if keep && show.presenter && !show.reading { - presenter_window(&ctx, &mut show, &doc, now, &mut keep); + if out.keep && show.presenter && !show.reading { + presenter_window(ctx, show, doc, now, &mut out.keep); } +} + +fn finish(app: &mut SlideApp, mut show: Show, f: &Fr, keep: bool) { + let Fr { ctx, now, .. } = *f; if keep { app.show = Some(show); } else { diff --git a/crates/ui-egui/src/show/zoom.rs b/crates/ui-egui/src/show/zoom.rs index 97d306a4..9c45fd4a 100644 --- a/crates/ui-egui/src/show/zoom.rs +++ b/crates/ui-egui/src/show/zoom.rs @@ -1,6 +1,6 @@ use super::presenter::{NOTES_MAX, NOTES_MIN}; use super::render::render_thumb; -use super::*; +use super::{Align2, Color32, CornerRadius, Pos2, Presentation, Rect, Show, Stroke, json, pos2, theme, vec2}; impl Show { pub(super) fn zoom_uv(&self) -> Rect { @@ -344,7 +344,7 @@ pub(super) fn slide_to_screen(srect: Rect, uv: Rect, nx: f32, ny: f32) -> Pos2 { pos2(srect.min.x + u * srect.width(), srect.min.y + v * srect.height()) } -pub(super) fn slide_box(srect: Rect, uv: Rect, slide_w: f32, slide_h: f32, x: f32, y: f32, w: f32, h: f32) -> Rect { +pub(super) fn slide_box(srect: Rect, uv: Rect, (slide_w, slide_h): (f32, f32), (x, y, w, h): (f32, f32, f32, f32)) -> Rect { let a = slide_to_screen(srect, uv, x / slide_w.max(1.0), y / slide_h.max(1.0)); let b = slide_to_screen(srect, uv, (x + w) / slide_w.max(1.0), (y + h) / slide_h.max(1.0)); Rect::from_min_max(a, b) diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 616769bc..743d1196 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Deserialize, LayoutId, MasterId, Serialize, SlideId, Target, qat, theme}; /// The operation the user asked for. Kept with the request so a late completion does not /// guess a command from the file name. diff --git a/crates/ui-egui/src/textures.rs b/crates/ui-egui/src/textures.rs index c6643c20..19a4b4f6 100644 --- a/crates/ui-egui/src/textures.rs +++ b/crates/ui-egui/src/textures.rs @@ -65,15 +65,13 @@ impl Textures { } /// A SmartArt layout drawn small for a gallery (`nodes` for a preview of the selected - /// graphic's own outline), cached; drawn with the active presentation's theme. + /// graphic's own outline), cached; `look` is the layout kind, style and colours; drawn with the active presentation's theme. pub fn smartart( &mut self, ctx: &egui::Context, p: &deckcraft_model::Presentation, slide: usize, - kind: &str, - style: &str, - colors: &str, + (kind, style, colors): (&str, &str, &str), nodes: Option<&[deckcraft_model::DiagramNode]>, width_px: u32, ) -> Option {