mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-27 03:56:50 -06:00
refactor(ui-egui): split ribbon.rs into one file per tab under ribbon/
This commit is contained in:
+50
-3411
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
}
|
||||
}
|
||||
@@ -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!({}));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)");
|
||||
});
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
@@ -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"}));
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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!({}));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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}));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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}));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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}));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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!({}));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user