diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 37445777..51c46b6c 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -12,29 +12,57 @@ use crate::widgets::{ self, RIBBON_H, big_button, big_button_ex, color_split, dropdown, group, icon_toggle, rows, small_button, small_button_ex, small_button_named, }; -pub const TITLE_H: f32 = 38.0; +mod animations; +mod arrange; +mod chart; +mod design; +mod draw; +mod galleries; +mod home; +mod insert; +mod masters; +mod picture_format; +mod playback; +mod review; +mod shape_format; +mod slide_show; +mod smartart; +mod table; +mod title_bar; +mod transitions; +mod view; -/// macOS draws the window into the title bar (`with_fullsize_content_view`) and AppKit paints the -/// traffic lights itself, 16 pt from the top of the window on macOS 27. Moving them would take -/// AppKit calls (`unsafe`, forbidden here), so on macOS the title bar is the band they are centred -/// in, with the controls beside them (#71). Up to macOS 15 the lights are smaller and centred 14 pt -/// from the top, about 2 pt above this bar's centre. -pub const MAC_TITLE_BAR: f32 = 32.0; -/// The traffic lights as AppKit lays them out, measured on macOS 27: button diameter, -/// centre-to-centre pitch and the first button's left edge. -const MAC_LIGHT: f32 = 14.0; -const MAC_LIGHT_PITCH: f32 = 23.0; -const MAC_LIGHTS_LEFT: f32 = 9.0; -/// Where title-bar content starts on macOS: the end of the three lights plus the gap the lights -/// keep from the top of the window. -pub const MAC_CONTENT_LEFT: f32 = MAC_LIGHTS_LEFT + 2.0 * MAC_LIGHT_PITCH + MAC_LIGHT + (MAC_TITLE_BAR - MAC_LIGHT) / 2.0; - -/// The title bar's height, the left edge of its content and the height of its tallest control. -/// With the integrated title bar (macOS) the bar is shorter, so the controls shrink to keep a -/// margin above and below them. -fn title_geometry(integrated: bool) -> (f32, f32, f32) { - if integrated { (MAC_TITLE_BAR, MAC_CONTENT_LEFT, 22.0) } else { (TITLE_H, 10.0, 26.0) } -} +use animations::animations; +pub use animations::paint_anim_tile; +#[cfg(test)] +use animations::{animation_effect_rows, marked_options, text_build_id}; +pub use arrange::{align_menu, arrange_menu}; +use chart::chart_design; +use design::design; +pub use design::paint_theme_tile; +use draw::draw; +pub use galleries::{cref_param, effects_menu, outline_menu, paint_preset, shapes_gallery}; +pub use home::font_families; +use home::home; +use insert::insert; +pub use insert::table_grid_picker; +use masters::{handout_master, notes_master, slide_master}; +use picture_format::picture_format; +use playback::playback; +use review::review; +use shape_format::shape_format; +use slide_show::{record, slide_show}; +use smartart::smartart_design; +pub use table::paint_table_style; +use table::{table_design, table_layout}; +pub use title_bar::{MAC_CONTENT_LEFT, MAC_TITLE_BAR, TITLE_H, title_bar}; +#[cfg(test)] +use title_bar::{MAC_LIGHT, MAC_LIGHT_PITCH, MAC_LIGHTS_LEFT, title_geometry}; +pub use transitions::paint_transition_tile; +#[cfg(test)] +use transitions::transition_effect_rows; +use transitions::transitions; +use view::view; /// Is engine command `id` enabled right now? /// Whether `id` names an engine or UI command. @@ -53,72 +81,6 @@ fn run(app: &mut SlideApp, id: &str, p: Value) { let _ = app.run(id, p); } -/// The window's title row: traffic-light space, quick access, centred title, actions. -pub fn title_bar(app: &mut SlideApp, ui: &mut Ui) { - let t = Tokens::get(ui.ctx()); - let (height, left, control_h) = title_geometry(app.integrated_titlebar); - egui::Panel::top("title_bar").exact_size(height).frame(egui::Frame::NONE.fill(t.chrome)).show(ui, |ui| { - let rect = ui.max_rect(); - // Dragging the empty title bar moves the window. - let drag = ui.interact(rect, ui.scope_id().with("titledrag"), Sense::click_and_drag()); - // The drag surface is not a button: a full-width Button would be announced as one. - drag.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Window, true, "Window title bar")); - if drag.drag_started() { - ui.ctx().send_viewport_cmd(egui::ViewportCommand::StartDrag); - } - if drag.double_clicked() { - let max = ui.input(|i| i.viewport().maximized.unwrap_or(false)); - ui.ctx().send_viewport_cmd(egui::ViewportCommand::Maximized(!max)); - } - let mut child = ui.new_child( - egui::UiBuilder::new() - .max_rect(Rect::from_min_max(pos2(rect.min.x + left, rect.min.y), pos2(rect.min.x + 420.0, rect.max.y))) - .layout(egui::Layout::left_to_right(egui::Align::Center)), - ); - child.spacing_mut().item_spacing.x = 2.0; - let has_doc = app.session.active().is_some(); - let dirty = app.session.active().is_some_and(|d| d.is_dirty()); - crate::qat::show(app, &mut child); - let title = - app.session.active().map(|d| format!("{}{}", d.title(), if dirty { " — Edited" } else { "" })).unwrap_or_else(|| "DeckCraft".into()); - ui.painter().text(rect.center(), Align2::CENTER_CENTER, title, theme::font(13.0), t.text); - // Right side. - let mut right = ui.new_child( - egui::UiBuilder::new() - .max_rect(Rect::from_min_max(pos2(rect.max.x - 320.0, rect.min.y), pos2(rect.max.x - 10.0, rect.max.y))) - .layout(egui::Layout::right_to_left(egui::Align::Center)), - ); - right.spacing_mut().item_spacing.x = 6.0; - if icon_toggle(&mut right, Icon::Search, "Search commands (⇧⌘P)", true, false).clicked() { - app.palette = Some((String::new(), 0)); - } - let share = right.add( - egui::Button::new(egui::RichText::new(" Present ").color(t.accent_text).font(theme::bold(13.0))) - .fill(t.accent) - .corner_radius(control_h / 2.0) - .min_size(vec2(84.0, control_h)), - ); - if share.clicked() && has_doc { - let from = app.session.active().map(|d| d.selection.slide).unwrap_or(0); - app.start_show(from, false); - } - if icon_toggle(&mut right, Icon::CommentsBubble, "Comments", has_doc, app.ui.pane.as_deref() == Some("comments")).clicked() { - let _ = app.run("view.pane", json!({"pane": "comments", "toggle": true})); - } - if icon_toggle( - &mut right, - if app.ui.brightness == theme::Brightness::Dark { Icon::Sparkle } else { Icon::Palette }, - "Light/Dark appearance", - true, - false, - ) - .clicked() - { - let _ = app.run("view.dark", json!({})); - } - }); -} - /// Which contextual tabs apply to the selection. fn contextual(app: &SlideApp) -> Vec<&'static str> { let mut v = vec![]; @@ -327,27 +289,6 @@ fn big(app: &mut SlideApp, ui: &mut Ui, icon: Icon, label: &str, id: &str, p: Va crate::qat::ribbon_menu(app, &r, icon, label, id, &p); } -/// 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) { - let en = enabled(app, "insert.photoAlbum"); - let (main, arrow) = big_button_ex(ui, Icon::Picture, "Photo\nAlbum", en, true, false); - let _ = main.clone().on_hover_text(tip("insert.photoAlbum", "Photo Album…")); - main.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, en, "Photo Album…")); - if let Some(a) = &arrow { - let _ = a.clone().on_hover_text("Photo Album options"); - a.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, en, "Photo Album options")); - } - if main.clicked() { - run(app, "insert.photoAlbum", json!({})); - } - if let Some(a) = arrow { - egui::Popup::menu(&a).show(|ui| { - menu_item(app, ui, Icon::Picture, "New Photo Album…", "insert.photoAlbum", json!({})); - menu_item(app, ui, Icon::Picture, "Edit Photo Album…", "insert.photoAlbumEdit", json!({})); - }); - } -} - fn small(app: &mut SlideApp, ui: &mut Ui, icon: Icon, label: &str, id: &str, p: Value) { let en = enabled(app, id); let r = small_button(ui, icon, label, en, false).on_hover_text(tip(id, label)); @@ -392,890 +333,6 @@ pub fn fmt_state(app: &mut SlideApp) -> Value { app.session.execute("format.state", &json!({})).unwrap_or_default() } -// ---------- Home ---------- - -fn clipboard_group(app: &mut SlideApp, ui: &mut Ui) { - group(ui, |ui| { - let (main, arrow) = big_button_ex(ui, Icon::Paste, "Paste", enabled(app, "edit.paste"), true, false); - if main.on_hover_text("Paste (⌘V)").clicked() { - run(app, "edit.paste", json!({})); - } - if let Some(a) = arrow { - egui::Popup::menu(&a).show(|ui| { - if ui.button("Paste").clicked() { - run(app, "edit.paste", json!({})); - } - if ui.button("Paste and Match Formatting").clicked() { - run(app, "edit.pasteText", json!({})); - } - if ui.button("Paste Special…").clicked() { - run(app, "app.pasteSpecial", json!({})); - } - if ui.button("Paste Formatting").clicked() { - run(app, "format.painterApply", json!({})); - } - }); - } - rows(ui, |ui| { - small(app, ui, Icon::Cut, "Cut", "edit.cut", json!({})); - small(app, ui, Icon::Copy, "Copy", "edit.copy", json!({})); - let on = app.session.painter.is_some(); - let en = enabled(app, "format.painter"); - let r = small_button(ui, Icon::FormatPainter, "Format", en, on).on_hover_text("Format Painter (double-click to keep it on)"); - if r.double_clicked() { - run(app, "format.painter", json!({"sticky": true})); - } else if r.clicked() { - if on { - app.session.painter = None; - } else { - run(app, "format.painter", json!({})); - } - } - }); - }); -} - -fn layout_menu(app: &mut SlideApp, ui: &mut Ui, cmd: &str) { - let layouts: Vec<(String, deckcraft_model::LayoutId)> = app - .session - .active() - .and_then(|d| d.doc.masters.first().map(|m| m.layouts.iter().map(|l| (l.name.clone(), l.id)).collect())) - .unwrap_or_default(); - egui::Grid::new(("layouts", cmd)).spacing(vec2(6.0, 6.0)).show(ui, |ui| { - for (k, (name, id)) in layouts.iter().enumerate() { - let idx = app.session.active().and_then(|d| d.doc.masters.first().and_then(|m| m.layouts.iter().position(|l| l.id == *id))); - let resp = ui.vertical(|ui| { - let (rect, resp) = ui.allocate_exact_size(vec2(96.0, 54.0), Sense::click()); - if let (Some(d), Some(li)) = (app.session.active(), idx) { - let img = deckcraft_render::render_layout( - &d.doc, - 0, - Some(li), - &deckcraft_render::RenderOpts { - scale: 192.0 / d.doc.slide_size.width.max(1.0), - edit: true, - size: Some((192, 108)), - ..Default::default() - }, - ); - let tex = crate::textures::upload_editor(ui.ctx(), format!("layout-{li}"), crate::textures::to_color_image(&img, false)); - let at = crate::textures::device_pixel_rect(rect, ui.ctx().pixels_per_point()); - ui.painter().image(tex.id(), at, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); - } - let t = Tokens::get(ui.ctx()); - ui.painter().rect_stroke( - rect, - CornerRadius::ZERO, - Stroke::new(if resp.hovered() { 2.0 } else { 1.0 }, if resp.hovered() { t.accent } else { t.border }), - egui::StrokeKind::Outside, - ); - ui.add_sized(vec2(96.0, 14.0), egui::Label::new(egui::RichText::new(name).size(10.5)).truncate()); - resp - }); - if resp.inner.clicked() { - run(app, cmd, json!({"layout": id.0})); - ui.close(); - } - if k % 4 == 3 { - ui.end_row(); - } - } - }); -} - -/// Render the Home Slides group: New Slide, Layout, Reset, and Section controls. -fn slides_group(app: &mut SlideApp, ui: &mut Ui) { - group(ui, |ui| { - let (main, arrow) = big_button_ex(ui, Icon::NewSlide, "New\nSlide", enabled(app, "slide.new"), true, false); - if main.on_hover_text("New Slide (⇧⌘N)").clicked() { - run(app, "slide.new", json!({})); - } - if let Some(a) = arrow { - egui::Popup::menu(&a).width(420.0).show(|ui| new_slide_menu(app, ui)); - } - rows(ui, |ui| { - let (main, arrow) = small_button_ex(ui, Icon::Layout, "Layout", enabled(app, "slide.layout"), false, true); - if let Some(arrow) = arrow { - widgets::split_menu(&main, &arrow, |ui| layout_menu(app, ui, "slide.layout")); - } - small(app, ui, Icon::ResetSlide, "Reset", "slide.reset", json!({})); - let (main, arrow) = small_button_ex(ui, Icon::Section, "Section", enabled(app, "section.add"), false, true); - if let Some(arrow) = arrow { - widgets::split_menu(&main, &arrow, |ui| section_menu(app, ui)); - } - }); - }); -} - -/// The New Slide button's menu: layouts, duplication, outline import and slide reuse. -fn new_slide_menu(app: &mut SlideApp, ui: &mut Ui) { - layout_menu(app, ui, "slide.new"); - ui.separator(); - if ui.button("Duplicate Selected Slides").clicked() { - run(app, "slide.duplicate", json!({})); - } - if ui.button("Slides from Outline…").clicked() { - app.dialog = Some(crate::dialogs::Dialog::new("outline")); - } - let desktop = app.services.pick_open.is_some(); - for (label, keep) in [("Reuse Slides…", false), ("Reuse Slides (Keep Source Formatting)…", true)] { - let r = ui.add_enabled(desktop, egui::Button::new(label)); - if r.clicked() { - run(app, "app.reuseSlidesDialog", json!({"keepFormatting": keep})); - } - r.on_disabled_hover_text("Choosing a presentation to reuse needs the desktop app"); - } -} - -/// The Section split button's menu. -fn section_menu(app: &mut SlideApp, ui: &mut Ui) { - if ui.button("Add Section").clicked() { - run(app, "section.add", json!({})); - } - if ui.button("Remove All Sections").clicked() { - run(app, "section.removeAll", json!({})); - } -} - -pub fn font_families(app: &SlideApp) -> Vec { - let mut v = vec![]; - if let Some(m) = app.session.active().and_then(|d| d.doc.masters.first()) { - v.push(m.theme.fonts.major.latin.clone()); - v.push(m.theme.fonts.minor.latin.clone()); - } - v.extend(deckcraft_fonts::FontDb::global().families()); - v.dedup(); - v -} - -fn font_group(app: &mut SlideApp, ui: &mut Ui, st: &Value) { - let en = enabled(app, "format.bold"); - group(ui, |ui| { - rows(ui, |ui| { - ui.horizontal(|ui| { - ui.spacing_mut().item_spacing.x = 3.0; - let font = st.get("font").and_then(Value::as_str).unwrap_or("").to_string(); - let size = st.get("size").and_then(Value::as_f64); - let fams = font_families(app); - let theme_fonts: Vec = fams.iter().take(2).cloned().collect(); - dropdown(ui, "font", &font, 150.0, en, |ui| { - for (k, f) in fams.iter().enumerate() { - let label = if k == 0 && theme_fonts.len() == 2 { - format!("{f} (Headings)") - } else if k == 1 && theme_fonts.len() == 2 { - format!("{f} (Body)") - } else { - f.clone() - }; - if ui.button(egui::RichText::new(label)).clicked() { - run(app, "format.font", json!({"family": f})); - ui.close(); - } - if k == 1 { - ui.separator(); - } - } - ui.separator(); - // Fonts installed while DeckCraft runs (e.g. into ~/.local/share/fonts). - if ui.button("Rescan Installed Fonts").clicked() { - run(app, "font.rescan", json!({})); - ui.close(); - } - }); - let size_text = size.map(|s| widgets::fmt_num(s, "")).unwrap_or_default(); - dropdown(ui, "size", &size_text, 52.0, en, |ui| { - for s in deckcraft_engine::cmd::format::SIZES.iter().chain([72.0, 80.0, 88.0, 96.0].iter()) { - if ui.button(widgets::fmt_num(*s, "")).clicked() { - run(app, "format.size", json!({"size": s})); - ui.close(); - } - } - }); - toggle(app, ui, Icon::FontGrow, "Increase Font Size", "format.grow", false); - toggle(app, ui, Icon::FontShrink, "Decrease Font Size", "format.shrink", false); - toggle(app, ui, Icon::ClearFormatting, "Clear All Formatting", "format.clear", false); - }); - ui.add_space(3.0); - ui.horizontal(|ui| { - ui.spacing_mut().item_spacing.x = 1.0; - let b = |k: &str| st.get(k).and_then(Value::as_bool).unwrap_or(false); - toggle(app, ui, Icon::Bold, "Bold", "format.bold", b("bold")); - toggle(app, ui, Icon::Italic, "Italic", "format.italic", b("italic")); - toggle(app, ui, Icon::Underline, "Underline", "format.underline", b("underline")); - toggle(app, ui, Icon::Strikethrough, "Strikethrough", "format.strikethrough", b("strike")); - toggle(app, ui, Icon::Superscript, "Superscript", "format.superscript", b("superscript")); - toggle(app, ui, Icon::Subscript, "Subscript", "format.subscript", b("subscript")); - let r = icon_toggle(ui, Icon::CharSpacing, "Character Spacing", en, false); - egui::Popup::menu(&r).show(|ui| { - for (label, pt) in [("Very Tight", -3.0), ("Tight", -1.5), ("Normal", 0.0), ("Loose", 3.0), ("Very Loose", 6.0)] { - if ui.button(label).clicked() { - run(app, "format.spacing", json!({"pt": pt})); - } - } - }); - let r = icon_toggle(ui, Icon::ChangeCase, "Change Case", en, false); - egui::Popup::menu(&r).show(|ui| { - for (label, mode) in [ - ("Sentence case.", "sentence"), - ("lowercase", "lower"), - ("UPPERCASE", "upper"), - ("Capitalize Each Word", "title"), - ("tOGGLE cASE", "toggle"), - ] { - if ui.button(label).clicked() { - run(app, "format.case", json!({"mode": mode})); - } - } - }); - let sc = scheme(app); - let (m, a) = color_split(ui, Icon::Highlight, "Text Highlight Color", Color32::from_rgb(255, 230, 0), en); - if m.clicked() { - run(app, "format.highlight", json!({"color": "#FFFF00"})); - } - widgets::color_popup(&a).show(|ui| { - if let Some(c) = widgets::color_grid(ui, &sc, Some("No Color")) { - run(app, "format.highlight", json!({"color": c.map(|c| serde_json::to_value(cref_param(&c)).unwrap_or_default())})); - } - }); - let (m, a) = color_split(ui, Icon::FontColor, "Font Color", Color32::from_rgb(0xD8, 0x3F, 0x3F), en); - if m.clicked() { - run(app, "format.color", json!({"color": "#C00000"})); - } - widgets::color_popup(&a).show(|ui| { - if let Some(Some(c)) = widgets::color_grid(ui, &sc, None) { - run(app, "format.color", json!({"color": cref_param(&c)})); - } - }); - if ui.add_enabled(en, egui::Button::new("Font…").min_size(vec2(52.0, 22.0))).on_hover_text("Font").clicked() { - run(app, "format.fontDialog", json!({})); - } - }); - }); - }); -} - -/// A ColorRef as a command colour parameter. -pub fn cref_param(c: &deckcraft_model::ColorRef) -> Value { - match &c.base { - deckcraft_model::ColorBase::Scheme { slot } => { - let mut o = serde_json::Map::new(); - o.insert("scheme".into(), json!(slot.xml_name())); - for m in &c.mods { - if let Some(v) = m.value() { - o.insert(m.xml_name().into(), json!(v)); - } - } - Value::Object(o) - } - deckcraft_model::ColorBase::Rgb { rgb } if c.mods.is_empty() => json!(format!("#{}", rgb.hex())), - deckcraft_model::ColorBase::Rgb { rgb } => { - let mut o = serde_json::Map::new(); - o.insert("rgb".into(), json!(format!("#{}", rgb.hex()))); - for m in &c.mods { - if let Some(v) = m.value() { - o.insert(m.xml_name().into(), json!(v)); - } - } - Value::Object(o) - } - deckcraft_model::ColorBase::Preset { name } => { - let mut o = serde_json::Map::new(); - o.insert("preset".into(), json!(name)); - for m in &c.mods { - if let Some(v) = m.value() { - o.insert(m.xml_name().into(), json!(v)); - } - } - Value::Object(o) - } - deckcraft_model::ColorBase::System { name, last } => { - let mut o = serde_json::Map::new(); - o.insert("system".into(), json!(name)); - o.insert("last".into(), json!(format!("#{}", last.hex()))); - for m in &c.mods { - if let Some(v) = m.value() { - o.insert(m.xml_name().into(), json!(v)); - } - } - Value::Object(o) - } - } -} - -/// Convert to SmartArt: the layouts by category, three to a row; a click converts the selected -/// text. -/// A layout gallery grouped by category, each layout as a small cached picture with its name. -/// Clicking runs `cmd` with `{"kind": …}` plus `extra`; hovering shows the layout larger, drawn -/// with `nodes` (the selected graphic's outline) when given: a live preview without touching the -/// document. -fn smartart_gallery( - app: &mut SlideApp, - ui: &mut Ui, - cmd: &str, - extra: Value, - current: &str, - style: &str, - colors: &str, - nodes: Option<&[deckcraft_model::DiagramNode]>, -) { - use deckcraft_engine::cmd::smartart::{LAYOUTS, category}; - ui.set_min_width(440.0); - let slide = app.session.active().map(|d| d.selection.slide).unwrap_or(0); - egui::ScrollArea::vertical().max_height(520.0).show(ui, |ui| { - for (cat, title) in [ - ("list", "List"), - ("process", "Process"), - ("cycle", "Cycle"), - ("hierarchy", "Hierarchy"), - ("relationship", "Relationship"), - ("matrix", "Matrix"), - ("pyramid", "Pyramid"), - ("picture", "Picture"), - ] { - let kinds: Vec<(&str, &str)> = LAYOUTS.iter().copied().filter(|(k, _)| category(k) == cat).collect(); - if kinds.is_empty() { - continue; - } - ui.label(egui::RichText::new(title).strong()); - 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 r = ui - .vertical_centered(|ui| { - ui.set_width(100.0); - let r = match &thumb { - Some(t) => { - ui.add(egui::Button::image(egui::Image::new(t).fit_to_exact_size(egui::vec2(96.0, 60.0))).selected(*k == current)) - } - None => ui.add_sized([96.0, 60.0], egui::Button::new("").selected(*k == current)), - }; - ui.add(egui::Label::new(egui::RichText::new(*label).small()).truncate().selectable(false)); - r.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, *label)); - r.on_hover_text(*label) - }) - .inner; - if r.hovered() { - 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))); - }); - } - } - if r.clicked() { - let mut args = extra.clone(); - args["kind"] = json!(k); - run(app, cmd, args); - ui.close(); - } - if i % 4 == 3 { - ui.end_row(); - } - } - }); - ui.separator(); - } - }); -} - -/// Render paragraph formatting controls using the current paragraph state. -fn paragraph_group(app: &mut SlideApp, ui: &mut Ui, st: &Value) { - let en = enabled(app, "format.bullets"); - group(ui, |ui| { - rows(ui, |ui| { - ui.horizontal(|ui| { - ui.spacing_mut().item_spacing.x = 1.0; - let b = |k: &str| st.get(k).and_then(Value::as_bool).unwrap_or(false); - let (m, a) = small_button_named(ui, Icon::Bullets, "", "Bullets", en, b("bullets"), true); - if m.on_hover_text("Bullets").clicked() { - run(app, "format.bullets", json!({})); - } - if let Some(a) = a { - egui::Popup::menu(&a).show(|ui| { - if ui.button("None").clicked() { - run(app, "format.bullets", json!({"on": false})); - } - for ch in ["•", "○", "■", "□", "◆", "➢", "✓", "–", "★"] { - if ui.button(egui::RichText::new(format!("{ch} Bullet")).size(14.0)).clicked() { - run(app, "format.bullets", json!({"on": true, "char": ch})); - } - } - ui.separator(); - if ui.button("Bullets and Numbering…").clicked() { - let mut dlg = crate::dialogs::Dialog::new("bullets"); - dlg.fields.insert("tab".into(), "bulleted".into()); - app.dialog = Some(dlg); - } - }); - } - let (m, a) = small_button_named(ui, Icon::Numbering, "", "Numbering", en, b("numbering"), true); - if m.on_hover_text("Numbering").clicked() { - run(app, "format.numbering", json!({})); - } - if let Some(a) = a { - egui::Popup::menu(&a).show(|ui| { - if ui.button("None").clicked() { - run(app, "format.numbering", json!({"on": false})); - } - for (label, sch) in [ - ("1. 2. 3.", "arabicPeriod"), - ("1) 2) 3)", "arabicParenR"), - ("I. II. III.", "romanUcPeriod"), - ("i. ii. iii.", "romanLcPeriod"), - ("A. B. C.", "alphaUcPeriod"), - ("a) b) c)", "alphaLcParenR"), - ("a. b. c.", "alphaLcPeriod"), - ] { - if ui.button(label).clicked() { - run(app, "format.numbering", json!({"on": true, "scheme": sch})); - } - } - ui.separator(); - if ui.button("Bullets and Numbering…").clicked() { - let mut dlg = crate::dialogs::Dialog::new("bullets"); - dlg.fields.insert("tab".into(), "numbered".into()); - app.dialog = Some(dlg); - } - }); - } - toggle(app, ui, Icon::DecreaseIndent, "Decrease List Level", "format.outdent", false); - toggle(app, ui, Icon::IncreaseIndent, "Increase List Level", "format.indent", false); - let r = icon_toggle(ui, Icon::LineSpacing, "Line Spacing", en, false); - egui::Popup::menu(&r).show(|ui| { - for v in [1.0, 1.5, 2.0, 2.5, 3.0] { - if ui.button(format!("{v:.1}")).clicked() { - run(app, "format.lineSpacing", json!({"lines": v})); - } - } - if ui.button("Line Spacing Options…").clicked() { - run(app, "format.paragraphDialog", json!({})); - } - if ui.button("Tabs…").clicked() { - app.dialog = Some(crate::dialogs::Dialog::new("tabs")); - } - }); - let r = icon_toggle(ui, Icon::Columns, "Columns", en, false); - egui::Popup::menu(&r).show(|ui| { - for n in 1..=3 { - if ui.button(format!("{n} Column{}", if n > 1 { "s" } else { "" })).clicked() { - run(app, "format.columns", json!({"count": n, "spacing": 18})); - } - } - if ui.button("More Columns…").clicked() { - run(app, "format.columns", json!({})); - } - }); - }); - ui.add_space(3.0); - ui.horizontal(|ui| { - ui.spacing_mut().item_spacing.x = 1.0; - let al = st.get("align").and_then(Value::as_str).unwrap_or(""); - toggle(app, ui, Icon::AlignLeft, "Align Left", "format.alignLeft", al == "l"); - toggle(app, ui, Icon::AlignCenter, "Center", "format.alignCenter", al == "ctr"); - toggle(app, ui, Icon::AlignRight, "Align Right", "format.alignRight", al == "r"); - toggle(app, ui, Icon::Justify, "Justify", "format.justify", al == "just"); - let r = icon_toggle(ui, Icon::TextDirection, "Text Direction", en, false); - egui::Popup::menu(&r).show(|ui| { - for (l, d) in [ - ("Horizontal", "horizontal"), - ("Rotate all text 90°", "rotate90"), - ("Rotate all text 270°", "rotate270"), - ("Stacked", "stacked"), - ] { - if ui.button(l).clicked() { - run(app, "format.direction", json!({"dir": d})); - } - } - }); - let r = icon_toggle(ui, Icon::AlignText, "Align Text", en, false); - egui::Popup::menu(&r).show(|ui| { - for (l, a) in [("Top", "top"), ("Middle", "middle"), ("Bottom", "bottom")] { - if ui.button(l).clicked() { - run(app, "format.anchor", json!({"anchor": a})); - } - } - }); - 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)); - 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!({})); - } - }); - }); - }); -} - -/// Shapes gallery popup body: every preset by category. Returns the chosen preset. -pub fn shapes_gallery(ui: &mut Ui) -> Option<&'static str> { - let t = Tokens::get(ui.ctx()); - let mut out = None; - ui.set_max_width(330.0); - egui::ScrollArea::vertical().max_height(420.0).show(ui, |ui| { - for cat in deckcraft_geom::preset::Category::ALL { - ui.label(egui::RichText::new(cat.label()).font(theme::bold(12.0))); - ui.horizontal_wrapped(|ui| { - ui.spacing_mut().item_spacing = vec2(1.0, 1.0); - for p in deckcraft_geom::preset::CATALOG.iter().filter(|p| p.category == cat) { - let (r, resp) = ui.allocate_exact_size(vec2(26.0, 26.0), Sense::click()); - if resp.hovered() { - ui.painter().rect_filled(r, CornerRadius::same(3), t.hover); - } - paint_preset(ui.painter(), r.shrink(4.0), p.name, Color32::TRANSPARENT, t.text); - if resp.on_hover_text(p.label).clicked() { - out = Some(p.name); - } - } - if cat == deckcraft_geom::preset::Category::Lines { - for (name, label) in deckcraft_engine::tools::FREEFORM_TOOLS { - let (r, resp) = ui.allocate_exact_size(vec2(26.0, 26.0), Sense::click()); - if resp.hovered() { - ui.painter().rect_filled(r, CornerRadius::same(3), t.hover); - } - paint_freeform_icon(ui.painter(), r.shrink(5.0), name, t.text); - if resp.on_hover_text(label).clicked() { - out = Some(name); - } - } - } - }); - ui.add_space(4.0); - } - }); - if out.is_some() { - ui.close(); - } - out -} - -/// Draw a preset's outline (gallery icons). -pub fn paint_preset(p: &egui::Painter, r: Rect, name: &str, fill: Color32, stroke: Color32) { - let (w, h) = (r.width() as f64, r.height() as f64); - let Some(g) = deckcraft_geom::preset::build(name, w, h, &[]) else { return }; - for sp in &g.paths { - let mut pts: Vec = vec![]; - let mut polys: Vec<(Vec, bool)> = vec![]; - kurbo_flatten(&sp.path, |el| match el { - Fl::Move(x, y) => { - if pts.len() > 1 { - polys.push((std::mem::take(&mut pts), false)); - } - pts.clear(); - pts.push(pos2(r.min.x + x as f32, r.min.y + y as f32)); - } - Fl::Line(x, y) => pts.push(pos2(r.min.x + x as f32, r.min.y + y as f32)), - Fl::Close => { - if pts.len() > 1 { - polys.push((std::mem::take(&mut pts), true)); - } - } - }); - if pts.len() > 1 { - polys.push((pts, false)); - } - for (poly, closed) in polys { - if closed && fill != Color32::TRANSPARENT && sp.fill != deckcraft_geom::preset::FillMode::None { - p.add(egui::Shape::Path(egui::epaint::PathShape { points: poly.clone(), closed: true, fill, stroke: Stroke::NONE.into() })); - } - if sp.stroke { - p.add(egui::Shape::Path(egui::epaint::PathShape { - points: poly, - closed, - fill: Color32::TRANSPARENT, - stroke: Stroke::new(1.0, stroke).into(), - })); - } - } - } -} - -enum Fl { - Move(f64, f64), - Line(f64, f64), - Close, -} - -fn kurbo_flatten(path: &deckcraft_geom::BezPath, mut f: impl FnMut(Fl)) { - deckcraft_geom::preset_flatten(path, &mut |el| match el { - deckcraft_geom::PathEl::MoveTo(p) => f(Fl::Move(p.x, p.y)), - deckcraft_geom::PathEl::LineTo(p) => f(Fl::Line(p.x, p.y)), - deckcraft_geom::PathEl::ClosePath => f(Fl::Close), - _ => {} - }); -} - -/// Render drawing controls for shapes, text, arrange, quick styles, fill, and outline. -fn drawing_group(app: &mut SlideApp, ui: &mut Ui) { - group(ui, |ui| { - let en = enabled(app, "shape.insert"); - let r = big_button(ui, Icon::Shapes, "Shapes", en).on_hover_text("Shapes"); - egui::Popup::menu(&r).show(|ui| { - if let Some(p) = shapes_gallery(ui) { - app.session.set_tool(deckcraft_engine::ToolKind::Shape { preset: p.to_string() }); - } - }); - big(app, ui, Icon::TextBox, "Text Box", "insert.textBox", json!({})); - let sel = enabled(app, "shape.fill"); - let r = big_button(ui, Icon::Arrange, "Arrange", sel); - egui::Popup::menu(&r).show(|ui| arrange_menu(app, ui)); - let r = big_button(ui, Icon::QuickStyles, "Quick\nStyles", sel); - egui::Popup::menu(&r).width(330.0).show(|ui| quick_styles(app, ui)); - rows(ui, |ui| { - let sc = scheme(app); - let (m, a) = small_button_ex(ui, Icon::ShapeFill, "Shape Fill", sel, false, true); - if m.clicked() { - let c = app.last_fill.clone(); - run(app, "shape.fill", json!({"color": c})); - } - if let Some(a) = a { - widgets::color_popup(&a).show(|ui| { - if let Some(c) = widgets::color_grid(ui, &sc, Some("No Fill")) { - match c { - Some(c) => { - app.last_fill = cref_param(&c); - run(app, "shape.fill", json!({"color": cref_param(&c)})); - } - None => run(app, "shape.fill", json!({"none": true})), - } - } - }); - } - let (m, a) = small_button_ex(ui, Icon::ShapeOutline, "Shape Outline", sel, false, true); - if m.clicked() { - let c = app.last_outline.clone(); - run(app, "shape.line", json!({"color": c})); - } - if let Some(a) = a { - widgets::color_popup(&a).show(|ui| outline_menu(app, ui, &sc)); - } - let (main, arrow) = small_button_ex(ui, Icon::ShapeEffects, "Shape Effects", sel, false, true); - if let Some(arrow) = arrow { - widgets::split_menu(&main, &arrow, |ui| effects_menu(app, ui)); - } - }); - }); -} - -pub fn outline_menu(app: &mut SlideApp, ui: &mut Ui, sc: &deckcraft_color::ColorScheme) { - if let Some(c) = widgets::color_grid(ui, sc, Some("No Outline")) { - match c { - Some(c) => { - app.last_outline = cref_param(&c); - run(app, "shape.line", json!({"color": cref_param(&c)})); - } - None => run(app, "shape.line", json!({"none": true})), - } - } - ui.menu_button("Weight", |ui| { - for w in [0.25, 0.5, 0.75, 1.0, 1.5, 2.25, 3.0, 4.5, 6.0] { - if ui.button(format!("{w} pt")).clicked() { - run(app, "shape.line", json!({"width": w})); - ui.close(); - } - } - }); - ui.menu_button("Dashes", |ui| { - for (l, d) in [ - ("Solid", "solid"), - ("Round Dot", "sysDot"), - ("Square Dot", "sysDash"), - ("Dash", "dash"), - ("Dash Dot", "dashDot"), - ("Long Dash", "lgDash"), - ("Long Dash Dot", "lgDashDot"), - ] { - if ui.button(l).clicked() { - run(app, "shape.line", json!({"dash": d})); - ui.close(); - } - } - }); - ui.menu_button("Arrows", |ui| { - for (l, h, tl) in [ - ("None", "none", "none"), - ("Arrow at end", "none", "triangle"), - ("Arrow at start", "triangle", "none"), - ("Arrows at both ends", "triangle", "triangle"), - ("Stealth end", "none", "stealth"), - ("Oval ends", "oval", "oval"), - ] { - if ui.button(l).clicked() { - run(app, "shape.line", json!({"head": h, "tail": tl})); - ui.close(); - } - } - }); -} - -pub fn effects_menu(app: &mut SlideApp, ui: &mut Ui) { - ui.menu_button("Shadow", |ui| { - for (l, v) in [ - ("No Shadow", "none"), - ("Outer", "outer"), - ("Bottom", "bottom"), - ("Centered", "center"), - ("Inner", "inner"), - ("Perspective", "perspective"), - ] { - if ui.button(l).clicked() { - run(app, "shape.effects", json!({"shadow": v})); - } - } - }); - ui.menu_button("Reflection", |ui| { - for (l, v) in [("No Reflection", "none"), ("Tight", "tight"), ("Half", "half"), ("Full", "full")] { - if ui.button(l).clicked() { - run(app, "shape.effects", json!({"reflection": v})); - } - } - }); - ui.menu_button("Glow", |ui| { - if ui.button("No Glow").clicked() { - run(app, "shape.effects", json!({"glow": null})); - } - for r in [5.0, 8.0, 11.0, 18.0] { - if ui.button(format!("{r} pt glow")).clicked() { - run(app, "shape.effects", json!({"glow": r})); - } - } - }); - ui.menu_button("Soft Edges", |ui| { - for r in [0.0, 1.0, 2.5, 5.0, 10.0, 25.0] { - if ui.button(if r == 0.0 { "No Soft Edges".to_string() } else { format!("{r} pt") }).clicked() { - run(app, "shape.effects", json!({"softEdges": r})); - } - } - }); - ui.menu_button("Bevel", |ui| { - for b in [ - "none", - "circle", - "relaxedInset", - "cross", - "coolSlant", - "angle", - "softRound", - "convex", - "slope", - "divot", - "riblet", - "hardEdge", - "artDeco", - ] { - if ui.button(b).clicked() { - run(app, "shape.effects", json!({"bevel": b})); - } - } - }); - if ui.button("Format Shape…").clicked() { - let _ = app.run("view.pane", json!({"pane": "format", "tab": "effects"})); - } -} - -fn quick_styles(app: &mut SlideApp, ui: &mut Ui) { - let sc = scheme(app); - let slots = [ - deckcraft_color::SchemeSlot::Dk1, - deckcraft_color::SchemeSlot::Accent1, - deckcraft_color::SchemeSlot::Accent2, - deckcraft_color::SchemeSlot::Accent3, - deckcraft_color::SchemeSlot::Accent4, - deckcraft_color::SchemeSlot::Accent5, - deckcraft_color::SchemeSlot::Accent6, - ]; - egui::Grid::new("quickstyles").spacing(vec2(4.0, 4.0)).show(ui, |ui| { - for row in 0..6 { - for (k, slot) in slots.iter().enumerate() { - let c = theme::to_color32(sc.get(*slot)); - let (rect, resp) = ui.allocate_exact_size(vec2(40.0, 26.0), Sense::click()); - let p = ui.painter(); - let (fill, stroke, text) = match row { - 0 => (Color32::WHITE, c, Color32::BLACK), - 2 => (c.gamma_multiply(0.35).to_opaque().lerp_to_gamma(Color32::WHITE, 0.6), Color32::TRANSPARENT, c), - 5 => (c.lerp_to_gamma(Color32::BLACK, 0.25), Color32::WHITE, Color32::WHITE), - _ => (c, c.lerp_to_gamma(Color32::BLACK, 0.3), Color32::WHITE), - }; - p.rect(rect.shrink(2.0), CornerRadius::same(3), fill, Stroke::new(1.5, stroke), egui::StrokeKind::Inside); - p.text(rect.center(), Align2::CENTER_CENTER, "Abc", theme::font(11.0), text); - if resp.hovered() { - p.rect_stroke(rect, CornerRadius::same(3), Stroke::new(1.5, Tokens::get(ui.ctx()).accent), egui::StrokeKind::Inside); - } - if resp.clicked() { - run(app, "shape.quickStyle", json!({"index": row * 7 + k})); - ui.close(); - } - } - ui.end_row(); - } - }); -} - -pub fn arrange_menu(app: &mut SlideApp, ui: &mut Ui) { - ui.label(egui::RichText::new("Order Objects").font(theme::bold(12.0))); - for (l, id, ic) in [ - ("Bring to Front", "arrange.bringToFront", Icon::BringToFront), - ("Send to Back", "arrange.sendToBack", Icon::SendToBack), - ("Bring Forward", "arrange.bringForward", Icon::BringForward), - ("Send Backward", "arrange.sendBackward", Icon::SendBackward), - ] { - menu_item(app, ui, ic, l, id, json!({})); - } - ui.separator(); - ui.label(egui::RichText::new("Group Objects").font(theme::bold(12.0))); - menu_item(app, ui, Icon::Group, "Group", "arrange.group", json!({})); - menu_item(app, ui, Icon::Ungroup, "Ungroup", "arrange.ungroup", json!({})); - menu_item(app, ui, Icon::Group, "Regroup", "arrange.regroup", json!({})); - ui.separator(); - ui.label(egui::RichText::new("Position Objects").font(theme::bold(12.0))); - ui.menu_button("Align", |ui| align_menu(app, ui)); - ui.menu_button("Rotate", |ui| { - menu_item(app, ui, Icon::RotateRight, "Rotate Right 90°", "arrange.rotateRight", json!({})); - menu_item(app, ui, Icon::RotateLeft, "Rotate Left 90°", "arrange.rotateLeft", json!({})); - menu_item(app, ui, Icon::FlipV, "Flip Vertical", "arrange.flipVertical", json!({})); - menu_item(app, ui, Icon::FlipH, "Flip Horizontal", "arrange.flipHorizontal", json!({})); - }); - if ui.button("Selection Pane…").clicked() { - let _ = app.run("view.pane", json!({"pane": "selection"})); - ui.close(); - } -} - -pub fn align_menu(app: &mut SlideApp, ui: &mut Ui) { - let selection = app.session.active().map(|d| d.selection.shapes.clone()).unwrap_or_default(); - let to_slide = align_to_slide(app.align_to.as_ref(), &selection); - let to = if to_slide { "slide" } else { "selection" }; - for (l, e, ic) in [ - ("Align Left", "left", Icon::AlignObjLeft), - ("Align Center", "center", Icon::AlignObjCenter), - ("Align Right", "right", Icon::AlignObjRight), - ("Align Top", "top", Icon::AlignObjTop), - ("Align Middle", "middle", Icon::AlignObjMiddle), - ("Align Bottom", "bottom", Icon::AlignObjBottom), - ] { - menu_item(app, ui, ic, l, "arrange.align", json!({"edge": e, "to": to})); - } - ui.separator(); - menu_item(app, ui, Icon::DistributeH, "Distribute Horizontally", "arrange.distribute", json!({"dir": "horizontal", "to": to})); - menu_item(app, ui, Icon::DistributeV, "Distribute Vertically", "arrange.distribute", json!({"dir": "vertical", "to": to})); - ui.separator(); - let en = enabled(app, "arrange.align"); - for (l, slide) in [("Align to Slide", true), ("Align Selected Objects", false)] { - if widgets::check_item(ui, l, en, to_slide == slide).clicked() { - app.align_to = Some((selection.clone(), slide)); - ui.close(); - } - } -} - -/// Align to Slide or to the selected objects, as in PowerPoint: one object aligns to the slide and -/// several to each other, unless the other choice was ticked for this same selection. -fn align_to_slide(choice: Option<&(Vec, bool)>, selection: &[ShapeId]) -> bool { - match choice { - Some((ids, to_slide)) if ids.as_slice() == selection => *to_slide, - _ => selection.len() < 2, - } -} - pub fn menu_item(app: &mut SlideApp, ui: &mut Ui, icon: Icon, label: &str, id: &str, p: Value) { let en = enabled(app, id); let r = small_button(ui, icon, label, en, false); @@ -1285,2287 +342,6 @@ pub fn menu_item(app: &mut SlideApp, ui: &mut Ui, icon: Icon, label: &str, id: & } } -fn home(app: &mut SlideApp, ui: &mut Ui) { - let st = fmt_state(app); - clipboard_group(app, ui); - slides_group(app, ui); - font_group(app, ui, &st); - paragraph_group(app, ui, &st); - group(ui, |ui| { - big(app, ui, Icon::Picture, "Picture", "app.insertPictureDialog", json!({})); - }); - drawing_group(app, ui); - // Editing, as in PowerPoint: Find, Replace and Replace Fonts, then the Selection Pane. - group(ui, |ui| { - ui.vertical(|ui| { - small(app, ui, Icon::Search, "Find", "app.find", json!({})); - small(app, ui, Icon::Spelling, "Replace", "app.replace", json!({})); - if small_button(ui, Icon::Fonts, "Replace Fonts…", enabled(app, "font.replace"), false).clicked() { - app.dialog = Some(crate::dialogs::Dialog::new("replaceFonts")); - } - }); - // Also under Arrange, but that is disabled until something is selected (#132). - big(app, ui, Icon::SelectionPane, "Selection\nPane", "view.pane", json!({"pane": "selection"})); - }); - group(ui, |ui| { - big(app, ui, Icon::DesignIdeas, "Design\nIdeas", "view.pane", json!({"pane": "designer"})); - }); -} - -// ---------- Insert ---------- - -fn insert(app: &mut SlideApp, ui: &mut Ui) { - group(ui, |ui| { - let (main, arrow) = big_button_ex(ui, Icon::NewSlide, "New\nSlide", enabled(app, "slide.new"), true, false); - if main.clicked() { - run(app, "slide.new", json!({})); - } - if let Some(a) = arrow { - egui::Popup::menu(&a).width(420.0).show(|ui| layout_menu(app, ui, "slide.new")); - } - }); - group(ui, |ui| { - let r = big_button(ui, Icon::Table, "Table", enabled(app, "insert.table")); - egui::Popup::menu(&r).show(|ui| { - if let Some((rr, cc)) = table_grid_picker(ui) { - run(app, "insert.table", json!({"rows": rr, "cols": cc})); - ui.close(); - } - ui.separator(); - if ui.button("Insert Table…").clicked() { - app.dialog = Some(crate::dialogs::Dialog::new("table")); - ui.close(); - } - if ui.button("Draw Table").clicked() { - app.session.set_tool(deckcraft_engine::ToolKind::Shape { preset: deckcraft_engine::tools::DRAW_TABLE.into() }); - ui.close(); - } - }); - }); - group(ui, |ui| { - big(app, ui, Icon::Picture, "Pictures", "app.insertPictureDialog", json!({})); - photo_album_button(app, ui); - let r = big_button(ui, Icon::Screenshot, "Screenshot", false); - let _ = r.on_hover_text("Screenshot (not available yet)"); - }); - group(ui, |ui| { - let r = big_button(ui, Icon::Shapes, "Shapes", enabled(app, "shape.insert")); - egui::Popup::menu(&r).show(|ui| { - if let Some(p) = shapes_gallery(ui) { - app.session.set_tool(deckcraft_engine::ToolKind::Shape { preset: p.to_string() }); - } - }); - let r = big_button(ui, Icon::Icons, "Icons", enabled(app, "insert.icon")); - egui::Popup::menu(&r).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).width(340.0).show(|ui| icon_library(app, ui)); - big(app, ui, Icon::SmartArt, "SmartArt", "app.dialog", json!({"id": "smartart"})); - let r = big_button(ui, Icon::Chart, "Chart", enabled(app, "insert.chart")); - egui::Popup::menu(&r).show(|ui| { - for k in chart_menu_types() { - if k.is_none() { - ui.separator(); - } - if let Some(k) = k - && ui.button(k.label()).clicked() - { - run(app, "insert.chart", json!({"type": format!("{k:?}")})); - } - } - }); - }); - group(ui, |ui| { - let r = big_button(ui, Icon::Zoom, "Zoom", enabled(app, "insert.zoom")); - egui::Popup::menu(&r).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).width(300.0).show(|ui| zoom_menu(app, ui)); - big(app, ui, Icon::Link, "Link", "app.dialog", json!({"id": "hyperlink"})); - let r = big_button(ui, Icon::Action, "Action", enabled(app, "insert.actionButton")); - egui::Popup::menu(&r).show(|ui| { - for k in ["back", "forward", "beginning", "end", "home", "information", "return", "movie", "document", "sound", "help", "custom"] { - if ui.button(format!("Action Button: {k}")).clicked() { - run(app, "insert.actionButton", json!({"kind": k})); - } - } - ui.separator(); - let has_sel = app.session.active().is_some_and(|s| !s.selection.shapes.is_empty()); - if ui.add_enabled(has_sel, egui::Button::new("Action Settings…")).clicked() { - app.dialog = Some(crate::dialogs::Dialog::new("action")); - } - }); - }); - group(ui, |ui| { - big(app, ui, Icon::Comment, "Comment", "app.dialog", json!({"id": "comment"})); - }); - group(ui, |ui| { - big(app, ui, Icon::TextBox, "Text\nBox", "insert.textBox", json!({})); - big(app, ui, Icon::HeaderFooter, "Header &\nFooter", "app.dialog", json!({"id": "headerFooter"})); - let r = big_button(ui, Icon::WordArt, "WordArt", enabled(app, "insert.wordArt")); - egui::Popup::menu(&r).show(|ui| { - for i in 0..6 { - if ui.button(format!("WordArt style {}", i + 1)).clicked() { - run(app, "insert.wordArt", json!({"style": i})); - } - } - }); - big(app, ui, Icon::DateTime, "Date &\nTime", "insert.dateTime", json!({})); - big(app, ui, Icon::SlideNumber, "Slide\nNumber", "insert.slideNumber", json!({})); - }); - group(ui, |ui| { - big(app, ui, Icon::Equation, "Equation", "app.dialog", json!({"id": "equation"})); - big(app, ui, Icon::Symbol, "Symbol", "app.dialog", json!({"id": "symbol"})); - }); - group(ui, |ui| { - big(app, ui, Icon::Video, "Video", "app.insertVideoDialog", json!({})); - big(app, ui, Icon::Audio, "Audio", "app.insertAudioDialog", json!({})); - }); -} - -/// Hover-to-size table grid. Returns (rows, cols) when clicked. -pub fn table_grid_picker(ui: &mut Ui) -> Option<(usize, usize)> { - let t = Tokens::get(ui.ctx()); - let id = ui.scope_id().with("tgrid"); - let mut hover: (usize, usize) = ui.data_mut(|d| d.get_temp(id).unwrap_or((0, 0))); - let cell = 16.0; - let label = if hover.0 > 0 { format!("{}x{} Table", hover.1, hover.0) } else { "Insert Table".into() }; - ui.label(label); - let (rect, resp) = ui.allocate_exact_size(vec2(10.0 * (cell + 2.0), 8.0 * (cell + 2.0)), Sense::click()); - if let Some(p) = resp.hover_pos() { - let c = ((p.x - rect.min.x) / (cell + 2.0)).floor() as usize + 1; - let r = ((p.y - rect.min.y) / (cell + 2.0)).floor() as usize + 1; - hover = (r.min(8), c.min(10)); - } - for r in 0..8 { - for c in 0..10 { - let cr = Rect::from_min_size(rect.min + vec2(c as f32 * (cell + 2.0), r as f32 * (cell + 2.0)), vec2(cell, cell)); - let on = r < hover.0 && c < hover.1; - ui.painter().rect( - cr, - CornerRadius::ZERO, - if on { t.accent.gamma_multiply(0.3) } else { t.card }, - Stroke::new(1.0, if on { t.accent } else { t.border }), - egui::StrokeKind::Inside, - ); - } - } - ui.data_mut(|d| d.insert_temp(id, hover)); - if resp.clicked() && hover.0 > 0 { Some(hover) } else { None } -} - -fn render_icon_categories(app: &mut SlideApp, ui: &mut Ui, icons: &[deckcraft_engine::icons::Icon], color: Color32, hover: Color32) { - let mut cats: Vec<&str> = Vec::new(); - for icon in icons { - if !cats.contains(&icon.category) { - cats.push(icon.category); - } - } - for cat in cats { - ui.label(egui::RichText::new(cat).small().strong()); - ui.horizontal_wrapped(|ui| { - for icon in icons.iter().filter(|i| i.category == cat) { - icon_cell(app, ui, icon, color, hover); - } - }); - } -} - -fn render_filtered_icons(app: &mut SlideApp, ui: &mut Ui, icons: &[deckcraft_engine::icons::Icon], q: &str, color: Color32, hover: Color32) { - let mut n = 0; - ui.horizontal_wrapped(|ui| { - for icon in icons { - if icon.name.contains(q) || icon.label.to_lowercase().contains(q) { - icon_cell(app, ui, icon, color, hover); - n += 1; - } - } - }); - if n == 0 { - ui.label("No icons match."); - } -} - -/// Lucide stroke icons (Insert › Icons). Search filters the gallery; a click inserts the outline. -fn icon_library(app: &mut SlideApp, ui: &mut Ui) { - let t = Tokens::get(ui.ctx()); - let (color, hover) = (t.text, t.hover); - let id = ui.scope_id().with("icon-query"); - let mut query = ui.data_mut(|d| d.get_temp::(id).unwrap_or_default()); - ui.add(egui::TextEdit::singleline(&mut query).hint_text("Search icons").desired_width(ui.available_width())); - let q = query.trim().to_lowercase(); - ui.data_mut(|d| d.insert_temp(id, query)); - let icons = deckcraft_engine::icons::icons(); - egui::ScrollArea::vertical().id_salt("icon-scroll").max_height(420.0).show(ui, |ui| { - if q.is_empty() { - render_icon_categories(app, ui, icons, color, hover); - } else { - render_filtered_icons(app, ui, icons, &q, color, hover); - } - }); -} - -fn icon_cell(app: &mut SlideApp, ui: &mut Ui, icon: &deckcraft_engine::icons::Icon, color: Color32, hover: Color32) { - let (r, resp) = ui.allocate_exact_size(vec2(36.0, 36.0), Sense::click()); - if resp.hovered() { - ui.painter().rect_filled(r, CornerRadius::same(4), hover); - } - paint_icon(ui.painter(), r.shrink(6.0), icon, color); - if resp.on_hover_text(icon.label).clicked() { - let _ = app.run("insert.icon", json!({"name": icon.name})); - ui.close(); - } -} - -/// Stroke a Lucide outline into a gallery cell. The line is the design stroke scaled to the cell. -fn paint_icon(p: &egui::Painter, r: Rect, icon: &deckcraft_engine::icons::Icon, color: Color32) { - let Some(src) = deckcraft_engine::icons::outline(icon.name) else { return }; - let mut bp = src.clone(); - let (w, h) = (r.width().max(1.0) as f64, r.height().max(1.0) as f64); - bp.apply_affine(deckcraft_geom::Affine::scale_non_uniform(w / deckcraft_engine::icons::VIEW, h / deckcraft_engine::icons::VIEW)); - let sw = ((r.width() * icon.stroke as f32) / deckcraft_engine::icons::VIEW as f32).clamp(1.0, 3.5); - let mut pts: Vec = vec![]; - let mut polys: Vec<(Vec, bool)> = vec![]; - kurbo_flatten(&bp, |el| match el { - Fl::Move(x, y) => { - if pts.len() > 1 { - polys.push((std::mem::take(&mut pts), false)); - } - pts.clear(); - pts.push(pos2(r.min.x + x as f32, r.min.y + y as f32)); - } - Fl::Line(x, y) => pts.push(pos2(r.min.x + x as f32, r.min.y + y as f32)), - Fl::Close => { - if pts.len() > 1 { - polys.push((std::mem::take(&mut pts), true)); - } - } - }); - if pts.len() > 1 { - polys.push((pts, false)); - } - for (poly, closed) in polys { - p.add(egui::Shape::Path(egui::epaint::PathShape { - points: poly, - closed, - fill: Color32::TRANSPARENT, - stroke: egui::epaint::PathStroke::new(sw, color), - })); - } -} - -// ---------- Draw ---------- - -fn draw(app: &mut SlideApp, ui: &mut Ui) { - use deckcraft_engine::ToolKind; - let cur = app.session.tool.kind.clone(); - group(ui, |ui| { - let sel = matches!(cur, ToolKind::Select); - if big_button_ex(ui, Icon::LassoSelect, "Select", true, false, sel).0.clicked() { - app.session.set_tool(ToolKind::Select); - } - let er = matches!(&cur, ToolKind::Ink { mode, .. } if mode == "eraser"); - if big_button_ex(ui, Icon::Eraser, "Eraser", true, false, er).0.clicked() { - app.session.set_tool(ToolKind::Ink { mode: "eraser".into(), color: deckcraft_color::Rgba::BLACK, width: 2.0 }); - } - }); - group(ui, |ui| { - let pens: [(Icon, &str, &str, deckcraft_color::Rgba, f64); 5] = [ - (Icon::Pen, "Black Pen", "pen", deckcraft_color::Rgba::rgb(20, 20, 20), 1.5), - (Icon::Pen, "Red Pen", "pen", deckcraft_color::Rgba::rgb(0xD8, 0x3F, 0x3F), 1.5), - (Icon::Pen, "Blue Pen", "pen", deckcraft_color::Rgba::rgb(0x2E, 0x6F, 0xD8), 1.5), - (Icon::Pencil, "Pencil", "pen", deckcraft_color::Rgba::rgb(90, 90, 90), 1.0), - (Icon::HighlighterPen, "Highlighter", "highlighter", deckcraft_color::Rgba::rgb(255, 230, 0), 10.0), - ]; - for (icon, label, mode, color, width) in pens { - let on = matches!(&cur, ToolKind::Ink { mode: m, color: c, .. } if m == mode && *c == color); - if big_button_ex(ui, icon, label, true, false, on).0.clicked() { - app.session.set_tool(ToolKind::Ink { mode: mode.into(), color, width }); - } - } - }); - group(ui, |ui| { - let has_ink = app.session.doc().is_ok_and(|d| d.shapes().iter().any(|s| matches!(s.kind, deckcraft_model::ShapeKind::Ink { .. }))); - let r = big_button(ui, Icon::InkToShape, "Ink to\nShape", has_ink); - if r.on_hover_text("Ink to Shape: turn drawn lines, rectangles, ovals and triangles into shapes").clicked() { - app.session.set_tool(ToolKind::Select); - run(app, "draw.inkToShape", json!({})); - } - let r = big_button(ui, Icon::InkToText, "Ink to\nText", false); - let _ = r.on_hover_text("Ink to Text (coming soon)"); - let r = big_button(ui, Icon::InkToMath, "Ink to\nMath", false); - let _ = r.on_hover_text("Ink to Math (coming soon)"); - }); -} - -// ---------- Design ---------- - -/// Render the Design tab's themes, variants, colours, fonts, effects, background, and slide -/// size controls. -fn design(app: &mut SlideApp, ui: &mut Ui) { - let themes = deckcraft_model::theme::builtin_themes(); - let cur = app.session.active().and_then(|d| d.doc.masters.first().map(|m| m.theme.name.clone())).unwrap_or_default(); - group(ui, |ui| { - let sel = themes.iter().position(|th| th.name == cur); - let names: Vec = themes.iter().map(|t| t.name.clone()).collect(); - if let Some(i) = widgets::gallery( - ui, - themes.len(), - vec2(84.0, 56.0), - sel, - |p, r, i| { - if let Some(th) = themes.get(i) { - paint_theme_tile(p, r, th); - } - }, - |i| names.get(i).cloned().unwrap_or_default(), - ) && let Some(n) = names.get(i) - { - run(app, "design.theme", json!({"name": n})); - } - if small_button(ui, Icon::Themes, "Browse for Themes…", enabled(app, "design.browseTheme"), false).clicked() { - run(app, "app.browseThemeDialog", json!({})); - } - }); - // Variants: the current theme in its four colour variations. - if let Some(th) = app.session.active().and_then(|d| d.doc.masters.first().map(|m| m.theme.clone())) { - let variants = deckcraft_model::theme::theme_variants(&th); - let tiles: Vec = variants.iter().map(|c| deckcraft_model::Theme { colors: c.clone(), ..th.clone() }).collect(); - let names: Vec = variants.iter().map(|c| c.name.clone()).collect(); - let sel = variants.iter().position(|c| c.colors == th.colors.colors); - group(ui, |ui| { - if let Some(i) = widgets::gallery( - ui, - tiles.len(), - vec2(84.0, 56.0), - sel, - |p, r, i| { - if let Some(t) = tiles.get(i) { - paint_theme_tile(p, r, t); - } - }, - |i| names.get(i).cloned().unwrap_or_default(), - ) { - run(app, "design.variant", json!({"index": i})); - } - }); - } - group(ui, |ui| { - let r = big_button(ui, Icon::Colors, "Colors", true); - r.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, "Colors")); - egui::Popup::menu(&r).width(240.0).show(|ui| { - for cs in deckcraft_model::theme::builtin_color_schemes() { - ui.horizontal(|ui| { - let (rect, resp) = ui.allocate_exact_size(vec2(220.0, 20.0), Sense::click()); - let p = ui.painter(); - for (k, c) in cs.colors.iter().take(10).skip(2).enumerate() { - p.rect_filled( - Rect::from_min_size(rect.min + vec2(k as f32 * 12.0, 3.0), vec2(11.0, 14.0)), - CornerRadius::ZERO, - theme::to_color32(*c), - ); - } - p.text(rect.min + vec2(104.0, 10.0), Align2::LEFT_CENTER, &cs.name, theme::font(12.0), Tokens::get(ui.ctx()).text); - if resp.clicked() { - run(app, "design.colors", json!({"name": cs.name})); - ui.close(); - } - }); - } - ui.separator(); - if ui.button("Customize Colors…").clicked() { - run(app, "design.colors", json!({})); - ui.close(); - } - }); - let r = big_button(ui, Icon::Fonts, "Fonts", true); - r.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, "Fonts")); - egui::Popup::menu(&r).width(220.0).show(|ui| { - for fs in deckcraft_model::theme::builtin_font_schemes() { - if ui.button(format!("{}\n {} / {}", fs.name, fs.major.latin, fs.minor.latin)).clicked() { - run(app, "design.fonts", json!({"name": fs.name})); - } - } - ui.separator(); - if ui.button("Customize Fonts…").clicked() { - run(app, "design.fonts", json!({})); - ui.close(); - } - }); - let r = big_button(ui, Icon::ShapeEffects, "Effects", true); - r.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, "Effects")); - egui::Popup::menu(&r).width(180.0).show(|ui| { - for fs in deckcraft_model::theme::builtin_format_schemes() { - if ui.button(&fs.name).clicked() { - run(app, "design.effects", json!({"name": fs.name})); - ui.close(); - } - } - }); - let r = big_button(ui, Icon::BackgroundStyles, "Background\nStyles", true); - egui::Popup::menu(&r).show(|ui| { - egui::Grid::new("bgstyles").show(ui, |ui| { - let sc = scheme(app); - for i in 1..=12 { - let slots = [ - deckcraft_color::SchemeSlot::Lt1, - deckcraft_color::SchemeSlot::Lt2, - deckcraft_color::SchemeSlot::Dk2, - deckcraft_color::SchemeSlot::Dk1, - ]; - let base = theme::to_color32(sc.get(slots[(i - 1) % 4])); - let shade = match (i - 1) / 4 { - 0 => base, - 1 => base.lerp_to_gamma(Color32::GRAY, 0.15), - _ => base.lerp_to_gamma(Color32::BLACK, 0.12), - }; - let (rect, resp) = ui.allocate_exact_size(vec2(48.0, 28.0), Sense::click()); - ui.painter().rect(rect, CornerRadius::same(2), shade, Stroke::new(1.0, Tokens::get(ui.ctx()).border), egui::StrokeKind::Inside); - if resp.on_hover_text(format!("Style {i}")).clicked() { - run(app, "design.background", json!({"style": i, "all": true})); - ui.close(); - } - if i % 4 == 0 { - ui.end_row(); - } - } - }); - if ui.button("Reset Slide Background").clicked() { - run(app, "design.background", json!({"reset": true})); - } - }); - }); - group(ui, |ui| { - let r = big_button(ui, Icon::SlideSize, "Slide\nSize", true); - egui::Popup::menu(&r).show(|ui| { - if ui.button("Standard (4:3)").clicked() { - run(app, "design.slideSize", json!({"preset": "standard"})); - } - if ui.button("Widescreen (16:9)").clicked() { - run(app, "design.slideSize", json!({"preset": "widescreen"})); - } - if ui.button("Page Setup…").clicked() { - app.dialog = Some(crate::dialogs::Dialog::new("slideSize")); - } - }); - big(app, ui, Icon::FormatBackground, "Format\nBackground", "view.pane", json!({"pane": "background"})); - }); - group(ui, |ui| { - big(app, ui, Icon::DesignIdeas, "Design\nIdeas", "view.pane", json!({"pane": "designer"})); - }); -} - -pub fn paint_theme_tile(p: &egui::Painter, r: Rect, th: &deckcraft_model::Theme) { - let bg = theme::to_color32(th.colors.get(deckcraft_color::SchemeSlot::Lt1)); - let fg = theme::to_color32(th.colors.get(deckcraft_color::SchemeSlot::Dk1)); - p.rect(r, CornerRadius::same(2), bg, Stroke::new(1.0, Color32::from_gray(200)), egui::StrokeKind::Inside); - p.text(r.min + vec2(8.0, r.height() * 0.42), Align2::LEFT_CENTER, "Aa", theme::font(r.height() * 0.38), fg); - let w = (r.width() - 16.0) / 6.0; - for k in 0..6 { - let c = theme::to_color32(th.colors.colors.get(4 + k).copied().unwrap_or(deckcraft_color::Rgba::BLACK)); - p.rect_filled(Rect::from_min_size(pos2(r.min.x + 8.0 + k as f32 * w, r.max.y - 11.0), vec2(w - 1.5, 5.0)), CornerRadius::ZERO, c); - } -} - -// ---------- Transitions ---------- - -/// Render transition preview, gallery, options, sound, and timing controls for the current -/// slide. -fn transitions(app: &mut SlideApp, ui: &mut Ui) { - let cur = app.session.active().and_then(|d| d.current_slide().and_then(|s| s.transition.clone())); - let kind = cur.as_ref().map(|t| t.kind.clone()).unwrap_or_else(|| "none".into()); - group(ui, |ui| { - if big_button(ui, Icon::Preview, "Preview", true).clicked() { - let from = app.session.active().map(|d| d.selection.slide).unwrap_or(0); - app.start_show(from, true); - if let Some(s) = app.show.as_mut() { - s.preview_only = true; - } - } - }); - group(ui, |ui| { - let list = deckcraft_model::anim::TRANSITIONS; - let shown: Vec<_> = list.iter().take(12).collect(); - let sel = shown.iter().position(|x| x.0 == kind); - if let Some(i) = widgets::gallery( - ui, - shown.len(), - vec2(54.0, 56.0), - sel, - |p, r, i| paint_transition_tile(p, r, shown.get(i).map(|x| (x.0, x.1)).unwrap_or(("", ""))), - |i| shown.get(i).map(|x| x.1.to_string()).unwrap_or_default(), - ) && let Some(x) = shown.get(i) - { - run(app, "transition.set", json!({"kind": x.0})); - } - let more = widgets::drop_button(ui, "", vec2(14.0, 52.0), true); - egui::Popup::menu(&more).width(480.0).show(|ui| { - for cat in ["Subtle", "Exciting", "Dynamic Content"] { - ui.label(egui::RichText::new(cat).font(theme::bold(12.0))); - ui.horizontal_wrapped(|ui| { - for x in list.iter().filter(|x| x.2 == cat) { - let (r, resp) = ui.allocate_exact_size(vec2(56.0, 58.0), Sense::click()); - if resp.hovered() || x.0 == kind { - ui.painter().rect_filled(r, CornerRadius::same(4), Tokens::get(ui.ctx()).hover); - } - paint_transition_tile(ui.painter(), r.shrink(3.0), (x.0, x.1)); - if resp.clicked() { - run(app, "transition.set", json!({"kind": x.0})); - ui.close(); - } - } - }); - } - }); - }); - group(ui, |ui| { - let selected = cur.as_ref().map(|t| t.option.as_str()); - let rows = transition_effect_rows(&kind, selected); - let r = big_button(ui, Icon::EffectOptions, "Effect\nOptions", !rows.is_empty()); - egui::Popup::menu(&r).show(|ui| { - for &(o, on) in &rows { - if ui.selectable_label(on, deckcraft_model::anim::transition_option_label(&kind, o)).clicked() { - run(app, "transition.options", json!({"option": o})); - ui.close(); - } - } - }); - }); - group(ui, |ui| { - // Sound in its own column, so the timing column keeps its three rows. - rows(ui, |ui| { - ui.horizontal(|ui| { - let field = ui.label("Sound:"); - let stop = cur.as_ref().is_some_and(|t| t.stop_sound); - let name = cur - .as_ref() - .and_then(|t| t.sound) - .and_then(|m| app.session.active().and_then(|d| d.doc.media(m).map(|i| i.name.trim_end_matches(".wav").to_string()))) - .unwrap_or_else(|| if stop { "[Stop Previous Sound]".into() } else { "[No Sound]".into() }); - let combo = egui::ComboBox::from_id_salt("transition_sound").width(140.0).selected_text(name).show_ui(ui, |ui| { - let none = cur.as_ref().is_none_or(|t| t.sound.is_none() && !t.stop_sound); - if ui.selectable_label(none, "[No Sound]").clicked() { - run(app, "transition.sound", json!({"none": true})); - } - if ui.selectable_label(stop, "[Stop Previous Sound]").clicked() { - run(app, "transition.sound", json!({"stopPrevious": true})); - } - ui.separator(); - for (id, label) in deckcraft_engine::sounds::BUILTIN { - if ui.button(label).clicked() { - let lp = cur.as_ref().is_some_and(|t| t.sound_loop); - run(app, "transition.sound", json!({"builtin": id, "loop": lp})); - } - } - ui.separator(); - let can_pick = app.services.pick_open.is_some(); - let r = ui.add_enabled(can_pick, egui::Button::new("Other Sound…")); - if r.clicked() { - app.pick_transition_sound(); - } - r.on_disabled_hover_text("Choosing a sound file needs the desktop app"); - ui.separator(); - let has = cur.as_ref().is_some_and(|t| t.sound.is_some()); - let mut lp = cur.as_ref().is_some_and(|t| t.sound_loop); - if ui.add_enabled(has, egui::Checkbox::new(&mut lp, "Loop Until Next Sound")).changed() { - run(app, "transition.sound", json!({"loop": lp})); - } - }); - let _ = combo.response.labelled_by(field.id); - }); - }); - rows(ui, |ui| { - ui.horizontal(|ui| { - ui.label("Duration:"); - let d = cur.as_ref().map(|t| t.duration_ms as f64 / 1000.0).unwrap_or(1.0); - if let Some(v) = widgets::spinner(ui, "tdur", "Duration", d, 0.25, "", 46.0) { - run(app, "transition.timing", json!({"duration": (v.max(0.0) * 1000.0).round() as u64})); - } - }); - ui.horizontal(|ui| { - let mut click = cur.as_ref().is_none_or(|t| t.advance_on_click); - if ui.checkbox(&mut click, "On Mouse Click").changed() { - run(app, "transition.timing", json!({"onClick": click})); - } - }); - ui.horizontal(|ui| { - let after = cur.as_ref().and_then(|t| t.advance_after_ms); - let mut on = after.is_some(); - if ui.checkbox(&mut on, "After:").changed() { - run(app, "transition.timing", json!({"after": if on { json!(after.unwrap_or(0)) } else { Value::Null }})); - } - if let Some(v) = widgets::spinner(ui, "tafter", "After", after.unwrap_or(0) as f64 / 1000.0, 1.0, "", 46.0) { - run(app, "transition.timing", json!({"after": (v.max(0.0) * 1000.0).round() as u64})); - } - }); - }); - big(app, ui, Icon::ApplyToAll, "Apply\nTo All", "transition.applyAll", json!({})); - }); -} - -fn option_label(o: &str) -> String { - match o { - "l" => "From Left".into(), - "r" => "From Right".into(), - "u" => "From Top".into(), - "d" => "From Bottom".into(), - "horz" => "Horizontal".into(), - "vert" => "Vertical".into(), - "in" => "In".into(), - "out" => "Out".into(), - other => { - let mut s = String::new(); - for (i, c) in other.chars().enumerate() { - if i == 0 { - s.extend(c.to_uppercase()); - } else if c.is_uppercase() { - s.push(' '); - s.push(c); - } else { - s.push(c); - } - } - s - } - } -} - -pub fn paint_transition_tile(p: &egui::Painter, r: Rect, (id, label): (&str, &str)) { - let t = Tokens::get(p.ctx()); - let pic = Rect::from_min_size(r.min + vec2(4.0, 2.0), vec2(r.width() - 8.0, r.height() - 18.0)); - let blue = Color32::from_rgb(0x2E, 0x6F, 0xD8); - let light = Color32::from_rgb(0xBF, 0xD4, 0xF5); - p.rect_filled(pic, CornerRadius::same(2), light); - match id { - "none" => { - p.rect_filled(pic, CornerRadius::same(2), Color32::from_rgb(0xE6, 0xEC, 0xF5)); - } - "fade" | "dissolve" | "flash" => { - p.rect_filled(pic, CornerRadius::same(2), blue.gamma_multiply(0.5)); - } - "push" | "cover" | "uncover" | "pan" | "conveyor" => { - p.rect_filled(Rect::from_min_max(pos2(pic.center().x, pic.min.y), pic.max), CornerRadius::same(2), blue); - p.arrow(pos2(pic.center().x + 10.0, pic.center().y), vec2(-12.0, 0.0), Stroke::new(1.5, Color32::WHITE)); - } - "wipe" | "reveal" => { - p.rect_filled(Rect::from_min_max(pic.min, pos2(pic.center().x, pic.max.y)), CornerRadius::same(2), blue); - } - "split" | "doors" | "window" | "curtains" => { - p.rect_filled( - Rect::from_min_max(pos2(pic.min.x + pic.width() * 0.3, pic.min.y), pos2(pic.max.x - pic.width() * 0.3, pic.max.y)), - CornerRadius::ZERO, - blue, - ); - } - "randomBar" | "blinds" | "comb" | "shred" => { - for k in 0..5 { - let x = pic.min.x + k as f32 * pic.width() / 5.0; - p.rect_filled(Rect::from_min_size(pos2(x, pic.min.y), vec2(pic.width() / 10.0, pic.height())), CornerRadius::ZERO, blue); - } - } - "checker" | "honeycomb" | "glitter" | "fracture" => { - for k in 0..12 { - if k % 2 == (k / 4) % 2 { - let x = pic.min.x + (k % 4) as f32 * pic.width() / 4.0; - let y = pic.min.y + (k / 4) as f32 * pic.height() / 3.0; - p.rect_filled(Rect::from_min_size(pos2(x, y), vec2(pic.width() / 4.0, pic.height() / 3.0)), CornerRadius::ZERO, blue); - } - } - } - "shape" | "zoom" | "ripple" | "vortex" | "clock" => { - p.circle_filled(pic.center(), pic.height() * 0.38, blue); - } - "morph" => { - p.circle_filled(pos2(pic.min.x + pic.width() * 0.3, pic.center().y), pic.height() * 0.25, blue); - p.rect_filled( - Rect::from_center_size(pos2(pic.max.x - pic.width() * 0.3, pic.center().y), vec2(pic.height() * 0.45, pic.height() * 0.45)), - CornerRadius::same(2), - blue.gamma_multiply(0.7), - ); - } - _ => { - // Fake 3-D: a tilted card. - let pts = vec![ - pos2(pic.min.x + 6.0, pic.min.y + 4.0), - pos2(pic.max.x - 3.0, pic.min.y + 1.0), - pos2(pic.max.x - 3.0, pic.max.y - 1.0), - pos2(pic.min.x + 6.0, pic.max.y - 4.0), - ]; - p.add(egui::Shape::convex_polygon(pts, blue, Stroke::NONE)); - } - } - p.rect_stroke(pic, CornerRadius::same(2), Stroke::new(1.0, Color32::from_rgb(0x9B, 0xB4, 0xDB)), egui::StrokeKind::Inside); - p.text(pos2(r.center().x, r.max.y - 7.0), Align2::CENTER_CENTER, label, theme::font(10.0), t.text); -} - -// ---------- Animations ---------- - -/// Render animation preview, gallery, add/effect options, trigger, timing, and playback controls. -fn animations(app: &mut SlideApp, ui: &mut Ui) { - let has_sel = app.session.active().is_some_and(|d| !d.selection.shapes.is_empty()); - let cur: Option = app.session.active().and_then(|d| { - let s = d.current_slide()?; - let id = d.selection.shapes.first()?; - s.animations.iter().find(|a| a.shape == *id).cloned() - }); - group(ui, |ui| { - let playing = app.anim_preview_active(); - let (resp, _) = big_button_ex(ui, Icon::Preview, "Preview", true, false, playing); - if resp.on_hover_text("Play this slide's animations on the canvas").clicked() { - run(app, "animation.preview", json!({})); - } - }); - group(ui, |ui| { - let quick: Vec<_> = deckcraft_model::anim::ANIMATIONS - .iter() - .filter(|a| { - matches!( - a.0, - "appear" - | "fade" - | "fly" - | "float" - | "split" - | "wipe" - | "zoom" - | "pulse" - | "spin" - | "growShrink" - | "fadeOut" - | "flyOut" - | "lines" - ) - }) - .collect(); - let sel = cur.as_ref().and_then(|c| quick.iter().position(|a| a.0 == c.effect)); - if let Some(i) = widgets::gallery( - ui, - quick.len(), - vec2(50.0, 56.0), - sel, - |p, r, i| { - if let Some(a) = quick.get(i) { - paint_anim_tile(p, r, a.1, a.2, has_sel); - } - }, - |i| quick.get(i).map(|a| a.1.to_string()).unwrap_or_default(), - ) && let Some(a) = quick.get(i) - { - run(app, "animation.set", json!({"effect": a.0, "class": a.2.xml()})); - } - let more = widgets::drop_button(ui, "", vec2(14.0, 52.0), has_sel); - egui::Popup::menu(&more).width(420.0).show(|ui| { - for (cls, label) in - [(AnimClass::Entrance, "Entrance"), (AnimClass::Emphasis, "Emphasis"), (AnimClass::Exit, "Exit"), (AnimClass::Path, "Motion Paths")] - { - ui.label(egui::RichText::new(label).font(theme::bold(12.0))); - ui.horizontal_wrapped(|ui| { - for a in deckcraft_model::anim::ANIMATIONS.iter().filter(|a| a.2 == cls) { - let (r, resp) = ui.allocate_exact_size(vec2(56.0, 54.0), Sense::click()); - if resp.hovered() { - ui.painter().rect_filled(r, CornerRadius::same(4), Tokens::get(ui.ctx()).hover); - } - paint_anim_tile(ui.painter(), r.shrink(2.0), a.1, a.2, true); - if resp.clicked() { - run(app, "animation.set", json!({"effect": a.0, "class": a.2.xml()})); - ui.close(); - } - } - }); - } - }); - let effect_rows = cur.as_ref().map(|c| animation_effect_rows(&c.effect, c.class, Some(c.option.as_str()))).unwrap_or_default(); - let build = cur.as_ref().map(|c| text_build_id(c.text_build)); - let build_rows = marked_options(&["asOne", "byParagraph"], build); - let r = big_button(ui, Icon::EffectOptions, "Effect\nOptions", !effect_rows.is_empty()); - egui::Popup::menu(&r).show(|ui| { - for &(o, on) in &effect_rows { - if ui.selectable_label(on, option_label(o)).clicked() { - run(app, "animation.options", json!({"option": o})); - ui.close(); - } - } - ui.separator(); - for &(id, on) in &build_rows { - let label = if id == "byParagraph" { "By Paragraph" } else { "As One Object" }; - if ui.selectable_label(on, label).clicked() { - run(app, "animation.options", json!({"textBuild": id})); - ui.close(); - } - } - }); - }); - group(ui, |ui| { - let r = big_button(ui, Icon::AddAnimation, "Add\nAnimation", has_sel); - egui::Popup::menu(&r).width(260.0).show(|ui| { - for a in deckcraft_model::anim::ANIMATIONS.iter().filter(|a| a.2 != AnimClass::Media) { - if ui - .button(format!( - "{} — {}", - a.1, - match a.2 { - AnimClass::Entrance => "Entrance", - AnimClass::Emphasis => "Emphasis", - AnimClass::Exit => "Exit", - _ => "Motion", - } - )) - .clicked() - { - run(app, "animation.add", json!({"effect": a.0, "class": a.2.xml()})); - } - } - }); - big(app, ui, Icon::AnimationPane, "Animation\nPane", "view.pane", json!({"pane": "animation"})); - let r = big_button(ui, Icon::Trigger, "Trigger", cur.is_some()); - egui::Popup::menu(&r).show(|ui| { - if ui.button("On Click of… (none)").clicked() { - run(app, "animation.timing", json!({"trigger": null})); - } - let shapes: Vec<(u32, String)> = - app.session.active().map(|d| d.shapes().iter().map(|s| (s.id.0, s.name.clone())).collect()).unwrap_or_default(); - for (id, name) in shapes { - if ui.button(format!("On Click of {name}")).clicked() { - run(app, "animation.timing", json!({"trigger": id})); - } - } - }); - let on = app.session.anim_painter.is_some(); - let en = enabled(app, "animation.painter"); - let (r, _) = big_button_ex(ui, Icon::AnimationPainter, "Animation\nPainter", en, false, on); - let r = r.on_hover_text("Animation Painter (double-click to keep it on)"); - if r.double_clicked() { - run(app, "animation.painter", json!({"sticky": true})); - } else if r.clicked() { - if on { - app.session.anim_painter = None; - } else { - run(app, "animation.painter", json!({})); - } - } - }); - group(ui, |ui| { - rows(ui, |ui| { - ui.horizontal(|ui| { - ui.label("Start:"); - let s = cur.as_ref().map(|c| match c.start { - deckcraft_model::AnimStart::OnClick => "On Click", - deckcraft_model::AnimStart::WithPrevious => "With Previous", - deckcraft_model::AnimStart::AfterPrevious => "After Previous", - }); - dropdown(ui, "astart", s.unwrap_or(""), 112.0, cur.is_some(), |ui| { - for (l, v) in [("On Click", "onClick"), ("With Previous", "withPrevious"), ("After Previous", "afterPrevious")] { - if ui.button(l).clicked() { - run(app, "animation.timing", json!({"start": v})); - ui.close(); - } - } - }); - }); - ui.horizontal(|ui| { - ui.label("Duration:"); - if let Some(v) = - widgets::spinner(ui, "adur", "Duration", cur.as_ref().map(|c| c.duration_ms as f64 / 1000.0).unwrap_or(0.5), 0.25, "", 46.0) - { - run(app, "animation.timing", json!({"duration": (v.max(0.0) * 1000.0).round() as u64})); - } - }); - ui.horizontal(|ui| { - ui.label("Delay:"); - if let Some(v) = - widgets::spinner(ui, "adelay", "Delay", cur.as_ref().map(|c| c.delay_ms as f64 / 1000.0).unwrap_or(0.0), 0.25, "", 46.0) - { - run(app, "animation.timing", json!({"delay": (v.max(0.0) * 1000.0).round() as u64})); - } - }); - }); - }); -} - -pub fn paint_anim_tile(p: &egui::Painter, r: Rect, label: &str, class: AnimClass, enabled: bool) { - let t = Tokens::get(p.ctx()); - let icon = match class { - AnimClass::Entrance => Icon::AnimEntrance, - AnimClass::Emphasis => Icon::AnimEmphasis, - AnimClass::Exit => Icon::AnimExit, - _ => Icon::AnimPath, - }; - icons::paint(p, Rect::from_center_size(pos2(r.center().x, r.min.y + 18.0), vec2(28.0, 28.0)), icon, t.text, !enabled); - p.text(pos2(r.center().x, r.max.y - 8.0), Align2::CENTER_CENTER, label, theme::font(10.0), if enabled { t.text } else { t.text_faint }); -} - -// ---------- Slide Show / Record / Review / View ---------- - -fn slide_show(app: &mut SlideApp, ui: &mut Ui) { - group(ui, |ui| { - if big_button(ui, Icon::PlayFromStart, "Play from\nStart", true).clicked() { - app.start_show(app.show_start_index(), false); - } - if big_button(ui, Icon::PlayFromCurrent, "Play from\nCurrent Slide", true).clicked() { - let i = app.session.active().map(|d| d.selection.slide).unwrap_or(0); - app.start_show(i, false); - } - let on = app.ui.presenter_view; - if big_button_ex(ui, Icon::PresenterView, "Presenter\nView", true, false, on).0.clicked() { - app.ui.presenter_view = !on; - } - big(app, ui, Icon::CustomShow, "Custom\nShow", "app.dialog", json!({"id": "customShow"})); - }); - group(ui, |ui| { - big(app, ui, Icon::SetUpShow, "Set Up\nSlide Show", "app.dialog", json!({"id": "setupShow"})); - let hidden = app.session.active().and_then(|d| d.current_slide().map(|s| s.hidden)).unwrap_or(false); - if big_button_ex(ui, Icon::HideSlide, "Hide\nSlide", true, false, hidden).0.clicked() { - run(app, "slide.hide", json!({})); - } - }); - group(ui, |ui| { - if big_button(ui, Icon::RehearseTimings, "Rehearse\nTimings", true).clicked() { - app.start_show(app.show_start_index(), false); - if let Some(s) = app.show.as_mut() { - s.rehearse = true; - } - } - let r = big_button(ui, Icon::Record, "Record", false); - let _ = r.on_hover_text("Recording narration needs audio capture (coming soon)"); - }); - group(ui, |ui| { - rows(ui, |ui| { - let mut use_t = app.session.active().is_none_or(|d| d.doc.show.use_timings); - if ui.checkbox(&mut use_t, "Use Timings").changed() { - run(app, "show.setup", json!({"useTimings": use_t})); - } - let mut narr = app.session.active().is_none_or(|d| !d.doc.show.without_narration); - if ui.checkbox(&mut narr, "Play Narrations").changed() { - run(app, "show.setup", json!({"noNarration": !narr})); - } - let mut mc = app.session.active().is_none_or(|d| d.doc.show.show_media_controls); - if ui.checkbox(&mut mc, "Show Media Controls").changed() { - run(app, "show.setup", json!({"showMediaControls": mc})); - } - }); - }); -} - -fn record(app: &mut SlideApp, ui: &mut Ui) { - group(ui, |ui| { - let r = big_button(ui, Icon::Cameo, "Cameo", false); - let _ = r.on_hover_text("Camera capture is not available yet"); - if big_button(ui, Icon::PlayFromStart, "From\nBeginning", true).clicked() { - app.start_show(app.show_start_index(), false); - if let Some(s) = app.show.as_mut() { - s.rehearse = true; - } - } - if big_button(ui, Icon::PlayFromCurrent, "From\nCurrent Slide", true).clicked() { - let i = app.session.active().map(|d| d.selection.slide).unwrap_or(0); - app.start_show(i, false); - if let Some(s) = app.show.as_mut() { - s.rehearse = true; - } - } - }); - group(ui, |ui| { - if big_button(ui, Icon::Help, "Learn\nMore", true).clicked() { - app.open_url(deckcraft_engine::links::APP_PAGE); - } - }); -} - -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") }; - if small_button(ui, icon, label, enabled(app, id), false).on_hover_text(tip(id, label)).clicked() { - let params = match app.ui.comment_focus { - Some((slide, index)) if app.session.active().is_some_and(|d| d.selection.slide == slide) => json!({"slide": slide, "index": index}), - _ => json!({}), - }; - if let Ok(v) = app.run(id, params) - && let (Some(slide), Some(index)) = (v.get("slide").and_then(Value::as_u64), v.get("index").and_then(Value::as_u64)) - && let (Ok(slide), Ok(index)) = (usize::try_from(slide), usize::try_from(index)) - { - app.ui.comment_focus = Some((slide, index)); - app.ui.pane = Some("comments".into()); - } - } -} - -fn review(app: &mut SlideApp, ui: &mut Ui) { - group(ui, |ui| { - big(app, ui, Icon::Spelling, "Spelling", "app.dialog", json!({"id": "spelling"})); - big(app, ui, Icon::Thesaurus, "Thesaurus", "review.thesaurusPane", json!({})); - }); - group(ui, |ui| { - big(app, ui, Icon::Accessibility, "Check\nAccessibility", "app.dialog", json!({"id": "accessibility"})); - }); - group(ui, |ui| { - let r = big_button(ui, Icon::Translate, "Translate", false); - let _ = r.on_hover_text("Translation needs a language model (not bundled)"); - big(app, ui, Icon::Language, "Language", "app.dialog", json!({"id": "language"})); - }); - group(ui, |ui| { - big(app, ui, Icon::Compare, "Compare", "app.compareDialog", json!({})); - let comparing = enabled(app, "review.endCompare"); - if big_button(ui, Icon::Check, "Accept\nAll", comparing).clicked() { - run(app, "review.accept", json!({"all": true})); - } - if big_button(ui, Icon::Close, "End\nReview", comparing).clicked() { - run(app, "review.endCompare", json!({})); - if app.ui.pane.as_deref() == Some("revisions") { - app.ui.pane = None; - } - } - }); - group(ui, |ui| { - big(app, ui, Icon::NewComment, "New\nComment", "app.dialog", json!({"id": "comment"})); - rows(ui, |ui| { - comment_nav(app, ui, false); - comment_nav(app, ui, true); - }); - big(app, ui, Icon::DeleteComment, "Delete", "comment.delete", json!({})); - let show = app.ui.pane.as_deref() == Some("comments"); - if big_button_ex(ui, Icon::ShowComments, "Show\nComments", true, false, show).0.clicked() { - let _ = app.run("view.pane", json!({"pane": "comments", "toggle": true})); - } - }); -} - -fn view(app: &mut SlideApp, ui: &mut Ui) { - use crate::ViewMode; - group(ui, |ui| { - for (icon, label, id, mode) in [ - (Icon::ViewNormal, "Normal", "view.normal", Some(ViewMode::Normal)), - (Icon::ViewOutline, "Outline\nView", "view.outline", Some(ViewMode::Outline)), - (Icon::ViewSorter, "Slide\nSorter", "view.sorter", Some(ViewMode::Sorter)), - (Icon::ViewNotesPage, "Notes\nPage", "view.notesPage", Some(ViewMode::NotesPage)), - (Icon::ViewReading, "Reading\nView", "view.reading", None), - ] { - let sel = mode.is_some_and(|m| app.ui.view == m); - if big_button_ex(ui, icon, label, true, false, sel).0.clicked() { - let _ = app.run(id, json!({})); - } - } - }); - group(ui, |ui| { - let in_master = app.session.active().is_some_and(|d| d.selection.target != Target::Slides); - if big_button_ex(ui, Icon::SlideMaster, "Slide\nMaster", true, false, in_master).0.clicked() { - let _ = app.run(if in_master { "view.closeMaster" } else { "view.slideMaster" }, json!({})); - } - let in_handout = app.session.active().is_some_and(|d| d.selection.target == Target::Handout); - if big_button_ex(ui, Icon::HandoutMaster, "Handout\nMaster", true, false, in_handout).0.clicked() { - let _ = app.run(if in_handout { "view.closeMaster" } else { "view.handoutMaster" }, json!({})); - } - let in_notes = app.session.active().is_some_and(|d| d.selection.target == Target::NotesMaster); - if big_button_ex(ui, Icon::NotesMaster, "Notes\nMaster", true, false, in_notes).0.clicked() { - let _ = app.run(if in_notes { "view.closeMaster" } else { "view.notesMaster" }, json!({})); - } - }); - group(ui, |ui| { - rows(ui, |ui| { - let mut r = app.ui.ruler; - if ui.checkbox(&mut r, "Ruler").changed() { - app.ui.ruler = r; - } - let mut g = app.ui.gridlines; - if ui.checkbox(&mut g, "Gridlines").changed() { - app.ui.gridlines = g; - } - let mut gd = app.ui.guides; - if ui.checkbox(&mut gd, "Guides").changed() { - app.ui.guides = gd; - } - }); - if big_button(ui, Icon::Gridlines, "Grid\nOptions", true).clicked() { - let _ = app.run("view.gridOptions", json!({})); - } - let n = app.ui.notes; - if big_button_ex(ui, Icon::Notes, "Notes", true, false, n).0.clicked() { - app.ui.notes = !n; - } - }); - group(ui, |ui| { - let (main, arrow) = big_button_ex(ui, Icon::ZoomGlass, "Zoom", true, true, false); - if main.on_hover_text("Zoom").clicked() { - let _ = app.run("view.zoomDialog", json!({})); - } - if let Some(a) = arrow { - egui::Popup::menu(&a).show(|ui| { - for pct in [400, 200, 150, 100, 75, 66, 50, 33] { - if ui.button(format!("{pct}%")).clicked() { - let _ = app.run("view.zoom", json!({"percent": pct})); - ui.close(); - } - } - if ui.button("Fit").clicked() { - let _ = app.run("view.fit", json!({})); - ui.close(); - } - }); - } - if big_button(ui, Icon::FitToWindow, "Fit to\nWindow", true).clicked() { - app.ui.zoom = None; - } - }); - group(ui, |ui| { - let has = app.session.active().is_some(); - let on = app.doc_window.is_some(); - let resp = big_button_ex(ui, Icon::NewWindow, "New\nWindow", has, false, on).0; - if resp.on_hover_text("Open another window on this presentation").clicked() { - let _ = app.run("view.newWindow", json!({})); - } - }); - group(ui, |ui| { - let gs = app.ui.grayscale; - if big_button_ex(ui, Icon::Grayscale, "Grayscale", true, false, gs).0.clicked() { - app.ui.grayscale = !gs; - } - let dark = app.ui.brightness == theme::Brightness::Dark; - if big_button_ex(ui, Icon::Palette, "Dark\nMode", true, false, dark).0.clicked() { - let _ = app.run("view.dark", json!({})); - } - }); -} - -// ---------- contextual tabs ---------- - -/// Shape Format ▸ Text Effects ▸ Transform: No Transform, Follow Path, then the other presets. -fn transform_gallery(app: &mut SlideApp, ui: &mut Ui) { - let current = selected_warp(app); - ui.label(egui::RichText::new("Transform").font(theme::bold(12.0))); - ui.label("Follow Path"); - let mut picked = false; - ui.horizontal_wrapped(|ui| { - ui.spacing_mut().item_spacing = vec2(2.0, 2.0); - for p in deckcraft_geom::warp::CATALOG.iter().filter(|p| deckcraft_geom::warp::is_follow(p.id)) { - if warp_tile(ui, p.id, p.label, current.as_deref() == Some(p.id)) { - run(app, "format.textWarp", json!({"preset": p.id})); - picked = true; - } - } - }); - ui.label("Warp"); - egui::ScrollArea::vertical().max_height(220.0).id_salt("text_warp_gallery").show(ui, |ui| { - ui.horizontal_wrapped(|ui| { - ui.spacing_mut().item_spacing = vec2(2.0, 2.0); - if warp_tile(ui, "", "No Transform", current.is_none()) { - run(app, "format.textWarp", json!({"preset": "none"})); - picked = true; - } - for p in deckcraft_geom::warp::CATALOG.iter().filter(|p| !deckcraft_geom::warp::is_follow(p.id)) { - if warp_tile(ui, p.id, p.label, current.as_deref() == Some(p.id)) { - run(app, "format.textWarp", json!({"preset": p.id})); - picked = true; - } - } - }); - }); - if picked { - ui.close(); - } -} - -fn selected_warp(app: &SlideApp) -> Option { - let doc = app.session.active()?; - let id = doc.selection.text.as_ref().map(|t| t.shape).or_else(|| doc.selection.shapes.first().copied())?; - doc.shape(id)?.text.as_ref()?.body.warp.clone() -} - -fn warp_tile(ui: &mut Ui, preset: &str, label: &str, selected: bool) -> bool { - let t = Tokens::get(ui.ctx()); - let (rect, resp) = ui.allocate_exact_size(vec2(34.0, 28.0), Sense::click()); - if selected { - ui.painter().rect_filled(rect, CornerRadius::same(3), t.selected); - } else if resp.hovered() { - ui.painter().rect_filled(rect, CornerRadius::same(3), t.hover); - } - paint_warp_icon(ui.painter(), rect.shrink(4.0), (!preset.is_empty()).then_some(preset), t.text); - resp.on_hover_text(label).clicked() -} - -/// The preset's guides, or two straight lines for No Transform. -fn paint_warp_icon(p: &egui::Painter, r: Rect, name: Option<&str>, color: Color32) { - let Some(name) = name else { - let y0 = r.min.y + r.height() * 0.32; - let y1 = r.min.y + r.height() * 0.68; - p.line_segment([pos2(r.min.x, y0), pos2(r.max.x, y0)], Stroke::new(1.0, color)); - p.line_segment([pos2(r.min.x, y1), pos2(r.max.x, y1)], Stroke::new(1.0, color)); - return; - }; - let rect = deckcraft_geom::Rect::new((r.min.x + 1.0) as f64, (r.min.y + 1.0) as f64, (r.max.x - 1.0) as f64, (r.max.y - 1.0) as f64); - let Some(w) = deckcraft_geom::warp::warp(name, &[], rect) else { return }; - for band in &w.bands { - match band { - deckcraft_geom::warp::Band::Fill { top, bottom } => { - stroke_guide(p, top, color); - stroke_guide(p, bottom, color); - } - deckcraft_geom::warp::Band::Follow { path, .. } => stroke_guide(p, path, color), - } - } -} - -fn stroke_guide(p: &egui::Painter, g: &deckcraft_geom::warp::Guide, color: Color32) { - let len = g.length(); - if !(len.is_finite() && len > 1.0) { - return; - } - let n = 20i32; - let mut pts = Vec::with_capacity((n + 1) as usize); - for i in 0..=n { - let (q, _) = g.at(len * f64::from(i) / f64::from(n)); - if q.x.is_finite() && q.y.is_finite() { - pts.push(pos2(q.x as f32, q.y as f32)); - } - } - if pts.len() >= 2 { - p.add(egui::Shape::Path(egui::epaint::PathShape { - points: pts, - closed: false, - fill: Color32::TRANSPARENT, - stroke: Stroke::new(1.0, color).into(), - })); - } -} - -/// Render the Shape Format tab's shape, fill, outline, effects, text, arrange, and size controls. -fn shape_format(app: &mut SlideApp, ui: &mut Ui) { - let sc = scheme(app); - let sel = enabled(app, "shape.fill"); - group(ui, |ui| { - let r = big_button(ui, Icon::Shapes, "Shapes", true); - egui::Popup::menu(&r).show(|ui| { - if let Some(p) = shapes_gallery(ui) { - app.session.set_tool(deckcraft_engine::ToolKind::Shape { preset: p.to_string() }); - } - }); - big(app, ui, Icon::TextBox, "Text\nBox", "insert.textBox", json!({})); - let r = big_button(ui, Icon::EditShape, "Edit\nShape", sel); - egui::Popup::menu(&r).show(|ui| { - ui.label("Change Shape"); - if let Some(p) = shapes_gallery(ui) { - run(app, "shape.change", json!({"preset": p})); - } - ui.separator(); - if ui.add_enabled(enabled(app, "shape.editPoints"), egui::Button::new("Edit Points")).clicked() { - run(app, "shape.editPoints", json!({"on": true})); - ui.close(); - } - ui.separator(); - smooth_corners_row(app, ui); - }); - let can_merge = enabled(app, "shape.merge"); - let r = big_button(ui, Icon::MergeShapes, "Merge\nShapes", can_merge).on_hover_text("Merge Shapes"); - egui::Popup::menu(&r).show(|ui| { - for (op, label) in deckcraft_engine::cmd::merge::OPS { - if ui.button(label).clicked() { - run(app, "shape.merge", json!({"op": op})); - ui.close(); - } - } - }); - }); - group(ui, |ui| { - let r = big_button(ui, Icon::QuickStyles, "Shape\nStyles", sel); - egui::Popup::menu(&r).width(330.0).show(|ui| quick_styles(app, ui)); - rows(ui, |ui| { - let (m, a) = small_button_ex(ui, Icon::ShapeFill, "Shape Fill", sel, false, true); - if m.clicked() { - let c = app.last_fill.clone(); - run(app, "shape.fill", json!({"color": c})); - } - if let Some(a) = a { - widgets::color_popup(&a).show(|ui| { - if let Some(c) = widgets::color_grid(ui, &sc, Some("No Fill")) { - match c { - Some(c) => { - app.last_fill = cref_param(&c); - run(app, "shape.fill", json!({"color": cref_param(&c)})); - } - None => run(app, "shape.fill", json!({"none": true})), - } - } - if ui.button("Gradient…").clicked() { - let _ = app.run("view.pane", json!({"pane": "format", "tab": "fill"})); - ui.close(); - } - }); - } - let (m, a) = small_button_ex(ui, Icon::ShapeOutline, "Shape Outline", sel, false, true); - if m.clicked() { - let c = app.last_outline.clone(); - run(app, "shape.line", json!({"color": c})); - } - if let Some(a) = a { - widgets::color_popup(&a).show(|ui| outline_menu(app, ui, &sc)); - } - let (main, arrow) = small_button_ex(ui, Icon::ShapeEffects, "Shape Effects", sel, false, true); - if let Some(arrow) = arrow { - widgets::split_menu(&main, &arrow, |ui| effects_menu(app, ui)); - } - }); - }); - group(ui, |ui| { - rows(ui, |ui| { - let (main, arrow) = small_button_ex(ui, Icon::TextFill, "Text Fill", sel, false, true); - if let Some(arrow) = arrow { - widgets::split_color_popup(&main, &arrow).show(|ui| { - if let Some(Some(c)) = widgets::color_grid(ui, &sc, None) { - run(app, "format.color", json!({"color": cref_param(&c)})); - } - if let Some(sh) = app.session.active().and_then(|d| d.selected_shapes().first().map(|s| (*s).clone())) { - ui.separator(); - crate::panes::text_transparency_slider(app, ui, &sh); - } - }); - } - let (main, arrow) = small_button_ex(ui, Icon::TextOutline, "Text Outline", sel, false, true); - if let Some(arrow) = arrow { - widgets::split_color_popup(&main, &arrow).show(|ui| { - if let Some(c) = widgets::color_grid(ui, &sc, Some("No Outline")) { - run(app, "format.textOutline", json!({"color": c.map(|c| cref_param(&c))})); - } - }); - } - let r = small_button(ui, Icon::TextEffects, "Text Effects", sel, false); - egui::Popup::menu(&r).width(312.0).show(|ui| { - if ui.button("Shadow").clicked() { - run(app, "format.textShadow", json!({})); - ui.close(); - } - ui.separator(); - transform_gallery(app, ui); - }); - }); - }); - group(ui, |ui| { - big(app, ui, Icon::AltText, "Alt\nText", "app.dialog", json!({"id": "altText"})); - }); - arrange_group(app, ui); - size_group(app, ui); - group(ui, |ui| { - big(app, ui, Icon::FormatBackground, "Format\nPane", "view.pane", json!({"pane": "format"})); - }); -} - -/// Render ordering, align, group/ungroup, and rotate controls for the current selection. -fn arrange_group(app: &mut SlideApp, ui: &mut Ui) { - group(ui, |ui| { - let sel = enabled(app, "arrange.bringForward"); - let (m, a) = big_button_ex(ui, Icon::BringForward, "Bring\nForward", sel, true, false); - if m.clicked() { - run(app, "arrange.bringForward", json!({})); - } - if let Some(a) = a { - egui::Popup::menu(&a).show(|ui| { - menu_item(app, ui, Icon::BringForward, "Bring Forward", "arrange.bringForward", json!({})); - menu_item(app, ui, Icon::BringToFront, "Bring to Front", "arrange.bringToFront", json!({})); - }); - } - let (m, a) = big_button_ex(ui, Icon::SendBackward, "Send\nBackward", sel, true, false); - if m.clicked() { - run(app, "arrange.sendBackward", json!({})); - } - if let Some(a) = a { - egui::Popup::menu(&a).show(|ui| { - menu_item(app, ui, Icon::SendBackward, "Send Backward", "arrange.sendBackward", json!({})); - menu_item(app, ui, Icon::SendToBack, "Send to Back", "arrange.sendToBack", json!({})); - }); - } - big(app, ui, Icon::SelectionPane, "Selection\nPane", "view.pane", json!({"pane": "selection"})); - let r = big_button(ui, Icon::AlignObjects, "Align", sel); - egui::Popup::menu(&r).show(|ui| align_menu(app, ui)); - rows(ui, |ui| { - let (main, arrow) = small_button_ex(ui, Icon::Group, "Group", sel, false, true); - if main.clicked() { - run(app, "arrange.group", json!({})); - } - if let Some(arrow) = arrow { - egui::Popup::menu(&arrow).show(|ui| { - menu_item(app, ui, Icon::Group, "Group", "arrange.group", json!({})); - menu_item(app, ui, Icon::Ungroup, "Ungroup", "arrange.ungroup", json!({})); - menu_item(app, ui, Icon::Group, "Regroup", "arrange.regroup", json!({})); - }); - } - let (main, arrow) = small_button_ex(ui, Icon::Rotate, "Rotate", sel, false, true); - if main.clicked() { - run(app, "arrange.rotateRight", json!({})); - } - if let Some(arrow) = arrow { - egui::Popup::menu(&arrow).show(|ui| { - menu_item(app, ui, Icon::RotateRight, "Rotate Right 90°", "arrange.rotateRight", json!({})); - menu_item(app, ui, Icon::RotateLeft, "Rotate Left 90°", "arrange.rotateLeft", json!({})); - menu_item(app, ui, Icon::FlipV, "Flip Vertical", "arrange.flipVertical", json!({})); - menu_item(app, ui, Icon::FlipH, "Flip Horizontal", "arrange.flipHorizontal", json!({})); - }); - } - }); - }); -} - -/// Render width and height controls bound to the selected shape, plus the size-position dialog. -fn size_group(app: &mut SlideApp, ui: &mut Ui) { - let bx = app - .session - .active() - .and_then(|d| d.selection.shapes.first().and_then(|id| d.shape(*id).map(|s| deckcraft_engine::cmd::xfrm_of(&d.doc, &d.selection, s)))); - group(ui, |ui| { - rows(ui, |ui| { - let Some(x) = bx else { - ui.label("Height:"); - ui.label("Width:"); - return; - }; - let len = app.session.prefs.units; - let (ppu, suffix) = (len.points_per_unit(), len.suffix()); - // " cm" needs more room than the inch mark. - let width = if len == deckcraft_geom::Unit::Inches { 54.0 } else { 66.0 }; - ui.horizontal(|ui| { - ui.label("Height:"); - if let Some(v) = widgets::spinner(ui, "sh", "Height", x.h / ppu, 0.1, suffix, width) { - run(app, "shape.resize", json!({"h": v.max(0.0) * ppu})); - } - }); - ui.horizontal(|ui| { - ui.label("Width: "); - if let Some(v) = widgets::spinner(ui, "sw", "Width", x.w / ppu, 0.1, suffix, width) { - run(app, "shape.resize", json!({"w": v.max(0.0) * ppu})); - } - }); - }); - if ui.add_enabled(bx.is_some(), egui::Button::new("Size and Position…")).clicked() { - run(app, "shape.sizePosition", json!({})); - } - }); -} - -/// Render the Picture Format tab's corrections, colour, artistic effects, transparency, styles, -/// border, and picture actions. -fn picture_format(app: &mut SlideApp, ui: &mut Ui) { - group(ui, |ui| { - let r = big_button(ui, Icon::RemoveBackground, "Remove\nBackground", enabled(app, "picture.removeBackground")); - egui::Popup::menu(&r).show(|ui| { - for (label, tol) in [("Remove Background", 20.0), ("Remove More (looser)", 35.0), ("Remove Less (tighter)", 10.0)] { - if ui.button(label).clicked() { - run(app, "picture.removeBackground", json!({"tolerance": tol})); - } - } - }); - let r = big_button(ui, Icon::Corrections, "Corrections", true); - egui::Popup::menu(&r).show(|ui| { - for b in [-0.4, -0.2, 0.0, 0.2, 0.4] { - if ui.button(format!("Brightness {:+}%", (b * 100.0) as i32)).clicked() { - run(app, "picture.adjust", json!({"brightness": b})); - } - } - ui.separator(); - for c in [-0.4, -0.2, 0.0, 0.2, 0.4] { - if ui.button(format!("Contrast {:+}%", (c * 100.0) as i32)).clicked() { - run(app, "picture.adjust", json!({"contrast": c})); - } - } - }); - let r = big_button(ui, Icon::ColorAdjust, "Color", true); - egui::Popup::menu(&r).show(|ui| { - for s in [0.0, 0.33, 0.66, 1.0, 1.5, 2.0, 3.0] { - if ui.button(format!("Saturation {}%", (s * 100.0) as i32)).clicked() { - run(app, "picture.adjust", json!({"saturation": s})); - } - } - ui.separator(); - ui.label("Recolor"); - let sc = scheme(app); - let presets = deckcraft_engine::cmd::recolor::presets(); - let names: Vec = presets.iter().map(|(_, l)| l.clone()).collect(); - if let Some(i) = widgets::gallery( - ui, - presets.len(), - vec2(40.0, 30.0), - None, - |p, r, i| { - if let Some((id, _)) = presets.get(i) { - paint_recolor(p, r, id, &sc); - } - }, - |i| names.get(i).cloned().unwrap_or_default(), - ) && let Some((id, _)) = presets.get(i) - { - run(app, "picture.recolor", json!({"preset": id})); - } - ui.separator(); - // As in PowerPoint, the pointer picks the colour in the picture itself: a colour - // chosen from a grid almost never matches a photo's pixels. - if ui.button("Set Transparent Color").on_hover_text("Click a colour in the picture to make it transparent").clicked() { - crate::eyedropper::request_transparent_color(ui.ctx()); - ui.close(); - } - }); - let r = big_button(ui, Icon::ArtisticEffects, "Artistic\nEffects", enabled(app, "picture.artisticEffect")); - egui::Popup::menu(&r).show(|ui| { - if ui.button("None").clicked() { - run(app, "picture.artisticEffect", json!({"effect": "none"})); - } - for (e, l) in deckcraft_engine::cmd::artistic::EFFECTS { - if ui.button(*l).clicked() { - run(app, "picture.artisticEffect", json!({"effect": e})); - } - } - }); - let r = big_button(ui, Icon::Transparency, "Transparency", true); - egui::Popup::menu(&r).show(|ui| { - for tr in [0.0, 0.15, 0.3, 0.5, 0.65, 0.8, 0.95] { - if ui.button(format!("{}%", (tr * 100.0) as i32)).clicked() { - run(app, "picture.adjust", json!({"transparency": tr})); - } - } - }); - rows(ui, |ui| { - small(app, ui, Icon::ChangePicture, "Change Picture", "app.changePictureDialog", json!({})); - small(app, ui, Icon::ResetPicture, "Reset Picture", "picture.reset", json!({})); - let (main, arrow) = small_button_ex(ui, Icon::CompressPictures, "Compress Pictures", enabled(app, "picture.compress"), false, true); - if let Some(arrow) = arrow { - widgets::split_popup(&main, &arrow) - .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside) - .width(260.0) - .show(|ui| compress_menu(app, ui)); - } - }); - }); - group(ui, |ui| { - use deckcraft_engine::cmd::picture_style::{STYLES, look}; - let names: Vec = STYLES.iter().map(|(_, l)| l.to_string()).collect(); - if let Some(i) = widgets::gallery( - ui, - STYLES.len(), - vec2(56.0, 44.0), - None, - |p, r, i| paint_picture_style(p, r, STYLES.get(i).and_then(|(id, _)| look(id))), - |i| names.get(i).cloned().unwrap_or_default(), - ) && let Some((id, _)) = STYLES.get(i) - { - run(app, "picture.style", json!({"style": id})); - } - }); - group(ui, |ui| { - let sc = scheme(app); - let (m, a) = color_split(ui, Icon::QuickStyles, "Picture Border", Color32::BLACK, true); - if m.clicked() { - run(app, "shape.line", json!({"color": "#000000", "width": 1.0})); - } - widgets::color_popup(&a).show(|ui| { - if let Some(c) = widgets::color_grid(ui, &sc, Some("No Outline")) { - match c { - Some(c) => run(app, "shape.line", json!({"color": cref_param(&c)})), - None => run(app, "shape.line", json!({"none": true})), - } - } - ui.separator(); - ui.menu_button("Weight", |ui| { - for w in [0.25, 0.5, 0.75, 1.0, 1.5, 2.25, 3.0, 4.5, 6.0] { - if ui.button(format!("{w} pt")).clicked() { - run(app, "shape.line", json!({"width": w})); - } - } - }); - ui.menu_button("Dashes", |ui| { - for (l, d) in [ - ("Solid", "solid"), - ("Round Dot", "sysDot"), - ("Square Dot", "sysDash"), - ("Dash", "dash"), - ("Dash Dot", "dashDot"), - ("Long Dash", "lgDash"), - ] { - if ui.button(l).clicked() { - run(app, "shape.line", json!({"dash": d})); - } - } - }); - }); - if ui.small_button("Reset Style").on_hover_text("Remove the picture style").clicked() { - run(app, "picture.style", json!({"style": "none"})); - } - big(app, ui, Icon::AltText, "Alt\nText", "app.dialog", json!({"id": "altText"})); - }); - arrange_group(app, ui); - group(ui, |ui| { - let r = big_button(ui, Icon::Crop, "Crop", true); - egui::Popup::menu(&r).show(|ui| { - ui.label("Crop to Shape"); - if let Some(p) = shapes_gallery(ui) { - run(app, "shape.change", json!({"preset": p})); - } - ui.separator(); - for (l, v) in [("Crop 10% each side", 0.1), ("Crop 20% each side", 0.2), ("Reset Crop", 0.0)] { - if ui.button(l).clicked() { - run(app, "picture.crop", json!({"left": v, "top": v, "right": v, "bottom": v})); - } - } - }); - }); - size_group(app, ui); -} - -fn table_design(app: &mut SlideApp, ui: &mut Ui) { - let tbl = app - .session - .active() - .and_then(|d| d.selected_shapes().into_iter().find_map(|s| if let ShapeKind::Table(t) = &s.kind { Some(t.clone()) } else { None })); - group(ui, |ui| { - rows(ui, |ui| { - let flags = tbl.as_ref().map(|t| (t.first_row, t.last_row, t.band_row, t.first_col, t.last_col, t.band_col)).unwrap_or_default(); - ui.horizontal(|ui| { - let mut a = flags.0; - if ui.checkbox(&mut a, "Header Row").changed() { - run(app, "table.options", json!({"headerRow": a})); - } - let mut d = flags.3; - if ui.checkbox(&mut d, "First Column").changed() { - run(app, "table.options", json!({"firstColumn": d})); - } - }); - ui.horizontal(|ui| { - let mut b = flags.1; - if ui.checkbox(&mut b, "Total Row").changed() { - run(app, "table.options", json!({"totalRow": b})); - } - let mut e = flags.4; - if ui.checkbox(&mut e, "Last Column").changed() { - run(app, "table.options", json!({"lastColumn": e})); - } - }); - ui.horizontal(|ui| { - let mut c = flags.2; - if ui.checkbox(&mut c, "Banded Rows").changed() { - run(app, "table.options", json!({"bandedRows": c})); - } - let mut f = flags.5; - if ui.checkbox(&mut f, "Banded Columns").changed() { - run(app, "table.options", json!({"bandedColumns": f})); - } - }); - }); - }); - group(ui, |ui| { - let styles = deckcraft_model::table::table_styles(); - let quick: Vec<_> = styles.iter().filter(|(id, _)| id.starts_with("medium2") || id == "none" || id == "grid").take(8).collect(); - let cur = tbl.as_ref().map(|t| t.style.clone()).unwrap_or_default(); - let sel = quick.iter().position(|(id, _)| *id == cur); - let sc = scheme(app); - if let Some(i) = widgets::gallery( - ui, - quick.len(), - vec2(48.0, 40.0), - sel, - |p, r, i| { - if let Some((id, _)) = quick.get(i) { - paint_table_style(p, r, id, &sc); - } - }, - |i| quick.get(i).map(|x| x.1.clone()).unwrap_or_default(), - ) && let Some((id, _)) = quick.get(i) - { - run(app, "table.style", json!({"style": id})); - } - let more = widgets::drop_button(ui, "", vec2(14.0, 40.0), true); - egui::Popup::menu(&more).width(360.0).show(|ui| { - ui.horizontal_wrapped(|ui| { - for (id, label) in &styles { - let (r, resp) = ui.allocate_exact_size(vec2(48.0, 36.0), Sense::click()); - paint_table_style(ui.painter(), r.shrink(2.0), id, &sc); - if resp.on_hover_text(label).clicked() { - run(app, "table.style", json!({"style": id})); - ui.close(); - } - } - }); - }); - }); - group(ui, |ui| { - let sc = scheme(app); - let (m, a) = color_split(ui, Icon::Shading, "Shading", Color32::from_rgb(0x2E, 0x6F, 0xD8), true); - if m.clicked() { - run(app, "table.cellFill", json!({"color": "accent1"})); - } - widgets::color_popup(&a).show(|ui| { - if let Some(c) = widgets::color_grid(ui, &sc, Some("No Fill")) { - match c { - Some(c) => run(app, "table.cellFill", json!({"color": cref_param(&c)})), - None => run(app, "table.cellFill", json!({"none": true})), - } - } - }); - }); - group(ui, |ui| { - let r = big_button(ui, Icon::Borders, "Borders", true); - egui::Popup::menu(&r).show(|ui| { - let pen = app.table_pen.clone(); - for (label, which) in [ - ("No Border", "none"), - ("All Borders", "all"), - ("Outside Borders", "outside"), - ("Inside Borders", "inside"), - ("Top Border", "top"), - ("Bottom Border", "bottom"), - ("Left Border", "left"), - ("Right Border", "right"), - ("Inside Horizontal Border", "insideH"), - ("Inside Vertical Border", "insideV"), - ("Diagonal Down Border", "diagDown"), - ("Diagonal Up Border", "diagUp"), - ] { - let mut p = pen.clone(); - p["which"] = json!(which); - menu_item(app, ui, Icon::Borders, label, "table.borders", p); - } - ui.separator(); - ui.menu_button("Pen Weight", |ui| { - for w in [0.25, 0.5, 0.75, 1.0, 1.5, 2.25, 3.0, 4.5, 6.0] { - let cur = app.table_pen.get("width").and_then(Value::as_f64) == Some(w); - if ui.radio(cur, format!("{w} pt")).clicked() { - app.table_pen["width"] = json!(w); - } - } - }); - ui.menu_button("Pen Style", |ui| { - for (l, d) in [("Solid", "solid"), ("Dash", "dash"), ("Dot", "sysDot"), ("Dash Dot", "dashDot"), ("Long Dash", "lgDash")] { - let cur = app.table_pen.get("dash").and_then(Value::as_str).unwrap_or("solid") == d; - if ui.radio(cur, l).clicked() { - app.table_pen["dash"] = json!(d); - } - } - }); - ui.menu_button("Pen Color", |ui| { - let sc = scheme(app); - if let Some(Some(c)) = widgets::color_grid(ui, &sc, None) { - app.table_pen["color"] = cref_param(&c); - } - }); - }); - }); - // Draw Borders: the pencil splits cells along a line, the eraser merges across a border. - group(ui, |ui| { - use deckcraft_engine::ToolKind; - use deckcraft_engine::tools::{DRAW_TABLE, TABLE_ERASER}; - let cur = app.session.tool.kind.clone(); - for (icon, label, preset) in [(Icon::Pencil, "Draw Table", DRAW_TABLE), (Icon::Eraser, "Table Eraser", TABLE_ERASER)] { - let on = matches!(&cur, ToolKind::Shape { preset: p } if p == preset); - if big_button_ex(ui, icon, label, true, false, on).0.clicked() { - app.session.set_tool(if on { ToolKind::Select } else { ToolKind::Shape { preset: preset.into() } }); - } - } - }); -} - -/// A Picture Styles gallery tile: a landscape "picture" in the style's shape and frame. -fn paint_picture_style(p: &egui::Painter, r: Rect, lk: Option) { - let Some(lk) = lk else { return }; - let r = r.shrink(7.0); - if lk.shadow.is_some() { - p.rect_filled(r.translate(vec2(2.0, 2.0)), CornerRadius::same(2), Color32::from_black_alpha(60)); - } - let sky = Color32::from_rgb(0x7F, 0xB2, 0xE5); - let frame = lk.frame.map(|(c, w)| Stroke::new((w as f32 / 2.5).clamp(1.0, 4.0), theme::to_color32(c))); - let edge = Stroke::new(0.5, Color32::from_gray(150)); - match lk.preset { - "ellipse" => { - p.add(egui::Shape::ellipse_filled(r.center(), r.size() / 2.0, sky)); - p.add(egui::Shape::ellipse_stroke(r.center(), r.size() / 2.0, frame.unwrap_or(edge))); - } - preset => { - let round = if preset == "rect" { 0 } else { 6 }; - p.rect_filled(r, CornerRadius::same(round), sky); - p.rect_stroke(r, CornerRadius::same(round), frame.unwrap_or(edge), egui::StrokeKind::Middle); - } - } - // Hills, so the tile reads as a picture. - let base = r.bottom() - r.height() * 0.15; - p.line_segment( - [pos2(r.left() + r.width() * 0.2, base), pos2(r.center().x, r.top() + r.height() * 0.45)], - Stroke::new(1.5, Color32::from_rgb(0x3A, 0x7D, 0x44)), - ); - p.line_segment( - [pos2(r.center().x, r.top() + r.height() * 0.45), pos2(r.right() - r.width() * 0.2, base)], - Stroke::new(1.5, Color32::from_rgb(0x3A, 0x7D, 0x44)), - ); - if lk.reflection { - p.rect_filled( - Rect::from_min_max(pos2(r.left(), r.bottom() + 1.0), pos2(r.right(), r.bottom() + 5.0)), - CornerRadius::ZERO, - sky.gamma_multiply(0.3), - ); - } -} - -/// A Recolor gallery tile: a dark-to-light ramp as the preset would draw it. -fn paint_recolor(p: &egui::Painter, r: Rect, id: &str, sc: &deckcraft_color::ColorScheme) { - let mut adj = deckcraft_model::style::PictureAdjust::default(); - deckcraft_engine::cmd::recolor::apply(&mut adj, id); - let r = r.shrink(3.0); - let n = 8; - for k in 0..n { - let t = k as f64 / (n - 1) as f64; - // A colourful ramp, so "No Recolor" shows colour and the presets show their mapping. - let src = [t * 255.0, (0.4 + 0.6 * t) * 200.0, (1.0 - t) * 220.0]; - let y = 0.299 * src[0] + 0.587 * src[1] + 0.114 * src[2]; - let mut rgb = if adj.grayscale { [y; 3] } else { src }; - if adj.brightness != 0.0 || adj.contrast != 0.0 { - rgb = rgb.map(|v| (v - 127.5) * (1.0 + adj.contrast) + 127.5 + adj.brightness * 255.0); - } - if let Some((d, l)) = &adj.duotone { - let (d, l) = (d.resolve(sc, None), l.resolve(sc, None)); - let u = y / 255.0; - rgb = - [d.r as f64 + (l.r as f64 - d.r as f64) * u, d.g as f64 + (l.g as f64 - d.g as f64) * u, d.b as f64 + (l.b as f64 - d.b as f64) * u]; - } - if let Some(th) = adj.bilevel { - rgb = [if y >= th * 255.0 { 255.0 } else { 0.0 }; 3]; - } - let c = Color32::from_rgb(rgb[0].clamp(0.0, 255.0) as u8, rgb[1].clamp(0.0, 255.0) as u8, rgb[2].clamp(0.0, 255.0) as u8); - let w = r.width() / n as f32; - p.rect_filled(Rect::from_min_size(pos2(r.left() + k as f32 * w, r.top()), vec2(w + 0.5, r.height())), CornerRadius::ZERO, c); - } - p.rect_stroke(r, CornerRadius::ZERO, Stroke::new(0.5, Color32::from_gray(160)), egui::StrokeKind::Outside); -} - -pub fn paint_table_style(p: &egui::Painter, r: Rect, id: &str, sc: &deckcraft_color::ColorScheme) { - let slot = match id.rsplit('-').next().unwrap_or("accent1") { - "accent2" => deckcraft_color::SchemeSlot::Accent2, - "accent3" => deckcraft_color::SchemeSlot::Accent3, - "accent4" => deckcraft_color::SchemeSlot::Accent4, - "accent5" => deckcraft_color::SchemeSlot::Accent5, - "accent6" => deckcraft_color::SchemeSlot::Accent6, - "tx1" => deckcraft_color::SchemeSlot::Dk1, - _ => deckcraft_color::SchemeSlot::Accent1, - }; - let a = theme::to_color32(sc.get(slot)); - let kind = id.split('-').next().unwrap_or(""); - for row in 0..4 { - let y = r.min.y + row as f32 * r.height() / 4.0; - let rr = Rect::from_min_size(pos2(r.min.x, y), vec2(r.width(), r.height() / 4.0)); - let c = match (kind, row) { - ("none", _) | ("grid", _) => Color32::WHITE, - ("light1", 0) | ("light2", _) => Color32::WHITE, - ("dark1", 0) => Color32::BLACK, - ("dark1", _) => a.lerp_to_gamma(Color32::BLACK, 0.3), - (_, 0) => a, - (_, k) if k % 2 == 1 => a.lerp_to_gamma(Color32::WHITE, 0.6), - _ => a.lerp_to_gamma(Color32::WHITE, 0.8), - }; - p.rect_filled(rr, CornerRadius::ZERO, c); - p.line_segment([rr.left_bottom(), rr.right_bottom()], Stroke::new(0.5, if kind == "grid" { Color32::GRAY } else { Color32::WHITE })); - } - if kind == "grid" || kind == "light2" { - p.rect_stroke(r, CornerRadius::ZERO, Stroke::new(0.8, if kind == "grid" { Color32::GRAY } else { a }), egui::StrokeKind::Inside); - } -} - -fn table_layout(app: &mut SlideApp, ui: &mut Ui) { - group(ui, |ui| { - let r = big_button(ui, Icon::DeleteTable, "Delete", true); - egui::Popup::menu(&r).show(|ui| { - menu_item(app, ui, Icon::DeleteTable, "Delete Columns", "table.deleteColumn", json!({})); - menu_item(app, ui, Icon::DeleteTable, "Delete Rows", "table.deleteRow", json!({})); - menu_item(app, ui, Icon::DeleteTable, "Delete Table", "edit.delete", json!({})); - }); - big(app, ui, Icon::InsertAbove, "Insert\nAbove", "table.insertRowAbove", json!({})); - big(app, ui, Icon::InsertBelow, "Insert\nBelow", "table.insertRowBelow", json!({})); - big(app, ui, Icon::InsertLeft, "Insert\nLeft", "table.insertColumnLeft", json!({})); - big(app, ui, Icon::InsertRight, "Insert\nRight", "table.insertColumnRight", json!({})); - }); - group(ui, |ui| { - big(app, ui, Icon::MergeCells, "Merge\nCells", "table.merge", json!({})); - big(app, ui, Icon::SplitCells, "Split\nCells", "table.split", json!({})); - }); - group(ui, |ui| { - rows(ui, |ui| { - small(app, ui, Icon::DistributeV, "Distribute Rows", "table.distributeRows", json!({})); - small(app, ui, Icon::DistributeH, "Distribute Columns", "table.distributeColumns", json!({})); - }); - }); - group(ui, |ui| { - rows(ui, |ui| { - ui.horizontal(|ui| { - toggle(app, ui, Icon::AlignLeft, "Align Left", "format.alignLeft", false); - toggle(app, ui, Icon::AlignCenter, "Center", "format.alignCenter", false); - toggle(app, ui, Icon::AlignRight, "Align Right", "format.alignRight", false); - }); - ui.horizontal(|ui| { - for (l, a) in [("Top", "top"), ("Center", "middle"), ("Bottom", "bottom")] { - if ui.small_button(l).on_hover_text(format!("Align text to the {l} of the cell").to_lowercase()).clicked() { - run(app, "table.cellAlign", json!({"anchor": a})); - } - } - ui.menu_button("Cell Margins", |ui| { - for (l, preset) in [("Normal", "normal"), ("None", "none"), ("Narrow", "narrow"), ("Wide", "wide")] { - if ui.button(l).clicked() { - run(app, "table.cellMargins", json!({"preset": preset})); - ui.close(); - } - } - }); - }); - }); - }); - arrange_group(app, ui); -} - -fn chart_design(app: &mut SlideApp, ui: &mut Ui) { - group(ui, |ui| { - let r = big_button(ui, Icon::ChangeChartType, "Change\nChart Type", true); - egui::Popup::menu(&r).show(|ui| { - for k in chart_menu_types() { - let Some(k) = k else { - ui.separator(); - continue; - }; - if ui.button(k.label()).clicked() { - // Replace the chart's type keeping its data. - if let Some(id) = app.session.active().and_then(|d| d.selection.shapes.first().copied()) { - let _ = app.run("chart.type", json!({"id": id.0, "type": format!("{k:?}")})); - } - } - } - }); - big(app, ui, Icon::EditData, "Edit\nData", "app.dialog", json!({"id": "chartData"})); - }); - arrange_group(app, ui); -} - -/// SmartArt Design: Create Graphic (Text Pane, Add Shape, Promote, Demote, Remove) and Layouts. -fn smartart_design(app: &mut SlideApp, ui: &mut Ui) { - let sel = app.session.active().and_then(deckcraft_engine::cmd::smartart::selected); - let row = app.ui.smartart_row; - let (gid, n, kind, style, colors) = match &sel { - Some((id, d)) => (Some(id.0), d.nodes.len(), d.kind.clone(), d.style.clone(), d.colors.clone()), - None => (None, 0, String::new(), String::new(), String::new()), - }; - let at = row.min(n.saturating_sub(1)); - group(ui, |ui| { - if big_button(ui, Icon::SmartArt, "Text\nPane", gid.is_some()).on_hover_text("Type and organise the diagram's text").clicked() { - run(app, "view.pane", json!({"pane": "smartart", "toggle": true})); - } - rows(ui, |ui| { - let (main, arrow) = small_button_ex(ui, Icon::Plus, "Add Shape", gid.is_some(), false, true); - if let Some(arrow) = arrow { - widgets::split_menu(&main, &arrow, |ui| { - for (label, pos) in [ - ("Add Shape After", "after"), - ("Add Shape Before", "before"), - ("Add Shape Above", "above"), - ("Add Shape Below", "below"), - ("Add Assistant", "assistant"), - ] { - if ui.button(label).clicked() { - let index = if n == 0 { Value::Null } else { json!(at) }; - run(app, "smartArt.addNode", json!({"id": gid, "index": index, "position": pos})); - } - } - }); - } - if small_button(ui, Icon::DecreaseIndent, "Promote", n > 0, false).clicked() { - run(app, "smartArt.promote", json!({"id": gid, "index": at})); - } - if small_button(ui, Icon::IncreaseIndent, "Demote", n > 0, false).clicked() { - run(app, "smartArt.demote", json!({"id": gid, "index": at})); - } - }); - let pictures = kind.starts_with("picture"); - if small_button(ui, Icon::Picture, "Insert Picture", pictures && n > 0, false) - .on_hover_text("Put a picture in the selected node's picture placeholder") - .clicked() - { - run(app, "app.smartArtPictureDialog", json!({"id": gid, "index": at})); - } - }); - group(ui, |ui| { - let (main, arrow) = small_button_ex(ui, Icon::Layout, "Layouts", gid.is_some(), false, true); - 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()); - }); - } - let (main, arrow) = small_button_ex(ui, Icon::Layout, "Convert", gid.is_some(), false, true); - if let Some(arrow) = arrow { - widgets::split_menu(&main, &arrow, |ui| { - if ui.button("Convert to Text").clicked() { - run(app, "smartArt.toText", json!({"id": gid})); - } - if ui.button("Convert to Shapes").clicked() { - run(app, "smartArt.toShapes", json!({"id": gid})); - } - }); - } - }); - group(ui, |ui| { - let (main, arrow) = small_button_ex(ui, Icon::ChangeChartType, "Change Colors", gid.is_some(), false, true); - if let Some(arrow) = arrow { - widgets::split_menu(&main, &arrow, |ui| { - for (k, label) in deckcraft_engine::cmd::smartart::COLORS { - if matches!(k, "range23" | "accent1" | "accent1Grad") { - ui.separator(); - } - let on = colors == k || (colors.is_empty() && k == "colorful"); - if ui.selectable_label(on, label).clicked() { - run(app, "smartArt.colors", json!({"id": gid, "colors": k})); - } - } - }); - } - let (main, arrow) = small_button_ex(ui, Icon::SmartArt, "SmartArt Styles", gid.is_some(), false, true); - if let Some(arrow) = arrow { - widgets::split_menu(&main, &arrow, |ui| { - for (k, label) in deckcraft_engine::cmd::smartart::STYLES { - if k == "polished" { - ui.separator(); - ui.label("3-D"); - } - let on = style == k || (style.is_empty() && k == "flat"); - if ui.selectable_label(on, label).clicked() { - run(app, "smartArt.style", json!({"id": gid, "style": k})); - } - } - }); - } - }); - arrange_group(app, ui); -} - -fn playback(app: &mut SlideApp, ui: &mut Ui) { - let m = app - .session - .active() - .and_then(|d| d.selected_shapes().into_iter().find_map(|s| if let ShapeKind::Media(m) = &s.kind { Some(m.clone()) } else { None })); - group(ui, |ui| { - let playing = app.session.active().and_then(|d| d.selection.shapes.first().copied()).is_some_and(|id| app.media.is_playing(id)); - if big_button(ui, if playing { Icon::Pause } else { Icon::Play }, if playing { "Pause" } else { "Play" }, m.is_some()).clicked() { - run(app, "media.toggle", json!({})); - } - big(app, ui, Icon::TrimMedia, "Trim", "app.dialog", json!({"id": "trim"})); - }); - group(ui, |ui| { - rows(ui, |ui| { - let mut auto = m.as_ref().is_some_and(|x| x.autoplay); - if ui.checkbox(&mut auto, "Start Automatically").changed() { - run(app, "media.options", json!({"autoplay": auto})); - } - let mut across = m.as_ref().is_some_and(|x| x.play_across_slides); - if ui.checkbox(&mut across, "Play Across Slides").changed() { - run(app, "media.options", json!({"acrossSlides": across})); - } - let mut lp = m.as_ref().is_some_and(|x| x.loop_play); - if ui.checkbox(&mut lp, "Loop until Stopped").changed() { - run(app, "media.options", json!({"loop": lp})); - } - }); - rows(ui, |ui| { - let mut hide = m.as_ref().is_some_and(|x| x.hide_while_not_playing); - if ui.checkbox(&mut hide, "Hide During Show").changed() { - run(app, "media.options", json!({"hide": hide})); - } - let mut rw = m.as_ref().is_some_and(|x| x.rewind); - if ui.checkbox(&mut rw, "Rewind after Playing").changed() { - run(app, "media.options", json!({"rewind": rw})); - } - let mut fs = m.as_ref().is_some_and(|x| x.full_screen); - if ui.checkbox(&mut fs, "Play Full Screen").changed() { - run(app, "media.options", json!({"fullScreen": fs})); - } - }); - }); - group(ui, |ui| { - let r = big_button(ui, Icon::Volume, "Volume", m.is_some()); - egui::Popup::menu(&r).show(|ui| { - for (l, v) in [("Low", 0.25), ("Medium", 0.5), ("High", 1.0), ("Mute", 0.0)] { - if ui.button(l).clicked() { - run(app, "media.options", json!({"volume": v})); - } - } - }); - }); -} - -/// Preserve, and whether the open master or layout has a title or any footer placeholder. -fn master_checks(app: &SlideApp) -> (bool, bool, bool) { - let Some(st) = app.session.active() else { return (false, false, false) }; - let preserve = match st.selection.target { - Target::Master { master } => st.doc.masters.get(master).is_some_and(|m| m.preserve), - Target::Layout { master, layout } => st.doc.masters.get(master).and_then(|m| m.layouts.get(layout)).is_some_and(|l| l.preserve), - Target::Slides | Target::Handout | Target::NotesMaster => false, - }; - let title = st.shapes().iter().any(|sh| sh.ph_type().is_some_and(|k| k.is_title())); - let footers = st.shapes().iter().any(|sh| sh.ph_type().is_some_and(|k| k.is_footer_kind())); - (preserve, title, footers) -} - -fn slide_master(app: &mut SlideApp, ui: &mut Ui) { - group(ui, |ui| { - big(app, ui, Icon::NewSlide, "Insert\nLayout", "master.insertLayout", json!({})); - big(app, ui, Icon::DeleteSlide, "Delete", "master.deleteLayout", json!({})); - big(app, ui, Icon::Layout, "Rename", "app.dialog", json!({"id": "renameLayout"})); - }); - group(ui, |ui| { - rows(ui, |ui| { - let (preserve, title, footers) = master_checks(app); - let mut on = preserve; - if ui.checkbox(&mut on, "Preserve").changed() { - run(app, "master.preserve", json!({"preserve": on})); - } - let mut on = title; - if ui.checkbox(&mut on, "Title").changed() { - run(app, "master.placeholders", json!({"title": on})); - } - let mut on = footers; - if ui.checkbox(&mut on, "Footers").changed() { - run(app, "master.placeholders", json!({"footers": on})); - } - }); - }); - group(ui, |ui| { - let r = big_button(ui, Icon::Table, "Insert\nPlaceholder", true); - egui::Popup::menu(&r).show(|ui| { - for (l, k) in - [("Content", "content"), ("Text", "text"), ("Picture", "picture"), ("Chart", "chart"), ("Table", "table"), ("Media", "media")] - { - if ui.button(l).clicked() { - run(app, "master.insertPlaceholder", json!({"kind": k})); - } - } - }); - }); - group(ui, |ui| { - let r = big_button(ui, Icon::Themes, "Themes", true); - egui::Popup::menu(&r).show(|ui| { - for th in deckcraft_model::theme::builtin_themes() { - if ui.button(&th.name).clicked() { - run(app, "design.theme", json!({"name": th.name})); - } - } - ui.separator(); - if ui.button("Browse for Themes…").clicked() { - run(app, "app.browseThemeDialog", json!({})); - } - }); - big(app, ui, Icon::FormatBackground, "Background\nStyles", "view.pane", json!({"pane": "background"})); - }); - group(ui, |ui| { - if big_button(ui, Icon::Close, "Close\nMaster View", true).clicked() { - run(app, "view.closeMaster", json!({})); - } - }); -} - -fn notes_master(app: &mut SlideApp, ui: &mut Ui) { - group(ui, |ui| { - use deckcraft_model::PhType; - let shown = |app: &SlideApp, k: PhType| { - app.session.active().and_then(|d| d.doc.notes_master.as_ref()).is_some_and(|m| m.shapes.iter().any(|s| s.ph_type() == Some(k))) - }; - for col in [ - [("Header", "header", PhType::Header), ("Slide Image", "slideImage", PhType::SlideImage), ("Footer", "footer", PhType::Footer)], - [("Date", "date", PhType::Date), ("Body", "body", PhType::Body), ("Page Number", "pageNumber", PhType::SlideNum)], - ] { - rows(ui, |ui| { - for (label, key, k) in col { - let mut on = shown(app, k); - if ui.checkbox(&mut on, label).changed() { - run(app, "notes.placeholders", json!({ key: on })); - } - } - }); - } - }); - group(ui, |ui| { - big(app, ui, Icon::FormatBackground, "Background\nStyles", "view.pane", json!({"pane": "background"})); - }); - group(ui, |ui| { - if big_button(ui, Icon::Close, "Close\nMaster View", true).clicked() { - run(app, "view.closeMaster", json!({})); - } - }); -} - -fn handout_master(app: &mut SlideApp, ui: &mut Ui) { - group(ui, |ui| { - let r = big_button(ui, Icon::Layout, &format!("Slides Per\nPage: {}", app.ui.handout_per_page), true); - egui::Popup::menu(&r).show(|ui| { - for n in [1u8, 2, 3, 4, 6, 9] { - if ui.selectable_label(app.ui.handout_per_page == n, format!("{n} Slide{}", if n == 1 { "" } else { "s" })).clicked() { - app.ui.handout_per_page = n; - } - } - }); - }); - group(ui, |ui| { - let shown = |app: &SlideApp, k: deckcraft_model::PhType| { - app.session.active().and_then(|d| d.doc.handout_master.as_ref()).is_some_and(|m| m.shapes.iter().any(|s| s.ph_type() == Some(k))) - }; - rows(ui, |ui| { - use deckcraft_model::PhType; - for (label, key, k) in [ - ("Header", "header", PhType::Header), - ("Date", "date", PhType::Date), - ("Footer", "footer", PhType::Footer), - ("Page Number", "pageNumber", PhType::SlideNum), - ] { - let mut on = shown(app, k); - if ui.checkbox(&mut on, label).changed() { - run(app, "handout.placeholders", json!({ key: on })); - } - } - }); - }); - group(ui, |ui| { - big(app, ui, Icon::FormatBackground, "Background\nStyles", "view.pane", json!({"pane": "background"})); - }); - group(ui, |ui| { - if big_button(ui, Icon::Close, "Close\nMaster View", true).clicked() { - run(app, "view.closeMaster", json!({})); - } - }); -} - -/// The stored id is the one marked row. Every other id stays unmarked. -fn marked_options<'a>(options: &'a [&'a str], current: Option<&str>) -> Vec<(&'a str, bool)> { - options.iter().copied().map(|o| (o, current == Some(o))).collect() -} - -/// One row of Transitions ▸ Effect Options: the stored option and whether it is the current one. -fn transition_effect_rows(kind: &str, current: Option<&str>) -> Vec<(&'static str, bool)> { - let opts = deckcraft_model::anim::TRANSITIONS.iter().find(|x| x.0 == kind).map(|x| x.4).unwrap_or(&[]); - marked_options(opts, current) -} - -/// One row of Animations ▸ Effect Options for the selected effect. -fn animation_effect_rows(effect: &str, class: AnimClass, current: Option<&str>) -> Vec<(&'static str, bool)> { - let opts = deckcraft_model::anim::animation_info(effect, class).map(|x| x.5).unwrap_or(&[]); - marked_options(opts, current) -} - -fn text_build_id(build: deckcraft_model::anim::TextBuild) -> &'static str { - match build { - deckcraft_model::anim::TextBuild::ByParagraph => "byParagraph", - deckcraft_model::anim::TextBuild::ByWord => "byWord", - deckcraft_model::anim::TextBuild::ByLetter => "byLetter", - deckcraft_model::anim::TextBuild::AsOne => "asOne", - } -} - -/// Gallery glyphs for the freeform tools: a wave (curve), a polygon (freeform), a squiggle (scribble). -fn paint_freeform_icon(p: &egui::Painter, r: Rect, name: &str, ink: Color32) { - let at = |x: f32, y: f32| egui::pos2(r.min.x + x * r.width(), r.min.y + y * r.height()); - let pts: Vec = match name { - "curve" => (0..=24).map(|i| i as f32 / 24.0).map(|u| at(u, 0.5 - 0.38 * (u * std::f32::consts::TAU).sin())).collect(), - "freeform" => { - [(0.0, 0.85), (0.15, 0.2), (0.55, 0.45), (0.8, 0.05), (1.0, 0.75), (0.45, 1.0), (0.0, 0.85)].iter().map(|(x, y)| at(*x, *y)).collect() - } - _ => (0..=40).map(|i| i as f32 / 40.0).map(|u| at(u, 0.5 + 0.35 * (u * 19.0).sin() * (1.0 - u * 0.4))).collect(), - }; - p.add(egui::Shape::line(pts, egui::Stroke::new(1.3, ink))); -} - -/// The chart types in the Insert Chart and Change Chart Type menus: the main ones, a separator -/// (`None`), then the Office 2016 types not already listed. -fn chart_menu_types() -> Vec> { - use deckcraft_model::chart::ChartType; - let mut v: Vec> = ChartType::MAIN.iter().copied().map(Some).collect(); - v.push(None); - v.extend(ChartType::OFFICE_2016.iter().copied().filter(|k| !ChartType::MAIN.contains(k)).map(Some)); - v -} - #[cfg(test)] mod tests { use super::*; @@ -3658,140 +434,3 @@ mod drawtable_tests; #[cfg(test)] mod inktoshape_tests; - -/// Edit Shape ▸ Smooth Corners (#286): a slider that smooths a rectangle's corners into a -/// squircle. The radius is taken from the rectangle when the slider is first used and kept (in egui -/// memory, per shape) so later moves re-smooth the same corners. -fn smooth_corners_row(app: &mut SlideApp, ui: &mut egui::Ui) { - let Some((id, radius)) = app.session.active().and_then(|d| { - let id = *d.selection.shapes.first()?; - let sh = d.shape(id)?; - let x = sh.xfrm?; - let ss = x.w.min(x.h).max(0.0); - let own = match &sh.geom { - deckcraft_model::Geom::Preset { name, adj } if name == "roundRect" => { - Some(ss * adj.first().copied().unwrap_or(16667.0).clamp(0.0, 50000.0) / 100000.0) - } - deckcraft_model::Geom::Preset { name, .. } if name == "rect" => Some(ss * 16667.0 / 100000.0), - _ => None, - }; - Some((id, own)) - }) else { - return; - }; - let key = egui::Id::unique(("deckcraft-smooth-corners", id.0)); - let mut state: (f64, f64) = ui.data(|d| d.get_temp(key)).unwrap_or((radius.unwrap_or(0.0), 0.0)); - if let Some(r) = radius { - state.0 = r; - } - let usable = radius.is_some() || ui.data(|d| d.get_temp::<(f64, f64)>(key)).is_some(); - ui.horizontal(|ui| { - let label = ui.label("Smooth Corners"); - let mut pct = state.1; - let resp = ui.add_enabled(usable, egui::Slider::new(&mut pct, 0.0..=100.0).suffix("%").integer()).labelled_by(label.id); - let resp = if usable { resp } else { resp.on_disabled_hover_text("For rectangles and rounded rectangles") }; - if resp.changed() { - state.1 = pct; - ui.data_mut(|d| d.insert_temp(key, state)); - } - // One undo step per adjustment: apply when the drag ends (or on a click / keyboard step). - if resp.drag_stopped() || (resp.changed() && !resp.dragged()) { - run(app, "shape.smoothCorners", json!({"smoothing": state.1, "radius": state.0})); - } - }); -} - -/// Insert › Zoom: pick Summary, Section or Slide Zoom and what it zooms to, as PowerPoint's -/// Insert Zoom dialogs do (a checklist of slides or sections). -fn zoom_menu(app: &mut SlideApp, ui: &mut Ui) { - let Some(doc) = app.session.active().map(|d| d.doc.clone()) else { return }; - let id = egui::Id::unique("insert_zoom_menu"); - let (mut kind, mut picked): (u8, Vec) = ui.data_mut(|d| d.get_temp(id).unwrap_or((0, vec![]))); - ui.horizontal(|ui| { - for (k, label) in [(0u8, "Slide Zoom"), (1, "Section Zoom"), (2, "Summary Zoom")] { - if ui.selectable_label(kind == k, label).clicked() && kind != k { - kind = k; - picked.clear(); - } - } - }); - ui.separator(); - let items: Vec = if kind == 1 { - doc.sections.iter().map(|s| s.name.clone()).collect() - } else { - doc.slides - .iter() - .enumerate() - .map(|(i, s)| { - let t = s.title(); - format!("{}. {}", i + 1, t.lines().next().unwrap_or("").trim()) - }) - .collect() - }; - if items.is_empty() { - ui.label(if kind == 1 { "The presentation has no sections. Add one with Home › Section." } else { "No slides." }); - } - egui::ScrollArea::vertical().max_height(260.0).show(ui, |ui| { - for (i, label) in items.iter().enumerate() { - let mut on = picked.contains(&i); - if ui.checkbox(&mut on, label).changed() { - if on { - picked.push(i); - } else { - picked.retain(|x| *x != i); - } - } - } - }); - ui.data_mut(|d| d.insert_temp(id, (kind, picked.clone()))); - ui.separator(); - if ui.add_enabled(!picked.is_empty(), egui::Button::new("Insert")).clicked() { - let mut v = picked.clone(); - v.sort_unstable(); - let p = match kind { - 1 => json!({"kind": "section", "sections": v}), - 2 => json!({"kind": "summary", "slides": v}), - _ => json!({"kind": "slide", "slides": v}), - }; - run(app, "insert.zoom", p); - ui.data_mut(|d| d.remove::<(u8, Vec)>(id)); - ui.close(); - } -} - -/// Compress Pictures options, as in PowerPoint's dialog: resolution, apply to this picture or -/// all, delete cropped areas. The choices last for the session. -fn compress_menu(app: &mut SlideApp, ui: &mut Ui) { - let id = egui::Id::unique("compress_pictures_opts"); - let (mut ppi, mut only_selected, mut delete_cropped): (u32, bool, bool) = ui.data_mut(|d| d.get_temp(id).unwrap_or((220, true, true))); - ui.label(egui::RichText::new("Compression options").strong()); - ui.checkbox(&mut only_selected, "Apply only to this picture"); - ui.checkbox(&mut delete_cropped, "Delete cropped areas of pictures"); - ui.separator(); - ui.label(egui::RichText::new("Resolution").strong()); - for (v, label) in [ - (330, "HD (330 ppi): good quality for high-definition displays"), - (220, "Print (220 ppi): excellent quality on most printers and screens"), - (150, "Web (150 ppi): good for web pages and projectors"), - (96, "E-mail (96 ppi): minimise document size for sharing"), - ] { - ui.radio_value(&mut ppi, v, label); - } - ui.data_mut(|d| d.insert_temp(id, (ppi, only_selected, delete_cropped))); - ui.separator(); - if ui.button("Compress").clicked() { - let p = json!({"ppi": ppi, "deleteCropped": delete_cropped, "all": !only_selected}); - match app.session.execute("picture.compress", &p) { - Ok(r) => { - let kb = |v: &Value| v.as_u64().unwrap_or(0) / 1024; - let msg = match r["pictures"].as_u64().unwrap_or(0) { - 0 => "Pictures are already at or below that resolution".to_string(), - n => format!("Compressed {n} picture(s): {} KB → {} KB", kb(&r["before"]), kb(&r["after"])), - }; - app.set_status(msg); - } - Err(e) => app.set_status(e.to_string()), - } - ui.close(); - } -} diff --git a/crates/ui-egui/src/ribbon/animations.rs b/crates/ui-egui/src/ribbon/animations.rs new file mode 100644 index 00000000..0bdf1929 --- /dev/null +++ b/crates/ui-egui/src/ribbon/animations.rs @@ -0,0 +1,218 @@ +use super::transitions::option_label; +use super::*; + +// ---------- Animations ---------- + +/// Render animation preview, gallery, add/effect options, trigger, timing, and playback controls. +pub(super) fn animations(app: &mut SlideApp, ui: &mut Ui) { + let has_sel = app.session.active().is_some_and(|d| !d.selection.shapes.is_empty()); + let cur: Option = app.session.active().and_then(|d| { + let s = d.current_slide()?; + let id = d.selection.shapes.first()?; + s.animations.iter().find(|a| a.shape == *id).cloned() + }); + group(ui, |ui| { + let playing = app.anim_preview_active(); + let (resp, _) = big_button_ex(ui, Icon::Preview, "Preview", true, false, playing); + if resp.on_hover_text("Play this slide's animations on the canvas").clicked() { + run(app, "animation.preview", json!({})); + } + }); + group(ui, |ui| { + let quick: Vec<_> = deckcraft_model::anim::ANIMATIONS + .iter() + .filter(|a| { + matches!( + a.0, + "appear" + | "fade" + | "fly" + | "float" + | "split" + | "wipe" + | "zoom" + | "pulse" + | "spin" + | "growShrink" + | "fadeOut" + | "flyOut" + | "lines" + ) + }) + .collect(); + let sel = cur.as_ref().and_then(|c| quick.iter().position(|a| a.0 == c.effect)); + if let Some(i) = widgets::gallery( + ui, + quick.len(), + vec2(50.0, 56.0), + sel, + |p, r, i| { + if let Some(a) = quick.get(i) { + paint_anim_tile(p, r, a.1, a.2, has_sel); + } + }, + |i| quick.get(i).map(|a| a.1.to_string()).unwrap_or_default(), + ) && let Some(a) = quick.get(i) + { + run(app, "animation.set", json!({"effect": a.0, "class": a.2.xml()})); + } + let more = widgets::drop_button(ui, "", vec2(14.0, 52.0), has_sel); + egui::Popup::menu(&more).width(420.0).show(|ui| { + for (cls, label) in + [(AnimClass::Entrance, "Entrance"), (AnimClass::Emphasis, "Emphasis"), (AnimClass::Exit, "Exit"), (AnimClass::Path, "Motion Paths")] + { + ui.label(egui::RichText::new(label).font(theme::bold(12.0))); + ui.horizontal_wrapped(|ui| { + for a in deckcraft_model::anim::ANIMATIONS.iter().filter(|a| a.2 == cls) { + let (r, resp) = ui.allocate_exact_size(vec2(56.0, 54.0), Sense::click()); + if resp.hovered() { + ui.painter().rect_filled(r, CornerRadius::same(4), Tokens::get(ui.ctx()).hover); + } + paint_anim_tile(ui.painter(), r.shrink(2.0), a.1, a.2, true); + if resp.clicked() { + run(app, "animation.set", json!({"effect": a.0, "class": a.2.xml()})); + ui.close(); + } + } + }); + } + }); + let effect_rows = cur.as_ref().map(|c| animation_effect_rows(&c.effect, c.class, Some(c.option.as_str()))).unwrap_or_default(); + let build = cur.as_ref().map(|c| text_build_id(c.text_build)); + let build_rows = marked_options(&["asOne", "byParagraph"], build); + let r = big_button(ui, Icon::EffectOptions, "Effect\nOptions", !effect_rows.is_empty()); + egui::Popup::menu(&r).show(|ui| { + for &(o, on) in &effect_rows { + if ui.selectable_label(on, option_label(o)).clicked() { + run(app, "animation.options", json!({"option": o})); + ui.close(); + } + } + ui.separator(); + for &(id, on) in &build_rows { + let label = if id == "byParagraph" { "By Paragraph" } else { "As One Object" }; + if ui.selectable_label(on, label).clicked() { + run(app, "animation.options", json!({"textBuild": id})); + ui.close(); + } + } + }); + }); + group(ui, |ui| { + let r = big_button(ui, Icon::AddAnimation, "Add\nAnimation", has_sel); + egui::Popup::menu(&r).width(260.0).show(|ui| { + for a in deckcraft_model::anim::ANIMATIONS.iter().filter(|a| a.2 != AnimClass::Media) { + if ui + .button(format!( + "{} — {}", + a.1, + match a.2 { + AnimClass::Entrance => "Entrance", + AnimClass::Emphasis => "Emphasis", + AnimClass::Exit => "Exit", + _ => "Motion", + } + )) + .clicked() + { + run(app, "animation.add", json!({"effect": a.0, "class": a.2.xml()})); + } + } + }); + big(app, ui, Icon::AnimationPane, "Animation\nPane", "view.pane", json!({"pane": "animation"})); + let r = big_button(ui, Icon::Trigger, "Trigger", cur.is_some()); + egui::Popup::menu(&r).show(|ui| { + if ui.button("On Click of… (none)").clicked() { + run(app, "animation.timing", json!({"trigger": null})); + } + let shapes: Vec<(u32, String)> = + app.session.active().map(|d| d.shapes().iter().map(|s| (s.id.0, s.name.clone())).collect()).unwrap_or_default(); + for (id, name) in shapes { + if ui.button(format!("On Click of {name}")).clicked() { + run(app, "animation.timing", json!({"trigger": id})); + } + } + }); + let on = app.session.anim_painter.is_some(); + let en = enabled(app, "animation.painter"); + let (r, _) = big_button_ex(ui, Icon::AnimationPainter, "Animation\nPainter", en, false, on); + let r = r.on_hover_text("Animation Painter (double-click to keep it on)"); + if r.double_clicked() { + run(app, "animation.painter", json!({"sticky": true})); + } else if r.clicked() { + if on { + app.session.anim_painter = None; + } else { + run(app, "animation.painter", json!({})); + } + } + }); + group(ui, |ui| { + rows(ui, |ui| { + ui.horizontal(|ui| { + ui.label("Start:"); + let s = cur.as_ref().map(|c| match c.start { + deckcraft_model::AnimStart::OnClick => "On Click", + deckcraft_model::AnimStart::WithPrevious => "With Previous", + deckcraft_model::AnimStart::AfterPrevious => "After Previous", + }); + dropdown(ui, "astart", s.unwrap_or(""), 112.0, cur.is_some(), |ui| { + for (l, v) in [("On Click", "onClick"), ("With Previous", "withPrevious"), ("After Previous", "afterPrevious")] { + if ui.button(l).clicked() { + run(app, "animation.timing", json!({"start": v})); + ui.close(); + } + } + }); + }); + ui.horizontal(|ui| { + ui.label("Duration:"); + if let Some(v) = + widgets::spinner(ui, "adur", "Duration", cur.as_ref().map(|c| c.duration_ms as f64 / 1000.0).unwrap_or(0.5), 0.25, "", 46.0) + { + run(app, "animation.timing", json!({"duration": (v.max(0.0) * 1000.0).round() as u64})); + } + }); + ui.horizontal(|ui| { + ui.label("Delay:"); + if let Some(v) = + widgets::spinner(ui, "adelay", "Delay", cur.as_ref().map(|c| c.delay_ms as f64 / 1000.0).unwrap_or(0.0), 0.25, "", 46.0) + { + run(app, "animation.timing", json!({"delay": (v.max(0.0) * 1000.0).round() as u64})); + } + }); + }); + }); +} + +pub fn paint_anim_tile(p: &egui::Painter, r: Rect, label: &str, class: AnimClass, enabled: bool) { + let t = Tokens::get(p.ctx()); + let icon = match class { + AnimClass::Entrance => Icon::AnimEntrance, + AnimClass::Emphasis => Icon::AnimEmphasis, + AnimClass::Exit => Icon::AnimExit, + _ => Icon::AnimPath, + }; + icons::paint(p, Rect::from_center_size(pos2(r.center().x, r.min.y + 18.0), vec2(28.0, 28.0)), icon, t.text, !enabled); + p.text(pos2(r.center().x, r.max.y - 8.0), Align2::CENTER_CENTER, label, theme::font(10.0), if enabled { t.text } else { t.text_faint }); +} + +/// The stored id is the one marked row. Every other id stays unmarked. +pub(super) fn marked_options<'a>(options: &'a [&'a str], current: Option<&str>) -> Vec<(&'a str, bool)> { + options.iter().copied().map(|o| (o, current == Some(o))).collect() +} + +/// One row of Animations ▸ Effect Options for the selected effect. +pub(super) fn animation_effect_rows(effect: &str, class: AnimClass, current: Option<&str>) -> Vec<(&'static str, bool)> { + let opts = deckcraft_model::anim::animation_info(effect, class).map(|x| x.5).unwrap_or(&[]); + marked_options(opts, current) +} + +pub(super) fn text_build_id(build: deckcraft_model::anim::TextBuild) -> &'static str { + match build { + deckcraft_model::anim::TextBuild::ByParagraph => "byParagraph", + deckcraft_model::anim::TextBuild::ByWord => "byWord", + deckcraft_model::anim::TextBuild::ByLetter => "byLetter", + deckcraft_model::anim::TextBuild::AsOne => "asOne", + } +} diff --git a/crates/ui-egui/src/ribbon/arrange.rs b/crates/ui-egui/src/ribbon/arrange.rs new file mode 100644 index 00000000..f5b517bf --- /dev/null +++ b/crates/ui-egui/src/ribbon/arrange.rs @@ -0,0 +1,158 @@ +use super::*; + +pub fn arrange_menu(app: &mut SlideApp, ui: &mut Ui) { + ui.label(egui::RichText::new("Order Objects").font(theme::bold(12.0))); + for (l, id, ic) in [ + ("Bring to Front", "arrange.bringToFront", Icon::BringToFront), + ("Send to Back", "arrange.sendToBack", Icon::SendToBack), + ("Bring Forward", "arrange.bringForward", Icon::BringForward), + ("Send Backward", "arrange.sendBackward", Icon::SendBackward), + ] { + menu_item(app, ui, ic, l, id, json!({})); + } + ui.separator(); + ui.label(egui::RichText::new("Group Objects").font(theme::bold(12.0))); + menu_item(app, ui, Icon::Group, "Group", "arrange.group", json!({})); + menu_item(app, ui, Icon::Ungroup, "Ungroup", "arrange.ungroup", json!({})); + menu_item(app, ui, Icon::Group, "Regroup", "arrange.regroup", json!({})); + ui.separator(); + ui.label(egui::RichText::new("Position Objects").font(theme::bold(12.0))); + ui.menu_button("Align", |ui| align_menu(app, ui)); + ui.menu_button("Rotate", |ui| { + menu_item(app, ui, Icon::RotateRight, "Rotate Right 90°", "arrange.rotateRight", json!({})); + menu_item(app, ui, Icon::RotateLeft, "Rotate Left 90°", "arrange.rotateLeft", json!({})); + menu_item(app, ui, Icon::FlipV, "Flip Vertical", "arrange.flipVertical", json!({})); + menu_item(app, ui, Icon::FlipH, "Flip Horizontal", "arrange.flipHorizontal", json!({})); + }); + if ui.button("Selection Pane…").clicked() { + let _ = app.run("view.pane", json!({"pane": "selection"})); + ui.close(); + } +} + +pub fn align_menu(app: &mut SlideApp, ui: &mut Ui) { + let selection = app.session.active().map(|d| d.selection.shapes.clone()).unwrap_or_default(); + let to_slide = align_to_slide(app.align_to.as_ref(), &selection); + let to = if to_slide { "slide" } else { "selection" }; + for (l, e, ic) in [ + ("Align Left", "left", Icon::AlignObjLeft), + ("Align Center", "center", Icon::AlignObjCenter), + ("Align Right", "right", Icon::AlignObjRight), + ("Align Top", "top", Icon::AlignObjTop), + ("Align Middle", "middle", Icon::AlignObjMiddle), + ("Align Bottom", "bottom", Icon::AlignObjBottom), + ] { + menu_item(app, ui, ic, l, "arrange.align", json!({"edge": e, "to": to})); + } + ui.separator(); + menu_item(app, ui, Icon::DistributeH, "Distribute Horizontally", "arrange.distribute", json!({"dir": "horizontal", "to": to})); + menu_item(app, ui, Icon::DistributeV, "Distribute Vertically", "arrange.distribute", json!({"dir": "vertical", "to": to})); + ui.separator(); + let en = enabled(app, "arrange.align"); + for (l, slide) in [("Align to Slide", true), ("Align Selected Objects", false)] { + if widgets::check_item(ui, l, en, to_slide == slide).clicked() { + app.align_to = Some((selection.clone(), slide)); + ui.close(); + } + } +} + +/// Align to Slide or to the selected objects, as in PowerPoint: one object aligns to the slide and +/// several to each other, unless the other choice was ticked for this same selection. +fn align_to_slide(choice: Option<&(Vec, bool)>, selection: &[ShapeId]) -> bool { + match choice { + Some((ids, to_slide)) if ids.as_slice() == selection => *to_slide, + _ => selection.len() < 2, + } +} + +/// Render ordering, align, group/ungroup, and rotate controls for the current selection. +pub(super) fn arrange_group(app: &mut SlideApp, ui: &mut Ui) { + group(ui, |ui| { + let sel = enabled(app, "arrange.bringForward"); + let (m, a) = big_button_ex(ui, Icon::BringForward, "Bring\nForward", sel, true, false); + if m.clicked() { + run(app, "arrange.bringForward", json!({})); + } + if let Some(a) = a { + egui::Popup::menu(&a).show(|ui| { + menu_item(app, ui, Icon::BringForward, "Bring Forward", "arrange.bringForward", json!({})); + menu_item(app, ui, Icon::BringToFront, "Bring to Front", "arrange.bringToFront", json!({})); + }); + } + let (m, a) = big_button_ex(ui, Icon::SendBackward, "Send\nBackward", sel, true, false); + if m.clicked() { + run(app, "arrange.sendBackward", json!({})); + } + if let Some(a) = a { + egui::Popup::menu(&a).show(|ui| { + menu_item(app, ui, Icon::SendBackward, "Send Backward", "arrange.sendBackward", json!({})); + menu_item(app, ui, Icon::SendToBack, "Send to Back", "arrange.sendToBack", json!({})); + }); + } + big(app, ui, Icon::SelectionPane, "Selection\nPane", "view.pane", json!({"pane": "selection"})); + let r = big_button(ui, Icon::AlignObjects, "Align", sel); + egui::Popup::menu(&r).show(|ui| align_menu(app, ui)); + rows(ui, |ui| { + let (main, arrow) = small_button_ex(ui, Icon::Group, "Group", sel, false, true); + if main.clicked() { + run(app, "arrange.group", json!({})); + } + if let Some(arrow) = arrow { + egui::Popup::menu(&arrow).show(|ui| { + menu_item(app, ui, Icon::Group, "Group", "arrange.group", json!({})); + menu_item(app, ui, Icon::Ungroup, "Ungroup", "arrange.ungroup", json!({})); + menu_item(app, ui, Icon::Group, "Regroup", "arrange.regroup", json!({})); + }); + } + let (main, arrow) = small_button_ex(ui, Icon::Rotate, "Rotate", sel, false, true); + if main.clicked() { + run(app, "arrange.rotateRight", json!({})); + } + if let Some(arrow) = arrow { + egui::Popup::menu(&arrow).show(|ui| { + menu_item(app, ui, Icon::RotateRight, "Rotate Right 90°", "arrange.rotateRight", json!({})); + menu_item(app, ui, Icon::RotateLeft, "Rotate Left 90°", "arrange.rotateLeft", json!({})); + menu_item(app, ui, Icon::FlipV, "Flip Vertical", "arrange.flipVertical", json!({})); + menu_item(app, ui, Icon::FlipH, "Flip Horizontal", "arrange.flipHorizontal", json!({})); + }); + } + }); + }); +} + +/// Render width and height controls bound to the selected shape, plus the size-position dialog. +pub(super) fn size_group(app: &mut SlideApp, ui: &mut Ui) { + let bx = app + .session + .active() + .and_then(|d| d.selection.shapes.first().and_then(|id| d.shape(*id).map(|s| deckcraft_engine::cmd::xfrm_of(&d.doc, &d.selection, s)))); + group(ui, |ui| { + rows(ui, |ui| { + let Some(x) = bx else { + ui.label("Height:"); + ui.label("Width:"); + return; + }; + let len = app.session.prefs.units; + let (ppu, suffix) = (len.points_per_unit(), len.suffix()); + // " cm" needs more room than the inch mark. + let width = if len == deckcraft_geom::Unit::Inches { 54.0 } else { 66.0 }; + ui.horizontal(|ui| { + ui.label("Height:"); + if let Some(v) = widgets::spinner(ui, "sh", "Height", x.h / ppu, 0.1, suffix, width) { + run(app, "shape.resize", json!({"h": v.max(0.0) * ppu})); + } + }); + ui.horizontal(|ui| { + ui.label("Width: "); + if let Some(v) = widgets::spinner(ui, "sw", "Width", x.w / ppu, 0.1, suffix, width) { + run(app, "shape.resize", json!({"w": v.max(0.0) * ppu})); + } + }); + }); + if ui.add_enabled(bx.is_some(), egui::Button::new("Size and Position…")).clicked() { + run(app, "shape.sizePosition", json!({})); + } + }); +} diff --git a/crates/ui-egui/src/ribbon/chart.rs b/crates/ui-egui/src/ribbon/chart.rs new file mode 100644 index 00000000..97431ab3 --- /dev/null +++ b/crates/ui-egui/src/ribbon/chart.rs @@ -0,0 +1,25 @@ +use super::arrange::arrange_group; +use super::insert::chart_menu_types; +use super::*; + +pub(super) fn chart_design(app: &mut SlideApp, ui: &mut Ui) { + group(ui, |ui| { + let r = big_button(ui, Icon::ChangeChartType, "Change\nChart Type", true); + egui::Popup::menu(&r).show(|ui| { + for k in chart_menu_types() { + let Some(k) = k else { + ui.separator(); + continue; + }; + if ui.button(k.label()).clicked() { + // Replace the chart's type keeping its data. + if let Some(id) = app.session.active().and_then(|d| d.selection.shapes.first().copied()) { + let _ = app.run("chart.type", json!({"id": id.0, "type": format!("{k:?}")})); + } + } + } + }); + big(app, ui, Icon::EditData, "Edit\nData", "app.dialog", json!({"id": "chartData"})); + }); + arrange_group(app, ui); +} diff --git a/crates/ui-egui/src/ribbon/design.rs b/crates/ui-egui/src/ribbon/design.rs new file mode 100644 index 00000000..18305914 --- /dev/null +++ b/crates/ui-egui/src/ribbon/design.rs @@ -0,0 +1,170 @@ +use super::*; + +// ---------- Design ---------- + +/// Render the Design tab's themes, variants, colours, fonts, effects, background, and slide +/// size controls. +pub(super) fn design(app: &mut SlideApp, ui: &mut Ui) { + let themes = deckcraft_model::theme::builtin_themes(); + let cur = app.session.active().and_then(|d| d.doc.masters.first().map(|m| m.theme.name.clone())).unwrap_or_default(); + group(ui, |ui| { + let sel = themes.iter().position(|th| th.name == cur); + let names: Vec = themes.iter().map(|t| t.name.clone()).collect(); + if let Some(i) = widgets::gallery( + ui, + themes.len(), + vec2(84.0, 56.0), + sel, + |p, r, i| { + if let Some(th) = themes.get(i) { + paint_theme_tile(p, r, th); + } + }, + |i| names.get(i).cloned().unwrap_or_default(), + ) && let Some(n) = names.get(i) + { + run(app, "design.theme", json!({"name": n})); + } + if small_button(ui, Icon::Themes, "Browse for Themes…", enabled(app, "design.browseTheme"), false).clicked() { + run(app, "app.browseThemeDialog", json!({})); + } + }); + // Variants: the current theme in its four colour variations. + if let Some(th) = app.session.active().and_then(|d| d.doc.masters.first().map(|m| m.theme.clone())) { + let variants = deckcraft_model::theme::theme_variants(&th); + let tiles: Vec = variants.iter().map(|c| deckcraft_model::Theme { colors: c.clone(), ..th.clone() }).collect(); + let names: Vec = variants.iter().map(|c| c.name.clone()).collect(); + let sel = variants.iter().position(|c| c.colors == th.colors.colors); + group(ui, |ui| { + if let Some(i) = widgets::gallery( + ui, + tiles.len(), + vec2(84.0, 56.0), + sel, + |p, r, i| { + if let Some(t) = tiles.get(i) { + paint_theme_tile(p, r, t); + } + }, + |i| names.get(i).cloned().unwrap_or_default(), + ) { + run(app, "design.variant", json!({"index": i})); + } + }); + } + group(ui, |ui| { + let r = big_button(ui, Icon::Colors, "Colors", true); + r.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, "Colors")); + egui::Popup::menu(&r).width(240.0).show(|ui| { + for cs in deckcraft_model::theme::builtin_color_schemes() { + ui.horizontal(|ui| { + let (rect, resp) = ui.allocate_exact_size(vec2(220.0, 20.0), Sense::click()); + let p = ui.painter(); + for (k, c) in cs.colors.iter().take(10).skip(2).enumerate() { + p.rect_filled( + Rect::from_min_size(rect.min + vec2(k as f32 * 12.0, 3.0), vec2(11.0, 14.0)), + CornerRadius::ZERO, + theme::to_color32(*c), + ); + } + p.text(rect.min + vec2(104.0, 10.0), Align2::LEFT_CENTER, &cs.name, theme::font(12.0), Tokens::get(ui.ctx()).text); + if resp.clicked() { + run(app, "design.colors", json!({"name": cs.name})); + ui.close(); + } + }); + } + ui.separator(); + if ui.button("Customize Colors…").clicked() { + run(app, "design.colors", json!({})); + ui.close(); + } + }); + let r = big_button(ui, Icon::Fonts, "Fonts", true); + r.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, "Fonts")); + egui::Popup::menu(&r).width(220.0).show(|ui| { + for fs in deckcraft_model::theme::builtin_font_schemes() { + if ui.button(format!("{}\n {} / {}", fs.name, fs.major.latin, fs.minor.latin)).clicked() { + run(app, "design.fonts", json!({"name": fs.name})); + } + } + ui.separator(); + if ui.button("Customize Fonts…").clicked() { + run(app, "design.fonts", json!({})); + ui.close(); + } + }); + let r = big_button(ui, Icon::ShapeEffects, "Effects", true); + r.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, "Effects")); + egui::Popup::menu(&r).width(180.0).show(|ui| { + for fs in deckcraft_model::theme::builtin_format_schemes() { + if ui.button(&fs.name).clicked() { + run(app, "design.effects", json!({"name": fs.name})); + ui.close(); + } + } + }); + let r = big_button(ui, Icon::BackgroundStyles, "Background\nStyles", true); + egui::Popup::menu(&r).show(|ui| { + egui::Grid::new("bgstyles").show(ui, |ui| { + let sc = scheme(app); + for i in 1..=12 { + let slots = [ + deckcraft_color::SchemeSlot::Lt1, + deckcraft_color::SchemeSlot::Lt2, + deckcraft_color::SchemeSlot::Dk2, + deckcraft_color::SchemeSlot::Dk1, + ]; + let base = theme::to_color32(sc.get(slots[(i - 1) % 4])); + let shade = match (i - 1) / 4 { + 0 => base, + 1 => base.lerp_to_gamma(Color32::GRAY, 0.15), + _ => base.lerp_to_gamma(Color32::BLACK, 0.12), + }; + let (rect, resp) = ui.allocate_exact_size(vec2(48.0, 28.0), Sense::click()); + ui.painter().rect(rect, CornerRadius::same(2), shade, Stroke::new(1.0, Tokens::get(ui.ctx()).border), egui::StrokeKind::Inside); + if resp.on_hover_text(format!("Style {i}")).clicked() { + run(app, "design.background", json!({"style": i, "all": true})); + ui.close(); + } + if i % 4 == 0 { + ui.end_row(); + } + } + }); + if ui.button("Reset Slide Background").clicked() { + run(app, "design.background", json!({"reset": true})); + } + }); + }); + group(ui, |ui| { + let r = big_button(ui, Icon::SlideSize, "Slide\nSize", true); + egui::Popup::menu(&r).show(|ui| { + if ui.button("Standard (4:3)").clicked() { + run(app, "design.slideSize", json!({"preset": "standard"})); + } + if ui.button("Widescreen (16:9)").clicked() { + run(app, "design.slideSize", json!({"preset": "widescreen"})); + } + if ui.button("Page Setup…").clicked() { + app.dialog = Some(crate::dialogs::Dialog::new("slideSize")); + } + }); + big(app, ui, Icon::FormatBackground, "Format\nBackground", "view.pane", json!({"pane": "background"})); + }); + group(ui, |ui| { + big(app, ui, Icon::DesignIdeas, "Design\nIdeas", "view.pane", json!({"pane": "designer"})); + }); +} + +pub fn paint_theme_tile(p: &egui::Painter, r: Rect, th: &deckcraft_model::Theme) { + let bg = theme::to_color32(th.colors.get(deckcraft_color::SchemeSlot::Lt1)); + let fg = theme::to_color32(th.colors.get(deckcraft_color::SchemeSlot::Dk1)); + p.rect(r, CornerRadius::same(2), bg, Stroke::new(1.0, Color32::from_gray(200)), egui::StrokeKind::Inside); + p.text(r.min + vec2(8.0, r.height() * 0.42), Align2::LEFT_CENTER, "Aa", theme::font(r.height() * 0.38), fg); + let w = (r.width() - 16.0) / 6.0; + for k in 0..6 { + let c = theme::to_color32(th.colors.colors.get(4 + k).copied().unwrap_or(deckcraft_color::Rgba::BLACK)); + p.rect_filled(Rect::from_min_size(pos2(r.min.x + 8.0 + k as f32 * w, r.max.y - 11.0), vec2(w - 1.5, 5.0)), CornerRadius::ZERO, c); + } +} diff --git a/crates/ui-egui/src/ribbon/draw.rs b/crates/ui-egui/src/ribbon/draw.rs new file mode 100644 index 00000000..d1a38541 --- /dev/null +++ b/crates/ui-egui/src/ribbon/draw.rs @@ -0,0 +1,45 @@ +use super::*; + +// ---------- Draw ---------- + +pub(super) fn draw(app: &mut SlideApp, ui: &mut Ui) { + use deckcraft_engine::ToolKind; + let cur = app.session.tool.kind.clone(); + group(ui, |ui| { + let sel = matches!(cur, ToolKind::Select); + if big_button_ex(ui, Icon::LassoSelect, "Select", true, false, sel).0.clicked() { + app.session.set_tool(ToolKind::Select); + } + let er = matches!(&cur, ToolKind::Ink { mode, .. } if mode == "eraser"); + if big_button_ex(ui, Icon::Eraser, "Eraser", true, false, er).0.clicked() { + app.session.set_tool(ToolKind::Ink { mode: "eraser".into(), color: deckcraft_color::Rgba::BLACK, width: 2.0 }); + } + }); + group(ui, |ui| { + let pens: [(Icon, &str, &str, deckcraft_color::Rgba, f64); 5] = [ + (Icon::Pen, "Black Pen", "pen", deckcraft_color::Rgba::rgb(20, 20, 20), 1.5), + (Icon::Pen, "Red Pen", "pen", deckcraft_color::Rgba::rgb(0xD8, 0x3F, 0x3F), 1.5), + (Icon::Pen, "Blue Pen", "pen", deckcraft_color::Rgba::rgb(0x2E, 0x6F, 0xD8), 1.5), + (Icon::Pencil, "Pencil", "pen", deckcraft_color::Rgba::rgb(90, 90, 90), 1.0), + (Icon::HighlighterPen, "Highlighter", "highlighter", deckcraft_color::Rgba::rgb(255, 230, 0), 10.0), + ]; + for (icon, label, mode, color, width) in pens { + let on = matches!(&cur, ToolKind::Ink { mode: m, color: c, .. } if m == mode && *c == color); + if big_button_ex(ui, icon, label, true, false, on).0.clicked() { + app.session.set_tool(ToolKind::Ink { mode: mode.into(), color, width }); + } + } + }); + group(ui, |ui| { + let has_ink = app.session.doc().is_ok_and(|d| d.shapes().iter().any(|s| matches!(s.kind, deckcraft_model::ShapeKind::Ink { .. }))); + let r = big_button(ui, Icon::InkToShape, "Ink to\nShape", has_ink); + if r.on_hover_text("Ink to Shape: turn drawn lines, rectangles, ovals and triangles into shapes").clicked() { + app.session.set_tool(ToolKind::Select); + run(app, "draw.inkToShape", json!({})); + } + let r = big_button(ui, Icon::InkToText, "Ink to\nText", false); + let _ = r.on_hover_text("Ink to Text (coming soon)"); + let r = big_button(ui, Icon::InkToMath, "Ink to\nMath", false); + let _ = r.on_hover_text("Ink to Math (coming soon)"); + }); +} diff --git a/crates/ui-egui/src/ribbon/galleries.rs b/crates/ui-egui/src/ribbon/galleries.rs new file mode 100644 index 00000000..3feb1b74 --- /dev/null +++ b/crates/ui-egui/src/ribbon/galleries.rs @@ -0,0 +1,391 @@ +use super::*; + +/// A ColorRef as a command colour parameter. +pub fn cref_param(c: &deckcraft_model::ColorRef) -> Value { + match &c.base { + deckcraft_model::ColorBase::Scheme { slot } => { + let mut o = serde_json::Map::new(); + o.insert("scheme".into(), json!(slot.xml_name())); + for m in &c.mods { + if let Some(v) = m.value() { + o.insert(m.xml_name().into(), json!(v)); + } + } + Value::Object(o) + } + deckcraft_model::ColorBase::Rgb { rgb } if c.mods.is_empty() => json!(format!("#{}", rgb.hex())), + deckcraft_model::ColorBase::Rgb { rgb } => { + let mut o = serde_json::Map::new(); + o.insert("rgb".into(), json!(format!("#{}", rgb.hex()))); + for m in &c.mods { + if let Some(v) = m.value() { + o.insert(m.xml_name().into(), json!(v)); + } + } + Value::Object(o) + } + deckcraft_model::ColorBase::Preset { name } => { + let mut o = serde_json::Map::new(); + o.insert("preset".into(), json!(name)); + for m in &c.mods { + if let Some(v) = m.value() { + o.insert(m.xml_name().into(), json!(v)); + } + } + Value::Object(o) + } + deckcraft_model::ColorBase::System { name, last } => { + let mut o = serde_json::Map::new(); + o.insert("system".into(), json!(name)); + o.insert("last".into(), json!(format!("#{}", last.hex()))); + for m in &c.mods { + if let Some(v) = m.value() { + o.insert(m.xml_name().into(), json!(v)); + } + } + Value::Object(o) + } + } +} + +/// Convert to SmartArt: the layouts by category, three to a row; a click converts the selected +/// text. +/// A layout gallery grouped by category, each layout as a small cached picture with its name. +/// Clicking runs `cmd` with `{"kind": …}` plus `extra`; hovering shows the layout larger, drawn +/// with `nodes` (the selected graphic's outline) when given: a live preview without touching the +/// document. +pub(super) fn smartart_gallery( + app: &mut SlideApp, + ui: &mut Ui, + cmd: &str, + extra: Value, + current: &str, + style: &str, + colors: &str, + nodes: Option<&[deckcraft_model::DiagramNode]>, +) { + use deckcraft_engine::cmd::smartart::{LAYOUTS, category}; + ui.set_min_width(440.0); + let slide = app.session.active().map(|d| d.selection.slide).unwrap_or(0); + egui::ScrollArea::vertical().max_height(520.0).show(ui, |ui| { + for (cat, title) in [ + ("list", "List"), + ("process", "Process"), + ("cycle", "Cycle"), + ("hierarchy", "Hierarchy"), + ("relationship", "Relationship"), + ("matrix", "Matrix"), + ("pyramid", "Pyramid"), + ("picture", "Picture"), + ] { + let kinds: Vec<(&str, &str)> = LAYOUTS.iter().copied().filter(|(k, _)| category(k) == cat).collect(); + if kinds.is_empty() { + continue; + } + ui.label(egui::RichText::new(title).strong()); + 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 r = ui + .vertical_centered(|ui| { + ui.set_width(100.0); + let r = match &thumb { + Some(t) => { + ui.add(egui::Button::image(egui::Image::new(t).fit_to_exact_size(egui::vec2(96.0, 60.0))).selected(*k == current)) + } + None => ui.add_sized([96.0, 60.0], egui::Button::new("").selected(*k == current)), + }; + ui.add(egui::Label::new(egui::RichText::new(*label).small()).truncate().selectable(false)); + r.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, *label)); + r.on_hover_text(*label) + }) + .inner; + if r.hovered() { + 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))); + }); + } + } + if r.clicked() { + let mut args = extra.clone(); + args["kind"] = json!(k); + run(app, cmd, args); + ui.close(); + } + if i % 4 == 3 { + ui.end_row(); + } + } + }); + ui.separator(); + } + }); +} + +/// Shapes gallery popup body: every preset by category. Returns the chosen preset. +pub fn shapes_gallery(ui: &mut Ui) -> Option<&'static str> { + let t = Tokens::get(ui.ctx()); + let mut out = None; + ui.set_max_width(330.0); + egui::ScrollArea::vertical().max_height(420.0).show(ui, |ui| { + for cat in deckcraft_geom::preset::Category::ALL { + ui.label(egui::RichText::new(cat.label()).font(theme::bold(12.0))); + ui.horizontal_wrapped(|ui| { + ui.spacing_mut().item_spacing = vec2(1.0, 1.0); + for p in deckcraft_geom::preset::CATALOG.iter().filter(|p| p.category == cat) { + let (r, resp) = ui.allocate_exact_size(vec2(26.0, 26.0), Sense::click()); + if resp.hovered() { + ui.painter().rect_filled(r, CornerRadius::same(3), t.hover); + } + paint_preset(ui.painter(), r.shrink(4.0), p.name, Color32::TRANSPARENT, t.text); + if resp.on_hover_text(p.label).clicked() { + out = Some(p.name); + } + } + if cat == deckcraft_geom::preset::Category::Lines { + for (name, label) in deckcraft_engine::tools::FREEFORM_TOOLS { + let (r, resp) = ui.allocate_exact_size(vec2(26.0, 26.0), Sense::click()); + if resp.hovered() { + ui.painter().rect_filled(r, CornerRadius::same(3), t.hover); + } + paint_freeform_icon(ui.painter(), r.shrink(5.0), name, t.text); + if resp.on_hover_text(label).clicked() { + out = Some(name); + } + } + } + }); + ui.add_space(4.0); + } + }); + if out.is_some() { + ui.close(); + } + out +} + +/// Draw a preset's outline (gallery icons). +pub fn paint_preset(p: &egui::Painter, r: Rect, name: &str, fill: Color32, stroke: Color32) { + let (w, h) = (r.width() as f64, r.height() as f64); + let Some(g) = deckcraft_geom::preset::build(name, w, h, &[]) else { return }; + for sp in &g.paths { + let mut pts: Vec = vec![]; + let mut polys: Vec<(Vec, bool)> = vec![]; + kurbo_flatten(&sp.path, |el| match el { + Fl::Move(x, y) => { + if pts.len() > 1 { + polys.push((std::mem::take(&mut pts), false)); + } + pts.clear(); + pts.push(pos2(r.min.x + x as f32, r.min.y + y as f32)); + } + Fl::Line(x, y) => pts.push(pos2(r.min.x + x as f32, r.min.y + y as f32)), + Fl::Close => { + if pts.len() > 1 { + polys.push((std::mem::take(&mut pts), true)); + } + } + }); + if pts.len() > 1 { + polys.push((pts, false)); + } + for (poly, closed) in polys { + if closed && fill != Color32::TRANSPARENT && sp.fill != deckcraft_geom::preset::FillMode::None { + p.add(egui::Shape::Path(egui::epaint::PathShape { points: poly.clone(), closed: true, fill, stroke: Stroke::NONE.into() })); + } + if sp.stroke { + p.add(egui::Shape::Path(egui::epaint::PathShape { + points: poly, + closed, + fill: Color32::TRANSPARENT, + stroke: Stroke::new(1.0, stroke).into(), + })); + } + } + } +} + +pub(super) enum Fl { + Move(f64, f64), + Line(f64, f64), + Close, +} + +pub(super) fn kurbo_flatten(path: &deckcraft_geom::BezPath, mut f: impl FnMut(Fl)) { + deckcraft_geom::preset_flatten(path, &mut |el| match el { + deckcraft_geom::PathEl::MoveTo(p) => f(Fl::Move(p.x, p.y)), + deckcraft_geom::PathEl::LineTo(p) => f(Fl::Line(p.x, p.y)), + deckcraft_geom::PathEl::ClosePath => f(Fl::Close), + _ => {} + }); +} + +pub fn outline_menu(app: &mut SlideApp, ui: &mut Ui, sc: &deckcraft_color::ColorScheme) { + if let Some(c) = widgets::color_grid(ui, sc, Some("No Outline")) { + match c { + Some(c) => { + app.last_outline = cref_param(&c); + run(app, "shape.line", json!({"color": cref_param(&c)})); + } + None => run(app, "shape.line", json!({"none": true})), + } + } + ui.menu_button("Weight", |ui| { + for w in [0.25, 0.5, 0.75, 1.0, 1.5, 2.25, 3.0, 4.5, 6.0] { + if ui.button(format!("{w} pt")).clicked() { + run(app, "shape.line", json!({"width": w})); + ui.close(); + } + } + }); + ui.menu_button("Dashes", |ui| { + for (l, d) in [ + ("Solid", "solid"), + ("Round Dot", "sysDot"), + ("Square Dot", "sysDash"), + ("Dash", "dash"), + ("Dash Dot", "dashDot"), + ("Long Dash", "lgDash"), + ("Long Dash Dot", "lgDashDot"), + ] { + if ui.button(l).clicked() { + run(app, "shape.line", json!({"dash": d})); + ui.close(); + } + } + }); + ui.menu_button("Arrows", |ui| { + for (l, h, tl) in [ + ("None", "none", "none"), + ("Arrow at end", "none", "triangle"), + ("Arrow at start", "triangle", "none"), + ("Arrows at both ends", "triangle", "triangle"), + ("Stealth end", "none", "stealth"), + ("Oval ends", "oval", "oval"), + ] { + if ui.button(l).clicked() { + run(app, "shape.line", json!({"head": h, "tail": tl})); + ui.close(); + } + } + }); +} + +pub fn effects_menu(app: &mut SlideApp, ui: &mut Ui) { + ui.menu_button("Shadow", |ui| { + for (l, v) in [ + ("No Shadow", "none"), + ("Outer", "outer"), + ("Bottom", "bottom"), + ("Centered", "center"), + ("Inner", "inner"), + ("Perspective", "perspective"), + ] { + if ui.button(l).clicked() { + run(app, "shape.effects", json!({"shadow": v})); + } + } + }); + ui.menu_button("Reflection", |ui| { + for (l, v) in [("No Reflection", "none"), ("Tight", "tight"), ("Half", "half"), ("Full", "full")] { + if ui.button(l).clicked() { + run(app, "shape.effects", json!({"reflection": v})); + } + } + }); + ui.menu_button("Glow", |ui| { + if ui.button("No Glow").clicked() { + run(app, "shape.effects", json!({"glow": null})); + } + for r in [5.0, 8.0, 11.0, 18.0] { + if ui.button(format!("{r} pt glow")).clicked() { + run(app, "shape.effects", json!({"glow": r})); + } + } + }); + ui.menu_button("Soft Edges", |ui| { + for r in [0.0, 1.0, 2.5, 5.0, 10.0, 25.0] { + if ui.button(if r == 0.0 { "No Soft Edges".to_string() } else { format!("{r} pt") }).clicked() { + run(app, "shape.effects", json!({"softEdges": r})); + } + } + }); + ui.menu_button("Bevel", |ui| { + for b in [ + "none", + "circle", + "relaxedInset", + "cross", + "coolSlant", + "angle", + "softRound", + "convex", + "slope", + "divot", + "riblet", + "hardEdge", + "artDeco", + ] { + if ui.button(b).clicked() { + run(app, "shape.effects", json!({"bevel": b})); + } + } + }); + if ui.button("Format Shape…").clicked() { + let _ = app.run("view.pane", json!({"pane": "format", "tab": "effects"})); + } +} + +pub(super) fn quick_styles(app: &mut SlideApp, ui: &mut Ui) { + let sc = scheme(app); + let slots = [ + deckcraft_color::SchemeSlot::Dk1, + deckcraft_color::SchemeSlot::Accent1, + deckcraft_color::SchemeSlot::Accent2, + deckcraft_color::SchemeSlot::Accent3, + deckcraft_color::SchemeSlot::Accent4, + deckcraft_color::SchemeSlot::Accent5, + deckcraft_color::SchemeSlot::Accent6, + ]; + egui::Grid::new("quickstyles").spacing(vec2(4.0, 4.0)).show(ui, |ui| { + for row in 0..6 { + for (k, slot) in slots.iter().enumerate() { + let c = theme::to_color32(sc.get(*slot)); + let (rect, resp) = ui.allocate_exact_size(vec2(40.0, 26.0), Sense::click()); + let p = ui.painter(); + let (fill, stroke, text) = match row { + 0 => (Color32::WHITE, c, Color32::BLACK), + 2 => (c.gamma_multiply(0.35).to_opaque().lerp_to_gamma(Color32::WHITE, 0.6), Color32::TRANSPARENT, c), + 5 => (c.lerp_to_gamma(Color32::BLACK, 0.25), Color32::WHITE, Color32::WHITE), + _ => (c, c.lerp_to_gamma(Color32::BLACK, 0.3), Color32::WHITE), + }; + p.rect(rect.shrink(2.0), CornerRadius::same(3), fill, Stroke::new(1.5, stroke), egui::StrokeKind::Inside); + p.text(rect.center(), Align2::CENTER_CENTER, "Abc", theme::font(11.0), text); + if resp.hovered() { + p.rect_stroke(rect, CornerRadius::same(3), Stroke::new(1.5, Tokens::get(ui.ctx()).accent), egui::StrokeKind::Inside); + } + if resp.clicked() { + run(app, "shape.quickStyle", json!({"index": row * 7 + k})); + ui.close(); + } + } + ui.end_row(); + } + }); +} + +/// Gallery glyphs for the freeform tools: a wave (curve), a polygon (freeform), a squiggle (scribble). +fn paint_freeform_icon(p: &egui::Painter, r: Rect, name: &str, ink: Color32) { + let at = |x: f32, y: f32| egui::pos2(r.min.x + x * r.width(), r.min.y + y * r.height()); + let pts: Vec = match name { + "curve" => (0..=24).map(|i| i as f32 / 24.0).map(|u| at(u, 0.5 - 0.38 * (u * std::f32::consts::TAU).sin())).collect(), + "freeform" => { + [(0.0, 0.85), (0.15, 0.2), (0.55, 0.45), (0.8, 0.05), (1.0, 0.75), (0.45, 1.0), (0.0, 0.85)].iter().map(|(x, y)| at(*x, *y)).collect() + } + _ => (0..=40).map(|i| i as f32 / 40.0).map(|u| at(u, 0.5 + 0.35 * (u * 19.0).sin() * (1.0 - u * 0.4))).collect(), + }; + p.add(egui::Shape::line(pts, egui::Stroke::new(1.3, ink))); +} diff --git a/crates/ui-egui/src/ribbon/home.rs b/crates/ui-egui/src/ribbon/home.rs new file mode 100644 index 00000000..826fba98 --- /dev/null +++ b/crates/ui-egui/src/ribbon/home.rs @@ -0,0 +1,471 @@ +use super::arrange::arrange_menu; +use super::galleries::{cref_param, effects_menu, outline_menu, quick_styles, shapes_gallery, smartart_gallery}; +use super::*; + +// ---------- Home ---------- + +fn clipboard_group(app: &mut SlideApp, ui: &mut Ui) { + group(ui, |ui| { + let (main, arrow) = big_button_ex(ui, Icon::Paste, "Paste", enabled(app, "edit.paste"), true, false); + if main.on_hover_text("Paste (⌘V)").clicked() { + run(app, "edit.paste", json!({})); + } + if let Some(a) = arrow { + egui::Popup::menu(&a).show(|ui| { + if ui.button("Paste").clicked() { + run(app, "edit.paste", json!({})); + } + if ui.button("Paste and Match Formatting").clicked() { + run(app, "edit.pasteText", json!({})); + } + if ui.button("Paste Special…").clicked() { + run(app, "app.pasteSpecial", json!({})); + } + if ui.button("Paste Formatting").clicked() { + run(app, "format.painterApply", json!({})); + } + }); + } + rows(ui, |ui| { + small(app, ui, Icon::Cut, "Cut", "edit.cut", json!({})); + small(app, ui, Icon::Copy, "Copy", "edit.copy", json!({})); + let on = app.session.painter.is_some(); + let en = enabled(app, "format.painter"); + let r = small_button(ui, Icon::FormatPainter, "Format", en, on).on_hover_text("Format Painter (double-click to keep it on)"); + if r.double_clicked() { + run(app, "format.painter", json!({"sticky": true})); + } else if r.clicked() { + if on { + app.session.painter = None; + } else { + run(app, "format.painter", json!({})); + } + } + }); + }); +} + +pub(super) fn layout_menu(app: &mut SlideApp, ui: &mut Ui, cmd: &str) { + let layouts: Vec<(String, deckcraft_model::LayoutId)> = app + .session + .active() + .and_then(|d| d.doc.masters.first().map(|m| m.layouts.iter().map(|l| (l.name.clone(), l.id)).collect())) + .unwrap_or_default(); + egui::Grid::new(("layouts", cmd)).spacing(vec2(6.0, 6.0)).show(ui, |ui| { + for (k, (name, id)) in layouts.iter().enumerate() { + let idx = app.session.active().and_then(|d| d.doc.masters.first().and_then(|m| m.layouts.iter().position(|l| l.id == *id))); + let resp = ui.vertical(|ui| { + let (rect, resp) = ui.allocate_exact_size(vec2(96.0, 54.0), Sense::click()); + if let (Some(d), Some(li)) = (app.session.active(), idx) { + let img = deckcraft_render::render_layout( + &d.doc, + 0, + Some(li), + &deckcraft_render::RenderOpts { + scale: 192.0 / d.doc.slide_size.width.max(1.0), + edit: true, + size: Some((192, 108)), + ..Default::default() + }, + ); + let tex = crate::textures::upload_editor(ui.ctx(), format!("layout-{li}"), crate::textures::to_color_image(&img, false)); + let at = crate::textures::device_pixel_rect(rect, ui.ctx().pixels_per_point()); + ui.painter().image(tex.id(), at, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); + } + let t = Tokens::get(ui.ctx()); + ui.painter().rect_stroke( + rect, + CornerRadius::ZERO, + Stroke::new(if resp.hovered() { 2.0 } else { 1.0 }, if resp.hovered() { t.accent } else { t.border }), + egui::StrokeKind::Outside, + ); + ui.add_sized(vec2(96.0, 14.0), egui::Label::new(egui::RichText::new(name).size(10.5)).truncate()); + resp + }); + if resp.inner.clicked() { + run(app, cmd, json!({"layout": id.0})); + ui.close(); + } + if k % 4 == 3 { + ui.end_row(); + } + } + }); +} + +/// Render the Home Slides group: New Slide, Layout, Reset, and Section controls. +fn slides_group(app: &mut SlideApp, ui: &mut Ui) { + group(ui, |ui| { + let (main, arrow) = big_button_ex(ui, Icon::NewSlide, "New\nSlide", enabled(app, "slide.new"), true, false); + if main.on_hover_text("New Slide (⇧⌘N)").clicked() { + run(app, "slide.new", json!({})); + } + if let Some(a) = arrow { + egui::Popup::menu(&a).width(420.0).show(|ui| new_slide_menu(app, ui)); + } + rows(ui, |ui| { + let (main, arrow) = small_button_ex(ui, Icon::Layout, "Layout", enabled(app, "slide.layout"), false, true); + if let Some(arrow) = arrow { + widgets::split_menu(&main, &arrow, |ui| layout_menu(app, ui, "slide.layout")); + } + small(app, ui, Icon::ResetSlide, "Reset", "slide.reset", json!({})); + let (main, arrow) = small_button_ex(ui, Icon::Section, "Section", enabled(app, "section.add"), false, true); + if let Some(arrow) = arrow { + widgets::split_menu(&main, &arrow, |ui| section_menu(app, ui)); + } + }); + }); +} + +/// The New Slide button's menu: layouts, duplication, outline import and slide reuse. +fn new_slide_menu(app: &mut SlideApp, ui: &mut Ui) { + layout_menu(app, ui, "slide.new"); + ui.separator(); + if ui.button("Duplicate Selected Slides").clicked() { + run(app, "slide.duplicate", json!({})); + } + if ui.button("Slides from Outline…").clicked() { + app.dialog = Some(crate::dialogs::Dialog::new("outline")); + } + let desktop = app.services.pick_open.is_some(); + for (label, keep) in [("Reuse Slides…", false), ("Reuse Slides (Keep Source Formatting)…", true)] { + let r = ui.add_enabled(desktop, egui::Button::new(label)); + if r.clicked() { + run(app, "app.reuseSlidesDialog", json!({"keepFormatting": keep})); + } + r.on_disabled_hover_text("Choosing a presentation to reuse needs the desktop app"); + } +} + +/// The Section split button's menu. +fn section_menu(app: &mut SlideApp, ui: &mut Ui) { + if ui.button("Add Section").clicked() { + run(app, "section.add", json!({})); + } + if ui.button("Remove All Sections").clicked() { + run(app, "section.removeAll", json!({})); + } +} + +pub fn font_families(app: &SlideApp) -> Vec { + let mut v = vec![]; + if let Some(m) = app.session.active().and_then(|d| d.doc.masters.first()) { + v.push(m.theme.fonts.major.latin.clone()); + v.push(m.theme.fonts.minor.latin.clone()); + } + v.extend(deckcraft_fonts::FontDb::global().families()); + v.dedup(); + v +} + +fn font_group(app: &mut SlideApp, ui: &mut Ui, st: &Value) { + let en = enabled(app, "format.bold"); + group(ui, |ui| { + rows(ui, |ui| { + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 3.0; + let font = st.get("font").and_then(Value::as_str).unwrap_or("").to_string(); + let size = st.get("size").and_then(Value::as_f64); + let fams = font_families(app); + let theme_fonts: Vec = fams.iter().take(2).cloned().collect(); + dropdown(ui, "font", &font, 150.0, en, |ui| { + for (k, f) in fams.iter().enumerate() { + let label = if k == 0 && theme_fonts.len() == 2 { + format!("{f} (Headings)") + } else if k == 1 && theme_fonts.len() == 2 { + format!("{f} (Body)") + } else { + f.clone() + }; + if ui.button(egui::RichText::new(label)).clicked() { + run(app, "format.font", json!({"family": f})); + ui.close(); + } + if k == 1 { + ui.separator(); + } + } + ui.separator(); + // Fonts installed while DeckCraft runs (e.g. into ~/.local/share/fonts). + if ui.button("Rescan Installed Fonts").clicked() { + run(app, "font.rescan", json!({})); + ui.close(); + } + }); + let size_text = size.map(|s| widgets::fmt_num(s, "")).unwrap_or_default(); + dropdown(ui, "size", &size_text, 52.0, en, |ui| { + for s in deckcraft_engine::cmd::format::SIZES.iter().chain([72.0, 80.0, 88.0, 96.0].iter()) { + if ui.button(widgets::fmt_num(*s, "")).clicked() { + run(app, "format.size", json!({"size": s})); + ui.close(); + } + } + }); + toggle(app, ui, Icon::FontGrow, "Increase Font Size", "format.grow", false); + toggle(app, ui, Icon::FontShrink, "Decrease Font Size", "format.shrink", false); + toggle(app, ui, Icon::ClearFormatting, "Clear All Formatting", "format.clear", false); + }); + ui.add_space(3.0); + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 1.0; + let b = |k: &str| st.get(k).and_then(Value::as_bool).unwrap_or(false); + toggle(app, ui, Icon::Bold, "Bold", "format.bold", b("bold")); + toggle(app, ui, Icon::Italic, "Italic", "format.italic", b("italic")); + toggle(app, ui, Icon::Underline, "Underline", "format.underline", b("underline")); + toggle(app, ui, Icon::Strikethrough, "Strikethrough", "format.strikethrough", b("strike")); + toggle(app, ui, Icon::Superscript, "Superscript", "format.superscript", b("superscript")); + toggle(app, ui, Icon::Subscript, "Subscript", "format.subscript", b("subscript")); + let r = icon_toggle(ui, Icon::CharSpacing, "Character Spacing", en, false); + egui::Popup::menu(&r).show(|ui| { + for (label, pt) in [("Very Tight", -3.0), ("Tight", -1.5), ("Normal", 0.0), ("Loose", 3.0), ("Very Loose", 6.0)] { + if ui.button(label).clicked() { + run(app, "format.spacing", json!({"pt": pt})); + } + } + }); + let r = icon_toggle(ui, Icon::ChangeCase, "Change Case", en, false); + egui::Popup::menu(&r).show(|ui| { + for (label, mode) in [ + ("Sentence case.", "sentence"), + ("lowercase", "lower"), + ("UPPERCASE", "upper"), + ("Capitalize Each Word", "title"), + ("tOGGLE cASE", "toggle"), + ] { + if ui.button(label).clicked() { + run(app, "format.case", json!({"mode": mode})); + } + } + }); + let sc = scheme(app); + let (m, a) = color_split(ui, Icon::Highlight, "Text Highlight Color", Color32::from_rgb(255, 230, 0), en); + if m.clicked() { + run(app, "format.highlight", json!({"color": "#FFFF00"})); + } + widgets::color_popup(&a).show(|ui| { + if let Some(c) = widgets::color_grid(ui, &sc, Some("No Color")) { + run(app, "format.highlight", json!({"color": c.map(|c| serde_json::to_value(cref_param(&c)).unwrap_or_default())})); + } + }); + let (m, a) = color_split(ui, Icon::FontColor, "Font Color", Color32::from_rgb(0xD8, 0x3F, 0x3F), en); + if m.clicked() { + run(app, "format.color", json!({"color": "#C00000"})); + } + widgets::color_popup(&a).show(|ui| { + if let Some(Some(c)) = widgets::color_grid(ui, &sc, None) { + run(app, "format.color", json!({"color": cref_param(&c)})); + } + }); + if ui.add_enabled(en, egui::Button::new("Font…").min_size(vec2(52.0, 22.0))).on_hover_text("Font").clicked() { + run(app, "format.fontDialog", json!({})); + } + }); + }); + }); +} + +/// Render paragraph formatting controls using the current paragraph state. +fn paragraph_group(app: &mut SlideApp, ui: &mut Ui, st: &Value) { + let en = enabled(app, "format.bullets"); + group(ui, |ui| { + rows(ui, |ui| { + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 1.0; + let b = |k: &str| st.get(k).and_then(Value::as_bool).unwrap_or(false); + let (m, a) = small_button_named(ui, Icon::Bullets, "", "Bullets", en, b("bullets"), true); + if m.on_hover_text("Bullets").clicked() { + run(app, "format.bullets", json!({})); + } + if let Some(a) = a { + egui::Popup::menu(&a).show(|ui| { + if ui.button("None").clicked() { + run(app, "format.bullets", json!({"on": false})); + } + for ch in ["•", "○", "■", "□", "◆", "➢", "✓", "–", "★"] { + if ui.button(egui::RichText::new(format!("{ch} Bullet")).size(14.0)).clicked() { + run(app, "format.bullets", json!({"on": true, "char": ch})); + } + } + ui.separator(); + if ui.button("Bullets and Numbering…").clicked() { + let mut dlg = crate::dialogs::Dialog::new("bullets"); + dlg.fields.insert("tab".into(), "bulleted".into()); + app.dialog = Some(dlg); + } + }); + } + let (m, a) = small_button_named(ui, Icon::Numbering, "", "Numbering", en, b("numbering"), true); + if m.on_hover_text("Numbering").clicked() { + run(app, "format.numbering", json!({})); + } + if let Some(a) = a { + egui::Popup::menu(&a).show(|ui| { + if ui.button("None").clicked() { + run(app, "format.numbering", json!({"on": false})); + } + for (label, sch) in [ + ("1. 2. 3.", "arabicPeriod"), + ("1) 2) 3)", "arabicParenR"), + ("I. II. III.", "romanUcPeriod"), + ("i. ii. iii.", "romanLcPeriod"), + ("A. B. C.", "alphaUcPeriod"), + ("a) b) c)", "alphaLcParenR"), + ("a. b. c.", "alphaLcPeriod"), + ] { + if ui.button(label).clicked() { + run(app, "format.numbering", json!({"on": true, "scheme": sch})); + } + } + ui.separator(); + if ui.button("Bullets and Numbering…").clicked() { + let mut dlg = crate::dialogs::Dialog::new("bullets"); + dlg.fields.insert("tab".into(), "numbered".into()); + app.dialog = Some(dlg); + } + }); + } + toggle(app, ui, Icon::DecreaseIndent, "Decrease List Level", "format.outdent", false); + toggle(app, ui, Icon::IncreaseIndent, "Increase List Level", "format.indent", false); + let r = icon_toggle(ui, Icon::LineSpacing, "Line Spacing", en, false); + egui::Popup::menu(&r).show(|ui| { + for v in [1.0, 1.5, 2.0, 2.5, 3.0] { + if ui.button(format!("{v:.1}")).clicked() { + run(app, "format.lineSpacing", json!({"lines": v})); + } + } + if ui.button("Line Spacing Options…").clicked() { + run(app, "format.paragraphDialog", json!({})); + } + if ui.button("Tabs…").clicked() { + app.dialog = Some(crate::dialogs::Dialog::new("tabs")); + } + }); + let r = icon_toggle(ui, Icon::Columns, "Columns", en, false); + egui::Popup::menu(&r).show(|ui| { + for n in 1..=3 { + if ui.button(format!("{n} Column{}", if n > 1 { "s" } else { "" })).clicked() { + run(app, "format.columns", json!({"count": n, "spacing": 18})); + } + } + if ui.button("More Columns…").clicked() { + run(app, "format.columns", json!({})); + } + }); + }); + ui.add_space(3.0); + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 1.0; + let al = st.get("align").and_then(Value::as_str).unwrap_or(""); + toggle(app, ui, Icon::AlignLeft, "Align Left", "format.alignLeft", al == "l"); + toggle(app, ui, Icon::AlignCenter, "Center", "format.alignCenter", al == "ctr"); + toggle(app, ui, Icon::AlignRight, "Align Right", "format.alignRight", al == "r"); + toggle(app, ui, Icon::Justify, "Justify", "format.justify", al == "just"); + let r = icon_toggle(ui, Icon::TextDirection, "Text Direction", en, false); + egui::Popup::menu(&r).show(|ui| { + for (l, d) in [ + ("Horizontal", "horizontal"), + ("Rotate all text 90°", "rotate90"), + ("Rotate all text 270°", "rotate270"), + ("Stacked", "stacked"), + ] { + if ui.button(l).clicked() { + run(app, "format.direction", json!({"dir": d})); + } + } + }); + let r = icon_toggle(ui, Icon::AlignText, "Align Text", en, false); + egui::Popup::menu(&r).show(|ui| { + for (l, a) in [("Top", "top"), ("Middle", "middle"), ("Bottom", "bottom")] { + if ui.button(l).clicked() { + run(app, "format.anchor", json!({"anchor": a})); + } + } + }); + 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)); + 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!({})); + } + }); + }); + }); +} + +/// Render drawing controls for shapes, text, arrange, quick styles, fill, and outline. +fn drawing_group(app: &mut SlideApp, ui: &mut Ui) { + group(ui, |ui| { + let en = enabled(app, "shape.insert"); + let r = big_button(ui, Icon::Shapes, "Shapes", en).on_hover_text("Shapes"); + egui::Popup::menu(&r).show(|ui| { + if let Some(p) = shapes_gallery(ui) { + app.session.set_tool(deckcraft_engine::ToolKind::Shape { preset: p.to_string() }); + } + }); + big(app, ui, Icon::TextBox, "Text Box", "insert.textBox", json!({})); + let sel = enabled(app, "shape.fill"); + let r = big_button(ui, Icon::Arrange, "Arrange", sel); + egui::Popup::menu(&r).show(|ui| arrange_menu(app, ui)); + let r = big_button(ui, Icon::QuickStyles, "Quick\nStyles", sel); + egui::Popup::menu(&r).width(330.0).show(|ui| quick_styles(app, ui)); + rows(ui, |ui| { + let sc = scheme(app); + let (m, a) = small_button_ex(ui, Icon::ShapeFill, "Shape Fill", sel, false, true); + if m.clicked() { + let c = app.last_fill.clone(); + run(app, "shape.fill", json!({"color": c})); + } + if let Some(a) = a { + widgets::color_popup(&a).show(|ui| { + if let Some(c) = widgets::color_grid(ui, &sc, Some("No Fill")) { + match c { + Some(c) => { + app.last_fill = cref_param(&c); + run(app, "shape.fill", json!({"color": cref_param(&c)})); + } + None => run(app, "shape.fill", json!({"none": true})), + } + } + }); + } + let (m, a) = small_button_ex(ui, Icon::ShapeOutline, "Shape Outline", sel, false, true); + if m.clicked() { + let c = app.last_outline.clone(); + run(app, "shape.line", json!({"color": c})); + } + if let Some(a) = a { + widgets::color_popup(&a).show(|ui| outline_menu(app, ui, &sc)); + } + let (main, arrow) = small_button_ex(ui, Icon::ShapeEffects, "Shape Effects", sel, false, true); + if let Some(arrow) = arrow { + widgets::split_menu(&main, &arrow, |ui| effects_menu(app, ui)); + } + }); + }); +} + +pub(super) fn home(app: &mut SlideApp, ui: &mut Ui) { + let st = fmt_state(app); + clipboard_group(app, ui); + slides_group(app, ui); + font_group(app, ui, &st); + paragraph_group(app, ui, &st); + group(ui, |ui| { + big(app, ui, Icon::Picture, "Picture", "app.insertPictureDialog", json!({})); + }); + drawing_group(app, ui); + // Editing, as in PowerPoint: Find, Replace and Replace Fonts, then the Selection Pane. + group(ui, |ui| { + ui.vertical(|ui| { + small(app, ui, Icon::Search, "Find", "app.find", json!({})); + small(app, ui, Icon::Spelling, "Replace", "app.replace", json!({})); + if small_button(ui, Icon::Fonts, "Replace Fonts…", enabled(app, "font.replace"), false).clicked() { + app.dialog = Some(crate::dialogs::Dialog::new("replaceFonts")); + } + }); + // Also under Arrange, but that is disabled until something is selected (#132). + big(app, ui, Icon::SelectionPane, "Selection\nPane", "view.pane", json!({"pane": "selection"})); + }); + group(ui, |ui| { + big(app, ui, Icon::DesignIdeas, "Design\nIdeas", "view.pane", json!({"pane": "designer"})); + }); +} diff --git a/crates/ui-egui/src/ribbon/insert.rs b/crates/ui-egui/src/ribbon/insert.rs new file mode 100644 index 00000000..da2b6bb4 --- /dev/null +++ b/crates/ui-egui/src/ribbon/insert.rs @@ -0,0 +1,271 @@ +use super::galleries::{Fl, kurbo_flatten, shapes_gallery}; +use super::home::layout_menu; +use super::view::zoom_menu; +use super::*; + +/// 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) { + let en = enabled(app, "insert.photoAlbum"); + let (main, arrow) = big_button_ex(ui, Icon::Picture, "Photo\nAlbum", en, true, false); + let _ = main.clone().on_hover_text(tip("insert.photoAlbum", "Photo Album…")); + main.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, en, "Photo Album…")); + if let Some(a) = &arrow { + let _ = a.clone().on_hover_text("Photo Album options"); + a.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, en, "Photo Album options")); + } + if main.clicked() { + run(app, "insert.photoAlbum", json!({})); + } + if let Some(a) = arrow { + egui::Popup::menu(&a).show(|ui| { + menu_item(app, ui, Icon::Picture, "New Photo Album…", "insert.photoAlbum", json!({})); + menu_item(app, ui, Icon::Picture, "Edit Photo Album…", "insert.photoAlbumEdit", json!({})); + }); + } +} + +// ---------- Insert ---------- + +pub(super) fn insert(app: &mut SlideApp, ui: &mut Ui) { + group(ui, |ui| { + let (main, arrow) = big_button_ex(ui, Icon::NewSlide, "New\nSlide", enabled(app, "slide.new"), true, false); + if main.clicked() { + run(app, "slide.new", json!({})); + } + if let Some(a) = arrow { + egui::Popup::menu(&a).width(420.0).show(|ui| layout_menu(app, ui, "slide.new")); + } + }); + group(ui, |ui| { + let r = big_button(ui, Icon::Table, "Table", enabled(app, "insert.table")); + egui::Popup::menu(&r).show(|ui| { + if let Some((rr, cc)) = table_grid_picker(ui) { + run(app, "insert.table", json!({"rows": rr, "cols": cc})); + ui.close(); + } + ui.separator(); + if ui.button("Insert Table…").clicked() { + app.dialog = Some(crate::dialogs::Dialog::new("table")); + ui.close(); + } + if ui.button("Draw Table").clicked() { + app.session.set_tool(deckcraft_engine::ToolKind::Shape { preset: deckcraft_engine::tools::DRAW_TABLE.into() }); + ui.close(); + } + }); + }); + group(ui, |ui| { + big(app, ui, Icon::Picture, "Pictures", "app.insertPictureDialog", json!({})); + photo_album_button(app, ui); + let r = big_button(ui, Icon::Screenshot, "Screenshot", false); + let _ = r.on_hover_text("Screenshot (not available yet)"); + }); + group(ui, |ui| { + let r = big_button(ui, Icon::Shapes, "Shapes", enabled(app, "shape.insert")); + egui::Popup::menu(&r).show(|ui| { + if let Some(p) = shapes_gallery(ui) { + app.session.set_tool(deckcraft_engine::ToolKind::Shape { preset: p.to_string() }); + } + }); + let r = big_button(ui, Icon::Icons, "Icons", enabled(app, "insert.icon")); + egui::Popup::menu(&r).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).width(340.0).show(|ui| icon_library(app, ui)); + big(app, ui, Icon::SmartArt, "SmartArt", "app.dialog", json!({"id": "smartart"})); + let r = big_button(ui, Icon::Chart, "Chart", enabled(app, "insert.chart")); + egui::Popup::menu(&r).show(|ui| { + for k in chart_menu_types() { + if k.is_none() { + ui.separator(); + } + if let Some(k) = k + && ui.button(k.label()).clicked() + { + run(app, "insert.chart", json!({"type": format!("{k:?}")})); + } + } + }); + }); + group(ui, |ui| { + let r = big_button(ui, Icon::Zoom, "Zoom", enabled(app, "insert.zoom")); + egui::Popup::menu(&r).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).width(300.0).show(|ui| zoom_menu(app, ui)); + big(app, ui, Icon::Link, "Link", "app.dialog", json!({"id": "hyperlink"})); + let r = big_button(ui, Icon::Action, "Action", enabled(app, "insert.actionButton")); + egui::Popup::menu(&r).show(|ui| { + for k in ["back", "forward", "beginning", "end", "home", "information", "return", "movie", "document", "sound", "help", "custom"] { + if ui.button(format!("Action Button: {k}")).clicked() { + run(app, "insert.actionButton", json!({"kind": k})); + } + } + ui.separator(); + let has_sel = app.session.active().is_some_and(|s| !s.selection.shapes.is_empty()); + if ui.add_enabled(has_sel, egui::Button::new("Action Settings…")).clicked() { + app.dialog = Some(crate::dialogs::Dialog::new("action")); + } + }); + }); + group(ui, |ui| { + big(app, ui, Icon::Comment, "Comment", "app.dialog", json!({"id": "comment"})); + }); + group(ui, |ui| { + big(app, ui, Icon::TextBox, "Text\nBox", "insert.textBox", json!({})); + big(app, ui, Icon::HeaderFooter, "Header &\nFooter", "app.dialog", json!({"id": "headerFooter"})); + let r = big_button(ui, Icon::WordArt, "WordArt", enabled(app, "insert.wordArt")); + egui::Popup::menu(&r).show(|ui| { + for i in 0..6 { + if ui.button(format!("WordArt style {}", i + 1)).clicked() { + run(app, "insert.wordArt", json!({"style": i})); + } + } + }); + big(app, ui, Icon::DateTime, "Date &\nTime", "insert.dateTime", json!({})); + big(app, ui, Icon::SlideNumber, "Slide\nNumber", "insert.slideNumber", json!({})); + }); + group(ui, |ui| { + big(app, ui, Icon::Equation, "Equation", "app.dialog", json!({"id": "equation"})); + big(app, ui, Icon::Symbol, "Symbol", "app.dialog", json!({"id": "symbol"})); + }); + group(ui, |ui| { + big(app, ui, Icon::Video, "Video", "app.insertVideoDialog", json!({})); + big(app, ui, Icon::Audio, "Audio", "app.insertAudioDialog", json!({})); + }); +} + +/// Hover-to-size table grid. Returns (rows, cols) when clicked. +pub fn table_grid_picker(ui: &mut Ui) -> Option<(usize, usize)> { + let t = Tokens::get(ui.ctx()); + let id = ui.scope_id().with("tgrid"); + let mut hover: (usize, usize) = ui.data_mut(|d| d.get_temp(id).unwrap_or((0, 0))); + let cell = 16.0; + let label = if hover.0 > 0 { format!("{}x{} Table", hover.1, hover.0) } else { "Insert Table".into() }; + ui.label(label); + let (rect, resp) = ui.allocate_exact_size(vec2(10.0 * (cell + 2.0), 8.0 * (cell + 2.0)), Sense::click()); + if let Some(p) = resp.hover_pos() { + let c = ((p.x - rect.min.x) / (cell + 2.0)).floor() as usize + 1; + let r = ((p.y - rect.min.y) / (cell + 2.0)).floor() as usize + 1; + hover = (r.min(8), c.min(10)); + } + for r in 0..8 { + for c in 0..10 { + let cr = Rect::from_min_size(rect.min + vec2(c as f32 * (cell + 2.0), r as f32 * (cell + 2.0)), vec2(cell, cell)); + let on = r < hover.0 && c < hover.1; + ui.painter().rect( + cr, + CornerRadius::ZERO, + if on { t.accent.gamma_multiply(0.3) } else { t.card }, + Stroke::new(1.0, if on { t.accent } else { t.border }), + egui::StrokeKind::Inside, + ); + } + } + ui.data_mut(|d| d.insert_temp(id, hover)); + if resp.clicked() && hover.0 > 0 { Some(hover) } else { None } +} + +fn render_icon_categories(app: &mut SlideApp, ui: &mut Ui, icons: &[deckcraft_engine::icons::Icon], color: Color32, hover: Color32) { + let mut cats: Vec<&str> = Vec::new(); + for icon in icons { + if !cats.contains(&icon.category) { + cats.push(icon.category); + } + } + for cat in cats { + ui.label(egui::RichText::new(cat).small().strong()); + ui.horizontal_wrapped(|ui| { + for icon in icons.iter().filter(|i| i.category == cat) { + icon_cell(app, ui, icon, color, hover); + } + }); + } +} + +fn render_filtered_icons(app: &mut SlideApp, ui: &mut Ui, icons: &[deckcraft_engine::icons::Icon], q: &str, color: Color32, hover: Color32) { + let mut n = 0; + ui.horizontal_wrapped(|ui| { + for icon in icons { + if icon.name.contains(q) || icon.label.to_lowercase().contains(q) { + icon_cell(app, ui, icon, color, hover); + n += 1; + } + } + }); + if n == 0 { + ui.label("No icons match."); + } +} + +/// Lucide stroke icons (Insert › Icons). Search filters the gallery; a click inserts the outline. +fn icon_library(app: &mut SlideApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + let (color, hover) = (t.text, t.hover); + let id = ui.scope_id().with("icon-query"); + let mut query = ui.data_mut(|d| d.get_temp::(id).unwrap_or_default()); + ui.add(egui::TextEdit::singleline(&mut query).hint_text("Search icons").desired_width(ui.available_width())); + let q = query.trim().to_lowercase(); + ui.data_mut(|d| d.insert_temp(id, query)); + let icons = deckcraft_engine::icons::icons(); + egui::ScrollArea::vertical().id_salt("icon-scroll").max_height(420.0).show(ui, |ui| { + if q.is_empty() { + render_icon_categories(app, ui, icons, color, hover); + } else { + render_filtered_icons(app, ui, icons, &q, color, hover); + } + }); +} + +fn icon_cell(app: &mut SlideApp, ui: &mut Ui, icon: &deckcraft_engine::icons::Icon, color: Color32, hover: Color32) { + let (r, resp) = ui.allocate_exact_size(vec2(36.0, 36.0), Sense::click()); + if resp.hovered() { + ui.painter().rect_filled(r, CornerRadius::same(4), hover); + } + paint_icon(ui.painter(), r.shrink(6.0), icon, color); + if resp.on_hover_text(icon.label).clicked() { + let _ = app.run("insert.icon", json!({"name": icon.name})); + ui.close(); + } +} + +/// Stroke a Lucide outline into a gallery cell. The line is the design stroke scaled to the cell. +fn paint_icon(p: &egui::Painter, r: Rect, icon: &deckcraft_engine::icons::Icon, color: Color32) { + let Some(src) = deckcraft_engine::icons::outline(icon.name) else { return }; + let mut bp = src.clone(); + let (w, h) = (r.width().max(1.0) as f64, r.height().max(1.0) as f64); + bp.apply_affine(deckcraft_geom::Affine::scale_non_uniform(w / deckcraft_engine::icons::VIEW, h / deckcraft_engine::icons::VIEW)); + let sw = ((r.width() * icon.stroke as f32) / deckcraft_engine::icons::VIEW as f32).clamp(1.0, 3.5); + let mut pts: Vec = vec![]; + let mut polys: Vec<(Vec, bool)> = vec![]; + kurbo_flatten(&bp, |el| match el { + Fl::Move(x, y) => { + if pts.len() > 1 { + polys.push((std::mem::take(&mut pts), false)); + } + pts.clear(); + pts.push(pos2(r.min.x + x as f32, r.min.y + y as f32)); + } + Fl::Line(x, y) => pts.push(pos2(r.min.x + x as f32, r.min.y + y as f32)), + Fl::Close => { + if pts.len() > 1 { + polys.push((std::mem::take(&mut pts), true)); + } + } + }); + if pts.len() > 1 { + polys.push((pts, false)); + } + for (poly, closed) in polys { + p.add(egui::Shape::Path(egui::epaint::PathShape { + points: poly, + closed, + fill: Color32::TRANSPARENT, + stroke: egui::epaint::PathStroke::new(sw, color), + })); + } +} + +/// The chart types in the Insert Chart and Change Chart Type menus: the main ones, a separator +/// (`None`), then the Office 2016 types not already listed. +pub(super) fn chart_menu_types() -> Vec> { + use deckcraft_model::chart::ChartType; + let mut v: Vec> = ChartType::MAIN.iter().copied().map(Some).collect(); + v.push(None); + v.extend(ChartType::OFFICE_2016.iter().copied().filter(|k| !ChartType::MAIN.contains(k)).map(Some)); + v +} diff --git a/crates/ui-egui/src/ribbon/masters.rs b/crates/ui-egui/src/ribbon/masters.rs new file mode 100644 index 00000000..c39d2f15 --- /dev/null +++ b/crates/ui-egui/src/ribbon/masters.rs @@ -0,0 +1,141 @@ +use super::*; + +/// Preserve, and whether the open master or layout has a title or any footer placeholder. +fn master_checks(app: &SlideApp) -> (bool, bool, bool) { + let Some(st) = app.session.active() else { return (false, false, false) }; + let preserve = match st.selection.target { + Target::Master { master } => st.doc.masters.get(master).is_some_and(|m| m.preserve), + Target::Layout { master, layout } => st.doc.masters.get(master).and_then(|m| m.layouts.get(layout)).is_some_and(|l| l.preserve), + Target::Slides | Target::Handout | Target::NotesMaster => false, + }; + let title = st.shapes().iter().any(|sh| sh.ph_type().is_some_and(|k| k.is_title())); + let footers = st.shapes().iter().any(|sh| sh.ph_type().is_some_and(|k| k.is_footer_kind())); + (preserve, title, footers) +} + +pub(super) fn slide_master(app: &mut SlideApp, ui: &mut Ui) { + group(ui, |ui| { + big(app, ui, Icon::NewSlide, "Insert\nLayout", "master.insertLayout", json!({})); + big(app, ui, Icon::DeleteSlide, "Delete", "master.deleteLayout", json!({})); + big(app, ui, Icon::Layout, "Rename", "app.dialog", json!({"id": "renameLayout"})); + }); + group(ui, |ui| { + rows(ui, |ui| { + let (preserve, title, footers) = master_checks(app); + let mut on = preserve; + if ui.checkbox(&mut on, "Preserve").changed() { + run(app, "master.preserve", json!({"preserve": on})); + } + let mut on = title; + if ui.checkbox(&mut on, "Title").changed() { + run(app, "master.placeholders", json!({"title": on})); + } + let mut on = footers; + if ui.checkbox(&mut on, "Footers").changed() { + run(app, "master.placeholders", json!({"footers": on})); + } + }); + }); + group(ui, |ui| { + let r = big_button(ui, Icon::Table, "Insert\nPlaceholder", true); + egui::Popup::menu(&r).show(|ui| { + for (l, k) in + [("Content", "content"), ("Text", "text"), ("Picture", "picture"), ("Chart", "chart"), ("Table", "table"), ("Media", "media")] + { + if ui.button(l).clicked() { + run(app, "master.insertPlaceholder", json!({"kind": k})); + } + } + }); + }); + group(ui, |ui| { + let r = big_button(ui, Icon::Themes, "Themes", true); + egui::Popup::menu(&r).show(|ui| { + for th in deckcraft_model::theme::builtin_themes() { + if ui.button(&th.name).clicked() { + run(app, "design.theme", json!({"name": th.name})); + } + } + ui.separator(); + if ui.button("Browse for Themes…").clicked() { + run(app, "app.browseThemeDialog", json!({})); + } + }); + big(app, ui, Icon::FormatBackground, "Background\nStyles", "view.pane", json!({"pane": "background"})); + }); + group(ui, |ui| { + if big_button(ui, Icon::Close, "Close\nMaster View", true).clicked() { + run(app, "view.closeMaster", json!({})); + } + }); +} + +pub(super) fn notes_master(app: &mut SlideApp, ui: &mut Ui) { + group(ui, |ui| { + use deckcraft_model::PhType; + let shown = |app: &SlideApp, k: PhType| { + app.session.active().and_then(|d| d.doc.notes_master.as_ref()).is_some_and(|m| m.shapes.iter().any(|s| s.ph_type() == Some(k))) + }; + for col in [ + [("Header", "header", PhType::Header), ("Slide Image", "slideImage", PhType::SlideImage), ("Footer", "footer", PhType::Footer)], + [("Date", "date", PhType::Date), ("Body", "body", PhType::Body), ("Page Number", "pageNumber", PhType::SlideNum)], + ] { + rows(ui, |ui| { + for (label, key, k) in col { + let mut on = shown(app, k); + if ui.checkbox(&mut on, label).changed() { + run(app, "notes.placeholders", json!({ key: on })); + } + } + }); + } + }); + group(ui, |ui| { + big(app, ui, Icon::FormatBackground, "Background\nStyles", "view.pane", json!({"pane": "background"})); + }); + group(ui, |ui| { + if big_button(ui, Icon::Close, "Close\nMaster View", true).clicked() { + run(app, "view.closeMaster", json!({})); + } + }); +} + +pub(super) fn handout_master(app: &mut SlideApp, ui: &mut Ui) { + group(ui, |ui| { + let r = big_button(ui, Icon::Layout, &format!("Slides Per\nPage: {}", app.ui.handout_per_page), true); + egui::Popup::menu(&r).show(|ui| { + for n in [1u8, 2, 3, 4, 6, 9] { + if ui.selectable_label(app.ui.handout_per_page == n, format!("{n} Slide{}", if n == 1 { "" } else { "s" })).clicked() { + app.ui.handout_per_page = n; + } + } + }); + }); + group(ui, |ui| { + let shown = |app: &SlideApp, k: deckcraft_model::PhType| { + app.session.active().and_then(|d| d.doc.handout_master.as_ref()).is_some_and(|m| m.shapes.iter().any(|s| s.ph_type() == Some(k))) + }; + rows(ui, |ui| { + use deckcraft_model::PhType; + for (label, key, k) in [ + ("Header", "header", PhType::Header), + ("Date", "date", PhType::Date), + ("Footer", "footer", PhType::Footer), + ("Page Number", "pageNumber", PhType::SlideNum), + ] { + let mut on = shown(app, k); + if ui.checkbox(&mut on, label).changed() { + run(app, "handout.placeholders", json!({ key: on })); + } + } + }); + }); + group(ui, |ui| { + big(app, ui, Icon::FormatBackground, "Background\nStyles", "view.pane", json!({"pane": "background"})); + }); + group(ui, |ui| { + if big_button(ui, Icon::Close, "Close\nMaster View", true).clicked() { + run(app, "view.closeMaster", json!({})); + } + }); +} diff --git a/crates/ui-egui/src/ribbon/picture_format.rs b/crates/ui-egui/src/ribbon/picture_format.rs new file mode 100644 index 00000000..73c3bde6 --- /dev/null +++ b/crates/ui-egui/src/ribbon/picture_format.rs @@ -0,0 +1,278 @@ +use super::arrange::{arrange_group, size_group}; +use super::galleries::{cref_param, shapes_gallery}; +use super::*; + +/// Render the Picture Format tab's corrections, colour, artistic effects, transparency, styles, +/// border, and picture actions. +pub(super) fn picture_format(app: &mut SlideApp, ui: &mut Ui) { + group(ui, |ui| { + let r = big_button(ui, Icon::RemoveBackground, "Remove\nBackground", enabled(app, "picture.removeBackground")); + egui::Popup::menu(&r).show(|ui| { + for (label, tol) in [("Remove Background", 20.0), ("Remove More (looser)", 35.0), ("Remove Less (tighter)", 10.0)] { + if ui.button(label).clicked() { + run(app, "picture.removeBackground", json!({"tolerance": tol})); + } + } + }); + let r = big_button(ui, Icon::Corrections, "Corrections", true); + egui::Popup::menu(&r).show(|ui| { + for b in [-0.4, -0.2, 0.0, 0.2, 0.4] { + if ui.button(format!("Brightness {:+}%", (b * 100.0) as i32)).clicked() { + run(app, "picture.adjust", json!({"brightness": b})); + } + } + ui.separator(); + for c in [-0.4, -0.2, 0.0, 0.2, 0.4] { + if ui.button(format!("Contrast {:+}%", (c * 100.0) as i32)).clicked() { + run(app, "picture.adjust", json!({"contrast": c})); + } + } + }); + let r = big_button(ui, Icon::ColorAdjust, "Color", true); + egui::Popup::menu(&r).show(|ui| { + for s in [0.0, 0.33, 0.66, 1.0, 1.5, 2.0, 3.0] { + if ui.button(format!("Saturation {}%", (s * 100.0) as i32)).clicked() { + run(app, "picture.adjust", json!({"saturation": s})); + } + } + ui.separator(); + ui.label("Recolor"); + let sc = scheme(app); + let presets = deckcraft_engine::cmd::recolor::presets(); + let names: Vec = presets.iter().map(|(_, l)| l.clone()).collect(); + if let Some(i) = widgets::gallery( + ui, + presets.len(), + vec2(40.0, 30.0), + None, + |p, r, i| { + if let Some((id, _)) = presets.get(i) { + paint_recolor(p, r, id, &sc); + } + }, + |i| names.get(i).cloned().unwrap_or_default(), + ) && let Some((id, _)) = presets.get(i) + { + run(app, "picture.recolor", json!({"preset": id})); + } + ui.separator(); + // As in PowerPoint, the pointer picks the colour in the picture itself: a colour + // chosen from a grid almost never matches a photo's pixels. + if ui.button("Set Transparent Color").on_hover_text("Click a colour in the picture to make it transparent").clicked() { + crate::eyedropper::request_transparent_color(ui.ctx()); + ui.close(); + } + }); + let r = big_button(ui, Icon::ArtisticEffects, "Artistic\nEffects", enabled(app, "picture.artisticEffect")); + egui::Popup::menu(&r).show(|ui| { + if ui.button("None").clicked() { + run(app, "picture.artisticEffect", json!({"effect": "none"})); + } + for (e, l) in deckcraft_engine::cmd::artistic::EFFECTS { + if ui.button(*l).clicked() { + run(app, "picture.artisticEffect", json!({"effect": e})); + } + } + }); + let r = big_button(ui, Icon::Transparency, "Transparency", true); + egui::Popup::menu(&r).show(|ui| { + for tr in [0.0, 0.15, 0.3, 0.5, 0.65, 0.8, 0.95] { + if ui.button(format!("{}%", (tr * 100.0) as i32)).clicked() { + run(app, "picture.adjust", json!({"transparency": tr})); + } + } + }); + rows(ui, |ui| { + small(app, ui, Icon::ChangePicture, "Change Picture", "app.changePictureDialog", json!({})); + small(app, ui, Icon::ResetPicture, "Reset Picture", "picture.reset", json!({})); + let (main, arrow) = small_button_ex(ui, Icon::CompressPictures, "Compress Pictures", enabled(app, "picture.compress"), false, true); + if let Some(arrow) = arrow { + widgets::split_popup(&main, &arrow) + .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside) + .width(260.0) + .show(|ui| compress_menu(app, ui)); + } + }); + }); + group(ui, |ui| { + use deckcraft_engine::cmd::picture_style::{STYLES, look}; + let names: Vec = STYLES.iter().map(|(_, l)| l.to_string()).collect(); + if let Some(i) = widgets::gallery( + ui, + STYLES.len(), + vec2(56.0, 44.0), + None, + |p, r, i| paint_picture_style(p, r, STYLES.get(i).and_then(|(id, _)| look(id))), + |i| names.get(i).cloned().unwrap_or_default(), + ) && let Some((id, _)) = STYLES.get(i) + { + run(app, "picture.style", json!({"style": id})); + } + }); + group(ui, |ui| { + let sc = scheme(app); + let (m, a) = color_split(ui, Icon::QuickStyles, "Picture Border", Color32::BLACK, true); + if m.clicked() { + run(app, "shape.line", json!({"color": "#000000", "width": 1.0})); + } + widgets::color_popup(&a).show(|ui| { + if let Some(c) = widgets::color_grid(ui, &sc, Some("No Outline")) { + match c { + Some(c) => run(app, "shape.line", json!({"color": cref_param(&c)})), + None => run(app, "shape.line", json!({"none": true})), + } + } + ui.separator(); + ui.menu_button("Weight", |ui| { + for w in [0.25, 0.5, 0.75, 1.0, 1.5, 2.25, 3.0, 4.5, 6.0] { + if ui.button(format!("{w} pt")).clicked() { + run(app, "shape.line", json!({"width": w})); + } + } + }); + ui.menu_button("Dashes", |ui| { + for (l, d) in [ + ("Solid", "solid"), + ("Round Dot", "sysDot"), + ("Square Dot", "sysDash"), + ("Dash", "dash"), + ("Dash Dot", "dashDot"), + ("Long Dash", "lgDash"), + ] { + if ui.button(l).clicked() { + run(app, "shape.line", json!({"dash": d})); + } + } + }); + }); + if ui.small_button("Reset Style").on_hover_text("Remove the picture style").clicked() { + run(app, "picture.style", json!({"style": "none"})); + } + big(app, ui, Icon::AltText, "Alt\nText", "app.dialog", json!({"id": "altText"})); + }); + arrange_group(app, ui); + group(ui, |ui| { + let r = big_button(ui, Icon::Crop, "Crop", true); + egui::Popup::menu(&r).show(|ui| { + ui.label("Crop to Shape"); + if let Some(p) = shapes_gallery(ui) { + run(app, "shape.change", json!({"preset": p})); + } + ui.separator(); + for (l, v) in [("Crop 10% each side", 0.1), ("Crop 20% each side", 0.2), ("Reset Crop", 0.0)] { + if ui.button(l).clicked() { + run(app, "picture.crop", json!({"left": v, "top": v, "right": v, "bottom": v})); + } + } + }); + }); + size_group(app, ui); +} + +/// A Picture Styles gallery tile: a landscape "picture" in the style's shape and frame. +fn paint_picture_style(p: &egui::Painter, r: Rect, lk: Option) { + let Some(lk) = lk else { return }; + let r = r.shrink(7.0); + if lk.shadow.is_some() { + p.rect_filled(r.translate(vec2(2.0, 2.0)), CornerRadius::same(2), Color32::from_black_alpha(60)); + } + let sky = Color32::from_rgb(0x7F, 0xB2, 0xE5); + let frame = lk.frame.map(|(c, w)| Stroke::new((w as f32 / 2.5).clamp(1.0, 4.0), theme::to_color32(c))); + let edge = Stroke::new(0.5, Color32::from_gray(150)); + match lk.preset { + "ellipse" => { + p.add(egui::Shape::ellipse_filled(r.center(), r.size() / 2.0, sky)); + p.add(egui::Shape::ellipse_stroke(r.center(), r.size() / 2.0, frame.unwrap_or(edge))); + } + preset => { + let round = if preset == "rect" { 0 } else { 6 }; + p.rect_filled(r, CornerRadius::same(round), sky); + p.rect_stroke(r, CornerRadius::same(round), frame.unwrap_or(edge), egui::StrokeKind::Middle); + } + } + // Hills, so the tile reads as a picture. + let base = r.bottom() - r.height() * 0.15; + p.line_segment( + [pos2(r.left() + r.width() * 0.2, base), pos2(r.center().x, r.top() + r.height() * 0.45)], + Stroke::new(1.5, Color32::from_rgb(0x3A, 0x7D, 0x44)), + ); + p.line_segment( + [pos2(r.center().x, r.top() + r.height() * 0.45), pos2(r.right() - r.width() * 0.2, base)], + Stroke::new(1.5, Color32::from_rgb(0x3A, 0x7D, 0x44)), + ); + if lk.reflection { + p.rect_filled( + Rect::from_min_max(pos2(r.left(), r.bottom() + 1.0), pos2(r.right(), r.bottom() + 5.0)), + CornerRadius::ZERO, + sky.gamma_multiply(0.3), + ); + } +} + +/// A Recolor gallery tile: a dark-to-light ramp as the preset would draw it. +fn paint_recolor(p: &egui::Painter, r: Rect, id: &str, sc: &deckcraft_color::ColorScheme) { + let mut adj = deckcraft_model::style::PictureAdjust::default(); + deckcraft_engine::cmd::recolor::apply(&mut adj, id); + let r = r.shrink(3.0); + let n = 8; + for k in 0..n { + let t = k as f64 / (n - 1) as f64; + // A colourful ramp, so "No Recolor" shows colour and the presets show their mapping. + let src = [t * 255.0, (0.4 + 0.6 * t) * 200.0, (1.0 - t) * 220.0]; + let y = 0.299 * src[0] + 0.587 * src[1] + 0.114 * src[2]; + let mut rgb = if adj.grayscale { [y; 3] } else { src }; + if adj.brightness != 0.0 || adj.contrast != 0.0 { + rgb = rgb.map(|v| (v - 127.5) * (1.0 + adj.contrast) + 127.5 + adj.brightness * 255.0); + } + if let Some((d, l)) = &adj.duotone { + let (d, l) = (d.resolve(sc, None), l.resolve(sc, None)); + let u = y / 255.0; + rgb = + [d.r as f64 + (l.r as f64 - d.r as f64) * u, d.g as f64 + (l.g as f64 - d.g as f64) * u, d.b as f64 + (l.b as f64 - d.b as f64) * u]; + } + if let Some(th) = adj.bilevel { + rgb = [if y >= th * 255.0 { 255.0 } else { 0.0 }; 3]; + } + let c = Color32::from_rgb(rgb[0].clamp(0.0, 255.0) as u8, rgb[1].clamp(0.0, 255.0) as u8, rgb[2].clamp(0.0, 255.0) as u8); + let w = r.width() / n as f32; + p.rect_filled(Rect::from_min_size(pos2(r.left() + k as f32 * w, r.top()), vec2(w + 0.5, r.height())), CornerRadius::ZERO, c); + } + p.rect_stroke(r, CornerRadius::ZERO, Stroke::new(0.5, Color32::from_gray(160)), egui::StrokeKind::Outside); +} + +/// Compress Pictures options, as in PowerPoint's dialog: resolution, apply to this picture or +/// all, delete cropped areas. The choices last for the session. +fn compress_menu(app: &mut SlideApp, ui: &mut Ui) { + let id = egui::Id::unique("compress_pictures_opts"); + let (mut ppi, mut only_selected, mut delete_cropped): (u32, bool, bool) = ui.data_mut(|d| d.get_temp(id).unwrap_or((220, true, true))); + ui.label(egui::RichText::new("Compression options").strong()); + ui.checkbox(&mut only_selected, "Apply only to this picture"); + ui.checkbox(&mut delete_cropped, "Delete cropped areas of pictures"); + ui.separator(); + ui.label(egui::RichText::new("Resolution").strong()); + for (v, label) in [ + (330, "HD (330 ppi): good quality for high-definition displays"), + (220, "Print (220 ppi): excellent quality on most printers and screens"), + (150, "Web (150 ppi): good for web pages and projectors"), + (96, "E-mail (96 ppi): minimise document size for sharing"), + ] { + ui.radio_value(&mut ppi, v, label); + } + ui.data_mut(|d| d.insert_temp(id, (ppi, only_selected, delete_cropped))); + ui.separator(); + if ui.button("Compress").clicked() { + let p = json!({"ppi": ppi, "deleteCropped": delete_cropped, "all": !only_selected}); + match app.session.execute("picture.compress", &p) { + Ok(r) => { + let kb = |v: &Value| v.as_u64().unwrap_or(0) / 1024; + let msg = match r["pictures"].as_u64().unwrap_or(0) { + 0 => "Pictures are already at or below that resolution".to_string(), + n => format!("Compressed {n} picture(s): {} KB → {} KB", kb(&r["before"]), kb(&r["after"])), + }; + app.set_status(msg); + } + Err(e) => app.set_status(e.to_string()), + } + ui.close(); + } +} diff --git a/crates/ui-egui/src/ribbon/playback.rs b/crates/ui-egui/src/ribbon/playback.rs new file mode 100644 index 00000000..9322c76d --- /dev/null +++ b/crates/ui-egui/src/ribbon/playback.rs @@ -0,0 +1,55 @@ +use super::*; + +pub(super) fn playback(app: &mut SlideApp, ui: &mut Ui) { + let m = app + .session + .active() + .and_then(|d| d.selected_shapes().into_iter().find_map(|s| if let ShapeKind::Media(m) = &s.kind { Some(m.clone()) } else { None })); + group(ui, |ui| { + let playing = app.session.active().and_then(|d| d.selection.shapes.first().copied()).is_some_and(|id| app.media.is_playing(id)); + if big_button(ui, if playing { Icon::Pause } else { Icon::Play }, if playing { "Pause" } else { "Play" }, m.is_some()).clicked() { + run(app, "media.toggle", json!({})); + } + big(app, ui, Icon::TrimMedia, "Trim", "app.dialog", json!({"id": "trim"})); + }); + group(ui, |ui| { + rows(ui, |ui| { + let mut auto = m.as_ref().is_some_and(|x| x.autoplay); + if ui.checkbox(&mut auto, "Start Automatically").changed() { + run(app, "media.options", json!({"autoplay": auto})); + } + let mut across = m.as_ref().is_some_and(|x| x.play_across_slides); + if ui.checkbox(&mut across, "Play Across Slides").changed() { + run(app, "media.options", json!({"acrossSlides": across})); + } + let mut lp = m.as_ref().is_some_and(|x| x.loop_play); + if ui.checkbox(&mut lp, "Loop until Stopped").changed() { + run(app, "media.options", json!({"loop": lp})); + } + }); + rows(ui, |ui| { + let mut hide = m.as_ref().is_some_and(|x| x.hide_while_not_playing); + if ui.checkbox(&mut hide, "Hide During Show").changed() { + run(app, "media.options", json!({"hide": hide})); + } + let mut rw = m.as_ref().is_some_and(|x| x.rewind); + if ui.checkbox(&mut rw, "Rewind after Playing").changed() { + run(app, "media.options", json!({"rewind": rw})); + } + let mut fs = m.as_ref().is_some_and(|x| x.full_screen); + if ui.checkbox(&mut fs, "Play Full Screen").changed() { + run(app, "media.options", json!({"fullScreen": fs})); + } + }); + }); + group(ui, |ui| { + let r = big_button(ui, Icon::Volume, "Volume", m.is_some()); + egui::Popup::menu(&r).show(|ui| { + for (l, v) in [("Low", 0.25), ("Medium", 0.5), ("High", 1.0), ("Mute", 0.0)] { + if ui.button(l).clicked() { + run(app, "media.options", json!({"volume": v})); + } + } + }); + }); +} diff --git a/crates/ui-egui/src/ribbon/review.rs b/crates/ui-egui/src/ribbon/review.rs new file mode 100644 index 00000000..70bf04d6 --- /dev/null +++ b/crates/ui-egui/src/ribbon/review.rs @@ -0,0 +1,58 @@ +use super::*; + +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") }; + if small_button(ui, icon, label, enabled(app, id), false).on_hover_text(tip(id, label)).clicked() { + let params = match app.ui.comment_focus { + Some((slide, index)) if app.session.active().is_some_and(|d| d.selection.slide == slide) => json!({"slide": slide, "index": index}), + _ => json!({}), + }; + if let Ok(v) = app.run(id, params) + && let (Some(slide), Some(index)) = (v.get("slide").and_then(Value::as_u64), v.get("index").and_then(Value::as_u64)) + && let (Ok(slide), Ok(index)) = (usize::try_from(slide), usize::try_from(index)) + { + app.ui.comment_focus = Some((slide, index)); + app.ui.pane = Some("comments".into()); + } + } +} + +pub(super) fn review(app: &mut SlideApp, ui: &mut Ui) { + group(ui, |ui| { + big(app, ui, Icon::Spelling, "Spelling", "app.dialog", json!({"id": "spelling"})); + big(app, ui, Icon::Thesaurus, "Thesaurus", "review.thesaurusPane", json!({})); + }); + group(ui, |ui| { + big(app, ui, Icon::Accessibility, "Check\nAccessibility", "app.dialog", json!({"id": "accessibility"})); + }); + group(ui, |ui| { + let r = big_button(ui, Icon::Translate, "Translate", false); + let _ = r.on_hover_text("Translation needs a language model (not bundled)"); + big(app, ui, Icon::Language, "Language", "app.dialog", json!({"id": "language"})); + }); + group(ui, |ui| { + big(app, ui, Icon::Compare, "Compare", "app.compareDialog", json!({})); + let comparing = enabled(app, "review.endCompare"); + if big_button(ui, Icon::Check, "Accept\nAll", comparing).clicked() { + run(app, "review.accept", json!({"all": true})); + } + if big_button(ui, Icon::Close, "End\nReview", comparing).clicked() { + run(app, "review.endCompare", json!({})); + if app.ui.pane.as_deref() == Some("revisions") { + app.ui.pane = None; + } + } + }); + group(ui, |ui| { + big(app, ui, Icon::NewComment, "New\nComment", "app.dialog", json!({"id": "comment"})); + rows(ui, |ui| { + comment_nav(app, ui, false); + comment_nav(app, ui, true); + }); + big(app, ui, Icon::DeleteComment, "Delete", "comment.delete", json!({})); + let show = app.ui.pane.as_deref() == Some("comments"); + if big_button_ex(ui, Icon::ShowComments, "Show\nComments", true, false, show).0.clicked() { + let _ = app.run("view.pane", json!({"pane": "comments", "toggle": true})); + } + }); +} diff --git a/crates/ui-egui/src/ribbon/shape_format.rs b/crates/ui-egui/src/ribbon/shape_format.rs new file mode 100644 index 00000000..c8033552 --- /dev/null +++ b/crates/ui-egui/src/ribbon/shape_format.rs @@ -0,0 +1,266 @@ +use super::arrange::{arrange_group, size_group}; +use super::galleries::{cref_param, effects_menu, outline_menu, quick_styles, shapes_gallery}; +use super::*; + +// ---------- contextual tabs ---------- + +/// Shape Format ▸ Text Effects ▸ Transform: No Transform, Follow Path, then the other presets. +fn transform_gallery(app: &mut SlideApp, ui: &mut Ui) { + let current = selected_warp(app); + ui.label(egui::RichText::new("Transform").font(theme::bold(12.0))); + ui.label("Follow Path"); + let mut picked = false; + ui.horizontal_wrapped(|ui| { + ui.spacing_mut().item_spacing = vec2(2.0, 2.0); + for p in deckcraft_geom::warp::CATALOG.iter().filter(|p| deckcraft_geom::warp::is_follow(p.id)) { + if warp_tile(ui, p.id, p.label, current.as_deref() == Some(p.id)) { + run(app, "format.textWarp", json!({"preset": p.id})); + picked = true; + } + } + }); + ui.label("Warp"); + egui::ScrollArea::vertical().max_height(220.0).id_salt("text_warp_gallery").show(ui, |ui| { + ui.horizontal_wrapped(|ui| { + ui.spacing_mut().item_spacing = vec2(2.0, 2.0); + if warp_tile(ui, "", "No Transform", current.is_none()) { + run(app, "format.textWarp", json!({"preset": "none"})); + picked = true; + } + for p in deckcraft_geom::warp::CATALOG.iter().filter(|p| !deckcraft_geom::warp::is_follow(p.id)) { + if warp_tile(ui, p.id, p.label, current.as_deref() == Some(p.id)) { + run(app, "format.textWarp", json!({"preset": p.id})); + picked = true; + } + } + }); + }); + if picked { + ui.close(); + } +} + +fn selected_warp(app: &SlideApp) -> Option { + let doc = app.session.active()?; + let id = doc.selection.text.as_ref().map(|t| t.shape).or_else(|| doc.selection.shapes.first().copied())?; + doc.shape(id)?.text.as_ref()?.body.warp.clone() +} + +fn warp_tile(ui: &mut Ui, preset: &str, label: &str, selected: bool) -> bool { + let t = Tokens::get(ui.ctx()); + let (rect, resp) = ui.allocate_exact_size(vec2(34.0, 28.0), Sense::click()); + if selected { + ui.painter().rect_filled(rect, CornerRadius::same(3), t.selected); + } else if resp.hovered() { + ui.painter().rect_filled(rect, CornerRadius::same(3), t.hover); + } + paint_warp_icon(ui.painter(), rect.shrink(4.0), (!preset.is_empty()).then_some(preset), t.text); + resp.on_hover_text(label).clicked() +} + +/// The preset's guides, or two straight lines for No Transform. +fn paint_warp_icon(p: &egui::Painter, r: Rect, name: Option<&str>, color: Color32) { + let Some(name) = name else { + let y0 = r.min.y + r.height() * 0.32; + let y1 = r.min.y + r.height() * 0.68; + p.line_segment([pos2(r.min.x, y0), pos2(r.max.x, y0)], Stroke::new(1.0, color)); + p.line_segment([pos2(r.min.x, y1), pos2(r.max.x, y1)], Stroke::new(1.0, color)); + return; + }; + let rect = deckcraft_geom::Rect::new((r.min.x + 1.0) as f64, (r.min.y + 1.0) as f64, (r.max.x - 1.0) as f64, (r.max.y - 1.0) as f64); + let Some(w) = deckcraft_geom::warp::warp(name, &[], rect) else { return }; + for band in &w.bands { + match band { + deckcraft_geom::warp::Band::Fill { top, bottom } => { + stroke_guide(p, top, color); + stroke_guide(p, bottom, color); + } + deckcraft_geom::warp::Band::Follow { path, .. } => stroke_guide(p, path, color), + } + } +} + +fn stroke_guide(p: &egui::Painter, g: &deckcraft_geom::warp::Guide, color: Color32) { + let len = g.length(); + if !(len.is_finite() && len > 1.0) { + return; + } + let n = 20i32; + let mut pts = Vec::with_capacity((n + 1) as usize); + for i in 0..=n { + let (q, _) = g.at(len * f64::from(i) / f64::from(n)); + if q.x.is_finite() && q.y.is_finite() { + pts.push(pos2(q.x as f32, q.y as f32)); + } + } + if pts.len() >= 2 { + p.add(egui::Shape::Path(egui::epaint::PathShape { + points: pts, + closed: false, + fill: Color32::TRANSPARENT, + stroke: Stroke::new(1.0, color).into(), + })); + } +} + +/// Render the Shape Format tab's shape, fill, outline, effects, text, arrange, and size controls. +pub(super) fn shape_format(app: &mut SlideApp, ui: &mut Ui) { + let sc = scheme(app); + let sel = enabled(app, "shape.fill"); + group(ui, |ui| { + let r = big_button(ui, Icon::Shapes, "Shapes", true); + egui::Popup::menu(&r).show(|ui| { + if let Some(p) = shapes_gallery(ui) { + app.session.set_tool(deckcraft_engine::ToolKind::Shape { preset: p.to_string() }); + } + }); + big(app, ui, Icon::TextBox, "Text\nBox", "insert.textBox", json!({})); + let r = big_button(ui, Icon::EditShape, "Edit\nShape", sel); + egui::Popup::menu(&r).show(|ui| { + ui.label("Change Shape"); + if let Some(p) = shapes_gallery(ui) { + run(app, "shape.change", json!({"preset": p})); + } + ui.separator(); + if ui.add_enabled(enabled(app, "shape.editPoints"), egui::Button::new("Edit Points")).clicked() { + run(app, "shape.editPoints", json!({"on": true})); + ui.close(); + } + ui.separator(); + smooth_corners_row(app, ui); + }); + let can_merge = enabled(app, "shape.merge"); + let r = big_button(ui, Icon::MergeShapes, "Merge\nShapes", can_merge).on_hover_text("Merge Shapes"); + egui::Popup::menu(&r).show(|ui| { + for (op, label) in deckcraft_engine::cmd::merge::OPS { + if ui.button(label).clicked() { + run(app, "shape.merge", json!({"op": op})); + ui.close(); + } + } + }); + }); + group(ui, |ui| { + let r = big_button(ui, Icon::QuickStyles, "Shape\nStyles", sel); + egui::Popup::menu(&r).width(330.0).show(|ui| quick_styles(app, ui)); + rows(ui, |ui| { + let (m, a) = small_button_ex(ui, Icon::ShapeFill, "Shape Fill", sel, false, true); + if m.clicked() { + let c = app.last_fill.clone(); + run(app, "shape.fill", json!({"color": c})); + } + if let Some(a) = a { + widgets::color_popup(&a).show(|ui| { + if let Some(c) = widgets::color_grid(ui, &sc, Some("No Fill")) { + match c { + Some(c) => { + app.last_fill = cref_param(&c); + run(app, "shape.fill", json!({"color": cref_param(&c)})); + } + None => run(app, "shape.fill", json!({"none": true})), + } + } + if ui.button("Gradient…").clicked() { + let _ = app.run("view.pane", json!({"pane": "format", "tab": "fill"})); + ui.close(); + } + }); + } + let (m, a) = small_button_ex(ui, Icon::ShapeOutline, "Shape Outline", sel, false, true); + if m.clicked() { + let c = app.last_outline.clone(); + run(app, "shape.line", json!({"color": c})); + } + if let Some(a) = a { + widgets::color_popup(&a).show(|ui| outline_menu(app, ui, &sc)); + } + let (main, arrow) = small_button_ex(ui, Icon::ShapeEffects, "Shape Effects", sel, false, true); + if let Some(arrow) = arrow { + widgets::split_menu(&main, &arrow, |ui| effects_menu(app, ui)); + } + }); + }); + group(ui, |ui| { + rows(ui, |ui| { + let (main, arrow) = small_button_ex(ui, Icon::TextFill, "Text Fill", sel, false, true); + if let Some(arrow) = arrow { + widgets::split_color_popup(&main, &arrow).show(|ui| { + if let Some(Some(c)) = widgets::color_grid(ui, &sc, None) { + run(app, "format.color", json!({"color": cref_param(&c)})); + } + if let Some(sh) = app.session.active().and_then(|d| d.selected_shapes().first().map(|s| (*s).clone())) { + ui.separator(); + crate::panes::text_transparency_slider(app, ui, &sh); + } + }); + } + let (main, arrow) = small_button_ex(ui, Icon::TextOutline, "Text Outline", sel, false, true); + if let Some(arrow) = arrow { + widgets::split_color_popup(&main, &arrow).show(|ui| { + if let Some(c) = widgets::color_grid(ui, &sc, Some("No Outline")) { + run(app, "format.textOutline", json!({"color": c.map(|c| cref_param(&c))})); + } + }); + } + let r = small_button(ui, Icon::TextEffects, "Text Effects", sel, false); + egui::Popup::menu(&r).width(312.0).show(|ui| { + if ui.button("Shadow").clicked() { + run(app, "format.textShadow", json!({})); + ui.close(); + } + ui.separator(); + transform_gallery(app, ui); + }); + }); + }); + group(ui, |ui| { + big(app, ui, Icon::AltText, "Alt\nText", "app.dialog", json!({"id": "altText"})); + }); + arrange_group(app, ui); + size_group(app, ui); + group(ui, |ui| { + big(app, ui, Icon::FormatBackground, "Format\nPane", "view.pane", json!({"pane": "format"})); + }); +} + +/// Edit Shape ▸ Smooth Corners (#286): a slider that smooths a rectangle's corners into a +/// squircle. The radius is taken from the rectangle when the slider is first used and kept (in egui +/// memory, per shape) so later moves re-smooth the same corners. +fn smooth_corners_row(app: &mut SlideApp, ui: &mut egui::Ui) { + let Some((id, radius)) = app.session.active().and_then(|d| { + let id = *d.selection.shapes.first()?; + let sh = d.shape(id)?; + let x = sh.xfrm?; + let ss = x.w.min(x.h).max(0.0); + let own = match &sh.geom { + deckcraft_model::Geom::Preset { name, adj } if name == "roundRect" => { + Some(ss * adj.first().copied().unwrap_or(16667.0).clamp(0.0, 50000.0) / 100000.0) + } + deckcraft_model::Geom::Preset { name, .. } if name == "rect" => Some(ss * 16667.0 / 100000.0), + _ => None, + }; + Some((id, own)) + }) else { + return; + }; + let key = egui::Id::unique(("deckcraft-smooth-corners", id.0)); + let mut state: (f64, f64) = ui.data(|d| d.get_temp(key)).unwrap_or((radius.unwrap_or(0.0), 0.0)); + if let Some(r) = radius { + state.0 = r; + } + let usable = radius.is_some() || ui.data(|d| d.get_temp::<(f64, f64)>(key)).is_some(); + ui.horizontal(|ui| { + let label = ui.label("Smooth Corners"); + let mut pct = state.1; + let resp = ui.add_enabled(usable, egui::Slider::new(&mut pct, 0.0..=100.0).suffix("%").integer()).labelled_by(label.id); + let resp = if usable { resp } else { resp.on_disabled_hover_text("For rectangles and rounded rectangles") }; + if resp.changed() { + state.1 = pct; + ui.data_mut(|d| d.insert_temp(key, state)); + } + // One undo step per adjustment: apply when the drag ends (or on a click / keyboard step). + if resp.drag_stopped() || (resp.changed() && !resp.dragged()) { + run(app, "shape.smoothCorners", json!({"smoothing": state.1, "radius": state.0})); + } + }); +} diff --git a/crates/ui-egui/src/ribbon/slide_show.rs b/crates/ui-egui/src/ribbon/slide_show.rs new file mode 100644 index 00000000..8542f547 --- /dev/null +++ b/crates/ui-egui/src/ribbon/slide_show.rs @@ -0,0 +1,78 @@ +use super::*; + +// ---------- Slide Show / Record / Review / View ---------- + +pub(super) fn slide_show(app: &mut SlideApp, ui: &mut Ui) { + group(ui, |ui| { + if big_button(ui, Icon::PlayFromStart, "Play from\nStart", true).clicked() { + app.start_show(app.show_start_index(), false); + } + if big_button(ui, Icon::PlayFromCurrent, "Play from\nCurrent Slide", true).clicked() { + let i = app.session.active().map(|d| d.selection.slide).unwrap_or(0); + app.start_show(i, false); + } + let on = app.ui.presenter_view; + if big_button_ex(ui, Icon::PresenterView, "Presenter\nView", true, false, on).0.clicked() { + app.ui.presenter_view = !on; + } + big(app, ui, Icon::CustomShow, "Custom\nShow", "app.dialog", json!({"id": "customShow"})); + }); + group(ui, |ui| { + big(app, ui, Icon::SetUpShow, "Set Up\nSlide Show", "app.dialog", json!({"id": "setupShow"})); + let hidden = app.session.active().and_then(|d| d.current_slide().map(|s| s.hidden)).unwrap_or(false); + if big_button_ex(ui, Icon::HideSlide, "Hide\nSlide", true, false, hidden).0.clicked() { + run(app, "slide.hide", json!({})); + } + }); + group(ui, |ui| { + if big_button(ui, Icon::RehearseTimings, "Rehearse\nTimings", true).clicked() { + app.start_show(app.show_start_index(), false); + if let Some(s) = app.show.as_mut() { + s.rehearse = true; + } + } + let r = big_button(ui, Icon::Record, "Record", false); + let _ = r.on_hover_text("Recording narration needs audio capture (coming soon)"); + }); + group(ui, |ui| { + rows(ui, |ui| { + let mut use_t = app.session.active().is_none_or(|d| d.doc.show.use_timings); + if ui.checkbox(&mut use_t, "Use Timings").changed() { + run(app, "show.setup", json!({"useTimings": use_t})); + } + let mut narr = app.session.active().is_none_or(|d| !d.doc.show.without_narration); + if ui.checkbox(&mut narr, "Play Narrations").changed() { + run(app, "show.setup", json!({"noNarration": !narr})); + } + let mut mc = app.session.active().is_none_or(|d| d.doc.show.show_media_controls); + if ui.checkbox(&mut mc, "Show Media Controls").changed() { + run(app, "show.setup", json!({"showMediaControls": mc})); + } + }); + }); +} + +pub(super) fn record(app: &mut SlideApp, ui: &mut Ui) { + group(ui, |ui| { + let r = big_button(ui, Icon::Cameo, "Cameo", false); + let _ = r.on_hover_text("Camera capture is not available yet"); + if big_button(ui, Icon::PlayFromStart, "From\nBeginning", true).clicked() { + app.start_show(app.show_start_index(), false); + if let Some(s) = app.show.as_mut() { + s.rehearse = true; + } + } + if big_button(ui, Icon::PlayFromCurrent, "From\nCurrent Slide", true).clicked() { + let i = app.session.active().map(|d| d.selection.slide).unwrap_or(0); + app.start_show(i, false); + if let Some(s) = app.show.as_mut() { + s.rehearse = true; + } + } + }); + group(ui, |ui| { + if big_button(ui, Icon::Help, "Learn\nMore", true).clicked() { + app.open_url(deckcraft_engine::links::APP_PAGE); + } + }); +} diff --git a/crates/ui-egui/src/ribbon/smartart.rs b/crates/ui-egui/src/ribbon/smartart.rs new file mode 100644 index 00000000..b8b38fbb --- /dev/null +++ b/crates/ui-egui/src/ribbon/smartart.rs @@ -0,0 +1,103 @@ +use super::arrange::arrange_group; +use super::galleries::smartart_gallery; +use super::*; + +/// SmartArt Design: Create Graphic (Text Pane, Add Shape, Promote, Demote, Remove) and Layouts. +pub(super) fn smartart_design(app: &mut SlideApp, ui: &mut Ui) { + let sel = app.session.active().and_then(deckcraft_engine::cmd::smartart::selected); + let row = app.ui.smartart_row; + let (gid, n, kind, style, colors) = match &sel { + Some((id, d)) => (Some(id.0), d.nodes.len(), d.kind.clone(), d.style.clone(), d.colors.clone()), + None => (None, 0, String::new(), String::new(), String::new()), + }; + let at = row.min(n.saturating_sub(1)); + group(ui, |ui| { + if big_button(ui, Icon::SmartArt, "Text\nPane", gid.is_some()).on_hover_text("Type and organise the diagram's text").clicked() { + run(app, "view.pane", json!({"pane": "smartart", "toggle": true})); + } + rows(ui, |ui| { + let (main, arrow) = small_button_ex(ui, Icon::Plus, "Add Shape", gid.is_some(), false, true); + if let Some(arrow) = arrow { + widgets::split_menu(&main, &arrow, |ui| { + for (label, pos) in [ + ("Add Shape After", "after"), + ("Add Shape Before", "before"), + ("Add Shape Above", "above"), + ("Add Shape Below", "below"), + ("Add Assistant", "assistant"), + ] { + if ui.button(label).clicked() { + let index = if n == 0 { Value::Null } else { json!(at) }; + run(app, "smartArt.addNode", json!({"id": gid, "index": index, "position": pos})); + } + } + }); + } + if small_button(ui, Icon::DecreaseIndent, "Promote", n > 0, false).clicked() { + run(app, "smartArt.promote", json!({"id": gid, "index": at})); + } + if small_button(ui, Icon::IncreaseIndent, "Demote", n > 0, false).clicked() { + run(app, "smartArt.demote", json!({"id": gid, "index": at})); + } + }); + let pictures = kind.starts_with("picture"); + if small_button(ui, Icon::Picture, "Insert Picture", pictures && n > 0, false) + .on_hover_text("Put a picture in the selected node's picture placeholder") + .clicked() + { + run(app, "app.smartArtPictureDialog", json!({"id": gid, "index": at})); + } + }); + group(ui, |ui| { + let (main, arrow) = small_button_ex(ui, Icon::Layout, "Layouts", gid.is_some(), false, true); + 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()); + }); + } + let (main, arrow) = small_button_ex(ui, Icon::Layout, "Convert", gid.is_some(), false, true); + if let Some(arrow) = arrow { + widgets::split_menu(&main, &arrow, |ui| { + if ui.button("Convert to Text").clicked() { + run(app, "smartArt.toText", json!({"id": gid})); + } + if ui.button("Convert to Shapes").clicked() { + run(app, "smartArt.toShapes", json!({"id": gid})); + } + }); + } + }); + group(ui, |ui| { + let (main, arrow) = small_button_ex(ui, Icon::ChangeChartType, "Change Colors", gid.is_some(), false, true); + if let Some(arrow) = arrow { + widgets::split_menu(&main, &arrow, |ui| { + for (k, label) in deckcraft_engine::cmd::smartart::COLORS { + if matches!(k, "range23" | "accent1" | "accent1Grad") { + ui.separator(); + } + let on = colors == k || (colors.is_empty() && k == "colorful"); + if ui.selectable_label(on, label).clicked() { + run(app, "smartArt.colors", json!({"id": gid, "colors": k})); + } + } + }); + } + let (main, arrow) = small_button_ex(ui, Icon::SmartArt, "SmartArt Styles", gid.is_some(), false, true); + if let Some(arrow) = arrow { + widgets::split_menu(&main, &arrow, |ui| { + for (k, label) in deckcraft_engine::cmd::smartart::STYLES { + if k == "polished" { + ui.separator(); + ui.label("3-D"); + } + let on = style == k || (style.is_empty() && k == "flat"); + if ui.selectable_label(on, label).clicked() { + run(app, "smartArt.style", json!({"id": gid, "style": k})); + } + } + }); + } + }); + arrange_group(app, ui); +} diff --git a/crates/ui-egui/src/ribbon/table.rs b/crates/ui-egui/src/ribbon/table.rs new file mode 100644 index 00000000..b2c0fde8 --- /dev/null +++ b/crates/ui-egui/src/ribbon/table.rs @@ -0,0 +1,236 @@ +use super::arrange::arrange_group; +use super::galleries::cref_param; +use super::*; + +pub(super) fn table_design(app: &mut SlideApp, ui: &mut Ui) { + let tbl = app + .session + .active() + .and_then(|d| d.selected_shapes().into_iter().find_map(|s| if let ShapeKind::Table(t) = &s.kind { Some(t.clone()) } else { None })); + group(ui, |ui| { + rows(ui, |ui| { + let flags = tbl.as_ref().map(|t| (t.first_row, t.last_row, t.band_row, t.first_col, t.last_col, t.band_col)).unwrap_or_default(); + ui.horizontal(|ui| { + let mut a = flags.0; + if ui.checkbox(&mut a, "Header Row").changed() { + run(app, "table.options", json!({"headerRow": a})); + } + let mut d = flags.3; + if ui.checkbox(&mut d, "First Column").changed() { + run(app, "table.options", json!({"firstColumn": d})); + } + }); + ui.horizontal(|ui| { + let mut b = flags.1; + if ui.checkbox(&mut b, "Total Row").changed() { + run(app, "table.options", json!({"totalRow": b})); + } + let mut e = flags.4; + if ui.checkbox(&mut e, "Last Column").changed() { + run(app, "table.options", json!({"lastColumn": e})); + } + }); + ui.horizontal(|ui| { + let mut c = flags.2; + if ui.checkbox(&mut c, "Banded Rows").changed() { + run(app, "table.options", json!({"bandedRows": c})); + } + let mut f = flags.5; + if ui.checkbox(&mut f, "Banded Columns").changed() { + run(app, "table.options", json!({"bandedColumns": f})); + } + }); + }); + }); + group(ui, |ui| { + let styles = deckcraft_model::table::table_styles(); + let quick: Vec<_> = styles.iter().filter(|(id, _)| id.starts_with("medium2") || id == "none" || id == "grid").take(8).collect(); + let cur = tbl.as_ref().map(|t| t.style.clone()).unwrap_or_default(); + let sel = quick.iter().position(|(id, _)| *id == cur); + let sc = scheme(app); + if let Some(i) = widgets::gallery( + ui, + quick.len(), + vec2(48.0, 40.0), + sel, + |p, r, i| { + if let Some((id, _)) = quick.get(i) { + paint_table_style(p, r, id, &sc); + } + }, + |i| quick.get(i).map(|x| x.1.clone()).unwrap_or_default(), + ) && let Some((id, _)) = quick.get(i) + { + run(app, "table.style", json!({"style": id})); + } + let more = widgets::drop_button(ui, "", vec2(14.0, 40.0), true); + egui::Popup::menu(&more).width(360.0).show(|ui| { + ui.horizontal_wrapped(|ui| { + for (id, label) in &styles { + let (r, resp) = ui.allocate_exact_size(vec2(48.0, 36.0), Sense::click()); + paint_table_style(ui.painter(), r.shrink(2.0), id, &sc); + if resp.on_hover_text(label).clicked() { + run(app, "table.style", json!({"style": id})); + ui.close(); + } + } + }); + }); + }); + group(ui, |ui| { + let sc = scheme(app); + let (m, a) = color_split(ui, Icon::Shading, "Shading", Color32::from_rgb(0x2E, 0x6F, 0xD8), true); + if m.clicked() { + run(app, "table.cellFill", json!({"color": "accent1"})); + } + widgets::color_popup(&a).show(|ui| { + if let Some(c) = widgets::color_grid(ui, &sc, Some("No Fill")) { + match c { + Some(c) => run(app, "table.cellFill", json!({"color": cref_param(&c)})), + None => run(app, "table.cellFill", json!({"none": true})), + } + } + }); + }); + group(ui, |ui| { + let r = big_button(ui, Icon::Borders, "Borders", true); + egui::Popup::menu(&r).show(|ui| { + let pen = app.table_pen.clone(); + for (label, which) in [ + ("No Border", "none"), + ("All Borders", "all"), + ("Outside Borders", "outside"), + ("Inside Borders", "inside"), + ("Top Border", "top"), + ("Bottom Border", "bottom"), + ("Left Border", "left"), + ("Right Border", "right"), + ("Inside Horizontal Border", "insideH"), + ("Inside Vertical Border", "insideV"), + ("Diagonal Down Border", "diagDown"), + ("Diagonal Up Border", "diagUp"), + ] { + let mut p = pen.clone(); + p["which"] = json!(which); + menu_item(app, ui, Icon::Borders, label, "table.borders", p); + } + ui.separator(); + ui.menu_button("Pen Weight", |ui| { + for w in [0.25, 0.5, 0.75, 1.0, 1.5, 2.25, 3.0, 4.5, 6.0] { + let cur = app.table_pen.get("width").and_then(Value::as_f64) == Some(w); + if ui.radio(cur, format!("{w} pt")).clicked() { + app.table_pen["width"] = json!(w); + } + } + }); + ui.menu_button("Pen Style", |ui| { + for (l, d) in [("Solid", "solid"), ("Dash", "dash"), ("Dot", "sysDot"), ("Dash Dot", "dashDot"), ("Long Dash", "lgDash")] { + let cur = app.table_pen.get("dash").and_then(Value::as_str).unwrap_or("solid") == d; + if ui.radio(cur, l).clicked() { + app.table_pen["dash"] = json!(d); + } + } + }); + ui.menu_button("Pen Color", |ui| { + let sc = scheme(app); + if let Some(Some(c)) = widgets::color_grid(ui, &sc, None) { + app.table_pen["color"] = cref_param(&c); + } + }); + }); + }); + // Draw Borders: the pencil splits cells along a line, the eraser merges across a border. + group(ui, |ui| { + use deckcraft_engine::ToolKind; + use deckcraft_engine::tools::{DRAW_TABLE, TABLE_ERASER}; + let cur = app.session.tool.kind.clone(); + for (icon, label, preset) in [(Icon::Pencil, "Draw Table", DRAW_TABLE), (Icon::Eraser, "Table Eraser", TABLE_ERASER)] { + let on = matches!(&cur, ToolKind::Shape { preset: p } if p == preset); + if big_button_ex(ui, icon, label, true, false, on).0.clicked() { + app.session.set_tool(if on { ToolKind::Select } else { ToolKind::Shape { preset: preset.into() } }); + } + } + }); +} + +pub fn paint_table_style(p: &egui::Painter, r: Rect, id: &str, sc: &deckcraft_color::ColorScheme) { + let slot = match id.rsplit('-').next().unwrap_or("accent1") { + "accent2" => deckcraft_color::SchemeSlot::Accent2, + "accent3" => deckcraft_color::SchemeSlot::Accent3, + "accent4" => deckcraft_color::SchemeSlot::Accent4, + "accent5" => deckcraft_color::SchemeSlot::Accent5, + "accent6" => deckcraft_color::SchemeSlot::Accent6, + "tx1" => deckcraft_color::SchemeSlot::Dk1, + _ => deckcraft_color::SchemeSlot::Accent1, + }; + let a = theme::to_color32(sc.get(slot)); + let kind = id.split('-').next().unwrap_or(""); + for row in 0..4 { + let y = r.min.y + row as f32 * r.height() / 4.0; + let rr = Rect::from_min_size(pos2(r.min.x, y), vec2(r.width(), r.height() / 4.0)); + let c = match (kind, row) { + ("none", _) | ("grid", _) => Color32::WHITE, + ("light1", 0) | ("light2", _) => Color32::WHITE, + ("dark1", 0) => Color32::BLACK, + ("dark1", _) => a.lerp_to_gamma(Color32::BLACK, 0.3), + (_, 0) => a, + (_, k) if k % 2 == 1 => a.lerp_to_gamma(Color32::WHITE, 0.6), + _ => a.lerp_to_gamma(Color32::WHITE, 0.8), + }; + p.rect_filled(rr, CornerRadius::ZERO, c); + p.line_segment([rr.left_bottom(), rr.right_bottom()], Stroke::new(0.5, if kind == "grid" { Color32::GRAY } else { Color32::WHITE })); + } + if kind == "grid" || kind == "light2" { + p.rect_stroke(r, CornerRadius::ZERO, Stroke::new(0.8, if kind == "grid" { Color32::GRAY } else { a }), egui::StrokeKind::Inside); + } +} + +pub(super) fn table_layout(app: &mut SlideApp, ui: &mut Ui) { + group(ui, |ui| { + let r = big_button(ui, Icon::DeleteTable, "Delete", true); + egui::Popup::menu(&r).show(|ui| { + menu_item(app, ui, Icon::DeleteTable, "Delete Columns", "table.deleteColumn", json!({})); + menu_item(app, ui, Icon::DeleteTable, "Delete Rows", "table.deleteRow", json!({})); + menu_item(app, ui, Icon::DeleteTable, "Delete Table", "edit.delete", json!({})); + }); + big(app, ui, Icon::InsertAbove, "Insert\nAbove", "table.insertRowAbove", json!({})); + big(app, ui, Icon::InsertBelow, "Insert\nBelow", "table.insertRowBelow", json!({})); + big(app, ui, Icon::InsertLeft, "Insert\nLeft", "table.insertColumnLeft", json!({})); + big(app, ui, Icon::InsertRight, "Insert\nRight", "table.insertColumnRight", json!({})); + }); + group(ui, |ui| { + big(app, ui, Icon::MergeCells, "Merge\nCells", "table.merge", json!({})); + big(app, ui, Icon::SplitCells, "Split\nCells", "table.split", json!({})); + }); + group(ui, |ui| { + rows(ui, |ui| { + small(app, ui, Icon::DistributeV, "Distribute Rows", "table.distributeRows", json!({})); + small(app, ui, Icon::DistributeH, "Distribute Columns", "table.distributeColumns", json!({})); + }); + }); + group(ui, |ui| { + rows(ui, |ui| { + ui.horizontal(|ui| { + toggle(app, ui, Icon::AlignLeft, "Align Left", "format.alignLeft", false); + toggle(app, ui, Icon::AlignCenter, "Center", "format.alignCenter", false); + toggle(app, ui, Icon::AlignRight, "Align Right", "format.alignRight", false); + }); + ui.horizontal(|ui| { + for (l, a) in [("Top", "top"), ("Center", "middle"), ("Bottom", "bottom")] { + if ui.small_button(l).on_hover_text(format!("Align text to the {l} of the cell").to_lowercase()).clicked() { + run(app, "table.cellAlign", json!({"anchor": a})); + } + } + ui.menu_button("Cell Margins", |ui| { + for (l, preset) in [("Normal", "normal"), ("None", "none"), ("Narrow", "narrow"), ("Wide", "wide")] { + if ui.button(l).clicked() { + run(app, "table.cellMargins", json!({"preset": preset})); + ui.close(); + } + } + }); + }); + }); + }); + arrange_group(app, ui); +} diff --git a/crates/ui-egui/src/ribbon/title_bar.rs b/crates/ui-egui/src/ribbon/title_bar.rs new file mode 100644 index 00000000..c8bdd351 --- /dev/null +++ b/crates/ui-egui/src/ribbon/title_bar.rs @@ -0,0 +1,95 @@ +use super::*; + +pub const TITLE_H: f32 = 38.0; + +/// macOS draws the window into the title bar (`with_fullsize_content_view`) and AppKit paints the +/// traffic lights itself, 16 pt from the top of the window on macOS 27. Moving them would take +/// AppKit calls (`unsafe`, forbidden here), so on macOS the title bar is the band they are centred +/// in, with the controls beside them (#71). Up to macOS 15 the lights are smaller and centred 14 pt +/// from the top, about 2 pt above this bar's centre. +pub const MAC_TITLE_BAR: f32 = 32.0; + +/// The traffic lights as AppKit lays them out, measured on macOS 27: button diameter, +/// centre-to-centre pitch and the first button's left edge. +pub(super) const MAC_LIGHT: f32 = 14.0; + +pub(super) const MAC_LIGHT_PITCH: f32 = 23.0; + +pub(super) const MAC_LIGHTS_LEFT: f32 = 9.0; + +/// Where title-bar content starts on macOS: the end of the three lights plus the gap the lights +/// keep from the top of the window. +pub const MAC_CONTENT_LEFT: f32 = MAC_LIGHTS_LEFT + 2.0 * MAC_LIGHT_PITCH + MAC_LIGHT + (MAC_TITLE_BAR - MAC_LIGHT) / 2.0; + +/// The title bar's height, the left edge of its content and the height of its tallest control. +/// With the integrated title bar (macOS) the bar is shorter, so the controls shrink to keep a +/// margin above and below them. +pub(super) fn title_geometry(integrated: bool) -> (f32, f32, f32) { + if integrated { (MAC_TITLE_BAR, MAC_CONTENT_LEFT, 22.0) } else { (TITLE_H, 10.0, 26.0) } +} + +/// The window's title row: traffic-light space, quick access, centred title, actions. +pub fn title_bar(app: &mut SlideApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + let (height, left, control_h) = title_geometry(app.integrated_titlebar); + egui::Panel::top("title_bar").exact_size(height).frame(egui::Frame::NONE.fill(t.chrome)).show(ui, |ui| { + let rect = ui.max_rect(); + // Dragging the empty title bar moves the window. + let drag = ui.interact(rect, ui.scope_id().with("titledrag"), Sense::click_and_drag()); + // The drag surface is not a button: a full-width Button would be announced as one. + drag.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Window, true, "Window title bar")); + if drag.drag_started() { + ui.ctx().send_viewport_cmd(egui::ViewportCommand::StartDrag); + } + if drag.double_clicked() { + let max = ui.input(|i| i.viewport().maximized.unwrap_or(false)); + ui.ctx().send_viewport_cmd(egui::ViewportCommand::Maximized(!max)); + } + let mut child = ui.new_child( + egui::UiBuilder::new() + .max_rect(Rect::from_min_max(pos2(rect.min.x + left, rect.min.y), pos2(rect.min.x + 420.0, rect.max.y))) + .layout(egui::Layout::left_to_right(egui::Align::Center)), + ); + child.spacing_mut().item_spacing.x = 2.0; + let has_doc = app.session.active().is_some(); + let dirty = app.session.active().is_some_and(|d| d.is_dirty()); + crate::qat::show(app, &mut child); + let title = + app.session.active().map(|d| format!("{}{}", d.title(), if dirty { " — Edited" } else { "" })).unwrap_or_else(|| "DeckCraft".into()); + ui.painter().text(rect.center(), Align2::CENTER_CENTER, title, theme::font(13.0), t.text); + // Right side. + let mut right = ui.new_child( + egui::UiBuilder::new() + .max_rect(Rect::from_min_max(pos2(rect.max.x - 320.0, rect.min.y), pos2(rect.max.x - 10.0, rect.max.y))) + .layout(egui::Layout::right_to_left(egui::Align::Center)), + ); + right.spacing_mut().item_spacing.x = 6.0; + if icon_toggle(&mut right, Icon::Search, "Search commands (⇧⌘P)", true, false).clicked() { + app.palette = Some((String::new(), 0)); + } + let share = right.add( + egui::Button::new(egui::RichText::new(" Present ").color(t.accent_text).font(theme::bold(13.0))) + .fill(t.accent) + .corner_radius(control_h / 2.0) + .min_size(vec2(84.0, control_h)), + ); + if share.clicked() && has_doc { + let from = app.session.active().map(|d| d.selection.slide).unwrap_or(0); + app.start_show(from, false); + } + if icon_toggle(&mut right, Icon::CommentsBubble, "Comments", has_doc, app.ui.pane.as_deref() == Some("comments")).clicked() { + let _ = app.run("view.pane", json!({"pane": "comments", "toggle": true})); + } + if icon_toggle( + &mut right, + if app.ui.brightness == theme::Brightness::Dark { Icon::Sparkle } else { Icon::Palette }, + "Light/Dark appearance", + true, + false, + ) + .clicked() + { + let _ = app.run("view.dark", json!({})); + } + }); +} diff --git a/crates/ui-egui/src/ribbon/transitions.rs b/crates/ui-egui/src/ribbon/transitions.rs new file mode 100644 index 00000000..3417e657 --- /dev/null +++ b/crates/ui-egui/src/ribbon/transitions.rs @@ -0,0 +1,239 @@ +use super::animations::marked_options; +use super::*; + +// ---------- Transitions ---------- + +/// Render transition preview, gallery, options, sound, and timing controls for the current +/// slide. +pub(super) fn transitions(app: &mut SlideApp, ui: &mut Ui) { + let cur = app.session.active().and_then(|d| d.current_slide().and_then(|s| s.transition.clone())); + let kind = cur.as_ref().map(|t| t.kind.clone()).unwrap_or_else(|| "none".into()); + group(ui, |ui| { + if big_button(ui, Icon::Preview, "Preview", true).clicked() { + let from = app.session.active().map(|d| d.selection.slide).unwrap_or(0); + app.start_show(from, true); + if let Some(s) = app.show.as_mut() { + s.preview_only = true; + } + } + }); + group(ui, |ui| { + let list = deckcraft_model::anim::TRANSITIONS; + let shown: Vec<_> = list.iter().take(12).collect(); + let sel = shown.iter().position(|x| x.0 == kind); + if let Some(i) = widgets::gallery( + ui, + shown.len(), + vec2(54.0, 56.0), + sel, + |p, r, i| paint_transition_tile(p, r, shown.get(i).map(|x| (x.0, x.1)).unwrap_or(("", ""))), + |i| shown.get(i).map(|x| x.1.to_string()).unwrap_or_default(), + ) && let Some(x) = shown.get(i) + { + run(app, "transition.set", json!({"kind": x.0})); + } + let more = widgets::drop_button(ui, "", vec2(14.0, 52.0), true); + egui::Popup::menu(&more).width(480.0).show(|ui| { + for cat in ["Subtle", "Exciting", "Dynamic Content"] { + ui.label(egui::RichText::new(cat).font(theme::bold(12.0))); + ui.horizontal_wrapped(|ui| { + for x in list.iter().filter(|x| x.2 == cat) { + let (r, resp) = ui.allocate_exact_size(vec2(56.0, 58.0), Sense::click()); + if resp.hovered() || x.0 == kind { + ui.painter().rect_filled(r, CornerRadius::same(4), Tokens::get(ui.ctx()).hover); + } + paint_transition_tile(ui.painter(), r.shrink(3.0), (x.0, x.1)); + if resp.clicked() { + run(app, "transition.set", json!({"kind": x.0})); + ui.close(); + } + } + }); + } + }); + }); + group(ui, |ui| { + let selected = cur.as_ref().map(|t| t.option.as_str()); + let rows = transition_effect_rows(&kind, selected); + let r = big_button(ui, Icon::EffectOptions, "Effect\nOptions", !rows.is_empty()); + egui::Popup::menu(&r).show(|ui| { + for &(o, on) in &rows { + if ui.selectable_label(on, deckcraft_model::anim::transition_option_label(&kind, o)).clicked() { + run(app, "transition.options", json!({"option": o})); + ui.close(); + } + } + }); + }); + group(ui, |ui| { + // Sound in its own column, so the timing column keeps its three rows. + rows(ui, |ui| { + ui.horizontal(|ui| { + let field = ui.label("Sound:"); + let stop = cur.as_ref().is_some_and(|t| t.stop_sound); + let name = cur + .as_ref() + .and_then(|t| t.sound) + .and_then(|m| app.session.active().and_then(|d| d.doc.media(m).map(|i| i.name.trim_end_matches(".wav").to_string()))) + .unwrap_or_else(|| if stop { "[Stop Previous Sound]".into() } else { "[No Sound]".into() }); + let combo = egui::ComboBox::from_id_salt("transition_sound").width(140.0).selected_text(name).show_ui(ui, |ui| { + let none = cur.as_ref().is_none_or(|t| t.sound.is_none() && !t.stop_sound); + if ui.selectable_label(none, "[No Sound]").clicked() { + run(app, "transition.sound", json!({"none": true})); + } + if ui.selectable_label(stop, "[Stop Previous Sound]").clicked() { + run(app, "transition.sound", json!({"stopPrevious": true})); + } + ui.separator(); + for (id, label) in deckcraft_engine::sounds::BUILTIN { + if ui.button(label).clicked() { + let lp = cur.as_ref().is_some_and(|t| t.sound_loop); + run(app, "transition.sound", json!({"builtin": id, "loop": lp})); + } + } + ui.separator(); + let can_pick = app.services.pick_open.is_some(); + let r = ui.add_enabled(can_pick, egui::Button::new("Other Sound…")); + if r.clicked() { + app.pick_transition_sound(); + } + r.on_disabled_hover_text("Choosing a sound file needs the desktop app"); + ui.separator(); + let has = cur.as_ref().is_some_and(|t| t.sound.is_some()); + let mut lp = cur.as_ref().is_some_and(|t| t.sound_loop); + if ui.add_enabled(has, egui::Checkbox::new(&mut lp, "Loop Until Next Sound")).changed() { + run(app, "transition.sound", json!({"loop": lp})); + } + }); + let _ = combo.response.labelled_by(field.id); + }); + }); + rows(ui, |ui| { + ui.horizontal(|ui| { + ui.label("Duration:"); + let d = cur.as_ref().map(|t| t.duration_ms as f64 / 1000.0).unwrap_or(1.0); + if let Some(v) = widgets::spinner(ui, "tdur", "Duration", d, 0.25, "", 46.0) { + run(app, "transition.timing", json!({"duration": (v.max(0.0) * 1000.0).round() as u64})); + } + }); + ui.horizontal(|ui| { + let mut click = cur.as_ref().is_none_or(|t| t.advance_on_click); + if ui.checkbox(&mut click, "On Mouse Click").changed() { + run(app, "transition.timing", json!({"onClick": click})); + } + }); + ui.horizontal(|ui| { + let after = cur.as_ref().and_then(|t| t.advance_after_ms); + let mut on = after.is_some(); + if ui.checkbox(&mut on, "After:").changed() { + run(app, "transition.timing", json!({"after": if on { json!(after.unwrap_or(0)) } else { Value::Null }})); + } + if let Some(v) = widgets::spinner(ui, "tafter", "After", after.unwrap_or(0) as f64 / 1000.0, 1.0, "", 46.0) { + run(app, "transition.timing", json!({"after": (v.max(0.0) * 1000.0).round() as u64})); + } + }); + }); + big(app, ui, Icon::ApplyToAll, "Apply\nTo All", "transition.applyAll", json!({})); + }); +} + +pub(super) fn option_label(o: &str) -> String { + match o { + "l" => "From Left".into(), + "r" => "From Right".into(), + "u" => "From Top".into(), + "d" => "From Bottom".into(), + "horz" => "Horizontal".into(), + "vert" => "Vertical".into(), + "in" => "In".into(), + "out" => "Out".into(), + other => { + let mut s = String::new(); + for (i, c) in other.chars().enumerate() { + if i == 0 { + s.extend(c.to_uppercase()); + } else if c.is_uppercase() { + s.push(' '); + s.push(c); + } else { + s.push(c); + } + } + s + } + } +} + +pub fn paint_transition_tile(p: &egui::Painter, r: Rect, (id, label): (&str, &str)) { + let t = Tokens::get(p.ctx()); + let pic = Rect::from_min_size(r.min + vec2(4.0, 2.0), vec2(r.width() - 8.0, r.height() - 18.0)); + let blue = Color32::from_rgb(0x2E, 0x6F, 0xD8); + let light = Color32::from_rgb(0xBF, 0xD4, 0xF5); + p.rect_filled(pic, CornerRadius::same(2), light); + match id { + "none" => { + p.rect_filled(pic, CornerRadius::same(2), Color32::from_rgb(0xE6, 0xEC, 0xF5)); + } + "fade" | "dissolve" | "flash" => { + p.rect_filled(pic, CornerRadius::same(2), blue.gamma_multiply(0.5)); + } + "push" | "cover" | "uncover" | "pan" | "conveyor" => { + p.rect_filled(Rect::from_min_max(pos2(pic.center().x, pic.min.y), pic.max), CornerRadius::same(2), blue); + p.arrow(pos2(pic.center().x + 10.0, pic.center().y), vec2(-12.0, 0.0), Stroke::new(1.5, Color32::WHITE)); + } + "wipe" | "reveal" => { + p.rect_filled(Rect::from_min_max(pic.min, pos2(pic.center().x, pic.max.y)), CornerRadius::same(2), blue); + } + "split" | "doors" | "window" | "curtains" => { + p.rect_filled( + Rect::from_min_max(pos2(pic.min.x + pic.width() * 0.3, pic.min.y), pos2(pic.max.x - pic.width() * 0.3, pic.max.y)), + CornerRadius::ZERO, + blue, + ); + } + "randomBar" | "blinds" | "comb" | "shred" => { + for k in 0..5 { + let x = pic.min.x + k as f32 * pic.width() / 5.0; + p.rect_filled(Rect::from_min_size(pos2(x, pic.min.y), vec2(pic.width() / 10.0, pic.height())), CornerRadius::ZERO, blue); + } + } + "checker" | "honeycomb" | "glitter" | "fracture" => { + for k in 0..12 { + if k % 2 == (k / 4) % 2 { + let x = pic.min.x + (k % 4) as f32 * pic.width() / 4.0; + let y = pic.min.y + (k / 4) as f32 * pic.height() / 3.0; + p.rect_filled(Rect::from_min_size(pos2(x, y), vec2(pic.width() / 4.0, pic.height() / 3.0)), CornerRadius::ZERO, blue); + } + } + } + "shape" | "zoom" | "ripple" | "vortex" | "clock" => { + p.circle_filled(pic.center(), pic.height() * 0.38, blue); + } + "morph" => { + p.circle_filled(pos2(pic.min.x + pic.width() * 0.3, pic.center().y), pic.height() * 0.25, blue); + p.rect_filled( + Rect::from_center_size(pos2(pic.max.x - pic.width() * 0.3, pic.center().y), vec2(pic.height() * 0.45, pic.height() * 0.45)), + CornerRadius::same(2), + blue.gamma_multiply(0.7), + ); + } + _ => { + // Fake 3-D: a tilted card. + let pts = vec![ + pos2(pic.min.x + 6.0, pic.min.y + 4.0), + pos2(pic.max.x - 3.0, pic.min.y + 1.0), + pos2(pic.max.x - 3.0, pic.max.y - 1.0), + pos2(pic.min.x + 6.0, pic.max.y - 4.0), + ]; + p.add(egui::Shape::convex_polygon(pts, blue, Stroke::NONE)); + } + } + p.rect_stroke(pic, CornerRadius::same(2), Stroke::new(1.0, Color32::from_rgb(0x9B, 0xB4, 0xDB)), egui::StrokeKind::Inside); + p.text(pos2(r.center().x, r.max.y - 7.0), Align2::CENTER_CENTER, label, theme::font(10.0), t.text); +} + +/// One row of Transitions ▸ Effect Options: the stored option and whether it is the current one. +pub(super) fn transition_effect_rows(kind: &str, current: Option<&str>) -> Vec<(&'static str, bool)> { + let opts = deckcraft_model::anim::TRANSITIONS.iter().find(|x| x.0 == kind).map(|x| x.4).unwrap_or(&[]); + marked_options(opts, current) +} diff --git a/crates/ui-egui/src/ribbon/view.rs b/crates/ui-egui/src/ribbon/view.rs new file mode 100644 index 00000000..8231f9a6 --- /dev/null +++ b/crates/ui-egui/src/ribbon/view.rs @@ -0,0 +1,155 @@ +use super::*; + +pub(super) fn view(app: &mut SlideApp, ui: &mut Ui) { + use crate::ViewMode; + group(ui, |ui| { + for (icon, label, id, mode) in [ + (Icon::ViewNormal, "Normal", "view.normal", Some(ViewMode::Normal)), + (Icon::ViewOutline, "Outline\nView", "view.outline", Some(ViewMode::Outline)), + (Icon::ViewSorter, "Slide\nSorter", "view.sorter", Some(ViewMode::Sorter)), + (Icon::ViewNotesPage, "Notes\nPage", "view.notesPage", Some(ViewMode::NotesPage)), + (Icon::ViewReading, "Reading\nView", "view.reading", None), + ] { + let sel = mode.is_some_and(|m| app.ui.view == m); + if big_button_ex(ui, icon, label, true, false, sel).0.clicked() { + let _ = app.run(id, json!({})); + } + } + }); + group(ui, |ui| { + let in_master = app.session.active().is_some_and(|d| d.selection.target != Target::Slides); + if big_button_ex(ui, Icon::SlideMaster, "Slide\nMaster", true, false, in_master).0.clicked() { + let _ = app.run(if in_master { "view.closeMaster" } else { "view.slideMaster" }, json!({})); + } + let in_handout = app.session.active().is_some_and(|d| d.selection.target == Target::Handout); + if big_button_ex(ui, Icon::HandoutMaster, "Handout\nMaster", true, false, in_handout).0.clicked() { + let _ = app.run(if in_handout { "view.closeMaster" } else { "view.handoutMaster" }, json!({})); + } + let in_notes = app.session.active().is_some_and(|d| d.selection.target == Target::NotesMaster); + if big_button_ex(ui, Icon::NotesMaster, "Notes\nMaster", true, false, in_notes).0.clicked() { + let _ = app.run(if in_notes { "view.closeMaster" } else { "view.notesMaster" }, json!({})); + } + }); + group(ui, |ui| { + rows(ui, |ui| { + let mut r = app.ui.ruler; + if ui.checkbox(&mut r, "Ruler").changed() { + app.ui.ruler = r; + } + let mut g = app.ui.gridlines; + if ui.checkbox(&mut g, "Gridlines").changed() { + app.ui.gridlines = g; + } + let mut gd = app.ui.guides; + if ui.checkbox(&mut gd, "Guides").changed() { + app.ui.guides = gd; + } + }); + if big_button(ui, Icon::Gridlines, "Grid\nOptions", true).clicked() { + let _ = app.run("view.gridOptions", json!({})); + } + let n = app.ui.notes; + if big_button_ex(ui, Icon::Notes, "Notes", true, false, n).0.clicked() { + app.ui.notes = !n; + } + }); + group(ui, |ui| { + let (main, arrow) = big_button_ex(ui, Icon::ZoomGlass, "Zoom", true, true, false); + if main.on_hover_text("Zoom").clicked() { + let _ = app.run("view.zoomDialog", json!({})); + } + if let Some(a) = arrow { + egui::Popup::menu(&a).show(|ui| { + for pct in [400, 200, 150, 100, 75, 66, 50, 33] { + if ui.button(format!("{pct}%")).clicked() { + let _ = app.run("view.zoom", json!({"percent": pct})); + ui.close(); + } + } + if ui.button("Fit").clicked() { + let _ = app.run("view.fit", json!({})); + ui.close(); + } + }); + } + if big_button(ui, Icon::FitToWindow, "Fit to\nWindow", true).clicked() { + app.ui.zoom = None; + } + }); + group(ui, |ui| { + let has = app.session.active().is_some(); + let on = app.doc_window.is_some(); + let resp = big_button_ex(ui, Icon::NewWindow, "New\nWindow", has, false, on).0; + if resp.on_hover_text("Open another window on this presentation").clicked() { + let _ = app.run("view.newWindow", json!({})); + } + }); + group(ui, |ui| { + let gs = app.ui.grayscale; + if big_button_ex(ui, Icon::Grayscale, "Grayscale", true, false, gs).0.clicked() { + app.ui.grayscale = !gs; + } + let dark = app.ui.brightness == theme::Brightness::Dark; + if big_button_ex(ui, Icon::Palette, "Dark\nMode", true, false, dark).0.clicked() { + let _ = app.run("view.dark", json!({})); + } + }); +} + +/// Insert › Zoom: pick Summary, Section or Slide Zoom and what it zooms to, as PowerPoint's +/// Insert Zoom dialogs do (a checklist of slides or sections). +pub(super) fn zoom_menu(app: &mut SlideApp, ui: &mut Ui) { + let Some(doc) = app.session.active().map(|d| d.doc.clone()) else { return }; + let id = egui::Id::unique("insert_zoom_menu"); + let (mut kind, mut picked): (u8, Vec) = ui.data_mut(|d| d.get_temp(id).unwrap_or((0, vec![]))); + ui.horizontal(|ui| { + for (k, label) in [(0u8, "Slide Zoom"), (1, "Section Zoom"), (2, "Summary Zoom")] { + if ui.selectable_label(kind == k, label).clicked() && kind != k { + kind = k; + picked.clear(); + } + } + }); + ui.separator(); + let items: Vec = if kind == 1 { + doc.sections.iter().map(|s| s.name.clone()).collect() + } else { + doc.slides + .iter() + .enumerate() + .map(|(i, s)| { + let t = s.title(); + format!("{}. {}", i + 1, t.lines().next().unwrap_or("").trim()) + }) + .collect() + }; + if items.is_empty() { + ui.label(if kind == 1 { "The presentation has no sections. Add one with Home › Section." } else { "No slides." }); + } + egui::ScrollArea::vertical().max_height(260.0).show(ui, |ui| { + for (i, label) in items.iter().enumerate() { + let mut on = picked.contains(&i); + if ui.checkbox(&mut on, label).changed() { + if on { + picked.push(i); + } else { + picked.retain(|x| *x != i); + } + } + } + }); + ui.data_mut(|d| d.insert_temp(id, (kind, picked.clone()))); + ui.separator(); + if ui.add_enabled(!picked.is_empty(), egui::Button::new("Insert")).clicked() { + let mut v = picked.clone(); + v.sort_unstable(); + let p = match kind { + 1 => json!({"kind": "section", "sections": v}), + 2 => json!({"kind": "summary", "slides": v}), + _ => json!({"kind": "slide", "slides": v}), + }; + run(app, "insert.zoom", p); + ui.data_mut(|d| d.remove::<(u8, Vec)>(id)); + ui.close(); + } +}