refactor(ui-egui): split ribbon.rs into one file per tab under ribbon/

This commit is contained in:
aiongg
2026-10-26 22:08:44 +00:00
parent 9545ff4d61
commit e84ba9def1
20 changed files with 3503 additions and 3411 deletions
File diff suppressed because it is too large Load Diff
+218
View File
@@ -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<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| {
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",
}
}
+158
View File
@@ -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<ShapeId>, 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!({}));
}
});
}
+25
View File
@@ -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);
}
+170
View File
@@ -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<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}));
}
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<deckcraft_model::Theme> = variants.iter().map(|c| deckcraft_model::Theme { colors: c.clone(), ..th.clone() }).collect();
let names: Vec<String> = 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);
}
}
+45
View File
@@ -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)");
});
}
+391
View File
@@ -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<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(),
}));
}
}
}
}
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<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)));
}
+471
View File
@@ -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<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();
}
}
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"}));
});
}
+271
View File
@@ -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::<String>(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<egui::Pos2> = vec![];
let mut polys: Vec<(Vec<egui::Pos2>, 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<Option<deckcraft_model::chart::ChartType>> {
use deckcraft_model::chart::ChartType;
let mut v: Vec<Option<ChartType>> = 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
}
+141
View File
@@ -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!({}));
}
});
}
+278
View File
@@ -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<String> = 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<String> = 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<deckcraft_engine::cmd::picture_style::Look>) {
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();
}
}
+55
View File
@@ -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}));
}
}
});
});
}
+58
View File
@@ -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}));
}
});
}
+266
View File
@@ -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<String> {
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}));
}
});
}
+78
View File
@@ -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);
}
});
}
+103
View File
@@ -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);
}
+236
View File
@@ -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);
}
+95
View File
@@ -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!({}));
}
});
}
+239
View File
@@ -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)
}
+155
View File
@@ -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<usize>) = 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<String> = 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<usize>)>(id));
ui.close();
}
}