Files
deckcraft/crates/ui-egui/src/ribbon.rs
T
8fe168c07b Animation Painter: copy and apply animation sequences between shapes (#54)
- engine: add animation.painter (Alt+Shift+C) and animation.painterApply (Alt+Shift+V) supporting single-use and sticky modes
- tools: apply animation painter on canvas shape click
- edit: disarm animation painter on deselect (Escape)
- ui-egui: add Animation Painter button in Advanced Animation ribbon group
- tests: unit tests for single and sticky animation painting
- docs/parity: mark Animation Painter as done (breadth parity to 80%)
- contributors: add Johannesgauss to contributors/people.toml

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 06:24:23 -04:00

2408 lines
106 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Title bar, ribbon tabs and the ribbon card with every tab's groups.
use deckcraft_engine::Target;
use deckcraft_model::{ShapeKind, anim::AnimClass};
use egui::{Align2, Color32, CornerRadius, Rect, Sense, Stroke, Ui, pos2, vec2};
use serde_json::{Value, json};
use crate::SlideApp;
use crate::icons::{self, Icon};
use crate::theme::{self, Tokens};
use crate::widgets::{self, RIBBON_H, big_button, big_button_ex, color_split, dropdown, group, icon_toggle, rows, small_button, small_button_ex};
pub const TITLE_H: f32 = 38.0;
/// Is engine command `id` enabled right now?
pub fn enabled(app: &SlideApp, id: &str) -> bool {
match deckcraft_engine::find_command(id) {
Some(c) => (c.enabled)(&app.session).is_ok(),
None => crate::UI_COMMANDS.iter().any(|c| c.0 == id),
}
}
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());
egui::Panel::top("title_bar").exact_size(TITLE_H).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.id().with("titledrag"), Sense::click_and_drag());
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 left = if app.integrated_titlebar { 84.0 } else { 10.0 };
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());
if icon_toggle(&mut child, Icon::Home, "Home", true, app.home_open()).clicked() {
let _ = app.run("app.home", json!({}));
}
if icon_toggle(&mut child, Icon::Save, "Save (⌘S)", has_doc, false).clicked() {
app.save();
}
if icon_toggle(&mut child, Icon::Undo, "Undo (⌘Z)", enabled(app, "edit.undo"), false).clicked() {
run(app, "edit.undo", json!({}));
}
if icon_toggle(&mut child, Icon::Redo, "Redo (⌘Y)", enabled(app, "edit.redo"), false).clicked() {
run(app, "edit.redo", json!({}));
}
if icon_toggle(&mut child, Icon::PlayFromStart, "Play from Start (F5)", has_doc, false).clicked() {
app.start_show(0, false);
}
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(CornerRadius::same(13))
.min_size(vec2(84.0, 26.0)),
);
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![];
let Some(st) = app.session.active() else { return v };
if st.selection.target != Target::Slides {
v.push("Slide Master");
}
let shapes = st.selected_shapes();
if shapes.iter().any(|s| matches!(s.kind, ShapeKind::Shape | ShapeKind::Group { .. } | ShapeKind::Connector { .. }))
|| st.selection.text.is_some()
{
v.push("Shape Format");
}
if shapes.iter().any(|s| matches!(s.kind, ShapeKind::Picture { .. })) {
v.push("Picture Format");
}
if shapes.iter().any(|s| matches!(s.kind, ShapeKind::Table(_))) || st.selection.text.as_ref().is_some_and(|t| t.cell.is_some()) {
v.push("Table Design");
v.push("Layout");
}
if shapes.iter().any(|s| matches!(s.kind, ShapeKind::Chart(_))) {
v.push("Chart Design");
}
if shapes.iter().any(|s| matches!(s.kind, ShapeKind::Media(_))) {
v.push("Playback");
}
v
}
const TABS: [&str; 10] = ["Home", "Insert", "Draw", "Design", "Transitions", "Animations", "Slide Show", "Record", "Review", "View"];
/// Tab row and the ribbon card.
pub fn show(app: &mut SlideApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
let ctx_tabs = contextual(app);
if !TABS.contains(&app.ui.tab.as_str()) && !ctx_tabs.contains(&app.ui.tab.as_str()) {
app.ui.tab = "Home".into();
}
let h = 28.0 + if app.ui.ribbon_collapsed { 4.0 } else { RIBBON_H + 20.0 };
egui::Panel::top("ribbon").exact_size(h).frame(egui::Frame::NONE.fill(t.chrome)).show(ui, |ui| {
let rect = ui.max_rect();
// Tabs.
let mut x = rect.min.x + 12.0;
let y = rect.min.y + 2.0;
let all: Vec<(&str, bool)> = TABS.iter().map(|s| (*s, false)).chain(ctx_tabs.iter().map(|s| (*s, true))).collect();
for (name, contextual) in all {
let font = theme::font(13.0);
let w = ui.fonts_mut(|f| f.layout_no_wrap(name.to_string(), font.clone(), t.text).size().x) + 18.0;
let r = Rect::from_min_size(pos2(x, y), vec2(w, 24.0));
let resp = ui.interact(r, ui.id().with(("tab", name)), Sense::click());
let active = app.ui.tab == name;
if resp.hovered() && !active {
ui.painter().rect_filled(r.shrink2(vec2(2.0, 2.0)), CornerRadius::same(4), t.hover);
}
let col = if contextual { t.contextual } else { t.text };
let f = if active { theme::bold(13.0) } else { font };
ui.painter().text(r.center(), Align2::CENTER_CENTER, name, f, col);
if active {
let uw = (w - 20.0).max(16.0);
ui.painter().rect_filled(Rect::from_center_size(pos2(r.center().x, r.max.y - 1.0), vec2(uw, 3.0)), CornerRadius::same(1), t.accent);
}
if resp.clicked() {
if active {
app.ui.ribbon_collapsed = !app.ui.ribbon_collapsed;
} else {
app.ui.tab = name.to_string();
app.ui.ribbon_collapsed = false;
}
}
if resp.double_clicked() {
app.ui.ribbon_collapsed = !app.ui.ribbon_collapsed;
}
x += w + 2.0;
}
if app.ui.ribbon_collapsed {
return;
}
// The white card.
let card = Rect::from_min_max(pos2(rect.min.x + 8.0, rect.min.y + 30.0), pos2(rect.max.x - 8.0, rect.min.y + 30.0 + RIBBON_H + 10.0));
ui.painter().rect_filled(card.translate(vec2(0.0, 1.0)), CornerRadius::same(10), t.card_border);
ui.painter().rect_filled(card, CornerRadius::same(10), t.card);
let inner = Rect::from_min_size(pos2(card.min.x + 10.0, card.min.y + 5.0), vec2(card.width() - 20.0, RIBBON_H));
let mut cui = ui.new_child(egui::UiBuilder::new().max_rect(inner).layout(egui::Layout::left_to_right(egui::Align::Min)));
cui.set_clip_rect(inner.expand2(vec2(0.0, 3.0)));
cui.spacing_mut().item_spacing.x = 1.0;
let ui = &mut cui;
let tab = app.ui.tab.clone();
match tab.as_str() {
"Home" => home(app, ui),
"Insert" => insert(app, ui),
"Draw" => draw(app, ui),
"Design" => design(app, ui),
"Transitions" => transitions(app, ui),
"Animations" => animations(app, ui),
"Slide Show" => slide_show(app, ui),
"Record" => record(app, ui),
"Review" => review(app, ui),
"View" => view(app, ui),
"Shape Format" => shape_format(app, ui),
"Picture Format" => picture_format(app, ui),
"Table Design" => table_design(app, ui),
"Layout" => table_layout(app, ui),
"Chart Design" => chart_design(app, ui),
"Playback" => playback(app, ui),
"Slide Master" => slide_master(app, ui),
_ => {}
}
});
}
fn big(app: &mut SlideApp, ui: &mut Ui, icon: Icon, label: &str, id: &str, p: Value) {
let en = enabled(app, id);
if big_button(ui, icon, label, en).on_hover_text(tip(id, label)).clicked() {
run(app, id, p);
}
}
fn small(app: &mut SlideApp, ui: &mut Ui, icon: Icon, label: &str, id: &str, p: Value) {
let en = enabled(app, id);
if small_button(ui, icon, label, en, false).on_hover_text(tip(id, label)).clicked() {
run(app, id, p);
}
}
fn toggle(app: &mut SlideApp, ui: &mut Ui, icon: Icon, label: &str, id: &str, on: bool) {
let en = enabled(app, id);
if icon_toggle(ui, icon, &tip(id, label), en, on).clicked() {
run(app, id, json!({}));
}
}
fn tip(id: &str, label: &str) -> String {
let sc = deckcraft_engine::find_command(id).and_then(|c| c.shortcut).or_else(|| crate::UI_COMMANDS.iter().find(|c| c.0 == id).and_then(|c| c.2));
match sc {
Some(s) => format!("{label} ({})", pretty_shortcut(s)),
None => label.to_string(),
}
}
pub fn pretty_shortcut(s: &str) -> String {
if cfg!(target_os = "macos") {
s.replace("Cmd+", "⌘").replace("Shift+", "⇧").replace("Alt+", "⌥").replace("Ctrl+", "⌃")
} else {
s.replace("Cmd+", "Ctrl+")
}
}
fn scheme(app: &SlideApp) -> deckcraft_color::ColorScheme {
app.session.active().and_then(|d| d.doc.masters.first().map(|m| m.scheme())).unwrap_or_else(|| deckcraft_model::Theme::default().colors)
}
/// The formatting state at the selection (bold, size…).
pub fn fmt_state(app: &mut SlideApp) -> Value {
if app.session.active().is_none_or(|d| d.selection.shapes.is_empty() && d.selection.text.is_none()) {
return json!({});
}
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 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 =
ui.ctx().load_texture(format!("layout-{li}"), crate::textures::to_color_image(&img, false), egui::TextureOptions::LINEAR);
ui.painter().image(tex.id(), rect, 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();
}
}
});
}
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| {
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"));
}
});
}
rows(ui, |ui| {
let (r, _) = small_button_ex(ui, Icon::Layout, "Layout", enabled(app, "slide.layout"), false, true);
egui::Popup::menu(&r).width(420.0).show(|ui| layout_menu(app, ui, "slide.layout"));
small(app, ui, Icon::ResetSlide, "Reset", "slide.reset", json!({}));
let (r, _) = small_button_ex(ui, Icon::Section, "Section", enabled(app, "section.add"), false, true);
egui::Popup::menu(&r).show(|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<String> {
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<String> = 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();
}
}
});
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)}));
}
});
});
});
});
}
/// 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)
}
_ => json!("#000000"),
}
}
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_ex(ui, Icon::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}));
}
}
});
}
let (m, a) = small_button_ex(ui, Icon::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}));
}
}
});
}
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() {
app.dialog = Some(crate::dialogs::Dialog::new("paragraph"));
}
});
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}));
}
}
});
});
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}));
}
}
});
});
});
});
}
/// 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<egui::Pos2> = vec![];
let mut polys: Vec<(Vec<egui::Pos2>, 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),
_ => {}
});
}
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() {
run(app, "shape.fill", json!({"color": "accent1"}));
}
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) => 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() {
run(app, "shape.line", json!({"color": "tx1", "width": 1}));
}
if let Some(a) = a {
widgets::color_popup(&a).show(|ui| outline_menu(app, ui, &sc));
}
let (_, a) = small_button_ex(ui, Icon::ShapeEffects, "Shape Effects", sel, false, true);
if let Some(a) = a {
egui::Popup::menu(&a).show(|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) => 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) {
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}));
}
ui.separator();
menu_item(app, ui, Icon::DistributeH, "Distribute Horizontally", "arrange.distribute", json!({"dir": "horizontal"}));
menu_item(app, ui, Icon::DistributeV, "Distribute Vertically", "arrange.distribute", json!({"dir": "vertical"}));
}
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);
if r.clicked() {
run(app, id, p);
ui.close();
}
}
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);
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();
}
});
});
group(ui, |ui| {
big(app, ui, Icon::Picture, "Pictures", "app.insertPictureDialog", json!({}));
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, "shape.insert"));
egui::Popup::menu(&r).width(260.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 deckcraft_model::chart::ChartType::MAIN {
if ui.button(k.label()).clicked() {
run(app, "insert.chart", json!({"type": format!("{k:?}")}));
}
}
});
});
group(ui, |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}));
}
}
});
});
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.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 }
}
/// A small built-in icon library: our own simple pictograms built from preset shapes.
fn icon_library(app: &mut SlideApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
let items: [(&str, &str); 24] = [
("star5", "Star"),
("heart", "Heart"),
("lightningBolt", "Lightning"),
("sun", "Sun"),
("moon", "Moon"),
("cloud", "Cloud"),
("smileyFace", "Smile"),
("noSmoking", "Not allowed"),
("mathPlus", "Plus"),
("mathMultiply", "Multiply"),
("mathEqual", "Equals"),
("chevron", "Chevron"),
("rightArrow", "Arrow"),
("quadArrow", "Move"),
("uturnArrow", "Return"),
("flowChartDecision", "Decision"),
("flowChartMagneticDisk", "Database"),
("flowChartDocument", "Document"),
("wedgeRoundRectCallout", "Speech"),
("cloudCallout", "Thought"),
("teardrop", "Pin"),
("donut", "Ring"),
("irregularSeal1", "Burst"),
("ribbon2", "Banner"),
];
ui.horizontal_wrapped(|ui| {
for (preset, label) in items {
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), t.hover);
}
paint_preset(ui.painter(), r.shrink(6.0), preset, t.text, t.text);
if resp.on_hover_text(label).clicked() {
let size = app.session.active().map(|d| d.doc.slide_size).unwrap_or(deckcraft_model::defaults::WIDE);
let _ = app.run("shape.insert", json!({"preset": preset, "rect": [size.width / 2.0 - 48.0, size.height / 2.0 - 48.0, 96, 96]}));
let _ = app.run("shape.fill", json!({"color": "tx1"}));
let _ = app.run("shape.line", json!({"none": true}));
ui.close();
}
}
});
}
// ---------- 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 r = big_button(ui, Icon::InkToShape, "Ink to\nShape", false);
let _ = r.on_hover_text("Ink to Shape (coming soon)");
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 ----------
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<String> = 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}));
}
});
group(ui, |ui| {
let r = big_button(ui, Icon::Colors, "Colors", true);
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();
}
});
}
});
let r = big_button(ui, Icon::Fonts, "Fonts", true);
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}));
}
}
});
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 ----------
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 opts: &[&str] = deckcraft_model::anim::TRANSITIONS.iter().find(|x| x.0 == kind).map(|x| x.4).unwrap_or(&[]);
let r = big_button(ui, Icon::EffectOptions, "Effect\nOptions", !opts.is_empty());
egui::Popup::menu(&r).show(|ui| {
for o in opts {
if ui.button(option_label(o)).clicked() {
run(app, "transition.options", json!({"option": o}));
}
}
});
});
group(ui, |ui| {
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", 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.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::light();
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 ----------
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<deckcraft_model::Animation> = 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| {
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;
s.autoplay = true;
}
}
});
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 opts: &[&str] = cur.as_ref().and_then(|c| deckcraft_model::anim::animation_info(&c.effect, c.class)).map(|x| x.5).unwrap_or(&[]);
let r = big_button(ui, Icon::EffectOptions, "Effect\nOptions", !opts.is_empty());
egui::Popup::menu(&r).show(|ui| {
for o in opts {
if ui.button(option_label(o)).clicked() {
run(app, "animation.options", json!({"option": o}));
}
}
ui.separator();
for (l, b) in [("As One Object", "asOne"), ("By Paragraph", "byParagraph")] {
if ui.button(l).clicked() {
run(app, "animation.options", json!({"textBuild": b}));
}
}
});
});
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", 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", 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::light();
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(0, 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(0, 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);
let _ = ui.checkbox(&mut mc, "Show Media Controls");
});
});
}
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(0, 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 review(app: &mut SlideApp, ui: &mut Ui) {
group(ui, |ui| {
big(app, ui, Icon::Spelling, "Spelling", "app.dialog", json!({"id": "spelling"}));
let r = big_button(ui, Icon::Thesaurus, "Thesaurus", false);
let _ = r.on_hover_text("Thesaurus (coming soon)");
});
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::NewComment, "New\nComment", "app.dialog", json!({"id": "comment"}));
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 r = big_button(ui, Icon::HandoutMaster, "Handout\nMaster", false);
let _ = r.on_hover_text("Handout Master (coming soon)");
let r = big_button(ui, Icon::NotesMaster, "Notes\nMaster", false);
let _ = r.on_hover_text("Notes Master (coming soon)");
});
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;
}
});
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 r = big_button(ui, Icon::ZoomGlass, "Zoom", true);
egui::Popup::menu(&r).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}));
}
}
});
if big_button(ui, Icon::FitToWindow, "Fit to\nWindow", true).clicked() {
app.ui.zoom = None;
}
});
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 ----------
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}));
}
});
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() {
run(app, "shape.fill", json!({"color": "accent1"}));
}
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) => 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 (_, a) = small_button_ex(ui, Icon::ShapeOutline, "Shape Outline", sel, false, true);
if let Some(a) = a {
widgets::color_popup(&a).show(|ui| outline_menu(app, ui, &sc));
}
let (_, a) = small_button_ex(ui, Icon::ShapeEffects, "Shape Effects", sel, false, true);
if let Some(a) = a {
egui::Popup::menu(&a).show(|ui| effects_menu(app, ui));
}
});
});
group(ui, |ui| {
rows(ui, |ui| {
let (_, a) = small_button_ex(ui, Icon::TextFill, "Text Fill", sel, false, true);
if let Some(a) = a {
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)}));
}
});
}
let (_, a) = small_button_ex(ui, Icon::TextOutline, "Text Outline", sel, false, true);
if let Some(a) = a {
widgets::color_popup(&a).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).show(|ui| {
if ui.button("Shadow").clicked() {
run(app, "format.textShadow", json!({}));
}
});
});
});
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"}));
});
}
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 (_, a) = small_button_ex(ui, Icon::Group, "Group", sel, false, true);
if let Some(a) = a {
egui::Popup::menu(&a).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 (_, a) = small_button_ex(ui, Icon::Rotate, "Rotate", sel, false, true);
if let Some(a) = a {
egui::Popup::menu(&a).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!({}));
});
}
});
});
}
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;
};
ui.horizontal(|ui| {
ui.label("Height:");
if let Some(v) = widgets::spinner(ui, "sh", x.h / 72.0, 0.1, "\"", 54.0) {
run(app, "shape.resize", json!({"h": v.max(0.0) * 72.0}));
}
});
ui.horizontal(|ui| {
ui.label("Width: ");
if let Some(v) = widgets::spinner(ui, "sw", x.w / 72.0, 0.1, "\"", 54.0) {
run(app, "shape.resize", json!({"w": v.max(0.0) * 72.0}));
}
});
});
});
}
fn picture_format(app: &mut SlideApp, ui: &mut Ui) {
group(ui, |ui| {
let r = big_button(ui, Icon::RemoveBackground, "Remove\nBackground", false);
let _ = r.on_hover_text("Remove Background (coming soon)");
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}));
}
}
if ui.button("Grayscale").clicked() {
run(app, "picture.adjust", json!({"grayscale": true}));
}
});
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.insertPictureDialog", json!({}));
small(app, ui, Icon::ResetPicture, "Reset Picture", "picture.reset", json!({}));
});
});
group(ui, |ui| {
let r = big_button(ui, Icon::QuickStyles, "Picture\nStyles", true);
egui::Popup::menu(&r).show(|ui| {
if ui.button("Simple Frame, White").clicked() {
run(app, "shape.line", json!({"color": "#FFFFFF", "width": 6}));
run(app, "shape.effects", json!({"shadow": "outer"}));
}
if ui.button("Drop Shadow Rectangle").clicked() {
run(app, "shape.effects", json!({"shadow": "bottom"}));
}
if ui.button("Rounded Diagonal Corner").clicked() {
run(app, "shape.change", json!({"preset": "round2DiagRect"}));
}
if ui.button("Soft Edge Rectangle").clicked() {
run(app, "shape.effects", json!({"softEdges": 10}));
}
if ui.button("Reflected Rounded Rectangle").clicked() {
run(app, "shape.change", json!({"preset": "roundRect"}));
run(app, "shape.effects", json!({"reflection": "half"}));
}
if ui.button("Oval").clicked() {
run(app, "shape.change", json!({"preset": "ellipse"}));
}
});
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})),
}
}
});
});
}
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).clicked() {
run(app, "format.anchor", json!({"anchor": a}));
}
}
});
});
});
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 deckcraft_model::chart::ChartType::MAIN {
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);
}
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}));
}
}
});
});
}
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| {
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}));
}
}
});
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!({}));
}
});
}
/// 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<egui::Pos2> = 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)));
}