M0: PowerPoint-style egui UI, slide show engine, icons, desktop app

- ui-egui: title bar, ribbon with every tab (Home, Insert, Draw, Design, Transitions,
  Animations, Slide Show, Record, Review, View) plus contextual Shape/Picture Format,
  Table Design/Layout, Chart Design, Playback and Slide Master tabs; thumbnail pane with
  sections and drag reorder; canvas with selection handles, smart guides, marquee,
  in-place text editing; notes pane; status bar with views and zoom; task panes
  (Format Shape, Background, Animation, Selection, Comments, Design Ideas); dialogs;
  Slide Sorter, Notes Page, Outline view; command palette; control channel
- anim: timelines for every animation effect, transition layers for every transition,
  morph pairing, show navigation (hidden slides, custom shows, loops)
- show: full-screen slide show with transitions, animations, pen, black/white
  screens, presenter view window, rehearse timings, reading view
- icons: 238 original icons drawn in code
- render: filters force single-threaded rendering (regression test), panic guard
- engine: chart type/data/options, SmartArt-style graphics
- apps/slidecraft: desktop host with control server, file dialogs, clipboard images
- placeholder app icon rendered by SlideCraft itself

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon ThomasandClaude Opus 5.5 committed 2026-10-06 00:08:13 -04:00
1 parent 6078b68e97
commit e62b7ccd15
48 files changed
+17564 -33

No files matched your search

+2
View File
@@ -17,6 +17,8 @@ deck and UI icons are original work of the SlideCraft contributors (drawn or gen
| `docs/brand/artcraft-mark.svg`, `docs/brand/artcraft-mark.png` | ArtCraft Team | ArtCraft brand kit | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | README footer, About |
| `docs/brand/artcraft-mark-black.svg`, `docs/brand/artcraft-mark-black.png` | ArtCraft Team | ArtCraft brand kit | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | Mark for light backgrounds |
| `docs/brand/LICENSE-brand.txt` | ArtCraft Team | craftrules `standards/license-files` | — (licence text) | Terms for the ArtCraft marks |
| `assets/app-icon/slidecraft-1024.png`, `assets/app-icon/hicolor/*/apps/ai.storyteller.slidecraft.png` | SlideCraft contributors | rendered by SlideCraft from `assets/app-icon/icon-source.slidecraft` (shapes only) | MIT OR Apache-2.0 | Placeholder app icon (presentation board) until the mascot icon is drawn |
| `assets/app-icon/icon-source.slidecraft` | SlideCraft contributors | made with `slidecraft-cli run` (see git history) | MIT OR Apache-2.0 | Source of the placeholder icon |
## Bundled through dependencies
Generated
+3646 -18
View File
File diff suppressed because it is too large. Load diff
+2 -1
View File
@@ -1,7 +1,7 @@
[workspace]
resolver = "3"
members = ["crates/*", "apps/*"]
default-members = ["crates/*", "apps/slidecraft-cli"]
default-members = ["crates/*", "apps/slidecraft", "apps/slidecraft-cli"]
[workspace.package]
version = "0.1.0"
@@ -20,6 +20,7 @@ slidecraft-text = { path = "crates/text" }
slidecraft-render = { path = "crates/render" }
slidecraft-pptx = { path = "crates/pptx" }
slidecraft-format = { path = "crates/format" }
slidecraft-anim = { path = "crates/anim" }
slidecraft-engine = { path = "crates/engine" }
slidecraft-mcp = { path = "crates/mcp" }
slidecraft-ui-egui = { path = "crates/ui-egui" }
+27
View File
@@ -0,0 +1,27 @@
[package]
name = "slidecraft"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "SlideCraft desktop app"
publish = false
default-run = "slidecraft"
[lints]
workspace = true
[dependencies]
slidecraft-engine = { workspace = true }
slidecraft-ui-egui = { workspace = true }
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11"] }
egui = { workspace = true }
serde_json = { workspace = true }
log = { workspace = true }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
rfd = { workspace = true }
arboard = { workspace = true, features = ["image-data"] }
image = { workspace = true }
[target.'cfg(all(unix, not(target_os = "macos")))'.dependencies]
winit = { version = "0.30", default-features = false, features = ["x11"] }
+62
View File
@@ -0,0 +1,62 @@
//! Loopback JSON-lines control server: one request per line, one reply per line.
//! This is the transport the MCP server (`slidecraft-cli mcp --connect`) wraps.
use std::io::{BufRead, BufReader, Write};
use std::net::{TcpListener, TcpStream};
use std::sync::mpsc::{Receiver, Sender, channel};
use std::time::Duration;
use serde_json::{Value, json};
use slidecraft_ui_egui::ControlRequest;
pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
let (tx, rx) = channel::<ControlRequest>();
let listener = match TcpListener::bind(("127.0.0.1", port)) {
Ok(l) => l,
Err(e) => {
eprintln!("slidecraft: control server failed to bind 127.0.0.1:{port}: {e}");
return rx;
}
};
eprintln!("slidecraft: control server listening on 127.0.0.1:{port}");
std::thread::spawn(move || {
for stream in listener.incoming().flatten() {
let tx = tx.clone();
let ctx = ctx.clone();
std::thread::spawn(move || serve(stream, tx, ctx));
}
});
rx
}
fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context) {
let Ok(read) = stream.try_clone() else { return };
let mut out = stream;
for line in BufReader::new(read).lines() {
let Ok(line) = line else { break };
if line.trim().is_empty() {
continue;
}
let reply = match serde_json::from_str::<Value>(&line) {
Ok(msg) => {
let id = msg.get("id").cloned().unwrap_or(Value::Null);
let method = msg.get("method").and_then(Value::as_str).unwrap_or("").to_string();
let params = msg.get("params").cloned().unwrap_or(json!({}));
let (req, rrx) = ControlRequest::new(method, params);
if tx.send(req).is_err() {
break;
}
ctx.request_repaint();
let mut r = rrx.recv_timeout(Duration::from_secs(60)).unwrap_or_else(|_| json!({"ok": false, "error": "timeout"}));
if let Some(o) = r.as_object_mut() {
o.insert("id".into(), id);
}
r
}
Err(e) => json!({"ok": false, "error": format!("bad JSON: {e}")}),
};
if writeln!(out, "{reply}").is_err() {
break;
}
}
}
+184
View File
@@ -0,0 +1,184 @@
//! SlideCraft desktop app.
//!
//! Usage: `slidecraft [--control <port>] [--sample] [--show] [files…]`
//!
//! `--control <port>` (or `SLIDECRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server:
//! `{"id":1,"method":"ui.inspect","params":{}}` → `{"id":1,"ok":true,"result":…}`.
//! See `slidecraft_ui_egui::control` for the methods.
#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
mod control_server;
use slidecraft_engine::Session;
use slidecraft_ui_egui::{Services, SlideApp};
struct App(SlideApp);
impl eframe::App for App {
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
self.0.logic(ctx);
}
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
self.0.raw_input_hook(raw);
}
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
self.0.ui(ui);
}
fn on_exit(&mut self) {
save_prefs(&self.0);
}
}
fn prefs_path() -> Option<std::path::PathBuf> {
let base = if cfg!(target_os = "macos") {
std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join("Library/Application Support/SlideCraft"))
} else if cfg!(windows) {
std::env::var_os("APPDATA").map(|a| std::path::PathBuf::from(a).join("SlideCraft"))
} else {
std::env::var_os("XDG_CONFIG_HOME")
.map(std::path::PathBuf::from)
.or_else(|| std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join(".config")))
.map(|c| c.join("slidecraft"))
};
base.map(|b| b.join("ui.json"))
}
fn load_prefs(app: &mut SlideApp) {
if std::env::var_os("SLIDECRAFT_NO_PREFS").is_some() {
return;
}
if let Some(p) = prefs_path()
&& let Ok(bytes) = std::fs::read(&p)
&& let Ok(ui) = serde_json::from_slice::<slidecraft_ui_egui::UiState>(&bytes)
{
app.ui = ui;
}
if let Some(p) = prefs_path().map(|p| p.with_file_name("prefs.json"))
&& let Ok(bytes) = std::fs::read(&p)
&& let Ok(prefs) = serde_json::from_slice::<slidecraft_engine::Prefs>(&bytes)
{
app.session.prefs = prefs;
}
}
fn save_prefs(app: &SlideApp) {
if std::env::var_os("SLIDECRAFT_NO_PREFS").is_some() {
return;
}
if let Some(p) = prefs_path() {
let _ = std::fs::create_dir_all(p.parent().unwrap_or(std::path::Path::new(".")));
if let Ok(bytes) = serde_json::to_vec_pretty(&app.ui) {
let _ = std::fs::write(&p, bytes);
}
if let Ok(bytes) = serde_json::to_vec_pretty(&app.session.prefs) {
let _ = std::fs::write(p.with_file_name("prefs.json"), bytes);
}
}
}
fn services() -> Services {
Services {
pick_open: Some(Box::new(|purpose: &str| {
let d = rfd::FileDialog::new();
let d = match purpose {
"picture" => d.add_filter("Pictures", &["png", "jpg", "jpeg", "gif", "webp", "bmp", "tif", "tiff"]),
"audio" => d.add_filter("Audio", &["wav", "mp3", "m4a", "aac", "flac", "ogg", "opus", "aif", "aiff"]),
"video" => d.add_filter("Video", &["mp4", "m4v", "mov", "webm"]),
_ => d
.add_filter("Presentations", &["slidecraft", "pptx", "potx", "ppsx"])
.add_filter("SlideCraft", &["slidecraft"])
.add_filter("PowerPoint", &["pptx", "potx", "ppsx"])
.add_filter("Outline", &["txt", "md"]),
};
d.pick_file().map(|p| p.to_string_lossy().to_string())
})),
pick_save: Some(Box::new(|name: &str| rfd::FileDialog::new().set_file_name(name).save_file().map(|p| p.to_string_lossy().to_string()))),
read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))),
write: Some(Box::new(|p: &str, b: &[u8]| std::fs::write(p, b).map_err(|e| e.to_string()))),
clipboard_image: Some(Box::new(|| {
let mut cb = arboard::Clipboard::new().ok()?;
let img = cb.get_image().ok()?;
let rgba = image::RgbaImage::from_raw(img.width as u32, img.height as u32, img.bytes.into_owned())?;
let mut out = Vec::new();
rgba.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).ok()?;
Some(out)
})),
..Default::default()
}
}
const APP_ID: &str = "ai.storyteller.slidecraft";
fn app_icon() -> Option<egui::IconData> {
let png: &[u8] = include_bytes!("../../../assets/app-icon/hicolor/256x256/apps/ai.storyteller.slidecraft.png");
eframe::icon_data::from_png_bytes(png).map_err(|e| log::warn!("app icon: {e}")).ok()
}
fn main() -> eframe::Result {
let mut control_port: Option<u16> = std::env::var("SLIDECRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok());
let mut files = Vec::new();
let mut sample = false;
let mut show = false;
let mut args = std::env::args().skip(1);
while let Some(a) = args.next() {
match a.as_str() {
"--control" => control_port = args.next().and_then(|p| p.parse().ok()),
"--sample" => sample = true,
"--show" => show = true,
"--version" => {
println!("slidecraft {}", env!("CARGO_PKG_VERSION"));
return Ok(());
}
_ => files.push(a),
}
}
let mut options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_title("SlideCraft")
.with_inner_size([1440.0, 900.0])
.with_min_inner_size([900.0, 560.0])
.with_drag_and_drop(true)
.with_fullsize_content_view(true)
.with_titlebar_shown(false)
.with_title_shown(false),
..Default::default()
};
options.viewport = options.viewport.with_app_id(APP_ID);
if let Some(icon) = app_icon() {
options.viewport = options.viewport.with_icon(icon);
}
#[cfg(all(unix, not(target_os = "macos")))]
if std::env::var_os("DISPLAY").is_some() && std::env::var_os("SLIDECRAFT_WAYLAND").is_none() {
options.event_loop_builder = Some(Box::new(|b| {
use winit::platform::x11::EventLoopBuilderExtX11;
b.with_x11();
}));
}
eframe::run_native(
"SlideCraft",
options,
Box::new(move |cc| {
let mut app = SlideApp::new(Session::new(), services());
load_prefs(&mut app);
app.integrated_titlebar = cfg!(target_os = "macos");
if let Some(port) = control_port {
let rx = control_server::start(port, cc.egui_ctx.clone());
app = app.with_control(rx);
}
if sample && let Err(e) = slidecraft_engine::sample::open_sample(&mut app.session) {
eprintln!("slidecraft: sample: {e}");
}
for f in files {
if let Err(e) = app.open_path(&f) {
eprintln!("slidecraft: {f}: {e}");
}
}
if show {
app.start_show(0, false);
}
Ok(Box::new(App(app)))
}),
)
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 818 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

+14
View File
@@ -0,0 +1,14 @@
[package]
name = "slidecraft-anim"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "SlideCraft slide show timing: animation sequences, per-shape animation state over time, and transition frame layouts"
[lints]
workspace = true
[dependencies]
slidecraft-model = { workspace = true }
slidecraft-geom = { workspace = true }
serde = { workspace = true }
+75
View File
@@ -0,0 +1,75 @@
//! Time easing: acceleration / deceleration fractions and bounce.
use crate::clampf;
/// Map linear progress `p` (0..1) through PowerPoint-style easing: `accel` and `decel` are the
/// fractions of the duration spent speeding up and slowing down (velocity ramps linearly);
/// `bounce` (0..1) makes the last part of the motion bounce against the end.
pub fn ease(p: f64, accel: f64, decel: f64, bounce: f64) -> f64 {
let p = clampf(p, 0.0, 1.0, 0.0);
let mut a = clampf(accel, 0.0, 1.0, 0.0);
let mut d = clampf(decel, 0.0, 1.0, 0.0);
if a + d > 1.0 {
let s = a + d;
a /= s;
d /= s;
}
let v = 2.0 / (2.0 - a - d);
let mut y = if a > 0.0 && p < a {
v * p * p / (2.0 * a)
} else if d > 0.0 && p > 1.0 - d {
let q = 1.0 - p;
1.0 - v * q * q / (2.0 * d)
} else {
v * a / 2.0 + v * (p - a)
};
let b = clampf(bounce, 0.0, 1.0, 0.0);
if b > 0.0 {
// Reach the end early, then bounce back with decaying hops over the last `b` fraction.
let hit = 1.0 - b;
if p < hit {
let q = if hit > 0.0 { p / hit } else { 1.0 };
y = q * q;
} else {
let s = (p - hit) / b;
y = 1.0 - bounce_hops(s) * 0.25;
}
}
clampf(y, -1.0, 2.0, 0.0)
}
/// Decaying hops for s in 0..1: 0 at the ends of each hop, peaks shrinking.
fn bounce_hops(s: f64) -> f64 {
let s = clampf(s, 0.0, 1.0, 1.0);
let hops = 3.0;
let k = (s * hops).floor().min(hops - 1.0);
let local = s * hops - k;
let amp = 0.5f64.powf(k);
amp * (local * std::f64::consts::PI).sin()
}
/// Classic bounce-out: falls to 1 and bounces with decaying height.
pub(crate) fn bounce_out(p: f64) -> f64 {
let p = clampf(p, 0.0, 1.0, 0.0);
let n = 7.5625;
let d = 2.75;
let y = if p < 1.0 / d {
n * p * p
} else if p < 2.0 / d {
let q = p - 1.5 / d;
n * q * q + 0.75
} else if p < 2.5 / d {
let q = p - 2.25 / d;
n * q * q + 0.9375
} else {
let q = p - 2.625 / d;
n * q * q + 0.984375
};
y.min(1.0)
}
/// Smooth ease-in-out (cubic smoothstep).
pub(crate) fn smooth(p: f64) -> f64 {
let p = clampf(p, 0.0, 1.0, 0.0);
p * p * (3.0 - 2.0 * p)
}
+317
View File
@@ -0,0 +1,317 @@
//! Evaluation of one animation effect at a progress value.
use std::f64::consts::PI;
use slidecraft_model::{AnimClass, Rgba, Xfrm};
use crate::AnimState;
use crate::easing::{bounce_out, ease, smooth};
use crate::path::{MotionPath, default_path};
/// Everything an effect needs besides its progress.
#[derive(Clone, Debug)]
pub(crate) struct EffectSpec {
pub class: AnimClass,
/// Base effect id (exit ids mapped to their entrance counterpart).
pub effect: String,
pub option: String,
pub accel: f64,
pub decel: f64,
pub bounce: f64,
pub amount: Option<f64>,
pub color: Option<Rgba>,
pub path: Option<MotionPath>,
/// Shape box in points (sanitized).
pub xf: Xfrm,
pub slide_w: f64,
pub slide_h: f64,
}
/// Map an exit effect id to the entrance effect it mirrors.
pub(crate) fn exit_base(effect: &str) -> &str {
match effect {
"disappear" => "appear",
"fadeOut" => "fade",
"flyOut" => "fly",
"floatOut" => "float",
"splitOut" => "split",
"wipeOut" => "wipe",
"shapeOut" => "shape",
"wheelOut" => "wheel",
"randomBarsOut" => "randomBars",
"shrinkTurn" => "grow",
"zoomOut" => "zoom",
"swivelOut" => "swivel",
"bounceOut" => "bounce",
"dissolveOut" => "dissolve",
other => other,
}
}
/// Emphasis effects whose end state persists after they finish (PowerPoint keeps the spun angle,
/// the new size, the new colour and transparency).
pub(crate) fn emphasis_persists(effect: &str) -> bool {
matches!(
effect,
"spin"
| "growShrink"
| "desaturate"
| "darken"
| "lighten"
| "transparency"
| "objectColor"
| "complementaryColor"
| "lineColor"
| "fillColor"
| "fontColor"
)
}
fn uses_default_smoothing(effect: &str) -> bool {
matches!(effect, "fly" | "float" | "rise" | "peek" | "lines" | "arcs" | "turns" | "shapes" | "loops" | "customPath")
}
impl EffectSpec {
/// Eased progress.
pub fn eased(&self, p: f64) -> f64 {
if self.accel <= 0.0 && self.decel <= 0.0 && self.bounce <= 0.0 && uses_default_smoothing(&self.effect) {
smooth(p)
} else {
ease(p, self.accel, self.decel, self.bounce)
}
}
/// The state delta at linear progress `p` (0..1) while the effect plays. `cur` is the shape's
/// current fill colour (for colour emphasis).
pub fn eval(&self, p: f64, cur: Rgba) -> AnimState {
let p = crate::clampf(p, 0.0, 1.0, 0.0);
let s = match self.class {
AnimClass::Entrance => self.entrance(p, false),
AnimClass::Exit => self.entrance(1.0 - p, true),
AnimClass::Emphasis => self.emphasis(p, cur),
AnimClass::Path => self.motion(p),
AnimClass::Media => AnimState::default(),
};
s.sanitized()
}
/// The state the effect leaves behind once done.
pub fn end_state(&self, cur: Rgba) -> AnimState {
match self.class {
AnimClass::Entrance | AnimClass::Media => AnimState::default(),
AnimClass::Exit => AnimState::hidden(),
AnimClass::Emphasis if emphasis_persists(&self.effect) => self.emphasis(1.0, cur).sanitized(),
AnimClass::Emphasis => AnimState::default(),
AnimClass::Path => self.motion(1.0).sanitized(),
}
}
fn entrance(&self, p: f64, exit: bool) -> AnimState {
let e = self.eased(p);
let q = 1.0 - e;
let o = self.option.as_str();
let Xfrm { x, y, w, h, .. } = self.xf;
let (sw, sh) = (self.slide_w, self.slide_h);
let mut st = AnimState::default();
let center_clip = |f: f64, horz: bool, vert: bool| {
let f = f.clamp(0.0, 1.0) / 2.0;
let (l, r) = if horz { (0.5 - f, 0.5 + f) } else { (0.0, 1.0) };
let (t, b) = if vert { (0.5 - f, 0.5 + f) } else { (0.0, 1.0) };
Some([l, t, r, b])
};
match self.effect.as_str() {
"appear" => st.visible = p > 0.0,
"fade" | "dissolve" | "randomBars" | "blinds" | "checkerboard" | "wheel" => st.opacity = e,
"fly" => {
let o = if o.is_empty() { "b" } else { o };
if o.contains('l') {
st.offset_x = -(x + w) * q;
}
if o.contains('r') {
st.offset_x = (sw - x) * q;
}
if o.contains('t') {
st.offset_y = -(y + h) * q;
}
if o.contains('b') {
st.offset_y = (sh - y) * q;
}
}
"float" | "rise" => {
// Float Up enters from below and leaves upward; Float Down the reverse.
let down = o == "d";
let dir = if down { -1.0 } else { 1.0 };
let dir = if exit { -dir } else { dir };
st.offset_y = dir * 0.1 * sh * q;
st.opacity = e;
}
"split" => {
let horz = o.starts_with("horz");
// "In" closes from the edges: approximated with the centre reveal plus a fade.
st.clip = if horz { center_clip(e, false, true) } else { center_clip(e, true, false) };
if o.ends_with("In") {
st.opacity = e;
}
}
"wipe" => {
st.clip = Some(match o {
"l" => [0.0, 0.0, e, 1.0],
"t" => [0.0, 0.0, 1.0, e],
"r" => [1.0 - e, 0.0, 1.0, 1.0],
_ => [0.0, 1.0 - e, 1.0, 1.0],
});
}
"shape" | "circle" | "box" | "diamond" | "expandClip" => {
let round = matches!(self.effect.as_str(), "circle" | "diamond")
|| o.starts_with("circle")
|| o.starts_with("diamond")
|| o.starts_with("plus");
let f = if round { (e * std::f64::consts::SQRT_2).min(1.0) } else { e };
st.clip = center_clip(f, true, true);
if o.ends_with("In") || o == "in" {
st.opacity = e;
}
}
"grow" => {
st.scale_x = e;
st.scale_y = e;
st.rotate = -90.0 * q;
st.opacity = e;
}
"zoom" => {
let s = if o == "out" { 1.0 + 3.0 * q } else { e };
st.scale_x = s;
st.scale_y = s;
st.opacity = e;
}
"swivel" => {
let s = e * (q * 2.5 * PI).cos();
if o == "vert" {
st.scale_y = s;
} else {
st.scale_x = s;
}
st.opacity = e.min(1.0);
}
"bounce" => {
let b = bounce_out(p);
st.offset_y = -(0.25 * sh) * (1.0 - b);
st.offset_x = -0.1 * sw * (1.0 - p);
st.opacity = (p * 4.0).min(1.0);
}
"peek" => match o {
"l" => {
st.offset_x = -w * q;
st.clip = Some([q, 0.0, 1.0, 1.0]);
}
"r" => {
st.offset_x = w * q;
st.clip = Some([0.0, 0.0, e, 1.0]);
}
"t" => {
st.offset_y = -h * q;
st.clip = Some([0.0, q, 1.0, 1.0]);
}
_ => {
st.offset_y = h * q;
st.clip = Some([0.0, 0.0, 1.0, e]);
}
},
"strips" => {
st.clip = Some(match o {
"ru" => [0.0, q, e, 1.0],
"ld" => [q, 0.0, 1.0, e],
"rd" => [0.0, 0.0, e, e],
_ => [q, q, 1.0, 1.0],
});
}
"expand" => {
st.scale_x = 0.3 + 0.7 * e;
st.opacity = e;
}
"spinner" => {
st.rotate = 360.0 * q;
st.scale_x = 0.5 + 0.5 * e;
st.scale_y = 0.5 + 0.5 * e;
st.opacity = e;
}
_ => st.opacity = e,
}
st
}
fn emphasis(&self, p: f64, cur: Rgba) -> AnimState {
let e = self.eased(p);
let bump = (p * PI).sin();
let mut st = AnimState::default();
let target = self.color.unwrap_or(Rgba::rgb(0xC0, 0x00, 0x00));
match self.effect.as_str() {
"pulse" => {
st.scale_x = 1.0 + 0.1 * bump;
st.scale_y = st.scale_x;
}
"colorPulse" => st.tint = Some(cur.lerp(target, bump)),
"teeter" => st.rotate = 8.0 * (p * 4.0 * PI).sin() * (1.0 - p * 0.5),
"spin" => {
let amt = self.amount.filter(|a| a.is_finite()).unwrap_or(360.0).clamp(-36_000.0, 36_000.0);
let sign = if self.option == "counterClockwise" { -1.0 } else { 1.0 };
st.rotate = sign * amt * e;
}
"growShrink" => {
let amt = self.amount.filter(|a| a.is_finite() && *a > 0.0).unwrap_or(1.5).clamp(0.0, 100.0);
let s = 1.0 + (amt - 1.0) * e;
match self.option.as_str() {
"horz" => st.scale_x = s,
"vert" => st.scale_y = s,
_ => {
st.scale_x = s;
st.scale_y = s;
}
}
}
"desaturate" => {
let l = (0.299 * cur.r as f64 + 0.587 * cur.g as f64 + 0.114 * cur.b as f64).round().clamp(0.0, 255.0) as u8;
st.tint = Some(cur.lerp(Rgba::rgba(l, l, l, cur.a), e));
}
"darken" => st.tint = Some(cur.lerp(Rgba::rgba(0, 0, 0, cur.a), 0.4 * e)),
"lighten" => st.tint = Some(cur.lerp(Rgba::rgba(255, 255, 255, cur.a), 0.4 * e)),
"transparency" => {
// `amount` is the transparency (0.5 = 50 %).
let a = self.amount.filter(|a| a.is_finite()).unwrap_or(0.5).clamp(0.0, 1.0);
st.opacity = 1.0 - a * e;
}
"objectColor" | "fillColor" | "lineColor" | "fontColor" => st.tint = Some(cur.lerp(target, e)),
"complementaryColor" => st.tint = Some(cur.lerp(Rgba::rgba(255 - cur.r, 255 - cur.g, 255 - cur.b, cur.a), e)),
"underline" | "boldFlash" => {
st.scale_x = 1.0 + 0.04 * bump;
st.scale_y = st.scale_x;
if self.effect == "boldFlash" {
st.tint = Some(cur.lerp(Rgba::rgba(0, 0, 0, cur.a), 0.3 * bump));
}
}
"wave" => {
st.offset_y = -0.02 * self.slide_h * bump;
st.scale_y = 1.0 + 0.05 * bump;
}
_ => {}
}
st
}
fn motion(&self, p: f64) -> AnimState {
let e = self.eased(p);
let (fx, fy) = match &self.path {
Some(mp) => mp.at(e),
None => (0.0, 0.0),
};
AnimState { offset_x: fx * self.slide_w, offset_y: fy * self.slide_h, ..Default::default() }
}
}
/// The path for a motion-path animation: its own, or the preset default.
pub(crate) fn motion_path(effect: &str, option: &str, own: Option<&str>) -> MotionPath {
match own {
Some(s) if !s.trim().is_empty() => MotionPath::parse(s),
_ => MotionPath::parse(&default_path(effect, option)),
}
}
+111
View File
@@ -0,0 +1,111 @@
//! Slide show timing for SlideCraft.
//!
//! - [`Timeline`]: a slide's animation list grouped into click steps, with per-shape (and
//! per-paragraph) [`AnimState`] at any point of the show.
//! - [`transition_layers`]: a slide transition frame as textured quads of the old/new slide images.
//! - [`morph_pairs`] / [`morph_xfrm`]: Morph transition shape matching and interpolation.
//! - [`ShowState`]: slide show navigation (hidden slides, custom shows, loop, timings).
//!
//! Pure logic: no rendering, no UI toolkit. Every function accepts hostile numbers (NaN, huge or
//! negative times) and never panics.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
mod easing;
mod effects;
mod morph;
mod path;
mod show;
mod timeline;
mod transition;
#[cfg(test)]
mod tests;
use serde::{Deserialize, Serialize};
pub use slidecraft_model::Rgba;
pub use easing::ease;
pub use morph::{morph_pairs, morph_xfrm};
pub use path::{MotionPath, default_path};
pub use show::{ShowAction, ShowState, show_order};
pub use timeline::{EffectTiming, Sequence, Timeline, TriggerPlay};
pub use transition::{Layer, Source, transition_layers};
/// Animation state of one shape (or one paragraph of a shape's text) at one moment of the show.
///
/// Same meaning as the renderer's `ShapeState`: translation in points, scale and rotation (degrees)
/// around the shape's centre, a reveal clip in shape-local unit space `[l, t, r, b]`, and a fill
/// colour override.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct AnimState {
pub visible: bool,
pub opacity: f64,
pub offset_x: f64,
pub offset_y: f64,
pub scale_x: f64,
pub scale_y: f64,
pub rotate: f64,
pub clip: Option<[f64; 4]>,
pub tint: Option<Rgba>,
}
impl Default for AnimState {
fn default() -> Self {
AnimState { visible: true, opacity: 1.0, offset_x: 0.0, offset_y: 0.0, scale_x: 1.0, scale_y: 1.0, rotate: 0.0, clip: None, tint: None }
}
}
impl AnimState {
/// A hidden state.
pub fn hidden() -> Self {
AnimState { visible: false, ..Default::default() }
}
/// Is this the identity (nothing to apply)?
pub fn is_identity(&self) -> bool {
*self == AnimState::default()
}
/// Apply `d` on top of `self`: opacities and scales multiply, offsets and rotations add, clips
/// intersect, a tint replaces.
pub fn compose(&self, d: &AnimState) -> AnimState {
let clip = match (self.clip, d.clip) {
(Some(a), Some(b)) => Some([a[0].max(b[0]), a[1].max(b[1]), a[2].min(b[2]), a[3].min(b[3])]),
(a, None) => a,
(None, b) => b,
};
AnimState {
visible: self.visible && d.visible,
opacity: self.opacity * d.opacity,
offset_x: self.offset_x + d.offset_x,
offset_y: self.offset_y + d.offset_y,
scale_x: self.scale_x * d.scale_x,
scale_y: self.scale_y * d.scale_y,
rotate: self.rotate + d.rotate,
clip,
tint: d.tint.or(self.tint),
}
}
/// Replace any non-finite number with its neutral value and clamp opacity and clip.
pub fn sanitized(mut self) -> AnimState {
let f = |v: f64, d: f64| if v.is_finite() { v } else { d };
self.opacity = f(self.opacity, 1.0).clamp(0.0, 1.0);
self.offset_x = f(self.offset_x, 0.0);
self.offset_y = f(self.offset_y, 0.0);
self.scale_x = f(self.scale_x, 1.0);
self.scale_y = f(self.scale_y, 1.0);
self.rotate = f(self.rotate, 0.0);
self.clip = self.clip.map(|c| {
let l = f(c[0], 0.0).clamp(0.0, 1.0);
let t = f(c[1], 0.0).clamp(0.0, 1.0);
let r = f(c[2], 1.0).clamp(0.0, 1.0);
let b = f(c[3], 1.0).clamp(0.0, 1.0);
[l, t, r.max(l), b.max(t)]
});
self
}
}
/// Clamp a hostile number into `[lo, hi]`, mapping NaN to `nan`.
pub(crate) fn clampf(v: f64, lo: f64, hi: f64, nan: f64) -> f64 {
if v.is_nan() { nan } else { v.clamp(lo, hi) }
}
+93
View File
@@ -0,0 +1,93 @@
//! Morph: matching shapes between two slides and interpolating their boxes.
use slidecraft_model::{Shape, ShapeId, Slide, Xfrm};
use crate::clampf;
fn text_of(s: &Shape) -> String {
s.text.as_ref().map(|t| t.text()).unwrap_or_default()
}
/// Pairs of (shape on `a`, shape on `b`) that Morph animates from one to the other: first by
/// `!!name` (forced match), then by exact name, then by the same kind and text, then by the same
/// kind and preset geometry. Each shape is used once; top-level shapes only (groups move whole).
pub fn morph_pairs(a: &Slide, b: &Slide) -> Vec<(ShapeId, ShapeId)> {
let mut pairs: Vec<(ShapeId, ShapeId)> = Vec::new();
let mut used_a = vec![false; a.shapes.len()];
let mut used_b = vec![false; b.shapes.len()];
let passes: [&dyn Fn(&Shape, &Shape) -> bool; 4] = [
&|x, y| x.name.starts_with("!!") && x.name == y.name,
&|x, y| !x.name.is_empty() && x.name == y.name,
&|x, y| {
if x.kind_name() != y.kind_name() {
return false;
}
let tx = text_of(x);
!tx.trim().is_empty() && tx == text_of(y)
},
&|x, y| {
x.kind_name() == y.kind_name()
&& x.geom.preset_name().is_some()
&& x.geom.preset_name() == y.geom.preset_name()
&& text_of(x).trim().is_empty() == text_of(y).trim().is_empty()
},
];
for pass in passes {
for (i, sa) in a.shapes.iter().enumerate() {
if used_a.get(i).copied().unwrap_or(true) {
continue;
}
let found = b.shapes.iter().enumerate().find(|(j, sb)| !used_b.get(*j).copied().unwrap_or(true) && pass(sa, sb));
if let Some((j, sb)) = found {
if let Some(u) = used_a.get_mut(i) {
*u = true;
}
if let Some(u) = used_b.get_mut(j) {
*u = true;
}
pairs.push((sa.id, sb.id));
}
}
}
pairs
}
fn lerp(a: f64, b: f64, t: f64) -> f64 {
let v = a + (b - a) * t;
if v.is_finite() {
v
} else if t < 0.5 {
a
} else {
b
}
}
/// Interpolate a box from `a` to `b` at `t` (0..1): position and size linearly, rotation along the
/// shortest way round; flips switch at the midpoint.
pub fn morph_xfrm(a: Xfrm, b: Xfrm, t: f64) -> Xfrm {
let t = clampf(t, 0.0, 1.0, 0.0);
if t >= 1.0 {
return b;
}
if t <= 0.0 {
return a;
}
let ra = if a.rot.is_finite() { a.rot } else { 0.0 };
let rb = if b.rot.is_finite() { b.rot } else { 0.0 };
let mut d = (rb - ra).rem_euclid(360.0);
if d > 180.0 {
d -= 360.0;
}
let rot = (ra + d * t).rem_euclid(360.0);
let late = t >= 0.5;
Xfrm {
x: lerp(a.x, b.x, t),
y: lerp(a.y, b.y, t),
w: lerp(a.w, b.w, t),
h: lerp(a.h, b.h, t),
rot: if rot.is_finite() { rot } else { 0.0 },
flip_h: if late { b.flip_h } else { a.flip_h },
flip_v: if late { b.flip_v } else { a.flip_v },
}
}
+293
View File
@@ -0,0 +1,293 @@
//! Motion paths: our SVG-like syntax (`M x y L x y C x1 y1 x2 y2 x y Q x1 y1 x y Z E`, lowercase
//! = relative) in slide-fraction units, relative to the shape's start position. Positions are
//! evaluated by arc length.
use std::fmt::Write as _;
const MAX_TOKENS: usize = 100_000;
const MAX_POINTS: usize = 20_000;
const CURVE_STEPS: usize = 16;
/// A flattened motion path.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct MotionPath {
/// Polyline vertices (slide fractions).
pub points: Vec<(f64, f64)>,
/// Cumulative arc length at each vertex.
cum: Vec<f64>,
}
#[derive(Clone, Copy, Debug, PartialEq)]
enum Tok {
Cmd(char),
Num(f64),
}
fn tokenize(s: &str) -> Vec<Tok> {
let cs: Vec<char> = s.chars().take(1_000_000).collect();
let mut out = Vec::new();
let mut i = 0;
while i < cs.len() && out.len() < MAX_TOKENS {
let c = cs.get(i).copied().unwrap_or(' ');
if c.is_whitespace() || c == ',' {
i += 1;
continue;
}
if c.is_ascii_digit() || c == '.' || c == '-' || c == '+' {
let start = i;
i += 1;
let mut seen_dot = c == '.';
while let Some(&d) = cs.get(i) {
if d.is_ascii_digit() {
i += 1;
} else if d == '.' && !seen_dot {
seen_dot = true;
i += 1;
} else if (d == 'e' || d == 'E') && {
let n1 = cs.get(i + 1).copied().unwrap_or(' ');
let n2 = cs.get(i + 2).copied().unwrap_or(' ');
n1.is_ascii_digit() || ((n1 == '-' || n1 == '+') && n2.is_ascii_digit())
} {
i += 2;
while cs.get(i).is_some_and(|d| d.is_ascii_digit()) {
i += 1;
}
break;
} else {
break;
}
}
let text: String = cs.get(start..i).map(|s| s.iter().collect()).unwrap_or_default();
match text.parse::<f64>() {
Ok(v) if v.is_finite() => out.push(Tok::Num(v)),
_ => out.push(Tok::Cmd('?')),
}
continue;
}
out.push(Tok::Cmd(c));
i += 1;
}
out
}
impl MotionPath {
/// Parse a path. Malformed input yields the part parsed before the error (possibly empty).
pub fn parse(s: &str) -> MotionPath {
let toks = tokenize(s);
let mut pts: Vec<(f64, f64)> = Vec::new();
let mut cur = (0.0, 0.0);
let mut start = (0.0, 0.0);
let mut cmd = 'M';
let mut i = 0;
let push = |pts: &mut Vec<(f64, f64)>, p: (f64, f64)| {
if pts.len() < MAX_POINTS {
pts.push(p);
}
};
let num = |i: usize| match toks.get(i) {
Some(Tok::Num(v)) => Some(*v),
_ => None,
};
while i < toks.len() {
if let Some(Tok::Cmd(c)) = toks.get(i) {
cmd = *c;
i += 1;
match cmd {
'Z' | 'z' => {
push(&mut pts, start);
cur = start;
continue;
}
'E' | 'e' => break,
'M' | 'm' | 'L' | 'l' | 'C' | 'c' | 'Q' | 'q' | 'H' | 'h' | 'V' | 'v' => continue,
_ => break,
}
}
let rel = cmd.is_ascii_lowercase();
let base = if rel { cur } else { (0.0, 0.0) };
let need = match cmd.to_ascii_uppercase() {
'M' | 'L' => 2,
'C' => 6,
'Q' => 4,
'H' | 'V' => 1,
_ => break,
};
let mut v = [0.0f64; 6];
let mut ok = true;
for (k, slot) in v.iter_mut().enumerate().take(need) {
match num(i + k) {
Some(x) => *slot = x,
None => ok = false,
}
}
if !ok {
break;
}
i += need;
match cmd.to_ascii_uppercase() {
'M' => {
cur = (base.0 + v[0], base.1 + v[1]);
start = cur;
// A move inside a motion path is a jump; it stays continuous as a segment.
push(&mut pts, cur);
cmd = if rel { 'l' } else { 'L' };
}
'L' => {
if pts.is_empty() {
push(&mut pts, cur);
}
cur = (base.0 + v[0], base.1 + v[1]);
push(&mut pts, cur);
}
'H' | 'V' => {
if pts.is_empty() {
push(&mut pts, cur);
}
cur = if cmd.eq_ignore_ascii_case(&'H') { (base.0 + v[0], cur.1) } else { (cur.0, base.1 + v[0]) };
push(&mut pts, cur);
}
'C' => {
if pts.is_empty() {
push(&mut pts, cur);
}
let p0 = cur;
let p1 = (base.0 + v[0], base.1 + v[1]);
let p2 = (base.0 + v[2], base.1 + v[3]);
let p3 = (base.0 + v[4], base.1 + v[5]);
for k in 1..=CURVE_STEPS {
let t = k as f64 / CURVE_STEPS as f64;
let u = 1.0 - t;
let a = u * u * u;
let b = 3.0 * u * u * t;
let c = 3.0 * u * t * t;
let d = t * t * t;
push(&mut pts, (a * p0.0 + b * p1.0 + c * p2.0 + d * p3.0, a * p0.1 + b * p1.1 + c * p2.1 + d * p3.1));
}
cur = p3;
}
'Q' => {
if pts.is_empty() {
push(&mut pts, cur);
}
let p0 = cur;
let p1 = (base.0 + v[0], base.1 + v[1]);
let p2 = (base.0 + v[2], base.1 + v[3]);
for k in 1..=CURVE_STEPS {
let t = k as f64 / CURVE_STEPS as f64;
let u = 1.0 - t;
push(&mut pts, (u * u * p0.0 + 2.0 * u * t * p1.0 + t * t * p2.0, u * u * p0.1 + 2.0 * u * t * p1.1 + t * t * p2.1));
}
cur = p2;
}
_ => break,
}
}
pts.retain(|p| p.0.is_finite() && p.1.is_finite());
let mut cum = Vec::with_capacity(pts.len());
let mut acc = 0.0;
let mut prev: Option<(f64, f64)> = None;
for p in &pts {
if let Some(q) = prev {
let d = ((p.0 - q.0).powi(2) + (p.1 - q.1).powi(2)).sqrt();
if d.is_finite() {
acc += d;
}
}
cum.push(acc);
prev = Some(*p);
}
MotionPath { points: pts, cum }
}
/// Total arc length (slide fractions).
pub fn length(&self) -> f64 {
self.cum.last().copied().unwrap_or(0.0)
}
/// Position at arc-length fraction `p` (0..1), relative to the path's origin (0, 0).
pub fn at(&self, p: f64) -> (f64, f64) {
let first = self.points.first().copied().unwrap_or((0.0, 0.0));
let len = self.length();
if len.is_nan() || len <= 0.0 || !len.is_finite() {
return first;
}
let p = crate::clampf(p, 0.0, 1.0, 0.0);
let target = p * len;
let i = self.cum.partition_point(|c| *c < target);
if i == 0 {
return first;
}
let (Some(a), Some(b), Some(ca), Some(cb)) = (self.points.get(i - 1), self.points.get(i), self.cum.get(i - 1), self.cum.get(i)) else {
return self.points.last().copied().unwrap_or(first);
};
let seg = cb - ca;
let f = if seg > 0.0 { (target - ca) / seg } else { 0.0 };
(a.0 + (b.0 - a.0) * f, a.1 + (b.1 - a.1) * f)
}
}
fn polygon(n: usize, r: f64, rot0: f64) -> String {
// Regular polygon through (0, 0), centred below it.
let mut s = String::from("M 0 0");
let c = (0.0, r);
let a0 = -std::f64::consts::FRAC_PI_2 + rot0;
let start = (c.0 + r * a0.cos(), c.1 + r * a0.sin());
for k in 1..=n {
let a = a0 + std::f64::consts::TAU * k as f64 / n as f64;
let _ = write!(s, " L {:.4} {:.4}", c.0 + r * a.cos() - start.0, c.1 + r * a.sin() - start.1);
}
s.push_str(" E");
s
}
/// The path a preset motion-path effect uses when the animation has none of its own.
pub fn default_path(effect: &str, option: &str) -> String {
let (sx, sy, swap) = match option {
"up" => (1.0, -1.0, false),
"right" => (1.0, 1.0, true),
"left" => (-1.0, 1.0, true),
_ => (1.0, 1.0, false),
};
// Base paths go down; `swap` turns them to the horizontal.
let tr = |pts: &[(f64, f64)]| -> Vec<(f64, f64)> { pts.iter().map(|&(x, y)| if swap { (y * sx, x * sy) } else { (x * sx, y * sy) }).collect() };
let fmt = |head: &str, pts: Vec<(f64, f64)>| {
let mut s = String::from(head);
for (x, y) in pts {
let _ = write!(s, " {x:.4} {y:.4}");
}
s.push_str(" E");
s
};
match effect {
"lines" => fmt("M 0 0 L", tr(&[(0.0, 0.25)])),
"arcs" => fmt("M 0 0 C", tr(&[(0.1, 0.05), (0.1, 0.2), (0.0, 0.25)])),
"turns" => {
let p = tr(&[(0.0, 0.12), (0.0, 0.2), (0.05, 0.25), (0.12, 0.25)]);
let mut s = String::from("M 0 0");
if let Some((x, y)) = p.first() {
let _ = write!(s, " L {x:.4} {y:.4} C");
}
for (x, y) in p.iter().skip(1) {
let _ = write!(s, " {x:.4} {y:.4}");
}
s.push_str(" E");
s
}
"shapes" => match option {
"diamond" => polygon(4, 0.1, 0.0),
"hexagon" => polygon(6, 0.1, 0.0),
"triangle" => polygon(3, 0.1, 0.0),
"square" => polygon(4, 0.1, std::f64::consts::FRAC_PI_4),
"octagon" => polygon(8, 0.1, 0.0),
"pentagon" => polygon(5, 0.1, 0.0),
"trapezoid" => "M 0 0 L 0.1 0 L 0.15 0.15 L -0.05 0.15 Z E".into(),
"parallelogram" => "M 0 0 L 0.15 0 L 0.1 0.15 L -0.05 0.15 Z E".into(),
_ => {
"M 0 0 C 0.0552 0 0.1 0.0448 0.1 0.1 C 0.1 0.1552 0.0552 0.2 0 0.2 C -0.0552 0.2 -0.1 0.1552 -0.1 0.1 C -0.1 0.0448 -0.0552 0 0 0 Z E"
.into()
}
},
"loops" => "M 0 0 C 0.08 -0.1 0.16 0 0.08 0.05 C 0 0.1 -0.08 0.1 -0.08 0 C -0.08 -0.1 0 -0.05 0 0 E".into(),
_ => String::new(),
}
}
+232
View File
@@ -0,0 +1,232 @@
//! Slide show navigation: click steps, slide order (hidden slides, custom shows, ranges), loop
//! and automatic advance.
use slidecraft_model::Presentation;
use crate::Timeline;
/// What the UI should do after a navigation call.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ShowAction {
/// Nothing changed (e.g. back on the first slide).
None,
/// Start playing main step `n` of the current slide (clock from 0).
PlayStep(usize),
/// Step `n` jumped to its end (it was playing); nothing plays now.
FinishStep(usize),
/// Went back before step `n` (showing the state before it).
StepBack(usize),
/// Moved to slide index `n` (play its transition; `ShowState::playing` tells whether step 0
/// starts by itself).
Slide(usize),
/// Past the last slide: show the end screen.
End,
/// Leave the show (next on the end screen).
Exit,
}
/// Where a slide show is.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub struct ShowState {
/// Index into `presentation.slides`.
pub slide: usize,
/// Main step that plays next or is playing; `== steps` when the slide is fully built.
pub step: usize,
/// Step `step` is playing (its clock runs). With `step`, gives `Timeline::state(.., step, t)`.
pub playing: bool,
/// On the end screen.
pub ended: bool,
}
/// Slide indices the show visits in order: the custom show or range from Set Up Slide Show, minus
/// hidden slides.
pub fn show_order(pres: &Presentation) -> Vec<usize> {
let n = pres.slides.len();
let base: Vec<usize> = match pres.show.custom_show.as_ref().and_then(|name| pres.custom_shows.iter().find(|c| &c.name == name)) {
Some(cs) => cs.slides.iter().filter_map(|id| pres.slides.iter().position(|s| s.id == *id)).collect(),
None => match pres.show.range {
Some((a, b)) => {
let a = (a.max(1) as usize).saturating_sub(1);
let b = (b as usize).min(n);
(a..b).collect()
}
None => (0..n).collect(),
},
};
base.into_iter().filter(|i| pres.slides.get(*i).is_some_and(|s| !s.hidden)).collect()
}
fn looping(pres: &Presentation) -> bool {
pres.show.loop_until_esc || pres.show.show_type == "kiosk"
}
impl ShowState {
/// Start at the first slide of the show order.
pub fn new(pres: &Presentation) -> ShowState {
match show_order(pres).first() {
Some(&i) => ShowState::start_at(pres, i),
None => ShowState { ended: true, ..Default::default() },
}
}
/// Start at slide `i` (hidden slides allowed: "from current slide").
pub fn start_at(pres: &Presentation, i: usize) -> ShowState {
let mut s = ShowState::default();
if i < pres.slides.len() {
s.enter(pres, i);
} else {
s.ended = true;
}
s
}
/// The timeline of slide `i`.
pub fn timeline(pres: &Presentation, i: usize) -> Timeline {
match pres.slides.get(i) {
Some(s) => Timeline::new(s, pres.slide_size.width, pres.slide_size.height),
None => Timeline::default(),
}
}
/// Main steps of slide `i` (0 with Show without animation).
pub fn steps(pres: &Presentation, i: usize) -> usize {
if pres.show.without_animation { 0 } else { ShowState::timeline(pres, i).steps() }
}
fn enter(&mut self, pres: &Presentation, i: usize) {
self.slide = i;
self.step = 0;
self.ended = false;
self.playing = !pres.show.without_animation && ShowState::timeline(pres, i).step_is_auto(0);
}
fn next_index(&self, pres: &Presentation) -> Option<usize> {
let order = show_order(pres);
match order.iter().position(|&i| i == self.slide) {
Some(p) => order.get(p + 1).copied(),
None => order.iter().copied().find(|&i| i > self.slide),
}
}
fn prev_index(&self, pres: &Presentation) -> Option<usize> {
let order = show_order(pres);
match order.iter().position(|&i| i == self.slide) {
Some(p) => p.checked_sub(1).and_then(|q| order.get(q).copied()),
None => order.iter().copied().rev().find(|&i| i < self.slide),
}
}
/// Click / → / Space: finish the playing step, else play the next step, else go to the next slide.
pub fn next(&mut self, pres: &Presentation) -> ShowAction {
if pres.slides.is_empty() {
self.ended = true;
return ShowAction::Exit;
}
if self.ended {
return ShowAction::Exit;
}
if self.playing {
return self.step_done(pres);
}
let steps = ShowState::steps(pres, self.slide);
if self.step < steps {
self.playing = true;
return ShowAction::PlayStep(self.step);
}
match self.next_index(pres) {
Some(i) => {
self.enter(pres, i);
ShowAction::Slide(i)
}
None if looping(pres) => match show_order(pres).first() {
Some(&i) => {
self.enter(pres, i);
ShowAction::Slide(i)
}
None => {
self.ended = true;
ShowAction::End
}
},
None => {
self.ended = true;
self.playing = false;
ShowAction::End
}
}
}
/// The playing step reached its end (the UI's clock passed `step_duration`).
pub fn step_done(&mut self, pres: &Presentation) -> ShowAction {
if !self.playing {
return ShowAction::None;
}
let n = self.step;
self.playing = false;
self.step = (self.step + 1).min(ShowState::steps(pres, self.slide));
ShowAction::FinishStep(n)
}
/// ← / Backspace: undo the last step, else go to the previous slide fully built.
pub fn prev(&mut self, pres: &Presentation) -> ShowAction {
if self.ended {
self.ended = false;
if self.slide >= pres.slides.len() {
return ShowAction::None;
}
self.step = ShowState::steps(pres, self.slide);
self.playing = false;
return ShowAction::Slide(self.slide);
}
if self.playing {
self.playing = false;
return ShowAction::StepBack(self.step);
}
if self.step > 0 {
self.step -= 1;
return ShowAction::StepBack(self.step);
}
let target = match self.prev_index(pres) {
Some(i) => Some(i),
None if looping(pres) => show_order(pres).last().copied().filter(|&i| i != self.slide),
None => None,
};
match target {
Some(i) => {
self.slide = i;
self.step = ShowState::steps(pres, i);
self.playing = false;
ShowAction::Slide(i)
}
None => ShowAction::None,
}
}
/// Jump to slide `i` (number + Enter, See All Slides); hidden slides are shown when jumped to.
pub fn goto(&mut self, pres: &Presentation, i: usize) -> ShowAction {
if i >= pres.slides.len() {
return ShowAction::None;
}
self.enter(pres, i);
ShowAction::Slide(i)
}
/// Seconds after which the current slide advances by itself (Advance Slide ▸ After), when the
/// show uses timings.
pub fn advance_after(&self, pres: &Presentation) -> Option<f64> {
if !pres.show.use_timings || self.ended {
return None;
}
pres.slides.get(self.slide).and_then(|s| s.transition.as_ref()).and_then(|t| t.advance_after_ms).map(|ms| ms as f64 / 1000.0)
}
/// Should the show advance now, `elapsed` seconds after the slide appeared? Waits for the
/// slide's animations: auto-advance happens once nothing plays and the time is up; remaining
/// click steps play automatically with timings.
pub fn auto_advance_due(&self, pres: &Presentation, elapsed: f64) -> bool {
match self.advance_after(pres) {
Some(after) => !self.playing && elapsed.is_finite() && elapsed >= after,
None => false,
}
}
}
+634
View File
@@ -0,0 +1,634 @@
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::sync::Arc;
use slidecraft_model::anim::{ANIMATIONS, TRANSITIONS, TextBuild};
use slidecraft_model::{
AnimClass, AnimStart, Animation, ColorRef, CustomShow, Presentation, Rgba, Shape, ShapeId, Slide, SlideId, TextBody, Transition, Xfrm,
};
use crate::*;
const W: f64 = 960.0;
const H: f64 = 540.0;
fn shape(id: u32, name: &str) -> Shape {
Shape { id: ShapeId(id), name: name.into(), xfrm: Some(Xfrm::new(100.0, 100.0, 200.0, 100.0)), ..Default::default() }
}
fn anim(shape: u32, class: AnimClass, effect: &str, start: AnimStart) -> Animation {
Animation { shape: ShapeId(shape), class, effect: effect.into(), start, duration_ms: 1000, ..Default::default() }
}
fn slide_with(anims: Vec<Animation>) -> Slide {
Slide { shapes: (1..=4).map(|i| shape(i, &format!("S{i}"))).collect(), animations: anims, ..Default::default() }
}
fn finite(s: &AnimState) -> bool {
s.opacity.is_finite()
&& s.offset_x.is_finite()
&& s.offset_y.is_finite()
&& s.scale_x.is_finite()
&& s.scale_y.is_finite()
&& s.rotate.is_finite()
&& s.clip.is_none_or(|c| c.iter().all(|v| v.is_finite()))
}
#[test]
fn default_state_is_identity() {
let s = AnimState::default();
assert!(s.visible && s.opacity == 1.0 && s.scale_x == 1.0 && s.scale_y == 1.0);
assert!(s.is_identity());
}
#[test]
fn on_click_starts_steps() {
let tl = Timeline::new(
&slide_with(vec![
anim(1, AnimClass::Entrance, "fade", AnimStart::OnClick),
anim(2, AnimClass::Entrance, "fade", AnimStart::OnClick),
anim(3, AnimClass::Entrance, "fade", AnimStart::OnClick),
]),
W,
H,
);
assert_eq!(tl.steps(), 3);
assert!(!tl.step_is_auto(0));
assert!((tl.step_duration(1) - 1.0).abs() < 1e-9);
}
#[test]
fn with_previous_joins_step() {
let tl = Timeline::new(
&slide_with(vec![anim(1, AnimClass::Entrance, "fade", AnimStart::OnClick), anim(2, AnimClass::Entrance, "fade", AnimStart::WithPrevious)]),
W,
H,
);
assert_eq!(tl.steps(), 1);
let e = tl.effects();
assert_eq!(e[1].step, 0);
assert_eq!(e[1].start, 0.0);
assert!((tl.step_duration(0) - 1.0).abs() < 1e-9);
}
#[test]
fn after_previous_chains() {
let tl = Timeline::new(
&slide_with(vec![
anim(1, AnimClass::Entrance, "fade", AnimStart::OnClick),
anim(2, AnimClass::Entrance, "fade", AnimStart::AfterPrevious),
anim(3, AnimClass::Entrance, "fade", AnimStart::AfterPrevious),
]),
W,
H,
);
assert_eq!(tl.steps(), 1);
let e = tl.effects();
assert!((e[1].start - 1.0).abs() < 1e-9);
assert!((e[2].start - 2.0).abs() < 1e-9);
assert!((tl.step_duration(0) - 3.0).abs() < 1e-9);
}
#[test]
fn delays_shift_start() {
let mut a = anim(2, AnimClass::Entrance, "fade", AnimStart::WithPrevious);
a.delay_ms = 250;
let mut b = anim(3, AnimClass::Entrance, "fade", AnimStart::AfterPrevious);
b.delay_ms = 500;
let tl = Timeline::new(&slide_with(vec![anim(1, AnimClass::Entrance, "fade", AnimStart::OnClick), a, b]), W, H);
let e = tl.effects();
assert!((e[1].start - 0.25).abs() < 1e-9);
// After previous: after the latest end (1.25) plus its delay.
assert!((e[2].start - 1.75).abs() < 1e-9);
assert!((tl.step_duration(0) - 2.75).abs() < 1e-9);
}
#[test]
fn auto_first_step() {
let tl = Timeline::new(
&slide_with(vec![anim(1, AnimClass::Entrance, "fade", AnimStart::AfterPrevious), anim(2, AnimClass::Entrance, "fade", AnimStart::OnClick)]),
W,
H,
);
assert_eq!(tl.steps(), 2);
assert!(tl.step_is_auto(0));
assert!(!tl.step_is_auto(1));
}
#[test]
fn hidden_before_entrance() {
let tl = Timeline::new(&slide_with(vec![anim(1, AnimClass::Entrance, "fade", AnimStart::OnClick)]), W, H);
assert!(!tl.state(ShapeId(1), None, 0, 0.0).visible);
let mid = tl.state(ShapeId(1), None, 0, 0.5);
assert!(mid.visible && mid.opacity > 0.0 && mid.opacity < 1.0);
let done = tl.state(ShapeId(1), None, 1, 0.0);
assert!(done.visible && (done.opacity - 1.0).abs() < 1e-9);
// Unanimated shapes are untouched.
assert!(tl.state(ShapeId(2), None, 0, 0.0).is_identity());
}
#[test]
fn hidden_after_exit() {
let tl = Timeline::new(&slide_with(vec![anim(1, AnimClass::Exit, "fadeOut", AnimStart::OnClick)]), W, H);
assert!(tl.state(ShapeId(1), None, 0, 0.0).visible);
let mid = tl.state(ShapeId(1), None, 0, 0.5);
assert!(mid.visible && mid.opacity < 1.0);
assert!(!tl.state(ShapeId(1), None, 1, 0.0).visible);
assert!(!tl.state(ShapeId(1), None, 0, 5.0).visible);
}
#[test]
fn entrance_then_exit() {
let tl = Timeline::new(
&slide_with(vec![anim(1, AnimClass::Entrance, "appear", AnimStart::OnClick), anim(1, AnimClass::Exit, "disappear", AnimStart::OnClick)]),
W,
H,
);
assert!(!tl.state(ShapeId(1), None, 0, 0.0).visible);
assert!(tl.state(ShapeId(1), None, 1, 0.0).visible);
assert!(!tl.state(ShapeId(1), None, 2, 0.0).visible);
}
#[test]
fn emphasis_returns_or_persists() {
let mut spin = anim(1, AnimClass::Emphasis, "spin", AnimStart::OnClick);
spin.amount = Some(90.0);
let tl = Timeline::new(&slide_with(vec![anim(2, AnimClass::Emphasis, "pulse", AnimStart::OnClick), spin]), W, H);
let p = tl.state(ShapeId(2), None, 0, 0.5);
assert!(p.scale_x > 1.0);
assert!(tl.state(ShapeId(2), None, 1, 0.0).is_identity());
assert!((tl.state(ShapeId(1), None, 2, 0.0).rotate - 90.0).abs() < 1e-9);
}
#[test]
fn color_emphasis_tints() {
let mut a = anim(1, AnimClass::Emphasis, "fillColor", AnimStart::OnClick);
a.color = Some(ColorRef::rgb(Rgba::rgb(255, 0, 0)));
let tl = Timeline::new(&slide_with(vec![a]), W, H);
assert_eq!(tl.state(ShapeId(1), None, 1, 0.0).tint, Some(Rgba::rgb(255, 0, 0)));
}
#[test]
fn every_animation_evaluates_finite() {
for (id, _, class, _, _, opts) in ANIMATIONS {
let mut options: Vec<&str> = opts.to_vec();
options.push("");
for o in options {
let mut a = anim(1, *class, id, AnimStart::OnClick);
a.option = o.into();
let tl = Timeline::new(&slide_with(vec![a]), W, H);
for (step, t) in [(0, 0.0), (0, 0.001), (0, 0.25), (0, 0.5), (0, 0.999), (0, 1.0), (0, 2.0), (1, 0.0)] {
let s = tl.state(ShapeId(1), None, step, t);
assert!(finite(&s), "{id} {o} {step} {t}: {s:?}");
}
let end = tl.state(ShapeId(1), None, 1, 0.0);
match class {
AnimClass::Entrance => assert!(end.visible && (end.opacity - 1.0).abs() < 1e-9, "{id}"),
AnimClass::Exit => assert!(!end.visible, "{id}"),
_ => {}
}
}
}
}
#[test]
fn fly_starts_off_slide() {
let mut a = anim(1, AnimClass::Entrance, "fly", AnimStart::OnClick);
a.option = "l".into();
let tl = Timeline::new(&slide_with(vec![a]), W, H);
let s = tl.state(ShapeId(1), None, 0, 1e-6);
// Shape at x=100, w=200: must start at or beyond the left edge.
assert!(100.0 + s.offset_x + 200.0 <= 0.01, "{s:?}");
let s = tl.state(ShapeId(1), None, 0, 0.999_999);
assert!(s.offset_x.abs() < 1.0);
}
#[test]
fn wipe_clips() {
let mut a = anim(1, AnimClass::Entrance, "wipe", AnimStart::OnClick);
a.option = "l".into();
let tl = Timeline::new(&slide_with(vec![a]), W, H);
let c = tl.state(ShapeId(1), None, 0, 0.5).clip.unwrap();
assert!(c[0] == 0.0 && c[2] > 0.0 && c[2] < 1.0);
}
#[test]
fn repeat_and_auto_reverse_extend_duration() {
let mut a = anim(1, AnimClass::Emphasis, "pulse", AnimStart::OnClick);
a.repeat = 3;
a.auto_reverse = true;
let tl = Timeline::new(&slide_with(vec![a]), W, H);
assert!((tl.step_duration(0) - 6.0).abs() < 1e-9);
// Until next click: one iteration counts, keeps playing within the step.
let mut b = anim(2, AnimClass::Emphasis, "spin", AnimStart::OnClick);
b.repeat = u32::MAX;
let tl = Timeline::new(&slide_with(vec![b]), W, H);
assert!((tl.step_duration(0) - 1.0).abs() < 1e-9);
let s = tl.state(ShapeId(2), None, 0, 10.5);
assert!(s.rotate > 0.0 && s.rotate < 360.0);
}
#[test]
fn rewind_reverts_entrance() {
let mut a = anim(1, AnimClass::Entrance, "fade", AnimStart::OnClick);
a.rewind = true;
let tl = Timeline::new(&slide_with(vec![a]), W, H);
assert!(!tl.state(ShapeId(1), None, 1, 0.0).visible);
}
#[test]
fn after_hide_on_next_click() {
let mut a = anim(1, AnimClass::Entrance, "fade", AnimStart::OnClick);
a.after = Some("hideOnNextClick".into());
let tl = Timeline::new(&slide_with(vec![a, anim(2, AnimClass::Entrance, "fade", AnimStart::OnClick)]), W, H);
assert!(tl.state(ShapeId(1), None, 0, 5.0).visible);
assert!(!tl.state(ShapeId(1), None, 1, 0.0).visible);
}
#[test]
fn triggered_sequences() {
let mut a = anim(1, AnimClass::Entrance, "fade", AnimStart::OnClick);
a.trigger = Some(ShapeId(3));
let tl = Timeline::new(&slide_with(vec![anim(2, AnimClass::Entrance, "fade", AnimStart::OnClick), a]), W, H);
assert_eq!(tl.steps(), 1);
assert_eq!(tl.trigger_shapes(), vec![ShapeId(3)]);
assert_eq!(tl.trigger_steps(ShapeId(3)), 1);
// Not played: hidden even after the main sequence.
assert!(!tl.state(ShapeId(1), None, 1, 0.0).visible);
let tp = [TriggerPlay { trigger: ShapeId(3), step: 1, t: 0.0 }];
assert!(tl.state_with(ShapeId(1), None, 0, 0.0, &tp).visible);
}
#[test]
fn text_build_by_paragraph() {
let mut s = slide_with(vec![]);
s.shapes[0].text = Some(TextBody::from_text("one\ntwo\nthree"));
let mut a = anim(1, AnimClass::Entrance, "fade", AnimStart::OnClick);
a.text_build = TextBuild::ByParagraph;
s.animations = vec![a.clone()];
let tl = Timeline::new(&s, W, H);
let n = tl.paragraph_targets(ShapeId(1)).len();
assert_eq!(n, 3);
// On click: one click per paragraph.
assert_eq!(tl.steps(), n);
if n >= 2 {
assert!(tl.state(ShapeId(1), Some(0), 1, 0.0).visible);
assert!(!tl.state(ShapeId(1), Some(1), 1, 0.0).visible);
}
// With previous: staggered within one step.
a.start = AnimStart::WithPrevious;
s.animations = vec![a];
let tl = Timeline::new(&s, W, H);
assert_eq!(tl.steps(), 1);
let e = tl.effects();
if e.len() >= 2 {
assert!((e[1].start - e[0].start - 0.5).abs() < 1e-9);
}
// The shape itself isn't hidden.
assert!(tl.state(ShapeId(1), None, 0, 0.0).visible);
}
#[test]
fn hostile_times_and_durations() {
let mut a = anim(1, AnimClass::Entrance, "fly", AnimStart::OnClick);
a.duration_ms = u32::MAX;
a.delay_ms = u32::MAX;
a.repeat = u32::MAX - 1;
a.smooth_start = f64::NAN;
a.smooth_end = -5.0;
a.bounce_end = f64::INFINITY;
a.amount = Some(f64::NAN);
let mut b = anim(2, AnimClass::Emphasis, "growShrink", AnimStart::AfterPrevious);
b.amount = Some(1e300);
b.duration_ms = 0;
let tl = Timeline::new(&slide_with(vec![a, b, anim(99, AnimClass::Path, "customPath", AnimStart::OnClick)]), f64::NAN, -1.0);
for t in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, -1.0, 0.0, 1e12, 1e300] {
for step in [0, 1, 2, 3, usize::MAX] {
for id in [1, 2, 99] {
let s = tl.state(ShapeId(id), None, step, t);
assert!(finite(&s));
let s = tl.state(ShapeId(id), Some(usize::MAX), step, t);
assert!(finite(&s));
}
}
let _ = tl.locate(t);
let _ = tl.state_at(ShapeId(1), None, t);
}
assert!(tl.step_duration(usize::MAX) == 0.0);
}
#[test]
fn empty_slide() {
let tl = Timeline::new(&Slide::default(), W, H);
assert_eq!(tl.steps(), 0);
assert!(tl.is_empty());
assert!(tl.state(ShapeId(1), None, 0, 1.0).is_identity());
assert_eq!(tl.locate(5.0), (0, 0.0));
}
#[test]
fn locate_play_all_clock() {
let tl = Timeline::new(
&slide_with(vec![anim(1, AnimClass::Entrance, "fade", AnimStart::OnClick), anim(2, AnimClass::Entrance, "fade", AnimStart::OnClick)]),
W,
H,
);
assert_eq!(tl.locate(0.5), (0, 0.5));
assert_eq!(tl.locate(1.5), (1, 0.5));
assert_eq!(tl.locate(9.0), (2, 0.0));
assert!((tl.total_duration() - 2.0).abs() < 1e-9);
}
#[test]
fn motion_path_parse_and_eval() {
let p = MotionPath::parse("M 0 0 L 0.25 0 L 0.25 0.25 E");
assert!((p.length() - 0.5).abs() < 1e-9);
let (x, y) = p.at(0.5);
assert!((x - 0.25).abs() < 1e-9 && y.abs() < 1e-9);
let (x, y) = p.at(1.0);
assert!((x - 0.25).abs() < 1e-9 && (y - 0.25).abs() < 1e-9);
// Relative commands and curves.
let q = MotionPath::parse("m 0 0 l 0.1 0 c 0 0.1 0 0.1 0 0.2 z");
assert!(q.length() > 0.0);
let c = MotionPath::parse("M0,0C0.1,0,0.2,0.1,0.2,0.2E");
assert!(c.points.len() > 2);
let e = MotionPath::parse("M 0 0 L 1e-1 -2.5E-1 E");
let (x, y) = e.at(1.0);
assert!((x - 0.1).abs() < 1e-9 && (y + 0.25).abs() < 1e-9);
}
#[test]
fn malformed_paths_dont_panic() {
for s in [
"",
"E",
"M",
"M 0",
"L 1 1",
"C 1 2 3",
"M 0 0 L x y",
"M NaN 1 L inf 2",
"M 1e400 0 L 0 0",
"Z Z Z",
"M 0 0 Q",
"--..ee",
"M 0 0 H 1 V 1 h -1 v -1",
"😀 M 1 1",
"M 0 0 L 0.5 0.5 X 9 9",
] {
let p = MotionPath::parse(s);
for t in [f64::NAN, -1.0, 0.0, 0.5, 1.0, 2.0] {
let (x, y) = p.at(t);
assert!(x.is_finite() && y.is_finite(), "{s}");
}
}
let big = "L 1 1 ".repeat(50_000);
let p = MotionPath::parse(&big);
assert!(p.points.len() <= 20_000);
}
#[test]
fn preset_paths_move() {
for (id, _, class, _, _, opts) in ANIMATIONS.iter().filter(|a| a.2 == AnimClass::Path && a.0 != "customPath") {
let _ = class;
for o in opts.iter().copied().chain([""]) {
let p = MotionPath::parse(&default_path(id, o));
assert!(p.length() > 0.0, "{id} {o}");
}
}
let mut a = anim(1, AnimClass::Path, "lines", AnimStart::OnClick);
a.option = "right".into();
let tl = Timeline::new(&slide_with(vec![a]), W, H);
let s = tl.state(ShapeId(1), None, 1, 0.0);
assert!((s.offset_x - 0.25 * W).abs() < 1e-6 && s.offset_y.abs() < 1e-6);
}
#[test]
fn custom_path_offsets_in_points() {
let mut a = anim(1, AnimClass::Path, "customPath", AnimStart::OnClick);
a.path = Some("M 0 0 L 0.5 0.5 E".into());
let tl = Timeline::new(&slide_with(vec![a]), W, H);
let s = tl.state(ShapeId(1), None, 1, 0.0);
assert!((s.offset_x - 480.0).abs() < 1e-6 && (s.offset_y - 270.0).abs() < 1e-6);
}
#[test]
fn easing_endpoints() {
for (a, d, b) in [(0.0, 0.0, 0.0), (0.5, 0.5, 0.0), (0.3, 0.0, 0.0), (0.0, 0.7, 0.0), (0.9, 0.9, 0.0), (0.0, 0.0, 0.3)] {
assert!(ease(0.0, a, d, b).abs() < 1e-9, "{a} {d} {b}");
assert!((ease(1.0, a, d, b) - 1.0).abs() < 1e-9, "{a} {d} {b}");
assert!(ease(0.5, a, d, b).is_finite());
}
assert!(ease(f64::NAN, f64::NAN, f64::NAN, f64::NAN).is_finite());
}
fn covers_at_end(layers: &[Layer]) -> bool {
layers.iter().any(|l| {
l.source == Source::New
&& (l.alpha - 1.0).abs() < 1e-9
&& l.quad == [[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]]
&& l.uv == [[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]]
})
}
#[test]
fn every_transition_finite_and_complete() {
for (id, _, _, _, opts) in TRANSITIONS {
let mut options: Vec<&str> = opts.to_vec();
options.push("");
for o in options {
for t in [0.0, 0.01, 0.25, 0.5, 0.75, 0.99, 1.0] {
let ls = transition_layers(id, o, t);
assert!(!ls.is_empty(), "{id} {o} {t}");
assert!(ls.len() < 5000, "{id} {o} {t}");
for l in &ls {
assert!(l.alpha.is_finite() && (0.0..=1.0).contains(&l.alpha));
assert!(l.quad.iter().chain(l.uv.iter()).all(|p| p[0].is_finite() && p[1].is_finite()));
assert!(l.src.iter().all(|v| v.is_finite()));
}
}
assert!(covers_at_end(&transition_layers(id, o, 1.0)), "{id} {o}");
let start = transition_layers(id, o, 0.0);
assert!(start.iter().any(|l| l.source == Source::Old && l.alpha == 1.0), "{id} {o}");
}
}
}
#[test]
fn transitions_hostile_t() {
for (id, ..) in TRANSITIONS {
for t in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, -5.0, 1e12] {
let ls = transition_layers(id, "bogus-option", t);
assert!(!ls.is_empty());
}
}
assert!(!transition_layers("no-such-kind", "", 0.5).is_empty());
}
#[test]
fn fade_and_push_shapes() {
let ls = transition_layers("fade", "smoothly", 0.5);
assert_eq!(ls.len(), 2);
assert_eq!(ls[1].source, Source::New);
assert!((ls[1].alpha - 0.5).abs() < 1e-9);
let ls = transition_layers("push", "l", 0.5);
let new = ls.iter().find(|l| l.source == Source::New).unwrap();
// Pushing left: the new slide comes from the right.
assert!(new.quad[0][0] > 0.0);
let ls = transition_layers("fade", "throughBlack", 0.25);
assert!(ls.iter().all(|l| l.source != Source::New));
}
#[test]
fn mid_transitions_mix_sources() {
// Most transitions show something of both slides (or black/white) midway.
for (id, ..) in TRANSITIONS.iter().filter(|t| !matches!(t.0, "none" | "cut" | "flip")) {
let ls = transition_layers(id, "", 0.5);
assert!(ls.iter().any(|l| l.source != Source::New) || ls.len() > 1, "{id}");
}
}
#[test]
fn random_is_deterministic() {
assert_eq!(transition_layers("random", "x", 0.4), transition_layers("random", "x", 0.4));
assert_eq!(transition_layers("dissolve", "", 0.4), transition_layers("dissolve", "", 0.4));
}
#[test]
fn morph_pairs_by_name_text_and_geometry() {
let mut a = Slide::default();
let mut b = Slide::default();
a.shapes = vec![shape(1, "!!Logo"), shape(2, "Title 1"), shape(3, "x"), shape(4, "Rect A")];
b.shapes = vec![shape(14, "Rect B"), shape(13, "y"), shape(12, "Title 1"), shape(11, "!!Logo")];
a.shapes[2].text = Some(TextBody::from_text("Hello"));
b.shapes[1].text = Some(TextBody::from_text("Hello"));
let p = morph_pairs(&a, &b);
assert!(p.contains(&(ShapeId(1), ShapeId(11))));
assert!(p.contains(&(ShapeId(2), ShapeId(12))));
assert!(p.contains(&(ShapeId(3), ShapeId(13))));
assert!(p.contains(&(ShapeId(4), ShapeId(14))));
assert_eq!(p.len(), 4);
assert!(morph_pairs(&Slide::default(), &b).is_empty());
}
#[test]
fn morph_xfrm_interpolates() {
let a = Xfrm { rot: 350.0, ..Xfrm::new(0.0, 0.0, 100.0, 100.0) };
let b = Xfrm { rot: 10.0, ..Xfrm::new(100.0, 50.0, 200.0, 100.0) };
let m = morph_xfrm(a, b, 0.5);
assert!((m.x - 50.0).abs() < 1e-9 && (m.w - 150.0).abs() < 1e-9);
assert!(m.rot.abs() < 1e-9 || (m.rot - 360.0).abs() < 1e-9, "{}", m.rot);
assert_eq!(morph_xfrm(a, b, 1.0), b);
assert_eq!(morph_xfrm(a, b, f64::NAN), a);
let n = Xfrm { x: f64::NAN, rot: f64::INFINITY, ..Xfrm::default() };
let m = morph_xfrm(n, b, 0.3);
assert!(m.rot.is_finite());
}
fn pres(n: usize) -> Presentation {
Presentation { slides: (0..n).map(|i| Arc::new(Slide { id: SlideId(1000 + i as u32), ..Default::default() })).collect(), ..Default::default() }
}
#[test]
fn show_skips_hidden_slides() {
let mut p = pres(4);
Arc::make_mut(&mut p.slides[1]).hidden = true;
let mut s = ShowState::new(&p);
assert_eq!(s.slide, 0);
assert_eq!(s.next(&p), ShowAction::Slide(2));
assert_eq!(s.prev(&p), ShowAction::Slide(0));
// Jumping to a hidden slide shows it; next continues after it.
assert_eq!(s.goto(&p, 1), ShowAction::Slide(1));
assert_eq!(s.next(&p), ShowAction::Slide(2));
assert_eq!(s.next(&p), ShowAction::Slide(3));
assert_eq!(s.next(&p), ShowAction::End);
assert_eq!(s.next(&p), ShowAction::Exit);
assert_eq!(s.goto(&p, 99), ShowAction::None);
}
#[test]
fn show_loops() {
let mut p = pres(3);
p.show.loop_until_esc = true;
let mut s = ShowState::new(&p);
s.next(&p);
s.next(&p);
assert_eq!(s.next(&p), ShowAction::Slide(0));
assert_eq!(s.prev(&p), ShowAction::Slide(2));
}
#[test]
fn show_custom_show_and_range() {
let mut p = pres(5);
p.custom_shows = vec![CustomShow { name: "Short".into(), slides: vec![SlideId(1003), SlideId(1001)] }];
p.show.custom_show = Some("Short".into());
assert_eq!(show_order(&p), vec![3, 1]);
let mut s = ShowState::new(&p);
assert_eq!(s.slide, 3);
assert_eq!(s.next(&p), ShowAction::Slide(1));
p.show.custom_show = None;
p.show.range = Some((2, 4));
assert_eq!(show_order(&p), vec![1, 2, 3]);
p.show.range = Some((0, 99));
assert_eq!(show_order(&p), vec![0, 1, 2, 3, 4]);
}
#[test]
fn show_steps_through_builds() {
let mut p = pres(2);
Arc::make_mut(&mut p.slides[0]).shapes = vec![shape(1, "A")];
Arc::make_mut(&mut p.slides[0]).animations = vec![anim(1, AnimClass::Entrance, "fade", AnimStart::OnClick)];
let mut s = ShowState::new(&p);
assert!(!s.playing);
assert_eq!(s.next(&p), ShowAction::PlayStep(0));
assert!(s.playing);
assert_eq!(s.step_done(&p), ShowAction::FinishStep(0));
assert_eq!(s.step, 1);
assert_eq!(s.prev(&p), ShowAction::StepBack(0));
assert_eq!(s.next(&p), ShowAction::PlayStep(0));
assert_eq!(s.next(&p), ShowAction::FinishStep(0));
assert_eq!(s.next(&p), ShowAction::Slide(1));
// Back to a fully built previous slide.
assert_eq!(s.prev(&p), ShowAction::Slide(0));
assert_eq!(s.step, 1);
}
#[test]
fn show_auto_advance() {
let mut p = pres(2);
Arc::make_mut(&mut p.slides[0]).transition = Some(Transition { advance_after_ms: Some(2000), ..Default::default() });
let s = ShowState::new(&p);
assert_eq!(s.advance_after(&p), Some(2.0));
assert!(!s.auto_advance_due(&p, 1.0));
assert!(s.auto_advance_due(&p, 2.5));
assert!(!s.auto_advance_due(&p, f64::NAN));
p.show.use_timings = false;
assert!(!s.auto_advance_due(&p, 5.0));
}
#[test]
fn show_empty_and_all_hidden() {
let p = pres(0);
let mut s = ShowState::new(&p);
assert!(s.ended);
assert_eq!(s.next(&p), ShowAction::Exit);
let _ = s.prev(&p);
let mut p = pres(2);
for sl in &mut p.slides {
Arc::make_mut(sl).hidden = true;
}
let mut s = ShowState::new(&p);
assert!(s.ended);
let _ = s.next(&p);
let _ = s.prev(&p);
}
#[test]
fn compose_and_sanitize() {
let a = AnimState { opacity: 0.5, clip: Some([0.0, 0.0, 0.5, 1.0]), ..Default::default() };
let b = AnimState { opacity: 0.5, offset_x: 3.0, clip: Some([0.25, 0.0, 1.0, 1.0]), ..Default::default() };
let c = a.compose(&b);
assert!((c.opacity - 0.25).abs() < 1e-9 && c.offset_x == 3.0 && c.clip == Some([0.25, 0.0, 0.5, 1.0]));
let bad = AnimState { opacity: f64::NAN, rotate: f64::INFINITY, clip: Some([f64::NAN, 2.0, -1.0, 0.5]), ..Default::default() }.sanitized();
assert!(finite(&bad));
}
+476
View File
@@ -0,0 +1,476 @@
//! A slide's animation sequence: click steps, effect start times and per-shape state over time.
use slidecraft_model::anim::TextBuild;
use slidecraft_model::style::Fill;
use slidecraft_model::{AnimClass, AnimStart, Animation, ColorScheme, Rgba, SchemeSlot, ShapeId, Slide, Xfrm};
use crate::AnimState;
use crate::effects::{EffectSpec, exit_base, motion_path};
/// Delay between paragraphs of a by-paragraph build that isn't on click (seconds).
pub const PARAGRAPH_STAGGER: f64 = 0.5;
const MAX_EFFECTS: usize = 100_000;
const MAX_PARAGRAPHS: usize = 1_000;
const MAX_ITERATIONS: f64 = 1.0e6;
/// Which sequence an effect belongs to.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum Sequence {
/// The slide's main click sequence.
Main,
/// An interactive sequence started by clicking this shape.
Trigger(ShapeId),
}
/// Timing of one (sub-)effect, for the Animation Pane timeline and the UI.
#[derive(Clone, Debug, PartialEq)]
pub struct EffectTiming {
/// Index into `slide.animations`.
pub anim: usize,
/// Animated shape and paragraph (`None` = the whole shape).
pub shape: ShapeId,
pub paragraph: Option<usize>,
pub sequence: Sequence,
/// Click step within its sequence.
pub step: usize,
/// Start within the step (seconds, delay included).
pub start: f64,
/// One iteration (seconds).
pub duration: f64,
/// Repeat count (fractional allowed); 1 = once.
pub iterations: f64,
/// Repeats until the next click.
pub until_next_click: bool,
pub auto_reverse: bool,
pub rewind: bool,
/// Time from start to end (all iterations, reverse included; one iteration if until-click).
pub span: f64,
pub class: AnimClass,
}
/// Playhead of a triggered sequence: `step` clicks on the trigger fully played, the current one
/// playing for `t` seconds.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct TriggerPlay {
pub trigger: ShapeId,
pub step: usize,
pub t: f64,
}
#[derive(Clone, Debug, Default)]
struct StepInfo {
auto: bool,
duration: f64,
}
#[derive(Clone, Debug)]
enum After {
None,
Hide,
HideOnNextClick,
Dim(Rgba),
}
#[derive(Clone, Debug)]
struct Effect {
timing: EffectTiming,
spec: EffectSpec,
after: After,
}
/// A slide's animations laid out in time.
#[derive(Clone, Debug, Default)]
pub struct Timeline {
effects: Vec<Effect>,
main: Vec<StepInfo>,
triggers: Vec<(ShapeId, Vec<StepInfo>)>,
/// Base fill colour per shape (for colour emphasis).
colors: Vec<(ShapeId, Rgba)>,
}
enum Status {
NotStarted,
Active(f64),
Done,
}
fn secs(ms: u32) -> f64 {
ms as f64 / 1000.0
}
fn finite_or(v: f64, d: f64) -> f64 {
if v.is_finite() { v } else { d }
}
fn sanitize_xfrm(x: Xfrm, sw: f64, sh: f64) -> Xfrm {
Xfrm {
x: finite_or(x.x, 0.0).clamp(-1.0e7, 1.0e7),
y: finite_or(x.y, 0.0).clamp(-1.0e7, 1.0e7),
w: finite_or(x.w, sw / 2.0).clamp(0.0, 1.0e7),
h: finite_or(x.h, sh / 2.0).clamp(0.0, 1.0e7),
..x
}
}
fn default_scheme() -> Option<ColorScheme> {
slidecraft_model::theme::builtin_color_schemes().into_iter().next()
}
impl Timeline {
/// Build the timeline of `slide` (slide size in points). Placeholders without their own box
/// get a centred default box; colours resolve against the default theme.
pub fn new(slide: &Slide, slide_w: f64, slide_h: f64) -> Timeline {
Timeline::with(slide, slide_w, slide_h, None, &|_| None)
}
/// Build with a colour scheme for theme colours and a lookup for shape boxes (placeholders
/// inherit their box from the layout; the lookup wins over `shape.xfrm`).
pub fn with(slide: &Slide, slide_w: f64, slide_h: f64, scheme: Option<&ColorScheme>, xfrm_of: &dyn Fn(ShapeId) -> Option<Xfrm>) -> Timeline {
let sw = finite_or(slide_w, 960.0).clamp(1.0, 1.0e7);
let sh = finite_or(slide_h, 540.0).clamp(1.0, 1.0e7);
let owned;
let scheme = match scheme {
Some(s) => Some(s),
None => {
owned = default_scheme();
owned.as_ref()
}
};
let resolve = |c: &slidecraft_model::ColorRef| -> Rgba {
match scheme {
Some(s) => c.resolve(s, None),
None => match &c.base {
slidecraft_model::ColorBase::Rgb { rgb } => *rgb,
_ => Rgba::rgb(0x15, 0x60, 0x82),
},
}
};
let accent = scheme.map(|s| s.get(SchemeSlot::Accent1)).unwrap_or(Rgba::rgb(0x15, 0x60, 0x82));
let mut tl = Timeline::default();
// Sequences in order of first appearance: main first.
let mut seqs: Vec<Sequence> = vec![Sequence::Main];
for a in &slide.animations {
if let Some(tr) = a.trigger {
let s = Sequence::Trigger(tr);
if !seqs.contains(&s) {
seqs.push(s);
}
}
}
for seq in seqs {
let mut steps: Vec<StepInfo> = Vec::new();
let mut group_begin = 0.0f64;
let mut max_end = 0.0f64;
for (ai, a) in slide.animations.iter().enumerate() {
let aseq = a.trigger.map(Sequence::Trigger).unwrap_or(Sequence::Main);
if aseq != seq {
continue;
}
if tl.effects.len() >= MAX_EFFECTS {
break;
}
let shape = slide.shape(a.shape);
// Paragraph targets of this animation.
let paras: Vec<Option<usize>> = match (a.paragraph, a.text_build) {
(Some(p), _) => vec![Some(p as usize)],
(None, TextBuild::ByParagraph) => {
let v: Vec<Option<usize>> = shape
.and_then(|s| s.text.as_ref())
.map(|t| {
t.paragraphs
.iter()
.enumerate()
.filter(|(_, p)| p.runs.iter().any(|r| !r.text.trim().is_empty()))
.map(|(i, _)| Some(i))
.take(MAX_PARAGRAPHS)
.collect()
})
.unwrap_or_default();
if v.is_empty() { vec![None] } else { v }
}
_ => vec![None],
};
let xf = xfrm_of(a.shape).or_else(|| shape.and_then(|s| s.xfrm)).unwrap_or(Xfrm::new(sw / 4.0, sh / 4.0, sw / 2.0, sh / 2.0));
let xf = sanitize_xfrm(xf, sw, sh);
let spec = build_spec(a, xf, sw, sh, &resolve);
let dur = secs(a.duration_ms);
let delay = secs(a.delay_ms);
let until_next_click = a.repeat == u32::MAX;
let iterations = if until_next_click || a.repeat <= 1 { 1.0 } else { (a.repeat as f64).min(MAX_ITERATIONS) };
let span = dur * if a.auto_reverse { 2.0 } else { 1.0 } * iterations;
let after = match a.after.as_deref() {
Some("hide") => After::Hide,
Some("hideOnNextClick") => After::HideOnNextClick,
Some(c) => Rgba::from_hex(c).map(After::Dim).unwrap_or(After::None),
None => After::None,
};
let mut first_start = 0.0;
for (k, para) in paras.iter().enumerate() {
let mode = if k == 0 {
a.start
} else if a.start == AnimStart::OnClick {
AnimStart::OnClick
} else {
AnimStart::WithPrevious
};
let first_of_seq = steps.is_empty();
let mode = if first_of_seq && matches!(seq, Sequence::Trigger(_)) { AnimStart::OnClick } else { mode };
match mode {
AnimStart::OnClick => {
steps.push(StepInfo { auto: false, duration: 0.0 });
group_begin = 0.0;
max_end = 0.0;
}
AnimStart::WithPrevious => {
if first_of_seq {
steps.push(StepInfo { auto: true, duration: 0.0 });
}
}
AnimStart::AfterPrevious => {
if first_of_seq {
steps.push(StepInfo { auto: true, duration: 0.0 });
}
group_begin = max_end;
}
}
let start = if k > 0 && a.start != AnimStart::OnClick { first_start + k as f64 * PARAGRAPH_STAGGER } else { group_begin + delay };
if k == 0 {
first_start = start;
}
let end = start + span;
max_end = max_end.max(end);
let step = steps.len().saturating_sub(1);
if let Some(s) = steps.last_mut() {
s.duration = s.duration.max(end);
}
tl.effects.push(Effect {
timing: EffectTiming {
anim: ai,
shape: a.shape,
paragraph: *para,
sequence: seq,
step,
start,
duration: dur,
iterations,
until_next_click,
auto_reverse: a.auto_reverse,
rewind: a.rewind,
span,
class: a.class,
},
spec: spec.clone(),
after: after.clone(),
});
}
}
match seq {
Sequence::Main => tl.main = steps,
Sequence::Trigger(s) => tl.triggers.push((s, steps)),
}
}
// Base colours of animated shapes.
for e in &tl.effects {
let id = e.timing.shape;
if tl.colors.iter().any(|(s, _)| *s == id) {
continue;
}
let c = match slide.shape(id).and_then(|s| s.fill.as_ref()) {
Some(Fill::Solid { color }) => resolve(color),
_ => accent,
};
tl.colors.push((id, c));
}
tl
}
/// Number of steps in the main sequence (clicks, plus an automatic first step when the first
/// effect starts with/after previous).
pub fn steps(&self) -> usize {
self.main.len()
}
/// Does main step `i` start by itself (no click), as the slide appears?
pub fn step_is_auto(&self, i: usize) -> bool {
self.main.get(i).is_some_and(|s| s.auto)
}
/// Duration of main step `i` in seconds (0 when out of range).
pub fn step_duration(&self, i: usize) -> f64 {
self.main.get(i).map(|s| s.duration).unwrap_or(0.0)
}
/// Total duration of the main sequence played without pauses.
pub fn total_duration(&self) -> f64 {
self.main.iter().map(|s| s.duration).sum()
}
/// Map a time on the "play everything" clock to `(step, t)` for [`Timeline::state`].
pub fn locate(&self, time: f64) -> (usize, f64) {
let mut t = finite_or(time, 0.0).max(0.0);
for (i, s) in self.main.iter().enumerate() {
if t < s.duration || (t == 0.0 && s.duration == 0.0 && i == 0) {
return (i, t);
}
t -= s.duration;
}
(self.main.len(), 0.0)
}
/// Shapes that start an interactive sequence when clicked.
pub fn trigger_shapes(&self) -> Vec<ShapeId> {
self.triggers.iter().map(|(s, _)| *s).collect()
}
/// Steps (clicks on the trigger) of a triggered sequence.
pub fn trigger_steps(&self, trigger: ShapeId) -> usize {
self.triggers.iter().find(|(s, _)| *s == trigger).map(|(_, v)| v.len()).unwrap_or(0)
}
pub fn trigger_step_duration(&self, trigger: ShapeId, i: usize) -> f64 {
self.triggers.iter().find(|(s, _)| *s == trigger).and_then(|(_, v)| v.get(i)).map(|s| s.duration).unwrap_or(0.0)
}
/// All effect timings in sequence order.
pub fn effects(&self) -> Vec<EffectTiming> {
self.effects.iter().map(|e| e.timing.clone()).collect()
}
/// Is anything animated on this slide?
pub fn is_empty(&self) -> bool {
self.effects.is_empty()
}
/// Paragraph indices of `shape` animated separately (by-paragraph builds).
pub fn paragraph_targets(&self, shape: ShapeId) -> Vec<usize> {
let mut v: Vec<usize> = self.effects.iter().filter(|e| e.timing.shape == shape).filter_map(|e| e.timing.paragraph).collect();
v.sort_unstable();
v.dedup();
v
}
/// Shapes with any effect.
pub fn animated_shapes(&self) -> Vec<ShapeId> {
let mut v: Vec<ShapeId> = Vec::new();
for e in &self.effects {
if !v.contains(&e.timing.shape) {
v.push(e.timing.shape);
}
}
v
}
/// State of `shape` (or of one of its paragraphs, `para`) when `step` main steps have been
/// fully played and step `step` has been playing for `t` seconds (`t <= 0`: not started;
/// `step >= steps()`: everything done). Paragraph states are relative to the shape's own state
/// (compose them). Triggered sequences are considered not played.
pub fn state(&self, shape: ShapeId, para: Option<usize>, step: usize, t: f64) -> AnimState {
self.state_with(shape, para, step, t, &[])
}
/// [`Timeline::state`] with the playheads of triggered sequences.
pub fn state_with(&self, shape: ShapeId, para: Option<usize>, step: usize, t: f64, triggers: &[TriggerPlay]) -> AnimState {
let mine = self.effects.iter().filter(|e| e.timing.shape == shape && e.timing.paragraph == para);
let base_color = self.colors.iter().find(|(s, _)| *s == shape).map(|(_, c)| *c).unwrap_or(Rgba::rgb(0x15, 0x60, 0x82));
let mut st = AnimState::default();
// Initial visibility comes from the first effect in the Animation Pane order.
let mut ordered: Vec<&Effect> = mine.collect();
ordered.sort_by_key(|e| e.timing.anim);
if let Some(e0) = ordered.first()
&& e0.timing.class == AnimClass::Entrance
{
st.visible = false;
}
// Then play in time order: main sequence first, then triggered sequences.
for e in &ordered {
let (pstep, pt) = match e.timing.sequence {
Sequence::Main => (step, t),
Sequence::Trigger(tr) => triggers.iter().find(|p| p.trigger == tr).map(|p| (p.step, p.t)).unwrap_or((0, 0.0)),
};
let cur = st.tint.unwrap_or(base_color);
let revert = e.timing.rewind || e.timing.auto_reverse;
match status(&e.timing, pstep, pt) {
Status::NotStarted => {}
Status::Active(p) => {
if e.timing.class == AnimClass::Entrance || e.timing.class == AnimClass::Exit {
st.visible = true;
}
st = st.compose(&e.spec.eval(p, cur));
}
Status::Done => {
match e.timing.class {
AnimClass::Entrance => st.visible = !revert,
AnimClass::Exit => st.visible = revert,
AnimClass::Emphasis | AnimClass::Path if !revert => {
let end = e.spec.end_state(cur);
st = st.compose(&end);
}
_ => {}
}
match e.after {
After::Hide => st.visible = false,
After::HideOnNextClick if pstep > e.timing.step => st.visible = false,
After::Dim(c) if pstep > e.timing.step => st.tint = Some(c),
_ => {}
}
}
}
}
st.sanitized()
}
/// State on the "play everything" clock (Animation Pane ▸ Play All, preview).
pub fn state_at(&self, shape: ShapeId, para: Option<usize>, time: f64) -> AnimState {
let (s, t) = self.locate(time);
self.state(shape, para, s, t)
}
}
fn status(e: &EffectTiming, step: usize, t: f64) -> Status {
if e.step < step {
return Status::Done;
}
if e.step > step {
return Status::NotStarted;
}
let t = finite_or(t, 0.0);
if t <= 0.0 {
return Status::NotStarted;
}
let local = t - e.start;
if local < 0.0 {
return Status::NotStarted;
}
let dur = e.duration;
if dur.is_nan() || dur <= 0.0 || !dur.is_finite() {
return Status::Done;
}
if !e.until_next_click && local >= e.span {
return Status::Done;
}
let period = if e.auto_reverse { 2.0 * dur } else { dur };
let c = local % period;
let c = if c.is_finite() { c } else { 0.0 };
let p = if c > dur { 2.0 - c / dur } else { c / dur };
Status::Active(p.clamp(0.0, 1.0))
}
fn build_spec(a: &Animation, xf: Xfrm, sw: f64, sh: f64, resolve: &dyn Fn(&slidecraft_model::ColorRef) -> Rgba) -> EffectSpec {
let effect = if a.class == AnimClass::Exit { exit_base(&a.effect).to_string() } else { a.effect.clone() };
let path = if a.class == AnimClass::Path { Some(motion_path(&a.effect, &a.option, a.path.as_deref())) } else { None };
EffectSpec {
class: a.class,
effect,
option: a.option.clone(),
accel: a.smooth_start,
decel: a.smooth_end,
bounce: a.bounce_end,
amount: a.amount,
color: a.color.as_ref().map(resolve),
path,
xf,
slide_w: sw,
slide_h: sh,
}
}
+907
View File
@@ -0,0 +1,907 @@
//! Slide transitions as layers of textured quads.
//!
//! A frame is drawn over black: each [`Layer`] maps part of a source image (old slide, new slide,
//! or a solid colour) onto a quad in slide-unit space (0..1, y down). Fake 3-D comes from
//! trapezoids; wedges are triangles (4th corner repeats the 3rd).
use std::f64::consts::PI;
use serde::{Deserialize, Serialize};
use crate::clampf;
use crate::easing::smooth;
/// What a layer shows.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum Source {
/// The outgoing slide.
Old,
/// The incoming slide.
New,
Black,
White,
}
/// One textured quad of a transition frame.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct Layer {
pub source: Source,
/// Source sub-rectangle in uv space (0..1): l, t, r, b (bounds of `uv`).
pub src: [f64; 4],
/// Destination corners in slide-unit space: tl, tr, br, bl.
pub quad: [[f64; 2]; 4],
/// Texture coordinate of each destination corner (tl, tr, br, bl). For plain rectangles these
/// are the corners of `src`; triangles and mirrored flaps carry their own. Draw with these.
pub uv: [[f64; 2]; 4],
pub alpha: f64,
}
type R = [f64; 4];
const FULL: R = [0.0, 0.0, 1.0, 1.0];
fn corners(r: R) -> [[f64; 2]; 4] {
[[r[0], r[1]], [r[2], r[1]], [r[2], r[3]], [r[0], r[3]]]
}
impl Layer {
/// The whole source over the whole slide.
pub fn full(source: Source, alpha: f64) -> Layer {
Layer::rect(source, FULL, FULL, alpha)
}
/// `src` rectangle of the source drawn into the `dst` rectangle.
pub fn rect(source: Source, src: R, dst: R, alpha: f64) -> Layer {
Layer { source, src, quad: corners(dst), uv: corners(src), alpha }
}
/// `src` rectangle of the source drawn onto a quad.
pub fn quad(source: Source, src: R, quad: [[f64; 2]; 4], alpha: f64) -> Layer {
Layer { source, src, quad, uv: corners(src), alpha }
}
/// The part of the source under `dst` drawn in place.
fn inplace(source: Source, dst: R, alpha: f64) -> Layer {
Layer::rect(source, dst, dst, alpha)
}
/// A triangle of the source drawn in place.
fn tri(source: Source, a: [f64; 2], b: [f64; 2], c: [f64; 2], alpha: f64) -> Layer {
let xs = [a[0], b[0], c[0]];
let ys = [a[1], b[1], c[1]];
let mn = |v: [f64; 3]| v.iter().copied().fold(f64::INFINITY, f64::min);
let mx = |v: [f64; 3]| v.iter().copied().fold(f64::NEG_INFINITY, f64::max);
Layer { source, src: [mn(xs), mn(ys), mx(xs), mx(ys)], quad: [a, b, c, c], uv: [a, b, c, c], alpha }
}
fn sanitized(mut self) -> Layer {
let f = |v: f64| if v.is_finite() { v.clamp(-1.0e6, 1.0e6) } else { 0.0 };
for v in self.src.iter_mut() {
*v = f(*v).clamp(0.0, 1.0);
}
for c in self.quad.iter_mut().chain(self.uv.iter_mut()) {
c[0] = f(c[0]);
c[1] = f(c[1]);
}
for c in self.uv.iter_mut() {
c[0] = c[0].clamp(0.0, 1.0);
c[1] = c[1].clamp(0.0, 1.0);
}
self.alpha = clampf(self.alpha, 0.0, 1.0, 0.0);
self
}
}
// ---------- geometry helpers ----------
fn offset(r: R, dx: f64, dy: f64) -> R {
[r[0] + dx, r[1] + dy, r[2] + dx, r[3] + dy]
}
fn moved(source: Source, dx: f64, dy: f64, alpha: f64) -> Layer {
Layer::rect(source, FULL, offset(FULL, dx, dy), alpha)
}
/// The whole source scaled about (cx, cy) and moved.
fn scaled(source: Source, s: f64, cx: f64, cy: f64, dx: f64, dy: f64, alpha: f64) -> Layer {
let r = [cx + (0.0 - cx) * s + dx, cy + (0.0 - cy) * s + dy, cx + (1.0 - cx) * s + dx, cy + (1.0 - cy) * s + dy];
Layer::rect(source, FULL, r, alpha)
}
/// Rotate a point about a pivot (degrees).
fn rot(p: [f64; 2], c: [f64; 2], deg: f64) -> [f64; 2] {
let (s, co) = deg.to_radians().sin_cos();
let (x, y) = (p[0] - c[0], p[1] - c[1]);
[c[0] + x * co - y * s, c[1] + x * s + y * co]
}
fn rot_quad(q: [[f64; 2]; 4], c: [f64; 2], deg: f64) -> [[f64; 2]; 4] {
[rot(q[0], c, deg), rot(q[1], c, deg), rot(q[2], c, deg), rot(q[3], c, deg)]
}
/// Horizontal perspective: left/right edges shortened by `li`/`ri` (fraction of height, each end).
fn persp_h(r: R, li: f64, ri: f64) -> [[f64; 2]; 4] {
let h = r[3] - r[1];
[[r[0], r[1] + li * h], [r[2], r[1] + ri * h], [r[2], r[3] - ri * h], [r[0], r[3] - li * h]]
}
/// Vertical perspective: top/bottom edges shortened by `ti`/`bi` (fraction of width, each end).
fn persp_v(r: R, ti: f64, bi: f64) -> [[f64; 2]; 4] {
let w = r[2] - r[0];
[[r[0] + ti * w, r[1]], [r[2] - ti * w, r[1]], [r[2] - bi * w, r[3]], [r[0] + bi * w, r[3]]]
}
/// Mirror a rectangle horizontally and/or vertically, or swap its axes (canonical → direction).
fn map_rect(r: R, mx: bool, my: bool, swap: bool) -> R {
let mut r = if swap { [r[1], r[0], r[3], r[2]] } else { r };
if mx {
r = [1.0 - r[2], r[1], 1.0 - r[0], r[3]];
}
if my {
r = [r[0], 1.0 - r[3], r[2], 1.0 - r[1]];
}
r
}
/// Motion direction of an option: l, r, u, d and diagonals.
fn dir(opt: &str, default: (f64, f64)) -> (f64, f64) {
let mut d = (0.0, 0.0);
if opt.contains('l') {
d.0 = -1.0;
}
if opt.contains('r') {
d.0 = 1.0;
}
if opt.contains('u') {
d.1 = -1.0;
}
if opt.contains('d') {
d.1 = 1.0;
}
if d == (0.0, 0.0) { default } else { d }
}
fn hash64(mut x: u64) -> u64 {
x = x.wrapping_add(0x9E37_79B9_7F4A_7C15);
x = (x ^ (x >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
x = (x ^ (x >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
x ^ (x >> 31)
}
/// Deterministic pseudo-random value in [0, 1).
fn rnd(i: u64) -> f64 {
(hash64(i) >> 11) as f64 / (1u64 << 53) as f64
}
fn ramp(t: f64, order: f64, window: f64) -> f64 {
let w = window.clamp(0.01, 1.0);
((t - order * (1.0 - w)) / w).clamp(0.0, 1.0)
}
fn grid(cols: usize, rows: usize) -> Vec<(usize, usize, R)> {
let mut v = Vec::with_capacity(cols * rows);
for r in 0..rows {
for c in 0..cols {
let cell = [c as f64 / cols as f64, r as f64 / rows as f64, (c + 1) as f64 / cols as f64, (r + 1) as f64 / rows as f64];
v.push((c, r, cell));
}
}
v
}
fn center(r: R) -> [f64; 2] {
[(r[0] + r[2]) / 2.0, (r[1] + r[3]) / 2.0]
}
fn scale_rect(r: R, s: f64) -> R {
let c = center(r);
let hw = (r[2] - r[0]) / 2.0 * s;
let hh = (r[3] - r[1]) / 2.0 * s;
[c[0] - hw, c[1] - hh, c[0] + hw, c[1] + hh]
}
// ---------- effects ----------
/// Layers of the transition `kind` (a [`slidecraft_model::anim::TRANSITIONS`] id) with `option` at
/// progress `t` (0..1). Draw in order over black. At `t >= 1` the result is the new slide alone;
/// at `t <= 0` (or NaN) the old slide alone. Morph returns a plain crossfade (the UI renders morph
/// frames with [`crate::morph_pairs`]).
pub fn transition_layers(kind: &str, option: &str, t: f64) -> Vec<Layer> {
let t = clampf(t, 0.0, 1.0, 0.0);
let v = if t >= 1.0 {
vec![Layer::full(Source::New, 1.0)]
} else if t <= 0.0 {
vec![Layer::full(Source::Old, 1.0)]
} else {
layers(kind, option, t, 0)
};
v.into_iter().map(Layer::sanitized).filter(|l| l.alpha > 0.0).collect()
}
fn crossfade(t: f64) -> Vec<Layer> {
vec![Layer::full(Source::Old, 1.0), Layer::full(Source::New, t)]
}
const RANDOM_KINDS: &[&str] = &[
"fade",
"push",
"wipe",
"split",
"reveal",
"randomBar",
"shape",
"uncover",
"cover",
"fallOver",
"drape",
"curtains",
"wind",
"prestige",
"fracture",
"crush",
"peelOff",
"pageCurlDouble",
"airplane",
"origami",
"dissolve",
"checker",
"blinds",
"clock",
"ripple",
"honeycomb",
"glitter",
"vortex",
"shred",
"switch",
"flip",
"gallery",
"cube",
"doors",
"box",
"comb",
"zoom",
"pan",
"ferris",
"conveyor",
"rotate",
"window",
"orbit",
"flythrough",
];
fn layers(kind: &str, option: &str, t: f64, depth: u32) -> Vec<Layer> {
use Source::{Black, New, Old, White};
let o = option;
let s = smooth(t);
match kind {
"none" => vec![Layer::full(New, 1.0)],
"cut" => {
if o == "throughBlack" && t < 0.5 {
vec![Layer::full(Black, 1.0)]
} else {
vec![Layer::full(New, 1.0)]
}
}
"fade" if o == "throughBlack" => {
if t < 0.5 {
vec![Layer::full(Black, 1.0), Layer::full(Old, 1.0 - 2.0 * t)]
} else {
vec![Layer::full(Black, 1.0), Layer::full(New, 2.0 * t - 1.0)]
}
}
"fade" | "morph" => crossfade(t),
"push" | "pan" => {
let (dx, dy) = dir(o, (0.0, -1.0));
vec![moved(Old, dx * s, dy * s, 1.0), moved(New, dx * (s - 1.0), dy * (s - 1.0), 1.0)]
}
"uncover" => {
let (dx, dy) = dir(o, (-1.0, 0.0));
vec![Layer::full(New, 1.0), moved(Old, dx * s, dy * s, 1.0)]
}
"cover" => {
let (dx, dy) = dir(o, (-1.0, 0.0));
vec![Layer::full(Old, 1.0), moved(New, dx * (s - 1.0), dy * (s - 1.0), 1.0)]
}
"wipe" => wipe(o, t),
"split" => {
let horz = o.starts_with("horz");
let mut v = vec![Layer::full(Old, 1.0)];
let rects: Vec<R> = if o.ends_with("In") {
vec![[0.0, 0.0, s / 2.0, 1.0], [1.0 - s / 2.0, 0.0, 1.0, 1.0]]
} else {
vec![[0.5 - s / 2.0, 0.0, 0.5 + s / 2.0, 1.0]]
};
for r in rects {
v.push(Layer::inplace(New, map_rect(r, false, false, horz), 1.0));
}
v
}
"reveal" => {
let dx = if o.ends_with("Right") { 1.0 } else { -1.0 };
if o.starts_with("black") {
if t < 0.5 {
vec![Layer::full(Black, 1.0), moved(Old, dx * t * 0.4, 0.0, 1.0 - 2.0 * t)]
} else {
vec![Layer::full(Black, 1.0), moved(New, -dx * (1.0 - t) * 0.2, 0.0, 2.0 * t - 1.0)]
}
} else {
vec![Layer::full(New, 1.0), moved(Old, dx * s * 0.6, 0.0, 1.0 - t)]
}
}
"randomBar" => {
let horz = o == "horz";
let n = 48;
let mut v = vec![Layer::full(Old, 1.0)];
for i in 0..n {
if t > rnd(i as u64 + 17) * 0.98 {
let r = [i as f64 / n as f64, 0.0, (i + 1) as f64 / n as f64, 1.0];
v.push(Layer::inplace(New, map_rect(r, false, false, horz), 1.0));
}
}
v
}
"shape" => {
let form = match o {
"circle" | "diamond" | "plus" => o,
_ => "box",
};
let inward = o == "in";
if inward {
let mut v = vec![Layer::full(New, 1.0)];
v.extend(shape_form(Old, form, 1.0 - s));
v
} else {
let mut v = vec![Layer::full(Old, 1.0)];
v.extend(shape_form(New, form, s));
v
}
}
"flash" => {
if t < 0.5 {
vec![Layer::full(Old, 1.0), Layer::full(White, (2.0 * t).min(1.0))]
} else {
vec![Layer::full(New, 1.0), Layer::full(White, 2.0 - 2.0 * t)]
}
}
"fallOver" => {
let sg = if o == "r" { 1.0 } else { -1.0 };
let a = t * t * PI / 2.0;
let top = 1.0 - a.cos();
let ins = 0.12 * a.sin();
let sh = sg * 0.15 * a.sin();
let q = [[ins + sh, top], [1.0 - ins + sh, top], [1.0, 1.0], [0.0, 1.0]];
vec![Layer::full(New, 1.0), Layer::quad(Old, FULL, q, 1.0 - t.powi(3))]
}
"drape" => {
let k = t * t;
let q = [[0.0, 0.0], [1.0 - 0.3 * k, k], [1.0 - 0.3 * k, 1.0 + k], [0.0, 1.0]];
let q = if o == "r" { mirror_q(q) } else { q };
vec![Layer::full(New, 1.0), Layer::quad(Old, FULL, q, 1.0 - k)]
}
"curtains" => {
let w = 0.5 * (1.0 - s);
let lq = [[0.0, 0.0], [w, 0.12 * s], [w, 1.0 - 0.12 * s], [0.0, 1.0]];
let rq = [[1.0 - w, 0.12 * s], [1.0, 0.0], [1.0, 1.0], [1.0 - w, 1.0 - 0.12 * s]];
vec![
scaled(New, 0.9 + 0.1 * s, 0.5, 0.5, 0.0, 0.0, 1.0),
Layer::quad(Old, [0.0, 0.0, 0.5, 1.0], lq, 1.0),
Layer::quad(Old, [0.5, 0.0, 1.0, 1.0], rq, 1.0),
]
}
"wind" => {
let d = if o == "r" { 1.0 } else { -1.0 };
let r = offset(FULL, d * 1.2 * s, -0.1 * s);
let q = [[r[0] + d * 0.2 * s, r[1]], [r[2] + d * 0.2 * s, r[1]], [r[2], r[3]], [r[0], r[3]]];
vec![moved(New, -d * 0.3 * (1.0 - s), 0.0, smooth((t * 1.5).min(1.0))), Layer::quad(Old, FULL, q, 1.0 - t)]
}
"prestige" => vec![scaled(Old, 1.0 - 0.3 * s, 0.5, 0.5, 0.0, 0.2 * s, 1.0 - t), scaled(New, 0.7 + 0.3 * s, 0.5, 0.5, 0.0, 0.0, s)],
"fracture" => {
let mut v = vec![Layer::full(New, 1.0)];
for (c, r, cell) in grid(8, 6) {
let i = (r * 8 + c) as u64;
let q = ramp(t, rnd(i + 101) * 0.5, 0.5);
let cc = center(cell);
let (dx, dy) = ((cc[0] - 0.5) * 1.5 * q, (cc[1] - 0.5) * 1.5 * q + 0.5 * q * q);
let quad = rot_quad(corners(offset(cell, dx, dy)), [cc[0] + dx, cc[1] + dy], (rnd(i + 7) - 0.5) * 180.0 * q);
v.push(Layer::quad(Old, cell, quad, 1.0 - q));
}
v
}
"crush" => {
let mut v = vec![Layer::full(New, 1.0)];
for (c, r, cell) in grid(4, 4) {
let i = (r * 4 + c) as u64;
let cc = center(cell);
let k = 1.0 - s;
let pos = [0.5 + (cc[0] - 0.5) * k, 0.5 + (cc[1] - 0.5) * k];
let half = scale_rect(cell, k * (0.8 + 0.2 * rnd(i + 3)));
let half = offset(half, pos[0] - cc[0], pos[1] - cc[1]);
let quad = rot_quad(corners(half), pos, (rnd(i + 5) - 0.5) * 60.0 * s);
v.push(Layer::quad(Old, cell, quad, 1.0 - t * t));
}
v
}
"peelOff" => {
let mut v = vec![Layer::full(New, 1.0)];
v.extend(peel(Old, [0.0, 0.0, 1.0, 1.0], s, o == "r"));
v
}
"pageCurlDouble" => {
let mut v = vec![Layer::full(New, 1.0)];
let left_first = o != "r";
v.extend(peel(Old, [0.0, 0.0, 0.5, 1.0], s, !left_first));
v.extend(peel(Old, [0.5, 0.0, 1.0, 1.0], s, left_first));
v
}
"airplane" => {
let d = if o == "r" { 1.0 } else { -1.0 };
let k = 1.0 - 0.8 * s;
let r = offset(scale_rect(FULL, k), d * 1.2 * s * s, -0.5 * s * s);
let q = persp_h(r, if d > 0.0 { 0.3 * s } else { 0.0 }, if d > 0.0 { 0.0 } else { 0.3 * s });
vec![Layer::full(New, 1.0), Layer::quad(Old, FULL, q, 1.0 - t * t)]
}
"origami" => {
let d = if o == "r" { 1.0 } else { -1.0 };
let mut v = vec![Layer::full(New, 1.0)];
if t < 0.5 {
let f = t * 2.0;
let w = 0.5 * (1.0 - 0.8 * f);
v.push(Layer::quad(Old, [0.0, 0.0, 0.5, 1.0], persp_h([0.5 - w, 0.0, 0.5, 1.0], 0.15 * f, 0.0), 1.0));
v.push(Layer::quad(Old, [0.5, 0.0, 1.0, 1.0], persp_h([0.5, 0.0, 0.5 + w, 1.0], 0.0, 0.15 * f), 1.0));
} else {
let f = (t - 0.5) * 2.0;
let r = offset(scale_rect([0.4, 0.0, 0.6, 1.0], 1.0 - 0.6 * f), d * f * 0.8, -0.3 * f);
v.push(Layer::quad(Old, FULL, persp_v(r, 0.1, 0.0), 1.0 - f));
}
v
}
"dissolve" => {
let mut v = vec![Layer::full(Old, 1.0)];
for (c, r, cell) in grid(32, 18) {
if t > rnd((r * 32 + c) as u64 + 991) * 0.98 {
v.push(Layer::inplace(New, cell, 1.0));
}
}
v
}
"checker" => {
let vert = o == "vert";
let (n, m) = (10usize, 8usize);
let mut v = vec![Layer::full(Old, 1.0)];
for (c, r, cell) in grid(n, m) {
let across = if vert { c } else { r };
let f = (t * 2.0 - (across % 2) as f64 * 0.5).clamp(0.0, 1.0);
if f <= 0.0 {
continue;
}
let rc = if vert {
[cell[0], cell[1], cell[2], cell[1] + (cell[3] - cell[1]) * f]
} else {
[cell[0], cell[1], cell[0] + (cell[2] - cell[0]) * f, cell[3]]
};
v.push(Layer::inplace(New, rc, 1.0));
}
v
}
"blinds" => {
let horz = o == "horz";
let n = 8;
let mut v = vec![Layer::full(Old, 1.0)];
for i in 0..n {
let x0 = i as f64 / n as f64;
let r = [x0, 0.0, x0 + s / n as f64, 1.0];
v.push(Layer::inplace(New, map_rect(r, false, false, horz), 1.0));
}
v
}
"clock" => clock(o, s),
"ripple" => {
let origin = match o {
"lu" => [0.0, 0.0],
"ru" => [1.0, 0.0],
"ld" => [0.0, 1.0],
"rd" => [1.0, 1.0],
_ => [0.5, 0.5],
};
let maxd = if origin == [0.5, 0.5] { 0.71 } else { 1.42 };
let mut v = vec![Layer::full(Old, 1.0)];
for (_, _, cell) in grid(24, 14) {
let cc = center(cell);
let d = ((cc[0] - origin[0]).powi(2) + (cc[1] - origin[1]).powi(2)).sqrt() / maxd;
let a = ramp(t, d.min(1.0), 0.3);
if a > 0.0 {
let sc = 1.0 + 0.15 * (a * PI).sin();
v.push(Layer::rect(New, cell, scale_rect(cell, sc), a));
}
}
v
}
"honeycomb" => {
let (cols, rows) = (12usize, 8usize);
let mut v = vec![Layer::full(Old, 1.0)];
for c in 0..cols {
let odd = c % 2 == 1;
let extra = if odd { 1 } else { 0 };
for r in 0..rows + extra {
let y0 = (r as f64 - if odd { 0.5 } else { 0.0 }) / rows as f64;
let cell = [c as f64 / cols as f64, y0.max(0.0), (c + 1) as f64 / cols as f64, (y0 + 1.0 / rows as f64).min(1.0)];
let a = ramp(t, rnd((c * 64 + r) as u64 + 4242), 0.35);
if a > 0.0 {
v.push(Layer::rect(New, cell, scale_rect(cell, 0.3 + 0.7 * a), a));
}
}
}
v
}
"glitter" => {
let (dx, dy) = dir(o, (-1.0, 0.0));
let mut v = vec![Layer::full(Old, 1.0)];
for (c, r, cell) in grid(20, 12) {
let cc = center(cell);
let grad = if dx < 0.0 {
1.0 - cc[0]
} else if dx > 0.0 {
cc[0]
} else if dy < 0.0 {
1.0 - cc[1]
} else {
cc[1]
};
let order = 0.6 * grad + 0.4 * rnd((r * 20 + c) as u64 + 77);
let a = ramp(t, order, 0.25);
if a > 0.0 {
let quad = rot_quad(corners(cell), cc, (1.0 - a) * 90.0);
v.push(Layer::quad(New, cell, quad, a));
}
}
v
}
"vortex" => {
let (dx, dy) = dir(o, (-1.0, 0.0));
let mut v = vec![Layer::full(New, 1.0)];
for (c, r, cell) in grid(16, 9) {
let i = (r * 16 + c) as u64;
let cc = center(cell);
let grad = 0.5 + 0.5 * ((cc[0] - 0.5) * -dx + (cc[1] - 0.5) * -dy);
let q = ramp(t, 0.5 * grad + 0.5 * rnd(i + 31), 0.4);
let ang = q * 2.0 * PI + rnd(i) * PI;
let rad = q * 1.2;
let (ox, oy) = (dx * q * 0.8 + rad * ang.cos() * 0.3, dy * q * 0.8 + rad * ang.sin() * 0.3);
let pos = [cc[0] + ox, cc[1] + oy];
let quad = rot_quad(corners(offset(scale_rect(cell, 1.0 - 0.5 * q), ox, oy)), pos, q * 360.0);
v.push(Layer::quad(Old, cell, quad, 1.0 - q));
}
v
}
"shred" => {
let mut v = vec![Layer::full(New, 1.0)];
if o == "particles" {
for (c, r, cell) in grid(24, 14) {
let i = (r * 24 + c) as u64;
let q = ramp(t, rnd(i + 555) * 0.6 + 0.4 * (1.0 - center(cell)[1]), 0.4);
let dy = q * q * 1.2;
let dx = (rnd(i + 9) - 0.5) * 0.3 * q;
v.push(Layer::rect(Old, cell, offset(scale_rect(cell, 1.0 - 0.6 * q), dx, dy), 1.0 - q));
}
} else {
let n = 16;
for i in 0..n {
let x0 = i as f64 / n as f64;
let x1 = (i + 1) as f64 / n as f64;
let q = ramp(t, i as f64 / n as f64 * 0.3 + 0.1 * rnd(i as u64), 0.7);
let dy = if i % 2 == 0 { 1.1 * q * q } else { -1.1 * q * q };
v.push(Layer::rect(Old, [x0, 0.0, x1, 1.0], [x0, dy, x1, 1.0 + dy], 1.0));
}
}
v
}
"switch" => {
let d = if o == "r" { 1.0 } else { -1.0 };
let k = (t * PI).sin();
let old = Layer::quad(
Old,
FULL,
persp_h(
offset(scale_rect(FULL, 1.0 - 0.25 * t), d * 0.45 * k, 0.0),
if d > 0.0 { 0.0 } else { 0.08 * k },
if d > 0.0 { 0.08 * k } else { 0.0 },
),
1.0,
);
let new = Layer::quad(
New,
FULL,
persp_h(
offset(scale_rect(FULL, 0.75 + 0.25 * t), -d * 0.45 * k, 0.0),
if d > 0.0 { 0.08 * k } else { 0.0 },
if d > 0.0 { 0.0 } else { 0.08 * k },
),
1.0,
);
if t < 0.5 { vec![new, old] } else { vec![old, new] }
}
"flip" => {
let a = PI * s;
let w = a.cos().abs();
let k = 0.1 * a.sin();
let r = [0.5 - w / 2.0, 0.0, 0.5 + w / 2.0, 1.0];
let right = o != "l";
if t < 0.5 {
let q = if right { persp_h(r, -k / 2.0, k) } else { persp_h(r, k, -k / 2.0) };
vec![Layer::quad(Old, FULL, q, 1.0)]
} else {
let q = if right { persp_h(r, k, -k / 2.0) } else { persp_h(r, -k / 2.0, k) };
vec![Layer::quad(New, FULL, q, 1.0)]
}
}
"gallery" | "conveyor" => {
let d = if o == "r" { 1.0 } else { -1.0 };
let k = (s * PI).sin();
let sc = 1.0 - if kind == "gallery" { 0.25 } else { 0.1 } * k;
let gap = if kind == "gallery" { 1.15 } else { 1.05 };
let old = offset(scale_rect(FULL, sc), d * s * gap, 0.0);
let new = offset(scale_rect(FULL, sc), d * (s - 1.0) * gap, 0.0);
let p = 0.08 * k;
vec![
Layer::quad(Old, FULL, if d > 0.0 { persp_h(old, 0.0, p) } else { persp_h(old, p, 0.0) }, 1.0),
Layer::quad(New, FULL, if d > 0.0 { persp_h(new, p, 0.0) } else { persp_h(new, 0.0, p) }, 1.0),
]
}
"cube" | "box" | "rotate" => {
let (dx, dy) = dir(o, (-1.0, 0.0));
let vertical = dy != 0.0 && dx == 0.0;
let neg = if vertical { dy < 0.0 } else { dx < 0.0 };
let k = (s * PI).sin();
let (far_old, far_new, shared) = match kind {
"cube" => (0.18 * s, 0.18 * (1.0 - s), 0.05 * k),
"box" => (0.0, 0.0, 0.18 * k),
_ => (0.25 * s, 0.25 * (1.0 - s), 0.0),
};
// Canonical: moving toward -x; old on the left [0, 1-s], new on the right [1-s, 1].
let split = 1.0 - s;
let (old_q, new_q) = if kind == "rotate" {
if t < 0.5 {
(Some(canon_q([0.0, 0.0, (1.0 - 2.0 * t).max(0.0), 1.0], far_old, 0.0)), None)
} else {
(None, Some(canon_q([1.0 - (2.0 * t - 1.0), 0.0, 1.0, 1.0], 0.0, far_new)))
}
} else {
(Some(canon_q([0.0, 0.0, split, 1.0], far_old, shared)), Some(canon_q([split, 0.0, 1.0, 1.0], shared, far_new)))
};
let fix = |q: [[f64; 2]; 4]| -> [[f64; 2]; 4] {
let q = if neg { q } else { mirror_q(q) };
if vertical { swap_q(q) } else { q }
};
let mut v = vec![Layer::full(Black, 1.0)];
if let Some(q) = old_q {
v.push(Layer::quad(Old, FULL, fix(q), 1.0));
}
if let Some(q) = new_q {
v.push(Layer::quad(New, FULL, fix(q), 1.0));
}
v
}
"doors" | "window" => {
let horz = o == "horz";
let (zoom0, persp) = if kind == "doors" { (0.85, 0.1) } else { (0.6, 0.2) };
let w = 0.5 * (1.0 - s);
let k = persp * s;
let left = [[0.0, 0.0], [w, -k], [w, 1.0 + k], [0.0, 1.0]];
let right = [[1.0 - w, -k], [1.0, 0.0], [1.0, 1.0], [1.0 - w, 1.0 + k]];
let (l, r) = if horz { (swap_q(left), swap_q(right)) } else { (left, right) };
let (sl, sr) = if horz { ([0.0, 0.0, 1.0, 0.5], [0.0, 0.5, 1.0, 1.0]) } else { ([0.0, 0.0, 0.5, 1.0], [0.5, 0.0, 1.0, 1.0]) };
vec![scaled(New, zoom0 + (1.0 - zoom0) * s, 0.5, 0.5, 0.0, 0.0, s.max(0.2)), Layer::quad(Old, sl, l, 1.0), Layer::quad(Old, sr, r, 1.0)]
}
"comb" => {
let vert = o == "vert";
let n = 8;
let mut v = Vec::new();
for i in 0..n {
let a = i as f64 / n as f64;
let b = (i + 1) as f64 / n as f64;
let d = if i % 2 == 0 { 1.0 } else { -1.0 };
let band = if vert { [a, 0.0, b, 1.0] } else { [0.0, a, 1.0, b] };
let (ox, oy) = if vert { (0.0, d * s) } else { (d * s, 0.0) };
let (nx, ny) = if vert { (0.0, -d * (1.0 - s)) } else { (-d * (1.0 - s), 0.0) };
v.push(Layer::rect(Old, band, offset(band, ox, oy), 1.0));
v.push(Layer::rect(New, band, offset(band, nx, ny), 1.0));
}
v
}
"zoom" => {
if o == "out" {
vec![scaled(New, 1.5 - 0.5 * s, 0.5, 0.5, 0.0, 0.0, s), scaled(Old, 1.0 - 0.7 * s, 0.5, 0.5, 0.0, 0.0, 1.0 - t)]
} else {
vec![scaled(Old, 1.0 + s, 0.5, 0.5, 0.0, 0.0, 1.0 - t), scaled(New, s.max(0.01), 0.5, 0.5, 0.0, 0.0, s)]
}
}
"orbit" => {
let (dx, dy) = dir(o, (-1.0, 0.0));
let k = (s * PI).sin();
let old = scaled(Old, 1.0 - 0.4 * k, 0.5, 0.5, dx * s + dy.abs() * 0.15 * k, dy * s + dx.abs() * 0.15 * k, 1.0 - 0.5 * s);
let new = scaled(New, 1.0 - 0.4 * k, 0.5, 0.5, dx * (s - 1.0) - dy.abs() * 0.15 * k, dy * (s - 1.0) - dx.abs() * 0.15 * k, 0.5 + 0.5 * s);
if t < 0.5 { vec![new, old] } else { vec![old, new] }
}
"flythrough" => {
let bounce = o.ends_with("Bounce");
let ns = |base: f64| if bounce { base + 0.08 * (s * PI).sin() } else { base };
if o.starts_with("out") {
vec![scaled(Old, 1.0 - 0.5 * s, 0.5, 0.5, 0.0, 0.0, 1.0 - t), scaled(New, ns(1.5 - 0.5 * s), 0.5, 0.5, 0.0, 0.0, s)]
} else {
vec![scaled(New, ns(0.5 + 0.5 * s), 0.5, 0.5, 0.0, 0.0, s), scaled(Old, 1.0 + 1.5 * s, 0.5, 0.5, 0.0, 0.0, 1.0 - t)]
}
}
"ferris" => {
let d = if o == "r" { 1.0 } else { -1.0 };
let pivot = [0.5, 2.5];
let a = 50.0 * s * d;
let oq = rot_quad(corners(FULL), pivot, a);
let nq = rot_quad(corners(FULL), pivot, a - 50.0 * d);
vec![Layer::quad(Old, FULL, oq, 1.0), Layer::quad(New, FULL, nq, 1.0)]
}
"random" if depth == 0 => {
let h = option.bytes().fold(0x51ED_u64, |h, b| hash64(h ^ b as u64));
let k = RANDOM_KINDS.get((h % RANDOM_KINDS.len() as u64) as usize).copied().unwrap_or("fade");
let opt = slidecraft_model::anim::TRANSITIONS.iter().find(|x| x.0 == k).and_then(|x| x.4.first().copied()).unwrap_or("");
layers(k, opt, t, depth + 1)
}
_ => crossfade(t),
}
}
fn mirror_q(q: [[f64; 2]; 4]) -> [[f64; 2]; 4] {
// Mirror x and keep the tl, tr, br, bl order (swap left/right corners).
let m = |p: [f64; 2]| [1.0 - p[0], p[1]];
[m(q[1]), m(q[0]), m(q[3]), m(q[2])]
}
fn swap_q(q: [[f64; 2]; 4]) -> [[f64; 2]; 4] {
// Transpose x/y; corners reordered so tl, tr, br, bl still map to the source's corners.
let s = |p: [f64; 2]| [p[1], p[0]];
[s(q[0]), s(q[3]), s(q[2]), s(q[1])]
}
fn canon_q(r: R, left_inset: f64, right_inset: f64) -> [[f64; 2]; 4] {
persp_h(r, left_inset, right_inset)
}
fn wipe(o: &str, t: f64) -> Vec<Layer> {
let mut v = vec![Layer::full(Source::Old, 1.0)];
let diag = o.len() == 2;
if diag {
// Diagonal: columns revealed by a slanted edge.
let (mx, my) = (o.contains('l'), o.contains('u'));
let n = 32;
for i in 0..n {
let x0 = i as f64 / n as f64;
let x1 = (i + 1) as f64 / n as f64;
let h = (2.0 * t - (x0 + x1) / 2.0).clamp(0.0, 1.0);
if h > 0.0 {
v.push(Layer::inplace(Source::New, map_rect([x0, 0.0, x1, h], mx, my, false), 1.0));
}
}
return v;
}
let (mx, swap) = match o {
"l" => (true, false),
"d" => (false, true),
"u" => (true, true),
_ => (false, false),
};
let soft = 0.15;
let e = t * (1.0 + soft);
let solid = (e - soft).clamp(0.0, 1.0);
if solid > 0.0 {
v.push(Layer::inplace(Source::New, map_rect([0.0, 0.0, solid, 1.0], mx, false, swap), 1.0));
}
let n = 6;
for k in 0..n {
let a = e - soft + soft * k as f64 / n as f64;
let b = e - soft + soft * (k + 1) as f64 / n as f64;
let (a, b) = (a.clamp(0.0, 1.0), b.clamp(0.0, 1.0));
if b > a {
let alpha = 1.0 - (k as f64 + 0.5) / n as f64;
v.push(Layer::inplace(Source::New, map_rect([a, 0.0, b, 1.0], mx, false, swap), alpha));
}
}
v
}
/// A centred shape of the source at size `f` (0 = nothing, 1 = covers the slide), as strips.
fn shape_form(src: Source, form: &str, f: f64) -> Vec<Layer> {
let f = f.clamp(0.0, 1.0);
if f <= 0.0 {
return vec![];
}
match form {
"plus" => {
let h = f / 2.0;
vec![Layer::inplace(src, [0.0, 0.5 - h, 1.0, 0.5 + h], 1.0), Layer::inplace(src, [0.5 - h, 0.0, 0.5 + h, 1.0], 1.0)]
}
"circle" | "diamond" => {
let n = 40;
let r = if form == "circle" { f * 0.75 } else { f * 1.02 };
let mut v = Vec::new();
for i in 0..n {
let y0 = i as f64 / n as f64;
let y1 = (i + 1) as f64 / n as f64;
let dy = ((y0 + y1) / 2.0 - 0.5).abs();
let half = if form == "circle" { if r > dy { (r * r - dy * dy).sqrt() } else { 0.0 } } else { r - dy };
if half > 0.0 {
let half = half.min(0.5);
v.push(Layer::inplace(src, [0.5 - half, y0, 0.5 + half, y1], 1.0));
}
}
v
}
_ => vec![Layer::inplace(src, scale_rect(FULL, f), 1.0)],
}
}
/// A peeling page: the part of `area` not yet peeled stays, the peeled part shows as a mirrored flap.
fn peel(src: Source, area: R, s: f64, toward_right: bool) -> Vec<Layer> {
let w = area[2] - area[0];
let p = s * w;
if toward_right {
let edge = area[0] + p;
let stay = [edge, area[1], area[2], area[3]];
let flap_src = [area[0], area[1], edge, area[3]];
let x2 = edge + p;
let flap = Layer {
source: src,
src: flap_src,
quad: [[x2, area[1]], [edge, area[1]], [edge, area[3]], [x2, area[3]]],
uv: corners(flap_src),
alpha: 0.85,
};
vec![Layer::inplace(src, stay, 1.0), flap]
} else {
let edge = area[2] - p;
let stay = [area[0], area[1], edge, area[3]];
let flap_src = [edge, area[1], area[2], area[3]];
let x2 = edge - p;
let flap = Layer {
source: src,
src: flap_src,
quad: [[edge, area[1]], [x2, area[1]], [x2, area[3]], [edge, area[3]]],
uv: corners(flap_src),
alpha: 0.85,
};
vec![Layer::inplace(src, stay, 1.0), flap]
}
}
/// Point on the unit square's boundary seen from the centre at angle `a` (radians, clockwise from
/// 12 o'clock).
fn boundary(a: f64) -> [f64; 2] {
let (dx, dy) = (a.sin(), -a.cos());
let m = dx.abs().max(dy.abs()).max(1e-9);
[0.5 + 0.5 * dx / m, 0.5 + 0.5 * dy / m]
}
fn clock(o: &str, s: f64) -> Vec<Layer> {
let mut v = vec![Layer::full(Source::Old, 1.0)];
let c = [0.5, 0.5];
let sweep = s * 2.0 * PI;
let (a0, a1) = match o {
"counterClockwise" => (-sweep, 0.0),
"wedge" => (-sweep / 2.0, sweep / 2.0),
_ => (0.0, sweep),
};
let span = a1 - a0;
if span <= 0.0 {
return v;
}
let n = ((span / (PI / 24.0)).ceil() as usize).clamp(1, 96);
let mut angles: Vec<f64> = (0..=n).map(|k| a0 + span * k as f64 / n as f64).collect();
// Square corners inside the sweep, so the fan reaches them.
for k in -8i32..=8 {
let a = PI / 4.0 + k as f64 * PI / 2.0;
if a > a0 && a < a1 {
angles.push(a);
}
}
angles.sort_by(f64::total_cmp);
for w in angles.windows(2) {
if let [x, y] = w
&& y > x
{
v.push(Layer::tri(Source::New, c, boundary(*x), boundary(*y), 1.0));
}
}
v
}
+194
View File
@@ -50,6 +50,34 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!("insert.dateTime", "Date & Time", ["Insert", "Text"], None, "{format?: datetime1..}", has_slide, date_time),
cmd!("insert.symbol", "Symbol", ["Insert", "Symbols"], None, "{text: character(s)}", has_slide, symbol),
cmd!("insert.hyperlink", "Link", ["Insert", "Links"], Some("Cmd+K"), "{url? | slide?: index, tooltip?, ids?}", has_text_or_shapes, hyperlink),
cmd!("chart.type", "Change Chart Type", ["Chart Design", "Type"], None, "{type, id?}", has_selection, chart_type_cmd),
cmd!(
"chart.data",
"Edit Data",
["Chart Design", "Data"],
None,
"{categories?: [..], series?: [{name, values}], id?}",
has_selection,
chart_data
),
cmd!(
"chart.options",
"Chart Elements",
["Chart Design", "Chart Layouts"],
None,
"{title?: string|null, legend?: b|t|l|r|null, dataLabels?: bool, gridlines?: bool, palette?, id?}",
has_selection,
chart_options
),
cmd!(
"insert.smartArt",
"SmartArt",
["Insert", "Illustrations"],
None,
"{kind: list|process|cycle|hierarchy|pyramid|matrix, items: [text]}",
has_slide,
smart_art
),
cmd!(
"insert.actionButton",
"Action Buttons",
@@ -539,3 +567,169 @@ fn action_button(s: &mut Session, p: &Value) -> Result<Value> {
fn _kinds() -> RunKind {
RunKind::Text
}
fn with_chart(s: &mut Session, p: &Value, cmd: &str, f: impl Fn(&mut Chart) -> Result<()>) -> Result<Value> {
edit_shapes(s, p, cmd, |sh| match &mut sh.kind {
ShapeKind::Chart(c) => f(c),
_ => Err(bad(cmd, "not a chart")),
})
}
fn chart_type_cmd(s: &mut Session, p: &Value) -> Result<Value> {
let k = chart_type(str_param(p, "type").unwrap_or("column"));
with_chart(s, p, "chart.type", |c| {
c.kind = k;
c.vary_colors = matches!(k, ChartType::Pie | ChartType::Doughnut) || c.series.len() == 1 && c.vary_colors;
c.raw = None;
Ok(())
})
}
fn chart_data(s: &mut Session, p: &Value) -> Result<Value> {
let cats: Option<Vec<String>> = p
.get("categories")
.and_then(Value::as_array)
.map(|a| a.iter().map(|v| v.as_str().map(String::from).unwrap_or_else(|| v.to_string())).collect());
let series: Option<Vec<slidecraft_model::chart::Series>> = p.get("series").and_then(Value::as_array).map(|a| {
a.iter()
.map(|sv| slidecraft_model::chart::Series {
name: sv.get("name").and_then(Value::as_str).unwrap_or("Series").to_string(),
values: sv.get("values").and_then(Value::as_array).map(|v| v.iter().map(Value::as_f64).collect()).unwrap_or_default(),
..Default::default()
})
.collect()
});
with_chart(s, p, "chart.data", |c| {
if let Some(cs) = &cats {
c.categories = cs.clone();
}
if let Some(se) = &series {
// Keep series formatting by position.
let old = std::mem::take(&mut c.series);
c.series = se
.iter()
.enumerate()
.map(|(i, n)| {
let mut x = old.get(i).cloned().unwrap_or_default();
x.name = n.name.clone();
x.values = n.values.clone();
x
})
.collect();
}
c.raw = None;
Ok(())
})
}
fn chart_options(s: &mut Session, p: &Value) -> Result<Value> {
with_chart(s, p, "chart.options", |c| {
if let Some(t) = p.get("title") {
c.title = t.as_str().map(String::from);
}
if let Some(l) = p.get("legend") {
c.legend = l.as_str().map(String::from);
}
if let Some(v) = bool_param(p, "dataLabels") {
c.data_labels = v;
}
if let Some(v) = bool_param(p, "gridlines") {
c.gridlines = v;
}
if let Some(v) = str_param(p, "palette") {
c.palette = v.to_string();
}
c.raw = None;
Ok(())
})
}
/// SmartArt-style graphics built from shapes (grouped), with our own layouts.
fn smart_art(s: &mut Session, p: &Value) -> Result<Value> {
let kind = str_param(p, "kind").unwrap_or("process").to_string();
let items: Vec<String> =
p.get("items").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(String::from).collect()).unwrap_or_default();
let items = if items.is_empty() { vec!["Text".to_string(); 3] } else { items };
let n = items.len().clamp(1, 12);
let size = s.doc()?.doc.slide_size;
let area = Xfrm::new(size.width * 0.1, size.height * 0.28, size.width * 0.8, size.height * 0.6);
let slots = [SchemeSlot::Accent1, SchemeSlot::Accent2, SchemeSlot::Accent3, SchemeSlot::Accent4, SchemeSlot::Accent5, SchemeSlot::Accent6];
let mut shapes: Vec<(String, Xfrm, usize)> = vec![];
match kind.as_str() {
"cycle" => {
let r = area.h.min(area.w) * 0.38;
let (cx, cy) = (area.x + area.w / 2.0, area.y + area.h / 2.0);
let d = r * 0.62;
for i in 0..n {
let a = (i as f64 / n as f64 * 360.0 - 90.0).to_radians();
shapes.push(("ellipse".into(), Xfrm::new(cx + r * a.cos() - d / 2.0, cy + r * a.sin() - d / 2.0, d, d), i));
}
}
"hierarchy" => {
let w = area.w / (n.max(2) - 1) as f64 * 0.8;
shapes.push(("roundRect".into(), Xfrm::new(area.x + area.w / 2.0 - w / 2.0, area.y, w, area.h * 0.3), 0));
for i in 1..n {
let x = area.x + (i - 1) as f64 * area.w / (n - 1).max(1) as f64;
shapes.push(("roundRect".into(), Xfrm::new(x + w * 0.1, area.y + area.h * 0.55, w, area.h * 0.3), i));
}
}
"pyramid" => {
let h = area.h / n as f64;
for i in 0..n {
let w = area.w * 0.25 + area.w * 0.75 * (i as f64 + 1.0) / n as f64;
shapes.push(("trapezoid".into(), Xfrm::new(area.x + (area.w - w) / 2.0, area.y + i as f64 * h, w, h * 0.95), i));
}
}
"matrix" => {
let (w, h) = (area.w / 2.0, area.h / 2.0);
for i in 0..n.min(4) {
shapes.push((
"roundRect".into(),
Xfrm::new(area.x + (i % 2) as f64 * w + 4.0, area.y + (i / 2) as f64 * h + 4.0, w - 8.0, h - 8.0),
i,
));
}
}
"list" => {
let h = area.h / n as f64;
for i in 0..n {
shapes.push(("roundRect".into(), Xfrm::new(area.x, area.y + i as f64 * h, area.w, h * 0.85), i));
}
}
_ => {
let gap = 24.0;
let w = (area.w - gap * (n as f64 - 1.0)) / n as f64;
for i in 0..n {
shapes.push((
if i + 1 < n { "homePlate".into() } else { "roundRect".into() },
Xfrm::new(area.x + i as f64 * (w + gap), area.y + area.h * 0.25, w, area.h * 0.5),
i,
));
}
}
}
let mut ids = vec![];
for (preset, x, i) in shapes {
let mut sh = Shape {
xfrm: Some(x),
geom: Geom::preset(&preset),
style: Some(ShapeStyle::accent(slots.get(i % 6).copied().unwrap_or(SchemeSlot::Accent1))),
..Default::default()
};
let mut body = TextBody::from_text(items.get(i).map(String::as_str).unwrap_or(""));
body.body.autofit = Some(AutoFit::Shrink { font_scale: 1.0, line_reduction: 0.0 });
for para in &mut body.paragraphs {
para.props.align = Some(slidecraft_model::text::Align::Center);
for r in &mut para.runs {
r.props.size = Some(20.0);
}
}
sh.text = Some(body);
ids.push(add_shape(s, sh, false)?.0);
}
let g = s.execute("arrange.group", &json!({"ids": ids}))?;
if let Some(gid) = g.get("id").and_then(Value::as_u64) {
let _ = s.execute("shape.rename", &json!({"id": gid, "name": format!("SmartArt ({kind})")}));
}
Ok(g)
}
+1 -1
View File
@@ -242,7 +242,7 @@ pub(crate) fn shape_of(s: &Session, id: ShapeId) -> Result<slidecraft_model::Sha
}
/// Effective box of a shape (placeholders inherit theirs).
pub(crate) fn xfrm_of(doc: &slidecraft_model::Presentation, sel: &crate::Selection, shape: &slidecraft_model::Shape) -> Xfrm {
pub fn xfrm_of(doc: &slidecraft_model::Presentation, sel: &crate::Selection, shape: &slidecraft_model::Shape) -> Xfrm {
if let Some(x) = shape.xfrm {
return x;
}
+6
View File
@@ -210,6 +210,11 @@ pub fn snap(v: f64, step: f64) -> f64 {
if step > 0.0 && step.is_finite() { (v / step).round() * step } else { v }
}
/// Flatten a path to move/line/close elements (tolerance 0.2).
pub fn preset_flatten(path: &BezPath, f: &mut dyn FnMut(PathEl)) {
kurbo::flatten(path.iter(), 0.2, f);
}
#[cfg(test)]
mod tests {
use super::*;
@@ -264,3 +269,4 @@ mod tests {
assert!((dist_to_segment(Point::new(0.0, 3.0), Point::new(0.0, 0.0), Point::new(0.0, 0.0)) - 3.0).abs() < 1e-9);
}
}
+74 -8
View File
@@ -184,15 +184,54 @@ thread_local! {
static RENDERER: std::cell::RefCell<Renderer> = std::cell::RefCell::new(Renderer::default());
}
/// Render slide `index` at `opts.scale` pixels per point.
/// Does anything on this slide (or its layout/master graphics) use blur-based effects?
pub fn needs_filters(pres: &Presentation, slide: &Slide) -> bool {
let Some(rctx) = Ctx::for_slide(pres, slide) else { return false };
let mut any = false;
let mut check = |shapes: &[Shape]| {
slidecraft_model::walk(shapes, &mut |s, _| {
if any {
return;
}
let (e, _) = resolve::effects(&rctx, s);
if e.is_some_and(|e| !e.is_empty()) {
any = true;
}
});
};
check(&slide.shapes);
if let Some(l) = rctx.layout {
check(&l.shapes);
}
check(&rctx.master.shapes);
any
}
/// Render slide `index` at `opts.scale` pixels per point. A panic inside the renderer becomes a
/// blank image (logged), never a crash.
pub fn render_slide(pres: &Presentation, index: usize, opts: &RenderOpts) -> Image {
let Some(slide) = pres.slides.get(index) else { return Image::default() };
RENDERER.with(|r| r.borrow_mut().slide(pres, slide, index, opts))
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| RENDERER.with(|r| r.borrow_mut().slide(pres, slide, index, opts)))) {
Ok(img) => img,
Err(_) => {
log::error!("rendering slide {index} panicked; showing a blank slide");
RENDERER.with(|r| *r.borrow_mut() = Renderer::default());
let (w, h) = output_size(pres, opts);
Image { width: w as u32, height: h as u32, pixels: vec![255; w as usize * h as usize * 4] }
}
}
}
/// Render a layout (Slide Master view thumbnail and canvas).
pub fn render_layout(pres: &Presentation, master: usize, layout: Option<usize>, opts: &RenderOpts) -> Image {
RENDERER.with(|r| r.borrow_mut().layout(pres, master, layout, opts))
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| RENDERER.with(|r| r.borrow_mut().layout(pres, master, layout, opts)))) {
Ok(img) => img,
Err(_) => {
log::error!("rendering a layout panicked; showing a blank image");
RENDERER.with(|r| *r.borrow_mut() = Renderer::default());
Image::default()
}
}
}
fn output_size(pres: &Presentation, opts: &RenderOpts) -> (u16, u16) {
@@ -318,7 +357,10 @@ impl Renderer {
let mut ctx = RenderContext::new_with(
w,
h,
vello_cpu::RenderSettings { num_threads: if cfg!(target_arch = "wasm32") { 0 } else { opts.threads }, ..Default::default() },
vello_cpu::RenderSettings {
num_threads: if cfg!(target_arch = "wasm32") || needs_filters(pres, slide) { 0 } else { opts.threads },
..Default::default()
},
);
if let Some(c) = opts.clear {
ctx.set_paint(color(c, 1.0));
@@ -888,10 +930,9 @@ pub fn draw_layout(ctx: &mut RenderContext, l: &slidecraft_text::TextLayout, tr:
ctx.fill_rect(&d.rect);
}
for run in &l.runs {
if (run.alpha <= 0.0 || run.color.a == 0)
&& run.outline.is_none() {
continue;
}
if (run.alpha <= 0.0 || run.color.a == 0) && run.outline.is_none() {
continue;
}
let k = run.size / run.face.upem.max(1.0);
ctx.set_paint(color(run.color, run.alpha));
for (gid, x, y) in &run.glyphs {
@@ -945,3 +986,28 @@ pub fn render_shape(pres: &Presentation, slide: &Slide, id: ShapeId, scale: f64)
#[cfg(test)]
mod tests;
#[cfg(test)]
mod mt_tests {
use super::*;
/// Regression: multithreaded rendering of a slide with shadows panicked inside vello_cpu.
#[test]
fn effects_render_with_threads_requested() {
let mut s = slidecraft_model::Presentation::default();
let sl = std::sync::Arc::make_mut(&mut s.slides[0]);
let mut sh = slidecraft_model::Shape {
id: ShapeId(900),
xfrm: Some(slidecraft_geom::Xfrm::new(10.0, 10.0, 100.0, 100.0)),
style: Some(slidecraft_model::ShapeStyle::accent(slidecraft_color::SchemeSlot::Accent1)),
..Default::default()
};
sh.effects = Some(slidecraft_model::Effects { soft_edge: Some(5.0), ..Default::default() });
sl.shapes.push(sh);
assert!(needs_filters(&s, &s.slides[0]));
let img = RENDERER.with(|r| r.borrow_mut().slide(&s, &s.slides[0], 0, &RenderOpts { scale: 0.5, threads: 4, ..Default::default() }));
assert_eq!(img.width, 480);
// The shape itself is drawn (not a blank fallback).
assert_ne!(img.pixel(30, 30), [255, 255, 255, 255]);
}
}
+1 -5
View File
@@ -73,11 +73,7 @@ pub fn look(ctx: &Ctx, t: &Table, r: usize, c: usize) -> CellLook {
border: Some((white, 1.0)),
},
"medium4" => CellLook {
fill: Some(if header || band || band_c {
tint(40000)
} else {
tint(20000)
}),
fill: Some(if header || band || band_c { tint(40000) } else { tint(20000) }),
text: None,
bold: header || total || first_col || last_col,
border: Some((a, 0.75)),
+26
View File
@@ -0,0 +1,26 @@
[package]
name = "slidecraft-ui-egui"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "SlideCraft egui frontend (PowerPoint-style UI); swappable — nothing below depends on it"
[lints]
workspace = true
[dependencies]
slidecraft-geom = { workspace = true }
slidecraft-color = { workspace = true }
slidecraft-model = { workspace = true }
slidecraft-fonts = { workspace = true }
slidecraft-text = { workspace = true }
slidecraft-render = { workspace = true }
slidecraft-anim = { workspace = true }
slidecraft-engine = { workspace = true }
egui = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
log = { workspace = true }
[dev-dependencies]
egui_kittest = { workspace = true, features = ["wgpu", "snapshot"] }
+65
View File
@@ -0,0 +1,65 @@
//! Renders every SlideCraft icon into a labelled PNG grid, on a light and a dark background.
//!
//! ```sh
//! cargo run -p slidecraft-ui-egui --example icon_gallery -- /tmp/gallery.png
//! ```
//!
//! Each cell shows the icon at 32 pt, at 16 pt, and disabled at 16 pt, with its name underneath.
//! An optional second argument filters icons by name substring.
use egui::{Align2, Color32, FontId, Rect, pos2, vec2};
use slidecraft_ui_egui::icons::{self, Icon};
const COLS: usize = 12;
const CELL_W: f32 = 110.0;
const CELL_H: f32 = 64.0;
const PAD: f32 = 12.0;
struct Theme {
bg: Color32,
ink: Color32,
label: Color32,
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut args = std::env::args().skip(1);
let out = args.next().unwrap_or_else(|| "icon_gallery.png".to_owned());
let filter = args.next();
let list: Vec<Icon> = Icon::ALL.iter().copied().filter(|i| filter.as_deref().is_none_or(|f| i.name().contains(f))).collect();
let rows = list.len().div_ceil(COLS);
let panel_h = rows as f32 * CELL_H + PAD * 2.0;
let width = COLS as f32 * CELL_W + PAD * 2.0;
let themes = [
Theme { bg: Color32::from_rgb(0xff, 0xff, 0xff), ink: Color32::from_rgb(0x3a, 0x3a, 0x3a), label: Color32::from_rgb(0x70, 0x70, 0x70) },
Theme { bg: Color32::from_rgb(0x2b, 0x2b, 0x2b), ink: Color32::from_rgb(0xe8, 0xe8, 0xe8), label: Color32::from_rgb(0xa8, 0xa8, 0xa8) },
];
let mut harness =
egui_kittest::Harness::builder().with_size(vec2(width, panel_h * themes.len() as f32)).with_pixels_per_point(2.0).wgpu().build_ui(|ui| {
let painter = ui.painter();
for (ti, theme) in themes.iter().enumerate() {
let top = ti as f32 * panel_h;
painter.rect_filled(Rect::from_min_size(pos2(0.0, top), vec2(width, panel_h)), 0.0, theme.bg);
for (n, &icon) in list.iter().enumerate() {
let x = PAD + (n % COLS) as f32 * CELL_W;
let y = top + PAD + (n / COLS) as f32 * CELL_H;
let big = Rect::from_min_size(pos2(x + 14.0, y + 4.0), vec2(32.0, 32.0));
icons::paint(painter, big, icon, theme.ink, false);
let small = Rect::from_min_size(pos2(x + 56.0, y + 12.0), vec2(16.0, 16.0));
icons::paint(painter, small, icon, theme.ink, false);
let dis = Rect::from_min_size(pos2(x + 80.0, y + 12.0), vec2(16.0, 16.0));
icons::paint(painter, dis, icon, theme.ink, true);
painter.text(pos2(x + CELL_W * 0.5, y + 46.0), Align2::CENTER_CENTER, icon.name(), FontId::proportional(9.5), theme.label);
}
}
});
harness.run();
let image = harness.render()?;
if let Some(dir) = std::path::Path::new(&out).parent() {
std::fs::create_dir_all(dir)?;
}
image.save(&out)?;
println!("wrote {} icons to {out}", list.len());
Ok(())
}
+491
View File
@@ -0,0 +1,491 @@
//! The slide canvas: the rendered slide, zoom and fit, pointer input routed to the engine's tools,
//! and the overlays drawn on top (selection box and handles, smart guides, marquee, drawing
//! previews, the text caret and text selection).
use egui::{Align2, Color32, CornerRadius, Pos2, Rect, Sense, Stroke, Ui, pos2, vec2};
use serde_json::json;
use slidecraft_engine::tools::{Hit, box_handles};
use slidecraft_engine::{Mods, PointerEvent, PointerKind, Target, ToolKind};
use slidecraft_geom::{Point, Xfrm};
use crate::SlideApp;
use crate::theme::{self, Tokens};
/// Slide-to-screen mapping.
#[derive(Clone, Copy, Debug)]
pub struct Xf {
pub origin: Pos2,
pub scale: f32,
}
impl Xf {
pub fn to_screen(&self, p: Point) -> Pos2 {
pos2(self.origin.x + p.x as f32 * self.scale, self.origin.y + p.y as f32 * self.scale)
}
pub fn to_slide(&self, p: Pos2) -> Point {
Point::new(((p.x - self.origin.x) / self.scale) as f64, ((p.y - self.origin.y) / self.scale) as f64)
}
}
fn mods(ui: &Ui) -> Mods {
ui.input(|i| Mods { shift: i.modifiers.shift, alt: i.modifiers.alt, cmd: i.modifiers.command || i.modifiers.ctrl })
}
/// The slide's rect on screen for the current zoom.
pub fn layout(app: &SlideApp, avail: Rect) -> (Rect, f32) {
let Some(d) = app.session.active() else { return (avail, 1.0) };
let (sw, sh) = (d.doc.slide_size.width as f32, d.doc.slide_size.height as f32);
let margin = 28.0;
let fit = ((avail.width() - margin * 2.0) / sw).min((avail.height() - margin * 2.0) / sh).max(0.05);
let scale = app.ui.zoom.unwrap_or(fit);
let size = vec2(sw * scale, sh * scale);
let mut r = Rect::from_center_size(avail.center(), size);
if size.x > avail.width() {
r = Rect::from_min_size(pos2(avail.min.x + margin, r.min.y), size);
}
if size.y > avail.height() {
r = Rect::from_min_size(pos2(r.min.x, avail.min.y + margin), size);
}
(r, scale)
}
pub fn show(app: &mut SlideApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
let avail = ui.available_rect_before_wrap();
app.canvas_rect = Some(avail);
let Some(st) = app.session.active() else { return };
if st.doc.slides.is_empty() && st.selection.target == Target::Slides {
let resp = ui.allocate_rect(avail, Sense::click());
ui.painter().text(avail.center(), Align2::CENTER_CENTER, "Click to add first slide", theme::font(22.0), t.text_dim);
if resp.clicked() {
let _ = app.run("slide.new", json!({"layout": "title"}));
}
return;
}
let (content_rect, scale) = layout(app, avail);
// Scroll when zoomed beyond the window.
let scroll_id = ui.id().with("canvas_scroll");
let mut offset: egui::Vec2 = ui.data_mut(|d| d.get_temp(scroll_id).unwrap_or_default());
let overflow = vec2((content_rect.width() + 56.0 - avail.width()).max(0.0), (content_rect.height() + 56.0 - avail.height()).max(0.0));
let resp = ui.allocate_rect(avail, Sense::click_and_drag());
let (wheel, zoom_delta, pinch_pos) = ui.input(|i| (i.smooth_scroll_delta, i.zoom_delta(), i.pointer.hover_pos()));
if resp.hovered() {
if (zoom_delta - 1.0).abs() > 1e-3 {
let cur = app.ui.zoom.unwrap_or(scale);
app.ui.zoom = Some((cur * zoom_delta).clamp(0.1, 4.0));
let _ = pinch_pos;
} else if overflow != egui::Vec2::ZERO {
offset -= wheel;
} else if wheel.y.abs() > 18.0 && app.session.tool.marquee.is_none() && !app.session.tool.dragging() {
// Wheel over a fitted slide pages through slides (like PowerPoint).
let id = if wheel.y < 0.0 { "slide.next" } else { "slide.previous" };
let gate = ui.id().with("wheel_gate");
let last: f64 = ui.data_mut(|d| d.get_temp(gate).unwrap_or(0.0));
let now = ui.input(|i| i.time);
if now - last > 0.35 {
ui.data_mut(|d| d.insert_temp(gate, now));
let _ = app.run(id, json!({}));
}
}
}
offset.x = offset.x.clamp(0.0, overflow.x);
offset.y = offset.y.clamp(0.0, overflow.y);
ui.data_mut(|d| d.insert_temp(scroll_id, offset));
let slide_rect = content_rect.translate(-offset);
app.slide_rect = Some(slide_rect);
app.canvas_scale = scale;
let xf = Xf { origin: slide_rect.min, scale };
let painter = ui.painter_at(avail);
// Shadow + slide.
painter.rect_filled(slide_rect.translate(vec2(0.0, 1.5)).expand(1.0), CornerRadius::same(2), t.shadow);
let ppp = ui.ctx().pixels_per_point();
let px = ((slide_rect.width() * ppp).round().max(1.0) as u32, (slide_rect.height() * ppp).round().max(1.0) as u32);
let st = app.session.active();
let tex = match st.map(|d| (d.selection.target, d)) {
Some((Target::Slides, d)) => {
let idx = d.selection.slide;
let doc = d.doc.clone();
app.textures.slide(ui.ctx(), &doc, idx, px, true, 0, app.ui.grayscale)
}
Some((Target::Master { master }, d)) => master_tex(ui.ctx(), &d.doc, master, None, px),
Some((Target::Layout { master, layout }, d)) => master_tex(ui.ctx(), &d.doc, master, Some(layout), px),
None => None,
};
if let Some(tex) = tex {
painter.image(tex.id(), slide_rect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
}
app.perf.render_ms = app.textures.last_render_ms;
if app.ui.gridlines {
let step = 72.0 / 2.0 * scale;
let mut x = slide_rect.min.x + step;
while x < slide_rect.max.x {
painter.line_segment([pos2(x, slide_rect.min.y), pos2(x, slide_rect.max.y)], Stroke::new(0.5, Color32::from_black_alpha(40)));
x += step;
}
let mut y = slide_rect.min.y + step;
while y < slide_rect.max.y {
painter.line_segment([pos2(slide_rect.min.x, y), pos2(slide_rect.max.x, y)], Stroke::new(0.5, Color32::from_black_alpha(40)));
y += step;
}
}
if app.ui.guides {
let c = slide_rect.center();
painter.line_segment(
[pos2(c.x, slide_rect.min.y), pos2(c.x, slide_rect.max.y)],
Stroke::new(1.0, Color32::from_rgba_unmultiplied(120, 120, 120, 160)),
);
painter.line_segment(
[pos2(slide_rect.min.x, c.y), pos2(slide_rect.max.x, c.y)],
Stroke::new(1.0, Color32::from_rgba_unmultiplied(120, 120, 120, 160)),
);
}
if app.ui.ruler {
ruler(&painter, avail, slide_rect, scale, &t);
}
pointer(app, ui, &resp, xf);
overlays(app, ui, &painter, xf, &t);
context_menu(app, &resp);
}
fn master_tex(
ctx: &egui::Context,
doc: &slidecraft_model::Presentation,
master: usize,
layout: Option<usize>,
px: (u32, u32),
) -> Option<egui::TextureHandle> {
let img = slidecraft_render::render_layout(
doc,
master,
layout,
&slidecraft_render::RenderOpts { scale: px.0 as f64 / doc.slide_size.width.max(1.0), edit: true, size: Some(px), ..Default::default() },
);
Some(ctx.load_texture("master-canvas", crate::textures::to_color_image(&img, false), egui::TextureOptions::LINEAR))
}
fn ruler(p: &egui::Painter, avail: Rect, slide: Rect, scale: f32, t: &Tokens) {
let top = Rect::from_min_max(avail.min, pos2(avail.max.x, avail.min.y + 16.0));
let left = Rect::from_min_max(avail.min, pos2(avail.min.x + 16.0, avail.max.y));
p.rect_filled(top, CornerRadius::ZERO, t.card);
p.rect_filled(left, CornerRadius::ZERO, t.card);
let inch = 72.0 * scale;
let mut k = 0;
loop {
let x = slide.min.x + k as f32 * inch / 4.0;
if x > slide.max.x {
break;
}
let h = if k % 4 == 0 {
8.0
} else if k % 2 == 0 {
5.0
} else {
3.0
};
p.line_segment([pos2(x, top.max.y - h), pos2(x, top.max.y)], Stroke::new(1.0, t.text_faint));
if k % 4 == 0 {
p.text(pos2(x + 2.0, top.min.y + 1.0), Align2::LEFT_TOP, format!("{}", k / 4), theme::font(8.0), t.text_dim);
}
k += 1;
}
let mut k = 0;
loop {
let y = slide.min.y + k as f32 * inch / 4.0;
if y > slide.max.y {
break;
}
let w = if k % 4 == 0 {
8.0
} else if k % 2 == 0 {
5.0
} else {
3.0
};
p.line_segment([pos2(left.max.x - w, y), pos2(left.max.x, y)], Stroke::new(1.0, t.text_faint));
k += 1;
}
}
fn pointer(app: &mut SlideApp, ui: &Ui, resp: &egui::Response, xf: Xf) {
let tol = (4.0 / xf.scale) as f64;
let m = mods(ui);
let Some(pos) = resp.interact_pointer_pos().or(resp.hover_pos()) else { return };
let p = xf.to_slide(pos);
let ev = |kind| PointerEvent { kind, x: p.x, y: p.y, mods: m, tol };
let primary = ui.input(|i| i.pointer.primary_pressed());
if resp.hovered() && !app.session.tool.dragging() {
let _ = app.session.pointer(ev(PointerKind::Move));
}
let r = if resp.triple_clicked() {
app.session.pointer(ev(PointerKind::TripleClick))
} else if resp.double_clicked() {
app.session.pointer(ev(PointerKind::DoubleClick))
} else if primary && resp.hovered() && !resp.secondary_clicked() && ui.input(|i| i.pointer.button_pressed(egui::PointerButton::Primary)) {
app.session.pointer(ev(PointerKind::Down))
} else if resp.dragged_by(egui::PointerButton::Primary) || (app.session.tool.dragging() && ui.input(|i| i.pointer.primary_down())) {
app.session.pointer(ev(PointerKind::Drag))
} else if app.session.tool.dragging() && ui.input(|i| i.pointer.primary_released() || !i.pointer.primary_down()) {
app.session.pointer(ev(PointerKind::Up))
} else {
Ok(serde_json::Value::Null)
};
if let Err(e) = r {
app.set_status(e.to_string());
}
// Cursor.
let cursor = match (&app.session.tool.kind, &app.session.tool.hover) {
(ToolKind::Shape { .. }, _) => egui::CursorIcon::Crosshair,
(ToolKind::TextBox, _) => egui::CursorIcon::Text,
(ToolKind::Ink { .. }, _) => egui::CursorIcon::Crosshair,
(_, Some(Hit::Resize { handle, .. })) => match handle {
0 | 4 => egui::CursorIcon::ResizeNwSe,
2 | 6 => egui::CursorIcon::ResizeNeSw,
1 | 5 => egui::CursorIcon::ResizeVertical,
_ => egui::CursorIcon::ResizeHorizontal,
},
(_, Some(Hit::Rotate { .. })) => egui::CursorIcon::Alias,
(_, Some(Hit::Adjust { .. } | Hit::LineEnd { .. })) => egui::CursorIcon::PointingHand,
(_, Some(Hit::Shape { text: true, .. })) => {
if app.session.active().is_some_and(|d| d.selection.text.is_some()) {
egui::CursorIcon::Text
} else {
egui::CursorIcon::Move
}
}
(_, Some(Hit::Shape { .. })) => egui::CursorIcon::Move,
_ => egui::CursorIcon::Default,
};
if resp.hovered() {
ui.ctx().set_cursor_icon(cursor);
}
if app.session.tool.dragging() {
ui.ctx().request_repaint();
}
}
fn poly(p: &egui::Painter, pts: [Pos2; 4], stroke: Stroke) {
for i in 0..4 {
p.line_segment([pts[i], pts[(i + 1) % 4]], stroke);
}
}
fn overlays(app: &mut SlideApp, ui: &Ui, painter: &egui::Painter, xf: Xf, t: &Tokens) {
let Some(st) = app.session.active() else { return };
let sel_stroke = Stroke::new(1.0, t.selection);
let editing = st.selection.text.clone();
for id in &st.selection.shapes {
let Some(sh) = st.shape(*id) else { continue };
let x = slidecraft_engine::cmd::xfrm_of(&st.doc, &st.selection, sh);
let a = x.affine();
let corners = [Point::new(0.0, 0.0), Point::new(x.w, 0.0), Point::new(x.w, x.h), Point::new(0.0, x.h)].map(|q| xf.to_screen(a * q));
if sh.is_line() {
let (p0, p1) = (xf.to_screen(a * Point::new(0.0, 0.0)), xf.to_screen(a * Point::new(x.w, x.h)));
for q in [p0, p1] {
painter.circle(q, 4.5, t.handle_fill, Stroke::new(1.0, t.handle_stroke));
}
continue;
}
let editing_this = editing.as_ref().is_some_and(|e| e.shape == *id && !e.notes);
if editing_this {
// Dashed box while editing text.
for i in 0..4 {
let (s, e) = (corners[i], corners[(i + 1) % 4]);
painter.add(egui::Shape::dashed_line(&[s, e], Stroke::new(1.0, t.selection), 4.0, 3.0));
}
} else {
poly(painter, corners, sel_stroke);
}
if st.selection.shapes.len() == 1 || !editing_this {
let (hs, rot) = box_handles(&x);
let rot_s = xf.to_screen(rot);
if !editing_this && sh.ph.as_ref().is_none_or(|_| true) {
let top_mid = xf.to_screen(hs[1]);
painter.line_segment([top_mid, rot_s], sel_stroke);
rotate_handle(painter, rot_s, t);
}
for h in hs {
painter.circle(xf.to_screen(h), 4.5, t.handle_fill, Stroke::new(1.0, t.handle_stroke));
}
let geo = slidecraft_render::shape_geometry(sh, x.w, x.h);
for hd in &geo.handles {
let q = xf.to_screen(a * hd.pos);
painter.circle(q, 4.5, Color32::from_rgb(0xFF, 0xD2, 0x3F), Stroke::new(1.0, Color32::from_rgb(0x8A, 0x6A, 0x00)));
}
}
}
// Caret / text selection.
if let Some(ts) = &editing
&& !ts.notes
&& ts.cell.is_none()
&& let Some(sh) = st.shape(ts.shape)
&& let Some(l) = slidecraft_engine::cmd::text::layout_for(st, ts)
{
let x = slidecraft_engine::cmd::xfrm_of(&st.doc, &st.selection, sh);
let a = x.affine();
let rot = if l.rotation != 0.0 {
let c = l.inner.center().to_vec2();
slidecraft_geom::Affine::translate(c) * slidecraft_geom::Affine::rotate(l.rotation.to_radians()) * slidecraft_geom::Affine::translate(-c)
} else {
slidecraft_geom::Affine::IDENTITY
};
let m = a * rot;
let (s0, s1) = ts.ordered();
for r in l.selection_rects(slidecraft_text::Pos { para: s0.0, ch: s0.1 }, slidecraft_text::Pos { para: s1.0, ch: s1.1 }) {
let pts = [Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x1, r.y1), Point::new(r.x0, r.y1)].map(|q| xf.to_screen(m * q));
painter.add(egui::Shape::convex_polygon(pts.to_vec(), t.text_selection, Stroke::NONE));
}
if !ts.is_range() {
let blink = (ui.input(|i| i.time) * 1.6).fract() < 0.6;
ui.ctx().request_repaint_after(std::time::Duration::from_millis(300));
if blink && let Some((cx, top, bot)) = l.caret(slidecraft_text::Pos { para: ts.caret.0, ch: ts.caret.1 }) {
let (p0, p1) = (xf.to_screen(m * Point::new(cx, top)), xf.to_screen(m * Point::new(cx, bot)));
painter.line_segment([p0, p1], Stroke::new(1.5, t.caret));
}
}
}
// Table cell editing: outline the cell.
if let Some(ts) = &editing
&& let (Some((r, c)), Some(sh)) = (ts.cell, st.shape(ts.shape))
&& let slidecraft_model::ShapeKind::Table(tb) = &sh.kind
{
let x = slidecraft_engine::cmd::xfrm_of(&st.doc, &st.selection, sh);
let y0: f64 = tb.rows.iter().take(r).map(|rr| rr.height).sum();
let x0: f64 = tb.cols.iter().take(c).sum();
let (w, h) = (tb.cols.get(c).copied().unwrap_or(0.0), tb.rows.get(r).map(|rr| rr.height).unwrap_or(0.0));
let a = x.affine();
let pts = [Point::new(x0, y0), Point::new(x0 + w, y0), Point::new(x0 + w, y0 + h), Point::new(x0, y0 + h)].map(|q| xf.to_screen(a * q));
poly(painter, pts, Stroke::new(2.0, t.accent));
}
// Smart guides, marquee, previews.
for g in &app.session.tool.guides {
let (a, b) =
if g.vertical { (Point::new(g.pos, g.from), Point::new(g.pos, g.to)) } else { (Point::new(g.from, g.pos), Point::new(g.to, g.pos)) };
painter.add(egui::Shape::dashed_line(&[xf.to_screen(a), xf.to_screen(b)], Stroke::new(1.0, t.guide), 5.0, 3.0));
}
if let Some(m) = app.session.tool.marquee {
let r = Rect::from_two_pos(xf.to_screen(Point::new(m.x0, m.y0)), xf.to_screen(Point::new(m.x1, m.y1)));
painter.rect(
r,
CornerRadius::ZERO,
Color32::from_rgba_unmultiplied(80, 130, 230, 30),
Stroke::new(1.0, Color32::from_rgb(80, 130, 230)),
egui::StrokeKind::Inside,
);
}
if let Some(pv) = app.session.tool.preview {
draw_preview(app, painter, xf, pv, t);
}
if app.session.tool.ink_preview.len() > 1
&& let ToolKind::Ink { color, width, mode } = &app.session.tool.kind
{
let pts: Vec<Pos2> = app.session.tool.ink_preview.iter().map(|(x, y, _)| xf.to_screen(Point::new(*x, *y))).collect();
let c = theme::to_color32(*color);
let c = if mode == "highlighter" { c.gamma_multiply(0.45) } else { c };
painter.add(egui::Shape::line(pts, Stroke::new((*width as f32 * xf.scale).max(1.0), c)));
}
}
fn rotate_handle(p: &egui::Painter, c: Pos2, t: &Tokens) {
p.circle_filled(c, 6.0, t.handle_fill);
p.add(egui::Shape::Path(egui::epaint::PathShape {
points: (0..=20)
.map(|i| {
let a = -0.4 + i as f32 / 20.0 * 5.2;
pos2(c.x + 4.0 * a.cos(), c.y + 4.0 * a.sin())
})
.collect(),
closed: false,
fill: Color32::TRANSPARENT,
stroke: Stroke::new(1.3, t.handle_stroke).into(),
}));
}
fn draw_preview(app: &SlideApp, p: &egui::Painter, xf: Xf, x: Xfrm, t: &Tokens) {
let preset = match &app.session.tool.kind {
ToolKind::Shape { preset } => preset.clone(),
_ => "rect".into(),
};
if slidecraft_geom::preset::is_line_like(&preset) {
let a = x.affine();
p.line_segment([xf.to_screen(a * Point::new(0.0, 0.0)), xf.to_screen(a * Point::new(x.w, x.h))], Stroke::new(1.5, t.accent));
return;
}
let r = Rect::from_two_pos(xf.to_screen(Point::new(x.x, x.y)), xf.to_screen(Point::new(x.x + x.w, x.y + x.h)));
if r.width() < 1.0 || r.height() < 1.0 {
return;
}
crate::ribbon::paint_preset(p, r, &preset, t.accent.gamma_multiply(0.25), t.accent);
}
fn context_menu(app: &mut SlideApp, resp: &egui::Response) {
resp.context_menu(|ui| {
let has_sel = app.session.active().is_some_and(|d| !d.selection.shapes.is_empty());
for (l, id) in [("Cut", "edit.cut"), ("Copy", "edit.copy"), ("Paste", "edit.paste")] {
if ui.add_enabled(crate::ribbon::enabled(app, id), egui::Button::new(l)).clicked() {
let _ = app.run(id, json!({}));
ui.close();
}
}
ui.separator();
if has_sel {
if ui.button("Edit Text").clicked() {
let _ = app.run("text.edit", json!({}));
ui.close();
}
ui.menu_button("Bring to Front", |ui| {
crate::ribbon::menu_item(app, ui, crate::icons::Icon::BringToFront, "Bring to Front", "arrange.bringToFront", json!({}));
crate::ribbon::menu_item(app, ui, crate::icons::Icon::BringForward, "Bring Forward", "arrange.bringForward", json!({}));
});
ui.menu_button("Send to Back", |ui| {
crate::ribbon::menu_item(app, ui, crate::icons::Icon::SendToBack, "Send to Back", "arrange.sendToBack", json!({}));
crate::ribbon::menu_item(app, ui, crate::icons::Icon::SendBackward, "Send Backward", "arrange.sendBackward", json!({}));
});
ui.menu_button("Group", |ui| {
crate::ribbon::menu_item(app, ui, crate::icons::Icon::Group, "Group", "arrange.group", json!({}));
crate::ribbon::menu_item(app, ui, crate::icons::Icon::Ungroup, "Ungroup", "arrange.ungroup", json!({}));
});
if ui.button("Link…").clicked() {
app.dialog = Some(crate::dialogs::Dialog::new("hyperlink"));
ui.close();
}
if ui.button("Edit Alt Text…").clicked() {
app.dialog = Some(crate::dialogs::Dialog::new("altText"));
ui.close();
}
if ui.button("Set as Default Shape").clicked() {
let _ = app.run("shape.setDefault", json!({}));
ui.close();
}
if ui.button("Format Shape…").clicked() {
let _ = app.run("view.pane", json!({"pane": "format"}));
ui.close();
}
} else {
ui.menu_button("Layout", |ui| {
let layouts: Vec<(String, u32)> = app
.session
.active()
.and_then(|d| d.doc.masters.first().map(|m| m.layouts.iter().map(|l| (l.name.clone(), l.id.0)).collect()))
.unwrap_or_default();
for (n, id) in layouts {
if ui.button(n).clicked() {
let _ = app.run("slide.layout", json!({"layout": id}));
ui.close();
}
}
});
if ui.button("Reset Slide").clicked() {
let _ = app.run("slide.reset", json!({}));
ui.close();
}
if ui.button("Format Background…").clicked() {
let _ = app.run("view.pane", json!({"pane": "background"}));
ui.close();
}
if ui.button("New Comment").clicked() {
app.dialog = Some(crate::dialogs::Dialog::new("comment"));
ui.close();
}
}
});
}
+347
View File
@@ -0,0 +1,347 @@
//! Programmatic control of the running app (agents, tests, MCP `--connect`).
//!
//! Methods (JSON lines over the host's transport, see `docs/control-protocol.md`):
//! - `engine.execute {command, params}` / `ui.menu.invoke {command, params}`: run any command (engine or UI)
//! - `engine.commands`: engine + UI commands with enablement; `document.inspect`; `ui.inspect`
//! - `ui.pointer {events:[{kind: down|drag|up|move|doubleclick, x, y}], mods}`: drive the active tool in
//! slide points (same path as the mouse)
//! - `ui.key {key, shift?, alt?, cmd?}` / `ui.text {text}`: keyboard through the engine
//! - `ui.tool.select {tool, preset?}`
//! - `ui.click {x, y, button?, count?}` / `ui.move {x, y}` / `ui.drag {x, y, toX, toY, steps?}`: real egui pointer
//! input in window points (reaches every widget)
//! - `ui.set {tab?, pane?, view?, zoom?, dark?, notes?, ribbonCollapsed?}`; `ui.dialog.close`
//! - `ui.screenshot {path?}`: the whole window as PNG; `ui.render {slide?, scale?, path?}`: one slide (headless)
//! - `ui.resize {width, height}`, `ui.focus`
//! - `show.start {from?}`, `show.key {key}`, `show.end`
//! - `app.open {path}` / `app.save {path?}` / `app.export {path, …}` / `app.quit`
use std::sync::mpsc::Sender;
use serde_json::{Value, json};
use slidecraft_engine::{Mods, PointerEvent};
use crate::SlideApp;
pub type ControlResponse = Value;
pub struct ControlRequest {
pub method: String,
pub params: Value,
pub reply: Sender<ControlResponse>,
}
impl ControlRequest {
pub fn new(method: impl Into<String>, params: Value) -> (Self, std::sync::mpsc::Receiver<ControlResponse>) {
let (tx, rx) = std::sync::mpsc::channel();
(Self { method: method.into(), params, reply: tx }, rx)
}
}
pub enum Outcome {
Done(Value),
Screenshot { path: Option<String> },
}
fn ok(v: Value) -> Outcome {
Outcome::Done(json!({"ok": true, "result": v}))
}
fn err(e: impl std::fmt::Display) -> Outcome {
Outcome::Done(json!({"ok": false, "error": e.to_string()}))
}
fn wrap(r: Result<Value, String>) -> Outcome {
match r {
Ok(v) => ok(v),
Err(e) => err(e),
}
}
pub fn all_commands(app: &SlideApp) -> Value {
let mut v: Vec<Value> = app.session.commands().into_iter().map(|c| serde_json::to_value(c).unwrap_or_default()).collect();
for (id, label, sc, params) in crate::UI_COMMANDS {
v.push(json!({"id": id, "label": label, "shortcut": sc, "params": params, "enabled": true, "ui": true}));
}
Value::Array(v)
}
pub fn inspect(app: &SlideApp, ctx: &egui::Context) -> Value {
let r = ctx.content_rect();
json!({
"ui": serde_json::to_value(&app.ui).unwrap_or_default(),
"tool": serde_json::to_value(&app.session.tool.kind).unwrap_or_default(),
"window": [r.width(), r.height()],
"canvasRect": app.canvas_rect.map(|c| json!([c.left(), c.top(), c.width(), c.height()])),
"slideRect": app.slide_rect.map(|c| json!([c.left(), c.top(), c.width(), c.height()])),
"canvasScale": app.canvas_scale,
"dialog": app.dialog.as_ref().map(|d| d.id.clone()),
"showing": app.show.as_ref().map(|s| json!({"slide": s.state.slide, "step": s.state.step, "ended": s.ended})),
"documents": app.session.documents().iter().map(|d| json!({"title": d.title(), "dirty": d.is_dirty(), "path": d.path})).collect::<Vec<_>>(),
"status": app.status.as_ref().map(|s| s.0.clone()),
"perf": {"frameMs": app.perf.frame_ms, "renderMs": app.perf.render_ms, "fps": app.perf.fps},
})
}
fn key_from(name: &str) -> Option<egui::Key> {
egui::Key::from_name(name).or(match name.to_ascii_lowercase().as_str() {
"enter" | "return" => Some(egui::Key::Enter),
"esc" | "escape" => Some(egui::Key::Escape),
"delete" => Some(egui::Key::Delete),
"backspace" => Some(egui::Key::Backspace),
"left" => Some(egui::Key::ArrowLeft),
"right" => Some(egui::Key::ArrowRight),
"up" => Some(egui::Key::ArrowUp),
"down" => Some(egui::Key::ArrowDown),
"space" => Some(egui::Key::Space),
"tab" => Some(egui::Key::Tab),
_ => None,
})
}
fn f(p: &Value, k: &str) -> Option<f32> {
p.get(k).and_then(Value::as_f64).map(|v| v as f32)
}
pub fn handle(app: &mut SlideApp, ctx: &egui::Context, req: &ControlRequest) -> Outcome {
let p = &req.params;
let s = |k: &str| p.get(k).and_then(Value::as_str);
match req.method.as_str() {
"engine.execute" | "ui.menu.invoke" | "command" => {
let Some(id) = s("command").or(s("id")) else { return err("missing `command`") };
let params = p.get("params").cloned().filter(|v| !v.is_null()).unwrap_or(json!({}));
wrap(app.run(id, params))
}
"engine.commands" => ok(all_commands(app)),
"document.inspect" => wrap(app.run("document.inspect", json!({}))),
"ui.inspect" => ok(inspect(app, ctx)),
"ui.pointer" => {
let Some(events) = p.get("events").and_then(Value::as_array) else { return err("missing `events`") };
let base = p.get("mods").map(mods_from).unwrap_or_default();
let mut last = Value::Null;
for e in events {
let kind = e.get("kind").and_then(Value::as_str).unwrap_or("");
let Some(kind) = slidecraft_mcp_kind(kind) else { return err(format!("unknown pointer kind `{kind}`")) };
let (Some(x), Some(y)) = (e.get("x").and_then(Value::as_f64), e.get("y").and_then(Value::as_f64)) else {
return err("pointer events need x and y");
};
let mods = e.get("mods").map(mods_from).unwrap_or(base);
match app.session.pointer(PointerEvent { kind, x, y, mods, tol: (4.0 / app.canvas_scale.max(0.05)) as f64 }) {
Ok(v) => last = v,
Err(e) => return err(e),
}
}
ok(json!({"result": last, "selection": app.session.active().map(|d| serde_json::to_value(&d.selection).unwrap_or_default())}))
}
"ui.key" => {
let Some(k) = s("key") else { return err("missing `key`") };
if let Some(show) = app.show.as_ref()
&& !show.ended
&& let Some(key) = key_from(k)
{
app.synthetic.push(egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE });
app.synthetic.push(egui::Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers: egui::Modifiers::NONE });
return ok(json!({"show": true}));
}
wrap(app.session.key(k, mods_from(p)).map_err(|e| e.to_string()))
}
"ui.text" => {
let Some(t) = s("text") else { return err("missing `text`") };
wrap(app.session.type_text(t).map_err(|e| e.to_string()))
}
"ui.tool.select" => {
let Some(name) = s("tool") else { return err("missing `tool`") };
match tool_kind(name, s("preset")) {
Some(k) => {
app.session.set_tool(k);
ok(json!({"tool": name}))
}
None => err(format!("unknown tool `{name}`")),
}
}
"ui.move" | "ui.click" | "ui.drag" => {
let (Some(x), Some(y)) = (f(p, "x"), f(p, "y")) else { return err("missing x/y") };
let pos = egui::pos2(x, y);
app.synthetic.push(egui::Event::PointerMoved(pos));
if req.method == "ui.click" {
let button = match s("button") {
Some("right" | "secondary") => egui::PointerButton::Secondary,
_ => egui::PointerButton::Primary,
};
let count = p.get("count").and_then(Value::as_u64).unwrap_or(1).clamp(1, 3);
for _ in 0..count {
app.synthetic.push(egui::Event::PointerButton { pos, button, pressed: true, modifiers: egui::Modifiers::NONE });
app.synthetic.push(egui::Event::PointerButton { pos, button, pressed: false, modifiers: egui::Modifiers::NONE });
}
} else if req.method == "ui.drag" {
let (Some(tx), Some(ty)) = (f(p, "toX"), f(p, "toY")) else { return err("missing toX/toY") };
let steps = p.get("steps").and_then(Value::as_u64).unwrap_or(8).clamp(1, 120);
app.synthetic.push(egui::Event::PointerButton {
pos,
button: egui::PointerButton::Primary,
pressed: true,
modifiers: egui::Modifiers::NONE,
});
for k in 1..=steps {
let a = k as f32 / steps as f32;
app.synthetic.push(egui::Event::PointerMoved(egui::pos2(x + (tx - x) * a, y + (ty - y) * a)));
}
app.synthetic.push(egui::Event::PointerButton {
pos: egui::pos2(tx, ty),
button: egui::PointerButton::Primary,
pressed: false,
modifiers: egui::Modifiers::NONE,
});
}
ctx.request_repaint();
ok(json!({"queued": app.synthetic.len()}))
}
"ui.set" => {
if let Some(t) = s("tab") {
app.ui.tab = t.to_string();
app.ui.ribbon_collapsed = false;
}
if let Some(pv) = p.get("pane") {
app.ui.pane = pv.as_str().map(String::from);
}
if let Some(v) = s("view") {
app.ui.view = match v {
"sorter" => crate::ViewMode::Sorter,
"outline" => crate::ViewMode::Outline,
"notesPage" => crate::ViewMode::NotesPage,
_ => crate::ViewMode::Normal,
};
}
if let Some(z) = p.get("zoom") {
app.ui.zoom = z.as_f64().map(|v| v as f32);
}
if let Some(d) = p.get("dark").and_then(Value::as_bool) {
let _ = app.run("view.dark", json!({"on": d}));
}
if let Some(n) = p.get("notes").and_then(Value::as_bool) {
app.ui.notes = n;
}
if let Some(c) = p.get("ribbonCollapsed").and_then(Value::as_bool) {
app.ui.ribbon_collapsed = c;
}
if let Some(ft) = s("formatTab") {
app.ui.format_tab = ft.to_string();
}
ok(serde_json::to_value(&app.ui).unwrap_or_default())
}
"ui.dialog.close" => {
app.dialog = None;
ok(Value::Null)
}
"ui.dialog.open" => {
let Some(id) = s("id") else { return err("missing `id`") };
app.dialog = Some(crate::dialogs::Dialog::new(id));
ok(Value::Null)
}
"ui.screenshot" => Outcome::Screenshot { path: s("path").map(String::from) },
"ui.render" => {
let Some(d) = app.session.active() else { return err("no presentation open") };
let i = p.get("slide").and_then(Value::as_u64).map(|v| v as usize).unwrap_or(d.selection.slide);
if i >= d.doc.slides.len() {
return err(format!("no slide {i}"));
}
let (png, w, h) = slidecraft_engine::cmd::file::render_png(
&d.doc,
i,
p.get("scale").and_then(Value::as_f64).unwrap_or(1.0),
p.get("edit").and_then(Value::as_bool).unwrap_or(false),
);
match s("path") {
Some(path) => match app.services.write.as_mut().map(|w| w(path, &png)) {
Some(Ok(())) => ok(json!({"path": path, "width": w, "height": h})),
Some(Err(e)) => err(e),
None => err("no writer"),
},
None => ok(json!({"png": slidecraft_engine::cmd::base64_encode(&png), "width": w, "height": h, "slide": i})),
}
}
"ui.resize" => {
let (Some(w), Some(h)) = (f(p, "width"), f(p, "height")) else { return err("missing width/height") };
ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(egui::vec2(w, h)));
ok(Value::Null)
}
"ui.focus" => {
ctx.send_viewport_cmd(egui::ViewportCommand::Focus);
ok(Value::Null)
}
"show.start" => {
let from = p.get("from").and_then(Value::as_u64).unwrap_or(0) as usize;
app.start_show(from, p.get("reading").and_then(Value::as_bool).unwrap_or(false));
ok(Value::Null)
}
"show.end" => {
if app.show.is_some() {
app.synthetic.push(egui::Event::Key {
key: egui::Key::Escape,
physical_key: None,
pressed: true,
repeat: false,
modifiers: egui::Modifiers::NONE,
});
}
ok(Value::Null)
}
"app.open" => {
let Some(path) = s("path") else { return err("missing `path`") };
wrap(app.open_path(path).map(|_| Value::Null))
}
"app.save" => wrap(app.run("file.save", p.clone())),
"app.export" => wrap(app.run("file.export", p.clone())),
"app.quit" => {
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
ok(Value::Null)
}
other => err(format!("unknown method `{other}`")),
}
}
fn mods_from(p: &Value) -> Mods {
let b = |n: &str| p.get(n).and_then(Value::as_bool).unwrap_or(false);
Mods { shift: b("shift"), alt: b("alt"), cmd: b("cmd") || b("ctrl") }
}
fn slidecraft_mcp_kind(k: &str) -> Option<slidecraft_engine::PointerKind> {
use slidecraft_engine::PointerKind::*;
Some(match k {
"down" => Down,
"drag" => Drag,
"up" => Up,
"move" => Move,
"doubleclick" | "dblclick" => DoubleClick,
"tripleclick" => TripleClick,
_ => return None,
})
}
pub fn tool_kind(name: &str, preset: Option<&str>) -> Option<slidecraft_engine::ToolKind> {
use slidecraft_engine::ToolKind;
Some(match name {
"select" | "selection" => ToolKind::Select,
"textBox" | "text" => ToolKind::TextBox,
"shape" => ToolKind::Shape { preset: preset.unwrap_or("rect").into() },
"pen" => ToolKind::Ink { mode: "pen".into(), color: slidecraft_color::Rgba::BLACK, width: 2.0 },
"highlighter" => ToolKind::Ink { mode: "highlighter".into(), color: slidecraft_color::Rgba::rgb(255, 230, 0), width: 10.0 },
"eraser" => ToolKind::Ink { mode: "eraser".into(), color: slidecraft_color::Rgba::BLACK, width: 2.0 },
other if slidecraft_geom::preset::info(other).is_some() => ToolKind::Shape { preset: other.into() },
_ => return None,
})
}
pub fn save_screenshot(app: &mut SlideApp, image: &egui::ColorImage, path: Option<&str>) -> Value {
let [w, h] = image.size;
let rgba: Vec<u8> = image.pixels.iter().flat_map(|c| c.to_array()).collect();
let img = slidecraft_render::Image { width: w as u32, height: h as u32, pixels: rgba };
let png = img.to_png();
let Some(path) = path else {
return json!({"ok": true, "result": {"width": w, "height": h, "png": slidecraft_engine::cmd::base64_encode(&png)}});
};
match app.services.write.as_mut() {
Some(wr) => match wr(path, &png) {
Ok(()) => json!({"ok": true, "result": {"path": path, "width": w, "height": h}}),
Err(e) => json!({"ok": false, "error": e}),
},
None => json!({"ok": false, "error": "no writer configured"}),
}
}
+782
View File
@@ -0,0 +1,782 @@
//! Dialogs (Insert Table, Slide Size, Header & Footer, Hyperlink, Set Up Show, Zoom, …), the start
//! screen and the About window.
use egui::{Align2, Color32, CornerRadius, Rect, Sense, Stroke, Ui, pos2, vec2};
use serde_json::{Value, json};
use crate::theme::{self, Tokens};
use crate::{SlideApp, ribbon};
#[derive(Clone, Debug, serde::Serialize)]
pub struct Dialog {
pub id: String,
pub params: Value,
/// Field values while the dialog is open.
#[serde(skip)]
pub fields: std::collections::HashMap<String, String>,
}
impl Dialog {
pub fn new(id: &str) -> Self {
Dialog { id: id.into(), params: Value::Null, fields: Default::default() }
}
pub fn modal(&self) -> bool {
true
}
fn get(&mut self, k: &str, default: &str) -> String {
self.fields.entry(k.into()).or_insert_with(|| default.to_string()).clone()
}
}
fn title(id: &str) -> &'static str {
match id {
"table" => "Insert Table",
"slideSize" => "Slide Size",
"headerFooter" => "Header and Footer",
"hyperlink" => "Insert Hyperlink",
"setupShow" => "Set Up Slide Show",
"zoom" => "Zoom",
"comment" => "New Comment",
"altText" => "Alt Text",
"outline" => "Slides from Outline",
"renameSection" => "Rename Section",
"renameShape" => "Rename",
"renameLayout" => "Rename Layout",
"paragraph" => "Paragraph",
"about" => "About SlideCraft",
"preferences" => "Preferences",
"export" => "Export",
"symbol" => "Symbol",
"equation" => "Equation",
"spelling" => "Spelling",
"accessibility" => "Accessibility Checker",
"language" => "Language",
"customShow" => "Custom Shows",
"chartData" => "Chart Data",
"trim" => "Trim Media",
"smartart" => "Choose a SmartArt Graphic",
_ => "SlideCraft",
}
}
pub fn show(app: &mut SlideApp, ctx: &egui::Context) {
let Some(mut d) = app.dialog.take() else { return };
let mut open = true;
let mut close = false;
egui::Window::new(title(&d.id))
.id(egui::Id::new(("dialog", d.id.clone())))
.collapsible(false)
.resizable(false)
.anchor(Align2::CENTER_CENTER, vec2(0.0, -40.0))
.open(&mut open)
.show(ctx, |ui| {
ui.set_min_width(340.0);
close = body(app, ui, &mut d);
});
if open && !close && !ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
app.dialog = Some(d);
}
}
fn buttons(ui: &mut Ui, ok_label: &str) -> (bool, bool) {
let mut ok = false;
let mut cancel = false;
ui.add_space(8.0);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let t = Tokens::get(ui.ctx());
if ui.add(egui::Button::new(egui::RichText::new(ok_label).color(t.accent_text)).fill(t.accent).min_size(vec2(72.0, 24.0))).clicked()
|| ui.input(|i| i.key_pressed(egui::Key::Enter))
{
ok = true;
}
if ui.add(egui::Button::new("Cancel").min_size(vec2(72.0, 24.0))).clicked() {
cancel = true;
}
});
(ok, cancel)
}
fn field(ui: &mut Ui, d: &mut Dialog, label: &str, key: &str, default: &str) {
let mut v = d.get(key, default);
ui.horizontal(|ui| {
ui.add_sized(vec2(120.0, 20.0), egui::Label::new(label));
ui.add(egui::TextEdit::singleline(&mut v).desired_width(180.0));
});
d.fields.insert(key.into(), v);
}
fn check(ui: &mut Ui, d: &mut Dialog, label: &str, key: &str, default: bool) -> bool {
let mut v = d.get(key, if default { "1" } else { "0" }) == "1";
ui.checkbox(&mut v, label);
d.fields.insert(key.into(), if v { "1".into() } else { "0".into() });
v
}
fn num(d: &mut Dialog, key: &str, default: f64) -> f64 {
d.get(key, "").trim().trim_end_matches(['"', '%', ' ']).parse::<f64>().ok().filter(|v| v.is_finite()).unwrap_or(default)
}
/// Returns true when the dialog should close.
fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
let run = |app: &mut SlideApp, id: &str, p: Value| {
let _ = app.run(id, p);
};
match d.id.as_str() {
"table" => {
field(ui, d, "Number of columns:", "cols", "5");
field(ui, d, "Number of rows:", "rows", "2");
let (ok, cancel) = buttons(ui, "Insert");
if ok {
let (r, c) = (num(d, "rows", 2.0) as usize, num(d, "cols", 5.0) as usize);
run(app, "insert.table", json!({"rows": r, "cols": c}));
}
ok || cancel
}
"slideSize" => {
let cur = app.session.active().map(|s| s.doc.slide_size).unwrap_or(slidecraft_model::defaults::WIDE);
ui.label("Slides sized for:");
let preset = d.get("preset", "");
egui::ComboBox::from_id_salt("sizes").selected_text(if preset.is_empty() { "Custom" } else { &preset }).show_ui(ui, |ui| {
for (label, w, h) in slidecraft_model::defaults::SLIDE_SIZES {
if ui.selectable_label(preset == *label, *label).clicked() {
d.fields.insert("preset".into(), label.to_string());
d.fields.insert("w".into(), format!("{:.2}", w / 72.0));
d.fields.insert("h".into(), format!("{:.2}", h / 72.0));
}
}
});
field(ui, d, "Width (in):", "w", &format!("{:.2}", cur.width / 72.0));
field(ui, d, "Height (in):", "h", &format!("{:.2}", cur.height / 72.0));
let maximize = check(ui, d, "Maximize content (else ensure fit)", "max", false);
let (ok, cancel) = buttons(ui, "OK");
if ok {
let (w, h) = (num(d, "w", cur.width / 72.0) * 72.0, num(d, "h", cur.height / 72.0) * 72.0);
run(app, "design.slideSize", json!({"w": w, "h": h, "scale": if maximize { "maximize" } else { "ensureFit" }}));
}
ok || cancel
}
"headerFooter" => {
let hf = app.session.active().map(|s| s.doc.header_footer.clone()).unwrap_or_default();
let date = check(ui, d, "Date and time", "date", hf.date);
field(ui, d, " Fixed date (empty = update automatically):", "dateText", &hf.date_text);
let num_on = check(ui, d, "Slide number", "num", hf.slide_number);
let foot = check(ui, d, "Footer", "footer", hf.footer);
field(ui, d, " Footer text:", "footerText", &hf.footer_text);
let hide = check(ui, d, "Don't show on title slide", "hide", hf.hide_on_title);
ui.add_space(6.0);
let mut apply_all = false;
let mut apply = false;
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
apply_all = ui.button("Apply to All").clicked();
apply = ui.button("Apply").clicked();
if ui.button("Cancel").clicked() {
d.fields.insert("cancel".into(), "1".into());
}
});
if apply || apply_all {
let dt = d.get("dateText", "");
let ft = d.get("footerText", "");
run(
app,
"design.headerFooter",
json!({"date": date, "dateText": dt, "slideNumber": num_on, "footer": foot, "footerText": ft, "hideOnTitle": hide, "all": apply_all}),
);
}
apply || apply_all || d.fields.contains_key("cancel")
}
"hyperlink" => {
let mode = d.get("mode", "url");
ui.horizontal(|ui| {
if ui.selectable_label(mode == "url", "Web Page or File").clicked() {
d.fields.insert("mode".into(), "url".into());
}
if ui.selectable_label(mode == "slide", "This Document").clicked() {
d.fields.insert("mode".into(), "slide".into());
}
if ui.selectable_label(mode == "email", "Email Address").clicked() {
d.fields.insert("mode".into(), "email".into());
}
});
match mode.as_str() {
"slide" => {
let titles: Vec<String> = app
.session
.active()
.map(|s| s.doc.slides.iter().enumerate().map(|(i, sl)| format!("{}. {}", i + 1, sl.title())).collect())
.unwrap_or_default();
let sel = d.get("slide", "0").parse::<usize>().unwrap_or(0);
egui::ScrollArea::vertical().max_height(180.0).show(ui, |ui| {
for (i, t) in titles.iter().enumerate() {
if ui.selectable_label(sel == i, t).clicked() {
d.fields.insert("slide".into(), i.to_string());
}
}
});
}
"email" => field(ui, d, "Email address:", "email", ""),
_ => field(ui, d, "Address:", "url", "https://"),
}
field(ui, d, "ScreenTip:", "tip", "");
let (ok, cancel) = buttons(ui, "OK");
if ok {
let tip = d.get("tip", "");
let p = match mode.as_str() {
"slide" => json!({"slide": d.get("slide", "0").parse::<usize>().unwrap_or(0), "tooltip": tip}),
"email" => json!({"url": format!("mailto:{}", d.get("email", "")), "tooltip": tip}),
_ => json!({"url": d.get("url", ""), "tooltip": tip}),
};
run(app, "insert.hyperlink", p);
}
ok || cancel
}
"setupShow" => {
let sh = app.session.active().map(|s| s.doc.show.clone()).unwrap_or_default();
let ty = d.get("type", &sh.show_type);
ui.label("Show type");
for (l, v) in [
("Presented by a speaker (full screen)", "speaker"),
("Browsed by an individual (window)", "browsed"),
("Browsed at a kiosk (full screen)", "kiosk"),
] {
if ui.radio(ty == v, l).clicked() {
d.fields.insert("type".into(), v.into());
}
}
let lp = check(ui, d, "Loop continuously until 'Esc'", "loop", sh.loop_until_esc);
let nn = check(ui, d, "Show without narration", "nn", sh.without_narration);
let na = check(ui, d, "Show without animation", "na", sh.without_animation);
let ut = check(ui, d, "Use timings, if present", "ut", sh.use_timings);
let (ok, cancel) = buttons(ui, "OK");
if ok {
let ty = d.get("type", "speaker");
run(app, "show.setup", json!({"type": ty, "loop": lp, "noNarration": nn, "noAnimation": na, "useTimings": ut}));
}
ok || cancel
}
"zoom" => {
for pct in [400, 200, 150, 100, 75, 66, 50, 33] {
if ui.radio(false, format!("{pct}%")).clicked() {
let _ = app.run("view.zoom", json!({"percent": pct}));
}
}
if ui.button("Fit").clicked() {
app.ui.zoom = None;
}
let (ok, cancel) = buttons(ui, "OK");
ok || cancel
}
"comment" => {
let mut v = d.get("text", "");
ui.add(egui::TextEdit::multiline(&mut v).hint_text("Start a conversation").desired_rows(4).desired_width(320.0));
d.fields.insert("text".into(), v.clone());
let (ok, cancel) = buttons(ui, "Post");
if ok && !v.trim().is_empty() {
run(app, "comment.add", json!({"text": v}));
app.ui.pane = Some("comments".into());
}
ok || cancel
}
"altText" => {
let cur = app.session.active().and_then(|s| s.selected_shapes().first().map(|x| x.descr.clone())).unwrap_or_default();
ui.label("How would you describe this object and its context to someone who is blind or has low vision?");
let mut v = d.get("text", &cur);
ui.add(egui::TextEdit::multiline(&mut v).desired_rows(4).desired_width(340.0));
d.fields.insert("text".into(), v.clone());
let deco = check(ui, d, "Mark as decorative", "deco", false);
let (ok, cancel) = buttons(ui, "OK");
if ok {
run(app, "shape.altText", json!({"text": v, "decorative": deco}));
}
ok || cancel
}
"outline" => {
ui.label("Paste or type an outline. Unindented lines become slide titles; tab-indented lines become bullets.");
let mut v = d.get("text", "");
ui.add(egui::TextEdit::multiline(&mut v).desired_rows(10).desired_width(380.0).code_editor());
d.fields.insert("text".into(), v.clone());
let (ok, cancel) = buttons(ui, "Insert");
if ok {
run(app, "slide.fromOutline", json!({"text": v}));
}
ok || cancel
}
"renameSection" | "renameShape" | "renameLayout" => {
let cur = d.params.get("name").and_then(Value::as_str).unwrap_or("").to_string();
field(ui, d, "Name:", "name", &cur);
let (ok, cancel) = buttons(ui, "Rename");
if ok {
let name = d.get("name", "");
match d.id.as_str() {
"renameSection" => run(app, "section.rename", json!({"index": d.params.get("index").cloned().unwrap_or_default(), "name": name})),
"renameShape" => run(app, "shape.rename", json!({"id": d.params.get("id").cloned().unwrap_or_default(), "name": name})),
_ => run(app, "master.renameLayout", json!({"name": name})),
}
}
ok || cancel
}
"paragraph" => {
field(ui, d, "Before text (in):", "left", "0");
field(ui, d, "Special — hanging (in):", "hang", "0");
field(ui, d, "Spacing before (pt):", "before", "0");
field(ui, d, "Spacing after (pt):", "after", "0");
field(ui, d, "Line spacing (lines):", "lines", "1.0");
let (ok, cancel) = buttons(ui, "OK");
if ok {
run(
app,
"format.paragraph",
json!({"indentLeft": num(d, "left", 0.0) * 72.0, "indentFirst": -num(d, "hang", 0.0) * 72.0, "spaceBefore": num(d, "before", 0.0), "spaceAfter": num(d, "after", 0.0), "lineSpacing": num(d, "lines", 1.0)}),
);
}
ok || cancel
}
"export" => {
ui.label("Export the presentation as:");
let fmt = d.get("fmt", "png");
for (l, f) in [
("PNG images (current slide)", "png"),
("PNG images (all slides)", "pngAll"),
("JPEG image (current slide)", "jpeg"),
("PowerPoint Presentation (.pptx)", "pptx"),
("Outline (.txt)", "outline"),
] {
if ui.radio(fmt == f, l).clicked() {
d.fields.insert("fmt".into(), f.into());
}
}
let (ok, cancel) = buttons(ui, "Export");
if ok {
let name = app.session.active().map(|s| s.title()).unwrap_or_else(|| "Presentation".into());
let (ext, all) = match fmt.as_str() {
"pngAll" => ("png", true),
"jpeg" => ("jpg", false),
"pptx" => ("pptx", false),
"outline" => ("txt", false),
_ => ("png", false),
};
let suggested = format!("{name}.{ext}");
if let Some(pick) = app.services.pick_save.as_mut() {
if let Some(path) = pick(&suggested) {
let r = app.session.execute("file.export", &json!({"path": path, "all": all}));
match r {
Ok(_) => app.set_status(format!("Exported {path}")),
Err(e) => app.set_status(e.to_string()),
}
}
} else if let (Some(dl), Some(s)) = (app.services.download.as_mut(), app.session.active()) {
let bytes = match ext {
"png" | "jpg" => {
let img = slidecraft_render::render_slide(
&s.doc,
s.selection.slide,
&slidecraft_render::RenderOpts { scale: 2.0, ..Default::default() },
);
if ext == "png" { img.to_png() } else { img.to_jpeg(92) }
}
other => slidecraft_engine::cmd::file::save_bytes(&s.doc, other).unwrap_or_default(),
};
dl(&suggested, &bytes);
}
}
ok || cancel
}
"symbol" => {
ui.label("Click a symbol to insert it at the insertion point.");
let groups: [(&str, &str); 6] = [
("Punctuation", "–—‘’“”…•·§¶†‡‰′″‹›«»¡¿"),
("Currency", "$€£¥¢₹₩₽₺₿"),
("Math", "±×÷≠≈≤≥∞√∑∏∫∂∆∇∈∉∩∪⊂⊃∀∃¬∧∨⇒⇔°′"),
("Greek", "αβγδεζηθικλμνξοπρστυφχψωΓΔΘΛΞΠΣΦΨΩ"),
("Arrows", "←↑→↓↔↕⇐⇑⇒⇓↖↗↘↙⟵⟶"),
("Shapes", "■□▪▫▲△▶▷▼▽◀◁◆◇○●◎★☆✓✔✗✘♠♣♥♦"),
];
for (g, chars) in groups {
ui.label(egui::RichText::new(g).font(theme::bold(12.0)));
ui.horizontal_wrapped(|ui| {
for c in chars.chars() {
if ui.add(egui::Button::new(egui::RichText::new(c.to_string()).size(16.0)).min_size(vec2(26.0, 26.0))).clicked() {
if app.session.active().is_some_and(|s| s.selection.text.is_none()) {
let _ = app.run("text.edit", json!({}));
}
let _ = app.run("insert.symbol", json!({"text": c.to_string()}));
}
}
});
}
let (ok, cancel) = buttons(ui, "Close");
ok || cancel
}
"equation" => {
ui.label("Type an equation in linear form (e.g. a^2+b^2=c^2, x=(-b±√(b^2-4ac))/2a).");
let mut v = d.get("eq", "");
ui.add(egui::TextEdit::singleline(&mut v).desired_width(340.0).font(theme::font(15.0)));
d.fields.insert("eq".into(), v.clone());
let (ok, cancel) = buttons(ui, "Insert");
if ok && !v.is_empty() {
let pretty = pretty_equation(&v);
let size = app.session.active().map(|s| s.doc.slide_size).unwrap_or(slidecraft_model::defaults::WIDE);
run(app, "insert.textBox", json!({"rect": [size.width / 2.0 - 150.0, size.height / 2.0 - 25.0, 300, 50], "text": pretty}));
run(app, "text.exit", json!({}));
run(app, "format.font", json!({"family": "Liberation Serif"}));
run(app, "format.italic", json!({"on": true}));
run(app, "format.size", json!({"size": 28}));
}
ok || cancel
}
"spelling" => {
let v = app.session.execute("review.spelling", &json!({})).unwrap_or_default();
let list = v.as_array().cloned().unwrap_or_default();
if list.is_empty() {
ui.label("The spelling check is complete. No issues found.");
}
for item in &list {
ui.horizontal(|ui| {
let w = item.get("word").and_then(Value::as_str).unwrap_or("");
ui.label(format!("Slide {}: “{w}”", item.get("slide").and_then(Value::as_u64).unwrap_or(0) + 1));
if ui.small_button("Go to").clicked() {
let _ = app.run("slide.go", json!({"index": item.get("slide").cloned().unwrap_or_default()}));
}
});
}
let (ok, cancel) = buttons(ui, "Close");
ok || cancel
}
"accessibility" => {
let v = app.session.execute("review.accessibility", &json!({})).unwrap_or_default();
let list = v.as_array().cloned().unwrap_or_default();
if list.is_empty() {
ui.label("No accessibility issues found. People with disabilities should not have difficulty reading this document.");
}
egui::ScrollArea::vertical().max_height(300.0).show(ui, |ui| {
for item in &list {
ui.horizontal(|ui| {
ui.label(format!(
"Slide {}: {}{}",
item.get("slide").and_then(Value::as_u64).unwrap_or(0) + 1,
item.get("issue").and_then(Value::as_str).unwrap_or(""),
item.get("name").and_then(Value::as_str).map(|n| format!(" — {n}")).unwrap_or_default()
));
if ui.small_button("Go to").clicked() {
let _ = app.run("slide.go", json!({"index": item.get("slide").cloned().unwrap_or_default()}));
if let Some(id) = item.get("shape") {
let _ = app.run("edit.select", json!({"ids": [id]}));
}
}
});
}
});
let (ok, cancel) = buttons(ui, "Close");
ok || cancel
}
"language" => {
ui.label("Mark selected text as:");
for l in [
"English (United States)",
"English (United Kingdom)",
"Español",
"Français",
"Deutsch",
"Italiano",
"Português",
"日本語",
"中文",
"العربية",
] {
let _ = ui.selectable_label(l.starts_with("English (United States)"), l);
}
let (ok, cancel) = buttons(ui, "OK");
ok || cancel
}
"customShow" => {
let shows = app.session.active().map(|s| s.doc.custom_shows.clone()).unwrap_or_default();
for s in &shows {
ui.horizontal(|ui| {
ui.label(format!("{} ({} slides)", s.name, s.slides.len()));
if ui.small_button("Delete").clicked() {
let _ = app.run("show.customShow", json!({"name": s.name, "delete": true}));
}
});
}
field(ui, d, "New show name:", "name", "Custom Show 1");
field(ui, d, "Slides (e.g. 1,3,5):", "slides", "1");
let (ok, cancel) = buttons(ui, "Create");
if ok {
let idx: Vec<usize> =
d.get("slides", "").split(',').filter_map(|s| s.trim().parse::<usize>().ok()).filter(|v| *v > 0).map(|v| v - 1).collect();
run(app, "show.customShow", json!({"name": d.get("name", "Custom Show"), "slides": idx}));
}
ok || cancel
}
"chartData" => {
let ch = app.session.active().and_then(|s| {
s.selected_shapes()
.into_iter()
.find_map(|x| if let slidecraft_model::ShapeKind::Chart(c) = &x.kind { Some(((**c).clone(), x.id)) } else { None })
});
let Some((c, id)) = ch else {
ui.label("Select a chart first.");
let (ok, cancel) = buttons(ui, "Close");
return ok || cancel;
};
ui.label("Edit the data: first row = series names, first column = categories.");
let mut grid: Vec<Vec<String>> = d.params.get("grid").and_then(|g| serde_json::from_value(g.clone()).ok()).unwrap_or_else(|| {
let mut g = vec![std::iter::once(String::new()).chain(c.series.iter().map(|s| s.name.clone())).collect::<Vec<_>>()];
for (i, cat) in c.categories.iter().enumerate() {
let mut row = vec![cat.clone()];
for s in &c.series {
row.push(s.values.get(i).copied().flatten().map(|v| v.to_string()).unwrap_or_default());
}
g.push(row);
}
g
});
egui::Grid::new("chartdata").striped(true).show(ui, |ui| {
for row in grid.iter_mut() {
for cell in row.iter_mut() {
ui.add(egui::TextEdit::singleline(cell).desired_width(70.0));
}
ui.end_row();
}
});
ui.horizontal(|ui| {
if ui.button("+ Row").clicked() {
let n = grid.first().map(|r| r.len()).unwrap_or(2);
grid.push(vec![String::new(); n]);
}
if ui.button("+ Series").clicked() {
for r in grid.iter_mut() {
r.push(String::new());
}
}
});
d.params = json!({"grid": grid});
let (ok, cancel) = buttons(ui, "OK");
if ok {
let names: Vec<String> = grid.first().map(|r| r.iter().skip(1).cloned().collect()).unwrap_or_default();
let cats: Vec<String> = grid.iter().skip(1).map(|r| r.first().cloned().unwrap_or_default()).collect();
let series: Vec<Value> = names.iter().enumerate().map(|(k, n)| json!({"name": n, "values": grid.iter().skip(1).map(|r| r.get(k + 1).and_then(|v| v.trim().parse::<f64>().ok())).collect::<Vec<_>>()})).collect();
run(app, "chart.data", json!({"id": id.0, "categories": cats, "series": series}));
}
ok || cancel
}
"trim" => {
field(ui, d, "Start time (s):", "start", "0");
field(ui, d, "End trim (s):", "end", "0");
field(ui, d, "Fade in (s):", "fin", "0");
field(ui, d, "Fade out (s):", "fout", "0");
let (ok, cancel) = buttons(ui, "OK");
if ok {
run(
app,
"media.options",
json!({"trimStart": (num(d, "start", 0.0) * 1000.0) as u64, "trimEnd": (num(d, "end", 0.0) * 1000.0) as u64, "fadeIn": (num(d, "fin", 0.0) * 1000.0) as u64, "fadeOut": (num(d, "fout", 0.0) * 1000.0) as u64}),
);
}
ok || cancel
}
"smartart" => {
ui.label("Pick a graphic; type one item per line.");
let kind = d.get("kind", "process");
ui.horizontal_wrapped(|ui| {
for (k, l) in [
("list", "Basic List"),
("process", "Basic Process"),
("cycle", "Basic Cycle"),
("hierarchy", "Hierarchy"),
("pyramid", "Pyramid"),
("matrix", "Matrix"),
] {
if ui.selectable_label(kind == k, l).clicked() {
d.fields.insert("kind".into(), k.into());
}
}
});
let mut v = d.get("text", "Plan\nBuild\nLaunch");
ui.add(egui::TextEdit::multiline(&mut v).desired_rows(5).desired_width(320.0));
d.fields.insert("text".into(), v.clone());
let (ok, cancel) = buttons(ui, "Insert");
if ok {
run(app, "insert.smartArt", json!({"kind": kind, "items": v.lines().filter(|l| !l.trim().is_empty()).collect::<Vec<_>>()}));
}
ok || cancel
}
"about" => {
about(app, ui);
let (ok, _) = buttons(ui, "Close");
ok
}
"preferences" => {
ui.label(egui::RichText::new("General").font(theme::bold(13.0)));
let mut dark = app.ui.brightness == theme::Brightness::Dark;
if ui.checkbox(&mut dark, "Dark appearance").changed() {
let _ = app.run("view.dark", json!({"on": dark}));
}
let mut scale = app.ui.ui_scale;
if ui.add(egui::Slider::new(&mut scale, 0.75..=1.75).text("Interface size")).changed() {
app.ui.ui_scale = scale;
}
ui.label(egui::RichText::new("Editing").font(theme::bold(13.0)));
ui.checkbox(&mut app.session.prefs.smart_guides, "Show smart guides");
ui.checkbox(&mut app.session.prefs.snap_to_grid, "Snap objects to grid");
ui.checkbox(&mut app.session.prefs.smart_quotes, "Replace straight quotes with smart quotes");
ui.checkbox(&mut app.session.prefs.autocorrect, "AutoCorrect as you type");
ui.horizontal(|ui| {
ui.label("Your name (comments):");
ui.text_edit_singleline(&mut app.session.prefs.author);
});
let (ok, _) = buttons(ui, "Done");
ok
}
_ => {
ui.label(format!("“{}” is not available yet.", d.id));
let (ok, cancel) = buttons(ui, "OK");
ok || cancel
}
}
}
fn pretty_equation(s: &str) -> String {
let sup = |c: char| match c {
'0' => '⁰',
'1' => '¹',
'2' => '²',
'3' => '³',
'4' => '⁴',
'5' => '⁵',
'6' => '⁶',
'7' => '⁷',
'8' => '⁸',
'9' => '⁹',
'n' => 'ⁿ',
'i' => 'ⁱ',
'+' => '⁺',
'-' => '⁻',
_ => c,
};
let mut out = String::new();
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
match c {
'^' => {
while let Some(&n) = chars.peek() {
if n.is_ascii_alphanumeric() || n == '+' || n == '-' {
out.push(sup(n));
chars.next();
if !n.is_ascii_digit() {
break;
}
} else {
break;
}
}
}
'*' => out.push('·'),
_ => out.push(c),
}
}
out.replace("sqrt", "√").replace("<=", "≤").replace(">=", "≥").replace("!=", "≠").replace("pi", "π").replace("+-", "±")
}
pub fn about(_app: &mut SlideApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
ui.vertical_centered(|ui| {
let (r, _) = ui.allocate_exact_size(vec2(64.0, 64.0), Sense::hover());
paint_logo(ui.painter(), r);
ui.label(egui::RichText::new("SlideCraft").font(theme::bold(22.0)));
ui.label(egui::RichText::new(format!("Version {}", env!("CARGO_PKG_VERSION"))).color(t.text_dim));
ui.add_space(6.0);
ui.label("Presentations and slide shows, rebuilt from scratch in pure Rust.");
ui.label("An open-source, clean-room project. Not affiliated with Microsoft.");
ui.add_space(6.0);
ui.hyperlink_to("Join our community on Discord", slidecraft_engine::links::DISCORD);
ui.hyperlink_to("getartcraft.com/apps/slidecraft", slidecraft_engine::links::APP_PAGE);
ui.hyperlink_to("Source on GitHub", slidecraft_engine::links::GITHUB);
ui.add_space(6.0);
ui.label(egui::RichText::new("MIT OR Apache-2.0 · © 2026 ArtCraft Team and the SlideCraft contributors").size(11.0).color(t.text_faint));
});
}
/// SlideCraft's mark, drawn in code: a slide card with a play triangle in our orange.
pub fn paint_logo(p: &egui::Painter, r: Rect) {
let orange = Color32::from_rgb(0xF2, 0x6B, 0x1D);
let deep = Color32::from_rgb(0xC2, 0x4E, 0x14);
let card = r.shrink(r.width() * 0.08);
p.rect_filled(card.translate(vec2(r.width() * 0.04, r.width() * 0.05)), CornerRadius::same((r.width() * 0.14) as u8), deep);
p.rect_filled(card, CornerRadius::same((r.width() * 0.14) as u8), orange);
let c = card.center();
let s = card.width() * 0.22;
p.add(egui::Shape::convex_polygon(
vec![pos2(c.x - s * 0.7, c.y - s), pos2(c.x + s, c.y), pos2(c.x - s * 0.7, c.y + s)],
Color32::WHITE,
Stroke::NONE,
));
}
/// The start screen (no presentation open): new from themes, open, recent files.
pub fn start_screen(app: &mut SlideApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
egui::ScrollArea::vertical().show(ui, |ui| {
ui.add_space(24.0);
ui.horizontal(|ui| {
ui.add_space(40.0);
let (r, _) = ui.allocate_exact_size(vec2(40.0, 40.0), Sense::hover());
paint_logo(ui.painter(), r);
ui.label(egui::RichText::new("SlideCraft").font(theme::bold(26.0)));
});
ui.add_space(16.0);
ui.horizontal(|ui| {
ui.add_space(40.0);
ui.label(egui::RichText::new("New presentation").font(theme::bold(16.0)));
});
ui.add_space(8.0);
ui.horizontal_wrapped(|ui| {
ui.add_space(40.0);
for th in slidecraft_model::theme::builtin_themes() {
let (r, resp) = ui.allocate_exact_size(vec2(180.0, 128.0), Sense::click());
let tile = Rect::from_min_size(r.min, vec2(180.0, 101.0));
ribbon::paint_theme_tile(ui.painter(), tile, &th);
if resp.hovered() {
ui.painter().rect_stroke(tile, CornerRadius::same(2), Stroke::new(2.0, t.accent), egui::StrokeKind::Outside);
}
ui.painter().text(pos2(r.min.x, r.max.y - 10.0), Align2::LEFT_CENTER, &th.name, theme::font(12.0), t.text);
if resp.clicked() {
let _ = app.run("file.new", json!({"theme": th.name}));
}
}
});
ui.add_space(16.0);
ui.horizontal(|ui| {
ui.add_space(40.0);
if ui.button("Open…").clicked() {
let _ = app.run("app.openDialog", json!({}));
}
if ui.button("Open the sample deck").clicked() {
if let Err(e) = slidecraft_engine::sample::open_sample(&mut app.session) {
app.set_status(e.to_string());
}
}
});
if !app.ui.recent.is_empty() {
ui.add_space(16.0);
ui.horizontal(|ui| {
ui.add_space(40.0);
ui.label(egui::RichText::new("Recent").font(theme::bold(16.0)));
});
for p in app.ui.recent.clone() {
ui.horizontal(|ui| {
ui.add_space(40.0);
if ui.link(&p).clicked()
&& let Err(e) = app.open_path(&p)
{
app.set_status(e);
}
});
}
}
ui.add_space(30.0);
ui.horizontal(|ui| {
ui.add_space(40.0);
ui.hyperlink_to("Join the ArtCraft community on Discord", slidecraft_engine::links::DISCORD);
});
});
}
File diff suppressed because it is too large. Load diff
+754
View File
@@ -0,0 +1,754 @@
//! SlideCraft's egui frontend: a PowerPoint-style UI over `slidecraft-engine`.
//!
//! The UI is thin: every action goes through [`SlideApp::run`], which handles UI commands
//! (views, panes, zoom) here and sends everything else to the engine. The ribbon, menus,
//! shortcuts, the command palette and the control channel all share that entry point.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
pub mod canvas;
pub mod control;
pub mod dialogs;
pub mod icons;
pub mod menus;
pub mod panes;
pub mod ribbon;
pub mod show;
pub mod sorter;
pub mod status;
pub mod textures;
pub mod theme;
pub mod thumbs;
pub mod widgets;
use std::sync::mpsc::{Receiver, Sender};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use slidecraft_engine::{Mods, Session, UiRequest};
pub use control::{ControlRequest, ControlResponse};
pub type ReadFn = Box<dyn Fn(&str) -> Result<Vec<u8>, String>>;
pub type WriteFn = Box<dyn FnMut(&str, &[u8]) -> Result<(), String>>;
pub type PickFn = Box<dyn FnMut(&str) -> Option<String>>;
pub type OpenAsyncFn = Box<dyn FnMut(&str)>;
pub type DownloadFn = Box<dyn FnMut(&str, &[u8])>;
/// Files `(name, bytes)` delivered asynchronously by the host (web file picker, dropped files).
pub type Inbox = std::sync::Arc<std::sync::Mutex<Vec<(String, Vec<u8>)>>>;
/// Platform services injected by the host (desktop or web).
#[derive(Default)]
pub struct Services {
/// Open-file dialog for a purpose (`open`, `picture`, `audio`, `video`) → path.
pub pick_open: Option<PickFn>,
/// Save dialog with a suggested name → path.
pub pick_save: Option<PickFn>,
pub read: Option<ReadFn>,
pub write: Option<WriteFn>,
/// Asynchronous open dialog (web): the chosen file arrives through `inbox`.
pub open_async: Option<OpenAsyncFn>,
/// Hand bytes to the user as a named file (browser download).
pub download: Option<DownloadFn>,
pub inbox: Option<Inbox>,
/// Image on the system clipboard (PNG bytes), when the host can read one.
pub clipboard_image: Option<Box<dyn FnMut() -> Option<Vec<u8>>>>,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum ViewMode {
#[default]
Normal,
Outline,
Sorter,
NotesPage,
Reading,
}
/// Persisted UI state.
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct UiState {
pub brightness: theme::Brightness,
pub tab: String,
pub ribbon_collapsed: bool,
pub view: ViewMode,
pub notes: bool,
pub notes_height: f32,
pub thumbs_width: f32,
/// Open task pane: `format`, `animation`, `selection`, `comments`, `designer`, `background`.
pub pane: Option<String>,
pub pane_width: f32,
pub ruler: bool,
pub gridlines: bool,
pub guides: bool,
pub smart_guides: bool,
/// None = fit to window.
pub zoom: Option<f32>,
pub sorter_zoom: f32,
pub recent: Vec<String>,
pub presenter_view: bool,
pub ui_scale: f32,
/// Format Shape pane: tab (`fill`, `effects`, `size`, `picture`, `text`).
pub format_tab: String,
pub grayscale: bool,
}
impl Default for UiState {
fn default() -> Self {
UiState {
brightness: theme::Brightness::Light,
tab: "Home".into(),
ribbon_collapsed: false,
view: ViewMode::Normal,
notes: true,
notes_height: 52.0,
thumbs_width: 236.0,
pane: None,
pane_width: 300.0,
ruler: false,
gridlines: false,
guides: false,
smart_guides: true,
zoom: None,
sorter_zoom: 1.0,
recent: vec![],
presenter_view: true,
ui_scale: 1.0,
format_tab: "fill".into(),
grayscale: false,
}
}
}
/// UI-only commands (id, label, shortcut, params).
pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[
("view.normal", "Normal", None, "{}"),
("view.outline", "Outline View", None, "{}"),
("view.sorter", "Slide Sorter", None, "{}"),
("view.notesPage", "Notes Page", None, "{}"),
("view.reading", "Reading View", None, "{}"),
("view.notes", "Notes", None, "{on?: bool}"),
("view.ruler", "Ruler", None, "{on?: bool}"),
("view.gridlines", "Gridlines", Some("Shift+F9"), "{on?: bool}"),
("view.guides", "Guides", Some("Alt+F9"), "{on?: bool}"),
("view.zoomIn", "Zoom In", Some("Cmd+="), "{}"),
("view.zoomOut", "Zoom Out", Some("Cmd+-"), "{}"),
("view.zoom", "Zoom…", None, "{percent}"),
("view.fit", "Fit to Window", None, "{}"),
("view.pane", "Task Pane", None, "{pane: format|animation|selection|comments|designer|background|null}"),
("view.tab", "Ribbon Tab", None, "{tab}"),
("view.collapseRibbon", "Collapse the Ribbon", Some("Cmd+F1"), "{}"),
("view.dark", "Dark Mode", None, "{on?: bool}"),
("view.grayscale", "Grayscale", None, "{on?: bool}"),
("show.start", "Slide Show", Some("F5"), "{from?: index}"),
("show.presenter", "Presenter View", None, "{on?: bool}"),
("show.end", "End Show", None, "{}"),
("app.about", "About SlideCraft", None, "{}"),
("app.palette", "Command Palette", Some("Cmd+Shift+P"), "{}"),
("app.preferences", "Preferences…", Some("Cmd+,"), "{}"),
("app.openDialog", "Open…", None, "{purpose?}"),
("app.insertPictureDialog", "Picture from File…", None, "{}"),
("app.insertAudioDialog", "Audio from File…", None, "{}"),
("app.insertVideoDialog", "Video from File…", None, "{}"),
("app.saveAsDialog", "Save As…", None, "{}"),
("app.exportDialog", "Export…", None, "{}"),
("app.dialog", "Open Dialog", None, "{id}"),
("app.links", "Community Links", None, "{}"),
];
pub struct Perf {
pub frame_ms: f64,
pub render_ms: f64,
pub fps: f64,
}
pub struct SlideApp {
pub session: Session,
pub services: Services,
pub ui: UiState,
pub textures: textures::Textures,
pub control_rx: Option<Receiver<ControlRequest>>,
pub dialog: Option<dialogs::Dialog>,
pub show: Option<show::Show>,
pub status: Option<(String, f64)>,
pub perf: Perf,
/// Synthetic input events (control channel `ui.click` etc.), injected one per frame.
pub synthetic: Vec<egui::Event>,
/// Canvas screen rect and points-per-slide-point scale (for control & tests).
pub canvas_rect: Option<egui::Rect>,
pub canvas_scale: f32,
pub slide_rect: Option<egui::Rect>,
pub palette: Option<(String, usize)>,
/// Integrated title bar (macOS traffic lights over our chrome).
pub integrated_titlebar: bool,
pending_urls: Vec<String>,
shot_token: u64,
queued_shots: Vec<(u64, f64, u32)>,
pending_shots: Vec<(u64, Option<String>, Sender<ControlResponse>, f64)>,
styled: bool,
restyle: bool,
fonts_ready: bool,
last_time: f64,
pub(crate) notes_buf: (u64, usize, String),
/// Thumbnail pane drag (slide index, current drop index).
pub(crate) thumb_drag: Option<(usize, usize)>,
}
impl SlideApp {
pub fn new(session: Session, services: Services) -> Self {
SlideApp {
session,
services,
ui: UiState::default(),
textures: textures::Textures::default(),
control_rx: None,
dialog: None,
show: None,
status: None,
perf: Perf { frame_ms: 0.0, render_ms: 0.0, fps: 0.0 },
synthetic: vec![],
canvas_rect: None,
canvas_scale: 1.0,
slide_rect: None,
palette: None,
integrated_titlebar: false,
pending_urls: vec![],
shot_token: 0,
queued_shots: vec![],
pending_shots: vec![],
styled: false,
restyle: true,
fonts_ready: false,
last_time: 0.0,
notes_buf: (0, usize::MAX, String::new()),
thumb_drag: None,
}
}
pub fn with_control(mut self, rx: Receiver<ControlRequest>) -> Self {
self.control_rx = Some(rx);
self
}
pub fn set_status(&mut self, msg: impl Into<String>) {
self.status = Some((msg.into(), now_ms()));
}
/// Run a command by id (UI commands here, everything else in the engine). Errors show in the
/// status bar and are returned.
pub fn run(&mut self, id: &str, params: Value) -> Result<Value, String> {
let r = if UI_COMMANDS.iter().any(|c| c.0 == id) {
self.run_ui(id, &params)
} else {
self.session.execute(id, &params).map_err(|e| e.to_string())
};
if let Err(e) = &r {
self.set_status(e.clone());
}
self.drain_requests();
r
}
fn flag(&mut self, params: &Value, cur: bool) -> bool {
params.get("on").and_then(Value::as_bool).unwrap_or(!cur)
}
fn run_ui(&mut self, id: &str, p: &Value) -> Result<Value, String> {
match id {
"view.normal" => self.ui.view = ViewMode::Normal,
"view.outline" => self.ui.view = ViewMode::Outline,
"view.sorter" => self.ui.view = ViewMode::Sorter,
"view.notesPage" => self.ui.view = ViewMode::NotesPage,
"view.reading" => self.start_show(self.session.active().map(|d| d.selection.slide).unwrap_or(0), true),
"view.notes" => self.ui.notes = self.flag(p, self.ui.notes),
"view.ruler" => self.ui.ruler = self.flag(p, self.ui.ruler),
"view.gridlines" => self.ui.gridlines = self.flag(p, self.ui.gridlines),
"view.guides" => self.ui.guides = self.flag(p, self.ui.guides),
"view.zoomIn" | "view.zoomOut" => {
let cur = self.ui.zoom.unwrap_or(self.canvas_scale);
let steps = [0.1, 0.25, 0.33, 0.5, 0.66, 0.75, 1.0, 1.25, 1.5, 2.0, 3.0, 4.0];
let next = if id == "view.zoomIn" {
steps.iter().copied().find(|s| *s > cur + 0.01).unwrap_or(4.0)
} else {
steps.iter().rev().copied().find(|s| *s < cur - 0.01).unwrap_or(0.1)
};
self.ui.zoom = Some(next);
}
"view.zoom" => {
let pct = p.get("percent").and_then(Value::as_f64).ok_or("missing `percent`")?;
self.ui.zoom = Some((pct as f32 / 100.0).clamp(0.1, 4.0));
}
"view.fit" => self.ui.zoom = None,
"view.pane" => {
let pane = p.get("pane").and_then(Value::as_str).map(String::from);
self.ui.pane =
if pane.is_some() && pane == self.ui.pane && p.get("toggle").and_then(Value::as_bool).unwrap_or(false) { None } else { pane };
if let Some(tab) = p.get("tab").and_then(Value::as_str) {
self.ui.format_tab = tab.to_string();
}
}
"view.tab" => {
let tab = p.get("tab").and_then(Value::as_str).ok_or("missing `tab`")?;
self.ui.tab = tab.to_string();
self.ui.ribbon_collapsed = false;
}
"view.collapseRibbon" => self.ui.ribbon_collapsed = !self.ui.ribbon_collapsed,
"view.dark" => {
let on = self.flag(p, self.ui.brightness == theme::Brightness::Dark);
self.ui.brightness = if on { theme::Brightness::Dark } else { theme::Brightness::Light };
self.restyle = true;
}
"view.grayscale" => self.ui.grayscale = self.flag(p, self.ui.grayscale),
"show.start" => {
let from = p.get("from").and_then(Value::as_u64).map(|v| v as usize).unwrap_or(0);
self.start_show(from, false);
}
"show.presenter" => self.ui.presenter_view = self.flag(p, self.ui.presenter_view),
"show.end" => self.show = None,
"app.about" => self.dialog = Some(dialogs::Dialog::new("about")),
"app.palette" => self.palette = Some((String::new(), 0)),
"app.preferences" => self.dialog = Some(dialogs::Dialog::new("preferences")),
"app.openDialog" => self.pick_and_open(),
"app.insertPictureDialog" => self.pick_and_insert("picture", "insert.picture"),
"app.insertAudioDialog" => self.pick_and_insert("audio", "insert.audio"),
"app.insertVideoDialog" => self.pick_and_insert("video", "insert.video"),
"app.saveAsDialog" => self.save_as_dialog("slidecraft"),
"app.exportDialog" => self.dialog = Some(dialogs::Dialog::new("export")),
"app.dialog" => {
let d = p.get("id").and_then(Value::as_str).ok_or("missing `id`")?;
self.dialog = Some(dialogs::Dialog::new(d));
}
"app.links" => {
use slidecraft_engine::links::*;
return Ok(json!({"discord": DISCORD, "website": WEBSITE, "apps": APPS, "appPage": APP_PAGE, "github": GITHUB}));
}
_ => return Err(format!("unknown UI command `{id}`")),
}
Ok(Value::Null)
}
pub fn start_show(&mut self, from: usize, reading: bool) {
if let Some(d) = self.session.active() {
self.show = Some(show::Show::new(&d.doc, from, reading, self.ui.presenter_view && !reading));
}
}
fn pick_and_open(&mut self) {
if let Some(pick) = self.services.pick_open.as_mut() {
if let Some(path) = pick("open") {
let _ = self.open_path(&path);
}
} else if let Some(f) = self.services.open_async.as_mut() {
f("open");
}
}
pub fn open_path(&mut self, path: &str) -> Result<(), String> {
self.session.execute("file.open", &json!({"path": path})).map_err(|e| e.to_string())?;
self.ui.recent.retain(|p| p != path);
self.ui.recent.insert(0, path.to_string());
self.ui.recent.truncate(12);
self.ui.view = ViewMode::Normal;
Ok(())
}
fn pick_and_insert(&mut self, purpose: &str, cmd: &str) {
if let Some(pick) = self.services.pick_open.as_mut() {
if let Some(path) = pick(purpose) {
let r = match self.services.read.as_ref().map(|r| r(&path)) {
Some(Ok(bytes)) => {
let name = std::path::Path::new(&path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
self.session.execute(cmd, &json!({"name": name, "data": slidecraft_engine::cmd::base64_encode(&bytes)}))
}
_ => self.session.execute(cmd, &json!({"path": path})),
};
if let Err(e) = r {
self.set_status(e.to_string());
}
}
} else if let Some(f) = self.services.open_async.as_mut() {
f(purpose);
}
}
pub fn save_as_dialog(&mut self, format: &str) {
let name = self.session.active().map(|d| d.title()).unwrap_or_else(|| "Presentation".into());
let ext = if format == "pptx" { "pptx" } else { "slidecraft" };
let suggested = format!("{name}.{ext}");
if let Some(pick) = self.services.pick_save.as_mut() {
if let Some(path) = pick(&suggested) {
match self.session.execute("file.saveAs", &json!({"path": path})) {
Ok(_) => {
self.set_status(format!("Saved {path}"));
self.ui.recent.retain(|p| *p != path);
self.ui.recent.insert(0, path);
}
Err(e) => self.set_status(e.to_string()),
}
}
} else if let Some(dl) = self.services.download.as_mut()
&& let Some(d) = self.session.active()
{
match slidecraft_engine::cmd::file::save_bytes(&d.doc, ext) {
Ok(bytes) => dl(&suggested, &bytes),
Err(e) => self.set_status(e.to_string()),
}
}
}
pub fn save(&mut self) {
if self.session.active().and_then(|d| d.path.clone()).is_some() {
match self.session.execute("file.save", &json!({})) {
Ok(_) => self.set_status("Saved"),
Err(e) => self.set_status(e.to_string()),
}
} else {
self.save_as_dialog("slidecraft");
}
}
/// Act on requests commands left for the UI.
fn drain_requests(&mut self) {
for r in std::mem::take(&mut self.session.ui_requests) {
match r {
UiRequest::Dialog { id, params } => {
let mut d = dialogs::Dialog::new(&id);
d.params = params;
self.dialog = Some(d);
}
UiRequest::Pane { id } => self.ui.pane = Some(id),
UiRequest::StartShow { from } => self.start_show(from, false),
UiRequest::Message { text } => self.set_status(text),
UiRequest::PickFile { purpose } => {
if purpose == "saveAs" {
self.save_as_dialog("slidecraft");
}
}
}
}
}
fn drain_inbox(&mut self) {
let Some(inbox) = self.services.inbox.clone() else { return };
let files: Vec<(String, Vec<u8>)> = inbox.lock().map(|mut v| std::mem::take(&mut *v)).unwrap_or_default();
for (name, bytes) in files {
self.open_or_insert_bytes(&name, &bytes);
}
}
/// A dropped or picked file: presentations open, media is inserted.
pub fn open_or_insert_bytes(&mut self, name: &str, bytes: &[u8]) {
let lower = name.to_ascii_lowercase();
let data = slidecraft_engine::cmd::base64_encode(bytes);
let ct = slidecraft_engine::cmd::insert::content_type(name, bytes);
let cmd = if lower.ends_with(".slidecraft") || lower.ends_with(".pptx") || lower.ends_with(".potx") || lower.ends_with(".ppsx") {
"file.openBytes"
} else if ct.starts_with("image/") {
"insert.picture"
} else if ct.starts_with("audio/") {
"insert.audio"
} else if ct.starts_with("video/") {
"insert.video"
} else if lower.ends_with(".txt") || lower.ends_with(".md") {
"slide.fromOutline"
} else {
self.set_status(format!("{name}: SlideCraft can't open this kind of file"));
return;
};
let p = if cmd == "slide.fromOutline" { json!({"text": String::from_utf8_lossy(bytes)}) } else { json!({"name": name, "data": data}) };
if self.session.active().is_none() && cmd != "file.openBytes" {
let _ = self.session.execute("file.new", &json!({}));
}
if let Err(e) = self.session.execute(cmd, &p) {
self.set_status(e.to_string());
}
}
/// Per-frame logic before layout: styles, control requests, screenshots, shortcuts.
pub fn logic(&mut self, ctx: &egui::Context) {
let scale = self.ui.ui_scale.clamp(0.5, 3.0);
if (ctx.zoom_factor() - scale).abs() > 1e-3 {
ctx.set_zoom_factor(scale);
}
if !self.styled {
theme::install_fonts(ctx);
self.styled = true;
self.restyle = true;
} else {
self.fonts_ready = true;
}
if self.restyle {
theme::apply(ctx, &theme::Tokens::for_brightness(self.ui.brightness));
self.restyle = false;
}
let now = ctx.input(|i| i.time);
let dt = now - self.last_time;
if dt > 0.0 {
self.perf.fps = self.perf.fps * 0.9 + (1.0 / dt).min(240.0) * 0.1;
}
self.last_time = now;
self.drain_control(ctx);
if self.fonts_ready {
self.drain_inbox();
}
if !self.synthetic.is_empty() {
ctx.request_repaint();
}
self.collect_screenshots(ctx);
self.issue_screenshots(ctx);
for f in ctx.input(|i| i.raw.dropped_files.clone()) {
let name = f.path().file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped file".into());
match f.bytes() {
Ok(bytes) => self.open_or_insert_bytes(&name, &bytes),
Err(e) => self.set_status(e),
}
}
}
/// Inject synthetic events (one pointer event per frame).
pub fn raw_input_hook(&mut self, raw: &mut egui::RawInput) {
if self.synthetic.is_empty() {
return;
}
let n = match self.synthetic.first() {
Some(egui::Event::PointerMoved(_) | egui::Event::PointerButton { .. }) => 1,
_ => self.synthetic.iter().position(|e| matches!(e, egui::Event::Key { pressed: false, .. })).map_or(self.synthetic.len(), |i| i + 1),
};
if let Some(egui::Event::PointerMoved(p) | egui::Event::PointerButton { pos: p, .. }) = self.synthetic.first() {
raw.events.push(egui::Event::PointerMoved(*p));
}
let n = n.min(self.synthetic.len());
raw.events.extend(self.synthetic.drain(..n));
}
/// Keyboard and clipboard for the slide (when no text field has focus).
fn keyboard(&mut self, ctx: &egui::Context) {
if ctx.memory(|m| m.focused().is_some()) || self.dialog.as_ref().is_some_and(|d| d.modal()) || self.palette.is_some() {
return;
}
let events = ctx.input(|i| i.events.clone());
for e in events {
match e {
egui::Event::Text(t) => {
if self.session.active().is_some_and(|d| d.selection.text.is_some() || d.selection.shapes.len() == 1) {
if let Err(e) = self.session.type_text(&t) {
self.set_status(e.to_string());
}
}
}
egui::Event::Copy => {
if let Ok(v) = self.session.execute("edit.copy", &json!({}))
&& let Some(t) = v.get("text").and_then(Value::as_str)
{
ctx.copy_text(t.to_string());
}
}
egui::Event::Cut => {
if let Ok(v) = self.session.execute("edit.cut", &json!({}))
&& let Some(t) = v.get("text").and_then(Value::as_str)
{
ctx.copy_text(t.to_string());
}
}
egui::Event::Paste(text) => {
// An image on the clipboard (screenshots, copied pictures) becomes a picture.
let img = if text.is_empty() { self.services.clipboard_image.as_mut().and_then(|f| f()) } else { None };
let r = match img {
Some(png) => self
.session
.execute("insert.picture", &json!({"name": "Pasted image.png", "data": slidecraft_engine::cmd::base64_encode(&png)})),
None => self.session.execute("edit.paste", &json!({"text": text})),
};
if let Err(e) = r {
self.set_status(e.to_string());
}
}
egui::Event::Key { key, pressed: true, modifiers, .. } => {
let mods = Mods { shift: modifiers.shift, alt: modifiers.alt, cmd: modifiers.command || modifiers.ctrl };
if menus::ui_shortcut(self, key, mods) {
continue;
}
let name = key_name(key);
match self.session.key(name, mods) {
Ok(_) => {}
Err(e) => self.set_status(e.to_string()),
}
}
_ => {}
}
}
self.drain_requests();
}
/// Lay out the whole window.
pub fn ui(&mut self, ui: &mut egui::Ui) {
let ctx = ui.ctx().clone();
if !self.fonts_ready {
ctx.request_repaint();
return;
}
let t0 = now_ms();
let t = theme::Tokens::get(&ctx);
if self.show.is_some() {
show::ui(self, ui);
self.perf.frame_ms = now_ms() - t0;
return;
}
self.keyboard(&ctx);
ribbon::title_bar(self, ui);
if self.session.active().is_some() {
ribbon::show(self, ui);
status::status_bar(self, ui);
}
egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.chrome)).show(ui, |ui| {
if self.session.active().is_none() {
dialogs::start_screen(self, ui);
return;
}
match self.ui.view {
ViewMode::Sorter => sorter::show(self, ui),
ViewMode::NotesPage => sorter::notes_page(self, ui),
ViewMode::Outline | ViewMode::Normal | ViewMode::Reading => {
if self.ui.pane.is_some() {
panes::show(self, ui);
}
if self.ui.view == ViewMode::Outline {
sorter::outline_pane(self, ui);
} else {
thumbs::show(self, ui);
}
egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.canvas)).show(ui, |ui| {
if self.ui.notes {
status::notes_pane(self, ui);
}
canvas::show(self, ui);
});
}
}
});
dialogs::show(self, &ctx);
menus::palette(self, &ctx);
for url in std::mem::take(&mut self.pending_urls) {
ctx.open_url(egui::OpenUrl::new_tab(url));
}
self.drain_requests();
self.perf.frame_ms = now_ms() - t0;
}
pub fn open_url(&mut self, url: &str) {
self.pending_urls.push(url.to_string());
}
fn drain_control(&mut self, ctx: &egui::Context) {
let Some(rx) = self.control_rx.take() else { return };
while let Ok(req) = rx.try_recv() {
let reply = req.reply.clone();
match control::handle(self, ctx, &req) {
control::Outcome::Done(v) => {
let _ = reply.send(v);
}
control::Outcome::Screenshot { path } => {
self.shot_token += 1;
let token = self.shot_token;
self.queued_shots.push((token, now_ms() + 120.0, 0));
self.pending_shots.push((token, path, reply, now_ms() + 8000.0));
}
}
}
self.control_rx = Some(rx);
}
fn issue_screenshots(&mut self, ctx: &egui::Context) {
let now = now_ms();
self.queued_shots.retain_mut(|(token, at, frames)| {
*frames += 1;
if now >= *at && *frames >= 3 {
ctx.send_viewport_cmd(egui::ViewportCommand::Screenshot(egui::UserData::new(*token)));
false
} else {
true
}
});
if !self.queued_shots.is_empty() || !self.pending_shots.is_empty() {
ctx.request_repaint_after(std::time::Duration::from_millis(16));
}
}
fn collect_screenshots(&mut self, ctx: &egui::Context) {
if self.pending_shots.is_empty() {
return;
}
let events: Vec<_> = ctx.input(|i| {
i.raw
.events
.iter()
.filter_map(|e| match e {
egui::Event::Screenshot { user_data, image, .. } => {
let token = user_data.data.as_ref().and_then(|d| d.downcast_ref::<u64>()).copied()?;
Some((token, image.clone()))
}
_ => None,
})
.collect()
});
for (token, image) in events {
if let Some(i) = self.pending_shots.iter().position(|(t, ..)| *t == token) {
let (_, path, reply, _) = self.pending_shots.remove(i);
let _ = reply.send(control::save_screenshot(self, &image, path.as_deref()));
}
}
let now = now_ms();
self.pending_shots.retain(|(_, _, reply, deadline)| {
if now < *deadline {
return true;
}
let _ = reply.send(json!({"ok": false, "error": "no frame was presented (screen locked or window hidden); use ui.render"}));
false
});
}
}
/// Engine key name for an egui key.
pub fn key_name(key: egui::Key) -> &'static str {
use egui::Key::*;
match key {
Enter => "Enter",
Escape => "Escape",
Backspace => "Backspace",
Delete => "Delete",
Tab => "Tab",
ArrowLeft => "Left",
ArrowRight => "Right",
ArrowUp => "Up",
ArrowDown => "Down",
Home => "Home",
End => "End",
PageUp => "PageUp",
PageDown => "PageDown",
Space => "Space",
F2 => "F2",
F5 => "F5",
F7 => "F7",
other => other.name(),
}
}
pub fn now_ms() -> f64 {
#[cfg(not(target_arch = "wasm32"))]
{
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs_f64() * 1000.0).unwrap_or(0.0)
}
#[cfg(target_arch = "wasm32")]
{
js_now()
}
}
#[cfg(target_arch = "wasm32")]
fn js_now() -> f64 {
// egui's input time is good enough for UI timing on the web.
0.0
}
+245
View File
@@ -0,0 +1,245 @@
//! UI-level shortcuts, the command palette, and the menu structure (shared with the native macOS
//! menu bar in the desktop app).
use serde_json::json;
use slidecraft_engine::Mods;
use crate::SlideApp;
use crate::theme::{self, Tokens};
/// Shortcuts the UI handles itself (views, show, zoom, palette, file dialogs). True when consumed.
pub fn ui_shortcut(app: &mut SlideApp, key: egui::Key, m: Mods) -> bool {
use egui::Key::*;
let id = match (key, m.cmd, m.shift, m.alt) {
(F5, _, false, _) => {
app.start_show(0, false);
return true;
}
(F5, _, true, _) | (Enter, true, false, false) => {
let i = app.session.active().map(|d| d.selection.slide).unwrap_or(0);
app.start_show(i, false);
return true;
}
(Enter, true, true, _) => {
app.start_show(0, false);
return true;
}
(S, true, false, false) => {
app.save();
return true;
}
(S, true, true, false) => "app.saveAsDialog",
(O, true, false, false) => "app.openDialog",
(P, true, true, false) => "app.palette",
(Comma, true, false, false) => "app.preferences",
(Equals | Plus, true, false, false) => "view.zoomIn",
(Minus, true, false, false) => "view.zoomOut",
(Num0, true, false, false) => "view.fit",
(F1, true, false, false) => "view.collapseRibbon",
(F9, false, true, false) => "view.gridlines",
(F9, false, false, true) => "view.guides",
(K, true, false, false) => {
app.dialog = Some(crate::dialogs::Dialog::new("hyperlink"));
return true;
}
(F, true, false, false) => {
app.palette = Some((String::new(), 0));
return true;
}
_ => return false,
};
let _ = app.run(id, json!({}));
true
}
/// ⇧⌘P: search every command by name and run it.
pub fn palette(app: &mut SlideApp, ctx: &egui::Context) {
let Some((mut query, mut sel)) = app.palette.take() else { return };
let t = Tokens::get(ctx);
let mut close = false;
let mut run: Option<String> = None;
egui::Window::new("command_palette")
.title_bar(false)
.anchor(egui::Align2::CENTER_TOP, egui::vec2(0.0, 90.0))
.fixed_size(egui::vec2(520.0, 380.0))
.show(ctx, |ui| {
let r = ui.add(egui::TextEdit::singleline(&mut query).hint_text("Search commands…").desired_width(f32::INFINITY).font(theme::font(15.0)));
r.request_focus();
let q = query.to_lowercase();
let mut items: Vec<(String, String, Option<&'static str>)> = app
.session
.commands()
.into_iter()
.filter(|c| c.enabled)
.map(|c| (c.id.to_string(), c.label.to_string(), c.shortcut))
.chain(crate::UI_COMMANDS.iter().map(|c| (c.0.to_string(), c.1.to_string(), c.2)))
.filter(|(id, label, _)| q.is_empty() || label.to_lowercase().contains(&q) || id.to_lowercase().contains(&q))
.collect();
items.sort_by_key(|(_, l, _)| !l.to_lowercase().starts_with(&q));
items.truncate(60);
let (down, up, enter, esc) = ui.input(|i| {
(
i.key_pressed(egui::Key::ArrowDown),
i.key_pressed(egui::Key::ArrowUp),
i.key_pressed(egui::Key::Enter),
i.key_pressed(egui::Key::Escape),
)
});
if down {
sel = (sel + 1).min(items.len().saturating_sub(1));
}
if up {
sel = sel.saturating_sub(1);
}
if esc {
close = true;
}
if enter && let Some((id, ..)) = items.get(sel) {
run = Some(id.clone());
}
egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| {
for (k, (id, label, sc)) in items.iter().enumerate() {
let r = ui.horizontal(|ui| {
let resp = ui.selectable_label(k == sel, label);
if let Some(s) = sc {
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.label(egui::RichText::new(crate::ribbon::pretty_shortcut(s)).color(t.text_faint));
});
}
resp
});
if r.inner.clicked() {
run = Some(id.clone());
}
}
});
});
if let Some(id) = run {
let _ = app.run(&id, json!({}));
close = true;
}
if !close {
app.palette = Some((query, sel));
}
}
/// The application menu tree: (menu, [(label, command id, shortcut)]), "-" = separator. Used by the
/// native macOS menu bar and as the parity catalogue's live side.
pub fn menu_tree() -> Vec<(&'static str, Vec<(&'static str, &'static str)>)> {
vec![
(
"File",
vec![
("New Presentation", "file.new"),
("Open…", "app.openDialog"),
("-", ""),
("Close", "file.close"),
("Save", "file.save"),
("Save As…", "app.saveAsDialog"),
("Export…", "app.exportDialog"),
("-", ""),
("Properties", "file.properties"),
],
),
(
"Edit",
vec![
("Undo", "edit.undo"),
("Redo", "edit.redo"),
("-", ""),
("Cut", "edit.cut"),
("Copy", "edit.copy"),
("Paste", "edit.paste"),
("Paste and Match Formatting", "edit.pasteText"),
("-", ""),
("Select All", "edit.selectAll"),
("Duplicate", "edit.duplicate"),
("Delete Slide", "slide.delete"),
("-", ""),
("Find…", "app.palette"),
],
),
(
"View",
vec![
("Normal", "view.normal"),
("Slide Sorter", "view.sorter"),
("Notes Page", "view.notesPage"),
("Outline View", "view.outline"),
("Reading View", "view.reading"),
("Slide Show", "show.start"),
("-", ""),
("Slide Master", "view.slideMaster"),
("-", ""),
("Ruler", "view.ruler"),
("Gridlines", "view.gridlines"),
("Guides", "view.guides"),
("-", ""),
("Zoom In", "view.zoomIn"),
("Zoom Out", "view.zoomOut"),
("Fit to Window", "view.fit"),
],
),
(
"Insert",
vec![
("New Slide", "slide.new"),
("Duplicate Slide", "slide.duplicate"),
("-", ""),
("Section", "section.add"),
("Text Box", "insert.textBox"),
("WordArt", "insert.wordArt"),
("Date and Time", "insert.dateTime"),
("Slide Number", "insert.slideNumber"),
("-", ""),
("Table…", "insert.table"),
("Chart", "insert.chart"),
("Picture from File…", "app.insertPictureDialog"),
("Audio from File…", "app.insertAudioDialog"),
("Video from File…", "app.insertVideoDialog"),
],
),
(
"Format",
vec![
("Bold", "format.bold"),
("Italic", "format.italic"),
("Underline", "format.underline"),
("-", ""),
("Align Left", "format.alignLeft"),
("Center", "format.alignCenter"),
("Align Right", "format.alignRight"),
("Justify", "format.justify"),
],
),
(
"Arrange",
vec![
("Bring to Front", "arrange.bringToFront"),
("Send to Back", "arrange.sendToBack"),
("Bring Forward", "arrange.bringForward"),
("Send Backward", "arrange.sendBackward"),
("-", ""),
("Group", "arrange.group"),
("Ungroup", "arrange.ungroup"),
("Regroup", "arrange.regroup"),
("-", ""),
("Rotate Left 90°", "arrange.rotateLeft"),
("Rotate Right 90°", "arrange.rotateRight"),
("Flip Horizontal", "arrange.flipHorizontal"),
("Flip Vertical", "arrange.flipVertical"),
],
),
(
"Slide Show",
vec![
("Play from Start", "show.fromStart"),
("Play from Current Slide", "show.fromCurrent"),
("-", ""),
("Hide Slide", "slide.hide"),
("Set Up Show…", "show.setup"),
],
),
("Help", vec![("About SlideCraft", "app.about"), ("Command Palette", "app.palette")]),
]
}
+648
View File
@@ -0,0 +1,648 @@
//! Task panes on the right: Format Shape, Format Background, Animation, Selection, Comments and
//! Design Ideas.
use egui::{Align2, Color32, CornerRadius, Rect, Sense, Stroke, Ui, pos2, vec2};
use serde_json::{Value, json};
use slidecraft_model::{AnimClass, AnimStart, Fill, ShapeKind};
use crate::icons::{self, Icon};
use crate::ribbon::{cref_param, paint_anim_tile};
use crate::theme::{self, Tokens};
use crate::{SlideApp, widgets};
pub fn show(app: &mut SlideApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
let Some(pane) = app.ui.pane.clone() else { return };
egui::Panel::right("task_pane")
.resizable(true)
.default_size(app.ui.pane_width)
.size_range(240.0..=520.0)
.frame(egui::Frame::NONE.fill(t.card).inner_margin(egui::Margin::same(10)))
.show(ui, |ui| {
app.ui.pane_width = ui.available_width() + 20.0;
let title = match pane.as_str() {
"format" => "Format Shape",
"background" => "Format Background",
"animation" => "Animation Pane",
"selection" => "Selection",
"comments" => "Comments",
"designer" => "Design Ideas",
_ => "Pane",
};
ui.horizontal(|ui| {
ui.label(egui::RichText::new(title).font(theme::bold(16.0)));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let (r, resp) = ui.allocate_exact_size(vec2(20.0, 20.0), Sense::click());
if resp.hovered() {
ui.painter().rect_filled(r, CornerRadius::same(4), t.hover);
}
icons::paint(ui.painter(), r.shrink(3.0), Icon::Close, t.text_dim, false);
if resp.clicked() {
app.ui.pane = None;
}
});
});
ui.add_space(6.0);
egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| match pane.as_str() {
"format" => format_shape(app, ui),
"background" => background(app, ui),
"animation" => animation(app, ui),
"selection" => selection(app, ui),
"comments" => comments(app, ui),
"designer" => designer(app, ui),
_ => {}
});
});
}
fn run(app: &mut SlideApp, id: &str, p: Value) {
let _ = app.run(id, p);
}
fn scheme(app: &SlideApp) -> slidecraft_color::ColorScheme {
app.session.active().and_then(|d| d.doc.masters.first().map(|m| m.scheme())).unwrap_or_else(|| slidecraft_model::Theme::default().colors)
}
fn color_button(
app: &mut SlideApp,
ui: &mut Ui,
label: &str,
none_label: Option<&str>,
on_pick: impl Fn(Option<slidecraft_model::ColorRef>) -> (String, Value),
) {
let sc = scheme(app);
ui.horizontal(|ui| {
ui.label(label);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let r = ui.add(egui::Button::new("Color ▾").min_size(vec2(70.0, 22.0)));
egui::Popup::menu(&r).show(|ui| {
if let Some(c) = widgets::color_grid(ui, &sc, none_label) {
let (id, p) = on_pick(c);
run(app, &id, p);
}
});
});
});
}
fn format_shape(app: &mut SlideApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
let Some(st) = app.session.active() else { return };
let Some(sh) = st.selected_shapes().first().map(|s| (*s).clone()) else {
ui.label(egui::RichText::new("Select a shape to format it.").color(t.text_dim));
return;
};
let x = slidecraft_engine::cmd::xfrm_of(&st.doc, &st.selection, &sh);
// Tabs as icons.
ui.horizontal(|ui| {
for (tab, icon, tip) in [
("fill", Icon::ShapeFill, "Fill & Line"),
("effects", Icon::ShapeEffects, "Effects"),
("size", Icon::SlideSize, "Size & Properties"),
("text", Icon::TextBox, "Text Options"),
] {
let on = app.ui.format_tab == tab;
if widgets::icon_toggle(ui, icon, tip, true, on).clicked() {
app.ui.format_tab = tab.into();
}
}
if matches!(sh.kind, ShapeKind::Picture { .. })
&& widgets::icon_toggle(ui, Icon::Picture, "Picture", true, app.ui.format_tab == "picture").clicked()
{
app.ui.format_tab = "picture".into();
}
});
ui.separator();
match app.ui.format_tab.as_str() {
"effects" => {
ui.label(egui::RichText::new("Shadow").font(theme::bold(13.0)));
ui.horizontal_wrapped(|ui| {
for (l, v) in [
("None", "none"),
("Outer", "outer"),
("Bottom", "bottom"),
("Center", "center"),
("Inner", "inner"),
("Perspective", "perspective"),
] {
if ui.button(l).clicked() {
run(app, "shape.effects", json!({"shadow": v}));
}
}
});
ui.add_space(6.0);
ui.label(egui::RichText::new("Reflection").font(theme::bold(13.0)));
ui.horizontal_wrapped(|ui| {
for (l, v) in [("None", "none"), ("Tight", "tight"), ("Half", "half"), ("Full", "full")] {
if ui.button(l).clicked() {
run(app, "shape.effects", json!({"reflection": v}));
}
}
});
ui.add_space(6.0);
ui.label(egui::RichText::new("Glow").font(theme::bold(13.0)));
let mut g = sh.effects.as_ref().and_then(|e| e.glow.as_ref().map(|g| g.radius)).unwrap_or(0.0);
if ui.add(egui::Slider::new(&mut g, 0.0..=40.0).text("size (pt)")).drag_stopped() {
run(app, "shape.effects", json!({"glow": if g > 0.0 { json!(g) } else { Value::Null }}));
}
ui.label(egui::RichText::new("Soft Edges").font(theme::bold(13.0)));
let mut s = sh.effects.as_ref().and_then(|e| e.soft_edge).unwrap_or(0.0);
if ui.add(egui::Slider::new(&mut s, 0.0..=50.0).text("size (pt)")).drag_stopped() {
run(app, "shape.effects", json!({"softEdges": s}));
}
if ui.button("Reset Effects").clicked() {
run(app, "shape.effects", json!({"reset": true}));
}
}
"size" => {
let mut fields = |ui: &mut Ui, label: &str, id: &str, v: f64, key: &str, cmd: &str, unit: f64| {
ui.horizontal(|ui| {
ui.add_sized(vec2(90.0, 20.0), egui::Label::new(label));
if let Some(nv) = widgets::spinner(ui, id, v / unit, 0.1, if unit == 72.0 { "\"" } else { "°" }, 70.0) {
run(app, cmd, json!({key: nv * unit}));
}
});
};
ui.label(egui::RichText::new("Size").font(theme::bold(13.0)));
fields(ui, "Height", "fh", x.h, "h", "shape.resize", 72.0);
fields(ui, "Width", "fw", x.w, "w", "shape.resize", 72.0);
fields(ui, "Rotation", "fr", x.rot, "deg", "shape.rotate", 1.0);
ui.add_space(6.0);
ui.label(egui::RichText::new("Position").font(theme::bold(13.0)));
fields(ui, "Horizontal", "fx", x.x, "x", "shape.move", 72.0);
fields(ui, "Vertical", "fy", x.y, "y", "shape.move", 72.0);
ui.add_space(6.0);
ui.label(egui::RichText::new("Alt Text").font(theme::bold(13.0)));
let id = ui.id().with(("alt", sh.id.0));
let mut alt: String = ui.data_mut(|d| d.get_temp(id).unwrap_or_else(|| sh.descr.clone()));
let r = ui.add(egui::TextEdit::multiline(&mut alt).desired_rows(3).desired_width(f32::INFINITY));
if r.lost_focus() && alt != sh.descr {
run(app, "shape.altText", json!({"text": alt}));
}
ui.data_mut(|d| d.insert_temp(id, alt));
}
"text" => {
ui.label(egui::RichText::new("Text Box").font(theme::bold(13.0)));
ui.horizontal(|ui| {
ui.label("Vertical alignment");
for (l, a) in [("Top", "top"), ("Middle", "middle"), ("Bottom", "bottom")] {
if ui.small_button(l).clicked() {
run(app, "format.anchor", json!({"anchor": a}));
}
}
});
ui.horizontal_wrapped(|ui| {
for (l, m) in [("Do not Autofit", "none"), ("Shrink text on overflow", "shrink"), ("Resize shape to fit text", "resize")] {
if ui.small_button(l).clicked() {
run(app, "format.autofit", json!({"mode": m}));
}
}
});
let wrap = sh.text.as_ref().and_then(|t| t.body.wrap).unwrap_or(true);
let mut w = wrap;
if ui.checkbox(&mut w, "Wrap text in shape").changed() {
run(app, "format.wrap", json!({"on": w}));
}
ui.label("Margins (pt)");
let b = sh.text.as_ref().map(|t| t.body.clone()).unwrap_or_default();
for (l, k, v) in [
("Left", "left", b.inset_l.unwrap_or(7.2)),
("Right", "right", b.inset_r.unwrap_or(7.2)),
("Top", "top", b.inset_t.unwrap_or(3.6)),
("Bottom", "bottom", b.inset_b.unwrap_or(3.6)),
] {
ui.horizontal(|ui| {
ui.add_sized(vec2(60.0, 20.0), egui::Label::new(l));
if let Some(nv) = widgets::spinner(ui, &format!("m{k}"), v, 1.0, " pt", 60.0) {
run(app, "format.margins", json!({k: nv.max(0.0)}));
}
});
}
ui.horizontal(|ui| {
ui.label("Columns");
for n in 1..=3 {
if ui.small_button(n.to_string()).clicked() {
run(app, "format.columns", json!({"count": n, "spacing": 18}));
}
}
});
}
"picture" => {
let adj = if let ShapeKind::Picture { fill } = &sh.kind { fill.adjust.clone() } else { Default::default() };
let mut b = adj.brightness as f32;
if ui.add(egui::Slider::new(&mut b, -1.0..=1.0).text("Brightness")).drag_stopped() {
run(app, "picture.adjust", json!({"brightness": b}));
}
let mut c = adj.contrast as f32;
if ui.add(egui::Slider::new(&mut c, -1.0..=1.0).text("Contrast")).drag_stopped() {
run(app, "picture.adjust", json!({"contrast": c}));
}
let mut s = adj.saturation.unwrap_or(1.0) as f32;
if ui.add(egui::Slider::new(&mut s, 0.0..=4.0).text("Saturation")).drag_stopped() {
run(app, "picture.adjust", json!({"saturation": s}));
}
let alpha = if let ShapeKind::Picture { fill } = &sh.kind { fill.alpha.unwrap_or(1.0) } else { 1.0 };
let mut tr = (1.0 - alpha) as f32;
if ui.add(egui::Slider::new(&mut tr, 0.0..=1.0).text("Transparency")).drag_stopped() {
run(app, "picture.adjust", json!({"transparency": tr}));
}
if ui.button("Reset Picture").clicked() {
run(app, "picture.reset", json!({}));
}
}
_ => {
// Fill & Line.
ui.label(egui::RichText::new("Fill").font(theme::bold(13.0)));
let kind = match &sh.fill {
None => "auto",
Some(Fill::None) => "none",
Some(Fill::Solid { .. }) => "solid",
Some(Fill::Gradient(_)) => "gradient",
Some(Fill::Picture(_)) => "picture",
Some(Fill::Pattern(_)) => "pattern",
_ => "auto",
};
for (l, k) in
[("No fill", "none"), ("Solid fill", "solid"), ("Gradient fill", "gradient"), ("Pattern fill", "pattern"), ("Automatic", "auto")]
{
if ui.radio(kind == k, l).clicked() {
match k {
"none" => run(app, "shape.fill", json!({"none": true})),
"solid" => run(app, "shape.fill", json!({"color": "accent1"})),
"gradient" => run(
app,
"shape.fill",
json!({"gradient": {"stops": [[0, "accent1"], [1, {"scheme": "accent1", "lumMod": 50000}]], "angle": 90}}),
),
"pattern" => run(app, "shape.fill", json!({"pattern": {"preset": "pct25", "fg": "accent1", "bg": "bg1"}})),
_ => run(app, "shape.fill", json!({"reset": true})),
}
}
}
color_button(app, ui, "Color", Some("No Fill"), |c| match c {
Some(c) => ("shape.fill".into(), json!({"color": cref_param(&c)})),
None => ("shape.fill".into(), json!({"none": true})),
});
ui.horizontal(|ui| {
ui.label("Transparency");
let id = ui.id().with("ftr");
let mut v: f32 = ui.data_mut(|d| d.get_temp(id).unwrap_or(0.0));
let r = ui.add(egui::Slider::new(&mut v, 0.0..=100.0).suffix("%"));
ui.data_mut(|d| d.insert_temp(id, v));
if r.drag_stopped() || r.lost_focus() {
let _ = app.run("shape.fill", json!({"transparency": v as f64 / 100.0}));
}
});
if kind == "gradient" {
ui.horizontal(|ui| {
ui.label("Angle");
for a in [0, 45, 90, 135, 180, 270] {
if ui.small_button(format!("{a}°")).clicked() {
run(
app,
"shape.fill",
json!({"gradient": {"stops": [[0, "accent1"], [1, {"scheme": "accent1", "lumMod": 50000}]], "angle": a}}),
);
}
}
});
}
ui.add_space(8.0);
ui.label(egui::RichText::new("Line").font(theme::bold(13.0)));
let none = matches!(sh.line.as_ref().and_then(|l| l.fill.as_ref()), Some(Fill::None));
if ui.radio(none, "No line").clicked() {
run(app, "shape.line", json!({"none": true}));
}
if ui.radio(!none && sh.line.is_some(), "Solid line").clicked() {
run(app, "shape.line", json!({"color": "tx1", "width": 1}));
}
if ui.radio(sh.line.is_none(), "Automatic").clicked() {
run(app, "shape.line", json!({"reset": true}));
}
color_button(app, ui, "Color", Some("No Outline"), |c| match c {
Some(c) => ("shape.line".into(), json!({"color": cref_param(&c)})),
None => ("shape.line".into(), json!({"none": true})),
});
ui.horizontal(|ui| {
ui.label("Width");
let w = sh.line.as_ref().and_then(|l| l.width).unwrap_or(0.75);
if let Some(v) = widgets::spinner(ui, "lw", w, 0.25, " pt", 60.0) {
run(app, "shape.line", json!({"width": v.max(0.0)}));
}
});
ui.horizontal_wrapped(|ui| {
ui.label("Dash");
for (l, d) in [("—", "solid"), ("···", "sysDot"), ("- -", "dash"), ("-·-", "dashDot"), ("— —", "lgDash")] {
if ui.small_button(l).clicked() {
run(app, "shape.line", json!({"dash": d}));
}
}
});
}
}
}
fn background(app: &mut SlideApp, ui: &mut Ui) {
ui.label(egui::RichText::new("Fill").font(theme::bold(13.0)));
if ui.button("Solid fill: theme background").clicked() {
run(app, "design.background", json!({"color": "bg1"}));
}
let sc = scheme(app);
ui.horizontal(|ui| {
ui.label("Color");
let r = ui.button("Color ▾");
egui::Popup::menu(&r).show(|ui| {
if let Some(Some(c)) = widgets::color_grid(ui, &sc, None) {
run(app, "design.background", json!({"color": cref_param(&c)}));
}
});
});
ui.label("Gradient presets");
ui.horizontal_wrapped(|ui| {
for (a, b) in [("accent1", "accent4"), ("accent2", "accent6"), ("dk2", "accent1"), ("lt2", "bg1"), ("accent3", "accent5")] {
let (r, resp) = ui.allocate_exact_size(vec2(44.0, 26.0), Sense::click());
let ca = theme::to_color32(sc.get(slidecraft_color::SchemeSlot::from_xml(a).unwrap_or(slidecraft_color::SchemeSlot::Accent1)));
let cb = theme::to_color32(sc.get(slidecraft_color::SchemeSlot::from_xml(b).unwrap_or(slidecraft_color::SchemeSlot::Accent1)));
let mut mesh = egui::Mesh::default();
mesh.colored_vertex(r.left_top(), ca);
mesh.colored_vertex(r.right_top(), cb);
mesh.colored_vertex(r.right_bottom(), cb);
mesh.colored_vertex(r.left_bottom(), ca);
mesh.add_triangle(0, 1, 2);
mesh.add_triangle(0, 2, 3);
ui.painter().add(egui::Shape::mesh(mesh));
if resp.clicked() {
run(app, "design.background", json!({"gradient": {"stops": [[0, a], [1, b]], "angle": 0}}));
}
}
});
if ui.button("Picture fill…").clicked()
&& let Some(pick) = app.services.pick_open.as_mut()
&& let Some(path) = pick("picture")
&& let Some(Ok(bytes)) = app.services.read.as_ref().map(|r| r(&path))
{
let _ = app.run("design.background", json!({"picture": slidecraft_engine::cmd::base64_encode(&bytes)}));
}
let hide = app.session.active().and_then(|d| d.current_slide().map(|s| !s.show_master_shapes)).unwrap_or(false);
let mut h = hide;
if ui.checkbox(&mut h, "Hide background graphics").changed() {
run(app, "design.hideBackgroundGraphics", json!({"hide": h}));
}
ui.add_space(8.0);
ui.horizontal(|ui| {
if ui.button("Apply to All").clicked() {
let bg = app.session.active().and_then(|d| d.current_slide().and_then(|s| s.background.clone()));
if let Some(slidecraft_model::Background::Fill { fill: Fill::Solid { color } }) = bg {
run(app, "design.background", json!({"color": cref_param(&color), "all": true}));
}
}
if ui.button("Reset Background").clicked() {
run(app, "design.background", json!({"reset": true}));
}
});
}
fn animation(app: &mut SlideApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
let Some(st) = app.session.active() else { return };
let Some(slide) = st.current_slide() else { return };
let anims = slide.animations.clone();
let names: Vec<(u32, String)> = st.shapes().iter().map(|s| (s.id.0, s.name.clone())).collect();
let selected: Vec<u32> = st.selection.shapes.iter().map(|i| i.0).collect();
ui.horizontal(|ui| {
if ui.button("▶ Play All").clicked() {
let from = app.session.active().map(|d| d.selection.slide).unwrap_or(0);
app.start_show(from, true);
if let Some(s) = app.show.as_mut() {
s.preview_only = true;
s.autoplay = true;
}
}
});
ui.separator();
if anims.is_empty() {
ui.label(egui::RichText::new("Select an object on the slide and add an animation from the Animations tab.").color(t.text_dim));
return;
}
let mut click = 0;
for (i, a) in anims.iter().enumerate() {
if a.start == AnimStart::OnClick {
click += 1;
}
let name = names.iter().find(|(id, _)| *id == a.shape.0).map(|(_, n)| n.clone()).unwrap_or_else(|| format!("Shape {}", a.shape));
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 30.0), Sense::click());
let sel = selected.contains(&a.shape.0);
if sel || resp.hovered() {
ui.painter().rect_filled(r, CornerRadius::same(4), if sel { t.selected } else { t.hover });
}
let p = ui.painter();
p.text(
pos2(r.min.x + 6.0, r.center().y),
Align2::LEFT_CENTER,
if a.start == AnimStart::OnClick { click.to_string() } else { String::new() },
theme::font(12.0),
t.text_dim,
);
let icon = match a.class {
AnimClass::Entrance => Icon::AnimEntrance,
AnimClass::Emphasis => Icon::AnimEmphasis,
AnimClass::Exit => Icon::AnimExit,
_ => Icon::AnimPath,
};
icons::paint(p, Rect::from_center_size(pos2(r.min.x + 30.0, r.center().y), vec2(18.0, 18.0)), icon, t.text, false);
p.text(pos2(r.min.x + 44.0, r.center().y), Align2::LEFT_CENTER, &name, theme::font(12.5), t.text);
// Timeline bar.
let bar_x = r.max.x - 80.0;
let w = (a.duration_ms as f32 / 1000.0 * 20.0).clamp(4.0, 70.0);
let col = match a.class {
AnimClass::Entrance => Color32::from_rgb(0x2E, 0xA3, 0x6F),
AnimClass::Emphasis => Color32::from_rgb(0xE3, 0xB2, 0x1C),
AnimClass::Exit => Color32::from_rgb(0xD8, 0x3F, 0x6B),
_ => Color32::from_rgb(0x2E, 0x6F, 0xD8),
};
p.rect_filled(
Rect::from_min_size(pos2(bar_x + (a.delay_ms as f32 / 1000.0 * 20.0).min(40.0), r.center().y - 4.0), vec2(w, 8.0)),
CornerRadius::same(2),
col,
);
if resp.clicked() {
run(app, "edit.select", json!({"ids": [a.shape.0]}));
}
resp.context_menu(|ui| {
for (l, v) in [("Start On Click", "onClick"), ("Start With Previous", "withPrevious"), ("Start After Previous", "afterPrevious")] {
if ui.button(l).clicked() {
run(app, "animation.timing", json!({"index": i, "start": v}));
ui.close();
}
}
ui.separator();
if ui.button("Move Up").clicked() {
run(app, "animation.move", json!({"index": i, "to": i.saturating_sub(1)}));
ui.close();
}
if ui.button("Move Down").clicked() {
run(app, "animation.move", json!({"index": i, "to": i + 1}));
ui.close();
}
if ui.button("Remove").clicked() {
run(app, "animation.remove", json!({"index": i}));
ui.close();
}
});
}
let _ = paint_anim_tile;
let _ = Stroke::NONE;
}
fn selection(app: &mut SlideApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
let Some(st) = app.session.active() else { return };
let shapes: Vec<(u32, String, bool, &'static str)> =
st.shapes().iter().rev().map(|s| (s.id.0, s.name.clone(), s.hidden, s.kind_name())).collect();
let selected: Vec<u32> = st.selection.shapes.iter().map(|i| i.0).collect();
ui.horizontal(|ui| {
if ui.button("Show All").clicked() {
let ids: Vec<u32> = shapes.iter().map(|s| s.0).collect();
run(app, "shape.visible", json!({"ids": ids, "visible": true}));
}
if ui.button("Hide All").clicked() {
let ids: Vec<u32> = shapes.iter().map(|s| s.0).collect();
run(app, "shape.visible", json!({"ids": ids, "visible": false}));
}
});
ui.separator();
let n = shapes.len();
for (k, (id, name, hidden, kind)) in shapes.into_iter().enumerate() {
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 26.0), Sense::click());
let sel = selected.contains(&id);
if sel || resp.hovered() {
ui.painter().rect_filled(r, CornerRadius::same(4), if sel { t.selected } else { t.hover });
}
ui.painter().text(
pos2(r.min.x + 8.0, r.center().y),
Align2::LEFT_CENTER,
&name,
theme::font(12.5),
if hidden { t.text_faint } else { t.text },
);
ui.painter().text(pos2(r.max.x - 34.0, r.center().y), Align2::RIGHT_CENTER, kind, theme::font(10.5), t.text_faint);
let eye = Rect::from_center_size(pos2(r.max.x - 14.0, r.center().y), vec2(16.0, 16.0));
icons::paint(ui.painter(), eye, if hidden { Icon::EyeOff } else { Icon::Eye }, t.text_dim, false);
let eye_resp = ui.interact(eye, ui.id().with(("eye", id)), Sense::click());
if eye_resp.clicked() {
run(app, "shape.visible", json!({"id": id, "visible": hidden}));
} else if resp.clicked() {
let add = ui.input(|i| i.modifiers.command || i.modifiers.shift);
run(app, "edit.select", json!({"ids": [id], "toggle": add}));
}
if resp.double_clicked() {
let mut d = crate::dialogs::Dialog::new("renameShape");
d.params = json!({"id": id, "name": name});
app.dialog = Some(d);
}
resp.context_menu(|ui| {
if ui.button("Bring Forward").clicked() {
run(app, "arrange.bringForward", json!({"ids": [id]}));
ui.close();
}
if ui.button("Send Backward").clicked() {
run(app, "arrange.sendBackward", json!({"ids": [id]}));
ui.close();
}
if ui.button("Rename…").clicked() {
let mut d = crate::dialogs::Dialog::new("renameShape");
d.params = json!({"id": id, "name": name});
app.dialog = Some(d);
ui.close();
}
});
let _ = (k, n);
}
}
fn comments(app: &mut SlideApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
if ui.button("+ New Comment").clicked() {
app.dialog = Some(crate::dialogs::Dialog::new("comment"));
}
ui.separator();
let Some(st) = app.session.active() else { return };
let list = st.current_slide().map(|s| s.comments.clone()).unwrap_or_default();
if list.is_empty() {
ui.label(egui::RichText::new("No comments on this slide.").color(t.text_dim));
}
for (i, c) in list.iter().enumerate() {
egui::Frame::NONE.fill(t.chrome).corner_radius(CornerRadius::same(8)).inner_margin(egui::Margin::same(8)).show(ui, |ui| {
ui.set_width(ui.available_width());
ui.horizontal(|ui| {
let (r, _) = ui.allocate_exact_size(vec2(24.0, 24.0), Sense::hover());
ui.painter().circle_filled(r.center(), 12.0, t.accent);
ui.painter().text(r.center(), Align2::CENTER_CENTER, &c.initials, theme::bold(11.0), Color32::WHITE);
ui.vertical(|ui| {
ui.label(egui::RichText::new(&c.author).font(theme::bold(12.5)));
ui.label(egui::RichText::new(c.date.replace('T', " ").trim_end_matches('Z')).size(10.5).color(t.text_faint));
});
});
ui.label(&c.text);
for r in &c.replies {
ui.label(egui::RichText::new(format!("↳ {}: {}", r.author, r.text)).color(t.text_dim));
}
ui.horizontal(|ui| {
let id = ui.id().with(("reply", i));
let mut txt: String = ui.data_mut(|d| d.get_temp(id).unwrap_or_default());
let r = ui.add(egui::TextEdit::singleline(&mut txt).hint_text("Reply…").desired_width(ui.available_width() - 70.0));
if r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) && !txt.trim().is_empty() {
run(app, "comment.reply", json!({"index": i, "text": txt}));
txt.clear();
}
ui.data_mut(|d| d.insert_temp(id, txt));
if ui.small_button(if c.resolved { "Reopen" } else { "Resolve" }).clicked() {
run(app, "comment.resolve", json!({"index": i, "resolved": !c.resolved}));
}
if ui.small_button("🗑").clicked() {
run(app, "comment.delete", json!({"index": i}));
}
});
});
ui.add_space(6.0);
}
}
/// Design Ideas: offline suggestions — the current slide in each theme and layout variations.
fn designer(app: &mut SlideApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
ui.label(egui::RichText::new("Pick a look for your slides. Ideas are generated on your device.").color(t.text_dim));
ui.add_space(6.0);
let Some(st) = app.session.active() else { return };
let doc = st.doc.clone();
let idx = st.selection.slide;
let w = ui.available_width() - 8.0;
for th in slidecraft_model::theme::builtin_themes() {
let mut d = (*doc).clone();
if let Some(m) = d.masters.first_mut() {
let m = std::sync::Arc::make_mut(m);
m.theme = th.clone();
}
let ppp = ui.ctx().pixels_per_point();
let px = (w * ppp) as u32;
let h = (px as f64 * d.slide_size.height / d.slide_size.width.max(1.0)) as u32;
let img = slidecraft_render::render_slide(
&d,
idx,
&slidecraft_render::RenderOpts { scale: px as f64 / d.slide_size.width.max(1.0), size: Some((px, h)), ..Default::default() },
);
let tex = ui.ctx().load_texture(format!("idea-{}", th.name), crate::textures::to_color_image(&img, false), egui::TextureOptions::LINEAR);
let (r, resp) = ui.allocate_exact_size(vec2(w, w * h as f32 / px.max(1) as f32), Sense::click());
ui.painter().image(tex.id(), r, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
ui.painter().rect_stroke(
r,
CornerRadius::same(2),
Stroke::new(if resp.hovered() { 2.5 } else { 1.0 }, if resp.hovered() { t.accent } else { t.border }),
egui::StrokeKind::Outside,
);
if resp.on_hover_text(&th.name).clicked() {
run(app, "design.theme", json!({"name": th.name}));
}
ui.add_space(8.0);
}
}
File diff suppressed because it is too large. Load diff
+606
View File
@@ -0,0 +1,606 @@
//! Slide Show: full-screen playback with transitions and animations (`slidecraft-anim`),
//! keyboard/mouse navigation, pen, black/white screens, presenter view and rehearsed timings.
use egui::{Align2, Color32, CornerRadius, Mesh, Pos2, Rect, Sense, Stroke, TextureHandle, Ui, pos2, vec2};
use serde_json::json;
use slidecraft_anim::{Layer, ShowAction, ShowState, Source, Timeline};
use slidecraft_model::{Presentation, ShapeId};
use slidecraft_render::{RenderOpts, ShapeState};
use crate::SlideApp;
use crate::theme;
pub struct Show {
pub state: ShowState,
pub reading: bool,
pub presenter: bool,
/// Preview from the Transitions/Animations tab: exits after the current slide.
pub preview_only: bool,
/// Play every step automatically (animation preview).
pub autoplay: bool,
pub rehearse: bool,
timeline: Option<(usize, Timeline)>,
step_start: f64,
slide_start: f64,
step_end: f64,
trans: Option<Transition>,
tex: Option<(Key, TextureHandle)>,
next_tex: Option<(usize, TextureHandle)>,
blank: Option<Color32>,
pen: bool,
strokes: Vec<Vec<Pos2>>,
goto: String,
started: bool,
show_start: f64,
timings: Vec<(usize, f64)>,
last_move: f64,
pub ended: bool,
}
#[derive(Clone, PartialEq)]
struct Key {
slide: usize,
step: usize,
size: (u32, u32),
doc: usize,
}
struct Transition {
old: Option<TextureHandle>,
start: f64,
kind: String,
option: String,
dur: f64,
}
impl Show {
pub fn new(doc: &Presentation, from: usize, reading: bool, presenter: bool) -> Self {
Show {
state: ShowState::start_at(doc, from),
reading,
presenter,
preview_only: false,
autoplay: false,
rehearse: false,
timeline: None,
step_start: 0.0,
slide_start: 0.0,
step_end: 0.0,
trans: None,
tex: None,
next_tex: None,
blank: None,
pen: false,
strokes: vec![],
goto: String::new(),
started: false,
show_start: 0.0,
timings: vec![],
last_move: 0.0,
ended: false,
}
}
fn timeline(&mut self, doc: &Presentation) -> &Timeline {
let i = self.state.slide;
if self.timeline.as_ref().is_none_or(|(k, _)| *k != i) {
self.timeline = Some((i, ShowState::timeline(doc, i)));
}
match &self.timeline {
Some((_, t)) => t,
None => {
static EMPTY: std::sync::OnceLock<Timeline> = std::sync::OnceLock::new();
EMPTY.get_or_init(Timeline::default)
}
}
}
fn apply(&mut self, a: ShowAction, doc: &Presentation, now: f64) -> bool {
match a {
ShowAction::PlayStep(_) => {
self.step_start = now;
}
ShowAction::FinishStep(_) | ShowAction::StepBack(_) => {
self.step_end = now;
}
ShowAction::Slide(i) => {
if self.rehearse {
self.timings.push((self.timeline.as_ref().map(|t| t.0).unwrap_or(i), now - self.slide_start));
}
let (kind, option, dur) = doc
.slides
.get(i)
.and_then(|s| s.transition.as_ref())
.map(|t| (t.kind.clone(), t.option.clone(), t.duration_ms as f64 / 1000.0))
.unwrap_or(("none".into(), String::new(), 0.0));
let old = self.tex.as_ref().map(|(_, t)| t.clone());
self.trans = if kind != "none" && dur > 0.0 && !doc.show.without_animation {
Some(Transition { old, start: now, kind, option, dur })
} else {
None
};
self.slide_start = now;
self.step_start = now;
self.step_end = now;
self.strokes.clear();
if self.preview_only {
return false;
}
self.after_enter(doc, now);
}
ShowAction::End => {
self.ended = true;
if self.preview_only {
return false;
}
}
ShowAction::Exit => return false,
ShowAction::None => {}
}
true
}
/// On entering a slide: auto-play its first step when it begins With/After Previous.
fn after_enter(&mut self, doc: &Presentation, now: f64) {
let auto = self.timeline(doc).step_is_auto(0);
if auto || self.autoplay {
let a = self.state.next(doc);
if matches!(a, ShowAction::PlayStep(_)) {
self.step_start = now;
}
}
}
}
fn state_for(tl: &Timeline, id: ShapeId, step: usize, t: f64) -> ShapeState {
let a = tl.state(id, None, step, t);
ShapeState {
visible: a.visible,
opacity: a.opacity,
offset: slidecraft_geom::Vec2::new(a.offset_x, a.offset_y),
scale: (a.scale_x, a.scale_y),
rotate: a.rotate,
clip: a.clip,
tint: a.tint,
}
}
fn fit(avail: Rect, doc: &Presentation) -> Rect {
let (sw, sh) = (doc.slide_size.width as f32, doc.slide_size.height as f32);
let s = (avail.width() / sw).min(avail.height() / sh);
Rect::from_center_size(avail.center(), vec2(sw * s, sh * s))
}
fn quad_mesh(tex: Option<egui::TextureId>, rect: Rect, l: &Layer, color: Color32) -> Mesh {
let mut m = match tex {
Some(id) => Mesh::with_texture(id),
None => Mesh::default(),
};
let a = (l.alpha.clamp(0.0, 1.0) * 255.0) as u8;
let col = if tex.is_some() { Color32::from_white_alpha(a) } else { color.gamma_multiply(l.alpha.clamp(0.0, 1.0) as f32) };
for k in 0..4 {
let q = l.quad.get(k).copied().unwrap_or([0.0, 0.0]);
let uv = l.uv.get(k).copied().unwrap_or([0.0, 0.0]);
let p = pos2(rect.min.x + q[0] as f32 * rect.width(), rect.min.y + q[1] as f32 * rect.height());
m.vertices.push(egui::epaint::Vertex { pos: p, uv: pos2(uv[0] as f32, uv[1] as f32), color: col });
}
m.add_triangle(0, 1, 2);
m.add_triangle(0, 2, 3);
m
}
pub fn ui(app: &mut SlideApp, ui: &mut Ui) {
let ctx = ui.ctx().clone();
let Some(doc) = app.session.active().map(|d| d.doc.clone()) else {
app.show = None;
return;
};
let now = ctx.input(|i| i.time);
let Some(mut show) = app.show.take() else { return };
if !show.started {
show.started = true;
show.slide_start = now;
show.step_start = now;
show.show_start = now;
show.last_move = now;
if !show.reading {
ctx.send_viewport_cmd(egui::ViewportCommand::Fullscreen(true));
}
show.after_enter(&doc, now);
}
let mut keep = true;
// Input.
let (keys, clicked, secondary, moved) = ctx.input(|i| {
let keys: Vec<(egui::Key, egui::Modifiers)> = i
.events
.iter()
.filter_map(|e| if let egui::Event::Key { key, pressed: true, modifiers, .. } = e { Some((*key, *modifiers)) } else { None })
.collect();
(keys, i.pointer.primary_clicked(), i.pointer.secondary_clicked(), i.pointer.delta().length() > 0.0)
});
if moved {
show.last_move = now;
}
let mut actions: Vec<ShowAction> = vec![];
for (k, m) in keys {
use egui::Key::*;
match k {
Escape | Minus => keep = false,
ArrowRight | ArrowDown | Space | PageDown | N => {
if show.ended {
keep = false;
} else {
actions.push(show.state.next(&doc));
}
}
Enter => {
if let Ok(n) = show.goto.parse::<usize>() {
actions.push(show.state.goto(&doc, n.saturating_sub(1)));
show.goto.clear();
} else if show.ended {
keep = false;
} else {
actions.push(show.state.next(&doc));
}
}
ArrowLeft | ArrowUp | Backspace | PageUp | P if !(k == P && m.command) => {
show.ended = false;
actions.push(show.state.prev(&doc));
}
P if m.command => show.pen = !show.pen,
A if m.command => show.pen = false,
Home => actions.push(show.state.goto(&doc, 0)),
End => actions.push(show.state.goto(&doc, doc.slides.len().saturating_sub(1))),
B | Period => show.blank = if show.blank == Some(Color32::BLACK) { None } else { Some(Color32::BLACK) },
W | Comma => show.blank = if show.blank == Some(Color32::WHITE) { None } else { Some(Color32::WHITE) },
E => show.strokes.clear(),
Num0 | Num1 | Num2 | Num3 | Num4 | Num5 | Num6 | Num7 | Num8 | Num9 => {
show.goto.push_str(k.name());
}
_ => {}
}
}
if clicked && !show.pen && !show.reading_bar_hit(&ctx) {
if show.ended {
keep = false;
} else if show.blank.is_some() {
show.blank = None;
} else {
actions.push(show.state.next(&doc));
}
}
if secondary && !show.ended {
actions.push(show.state.prev(&doc));
}
// Step clock.
let (step, playing) = (show.state.step, show.state.playing);
let t_step = if playing { now - show.step_start } else { 0.0 };
if playing {
let dur = show.timeline(&doc).step_duration(step);
if t_step >= dur {
actions.push(show.state.step_done(&doc));
show.step_end = now;
}
} else if show.trans.is_none() {
// Auto-advance (After: timings) or autoplay preview.
let idle = now - show.step_end.max(show.slide_start);
let cur_step = show.state.step;
let next_auto = show.timeline(&doc).step_is_auto(cur_step);
let n_steps = show.timeline(&doc).steps();
if show.autoplay && cur_step < n_steps && idle > 0.3 {
actions.push(show.state.next(&doc));
} else if next_auto && cur_step < n_steps {
actions.push(show.state.next(&doc));
} else if !show.rehearse && show.state.auto_advance_due(&doc, idle) && !show.ended {
actions.push(show.state.next(&doc));
} else if show.preview_only && cur_step >= n_steps && idle > 0.8 {
keep = false;
}
}
for a in actions {
if !show.apply(a, &doc, now) {
keep = false;
}
}
// Draw.
let full = ui.max_rect();
let painter = ui.painter_at(full);
painter.rect_filled(full, CornerRadius::ZERO, Color32::BLACK);
let avail = if show.reading { Rect::from_min_max(full.min, pos2(full.max.x, full.max.y - 34.0)) } else { full };
let srect = fit(avail, &doc);
let ppp = ctx.pixels_per_point();
let size = ((srect.width() * ppp).round().max(1.0) as u32, (srect.height() * ppp).round().max(1.0) as u32);
if show.ended {
painter.text(
pos2(full.center().x, full.min.y + 40.0),
Align2::CENTER_CENTER,
"End of slide show, click to exit.",
theme::font(18.0),
Color32::from_gray(200),
);
} else {
let idx = show.state.slide;
let step = show.state.step;
let animating = show.state.playing;
let t = if animating { now - show.step_start } else { 0.0 };
let key = Key { slide: idx, step, size, doc: std::sync::Arc::as_ptr(&doc) as usize };
let tex = if !animating && show.tex.as_ref().is_some_and(|(k, _)| *k == key) {
show.tex.as_ref().map(|(_, t)| t.clone())
} else {
let tl = show.timeline(&doc).clone();
let f = move |id: ShapeId| state_for(&tl, id, step, t);
let threads =
if cfg!(target_arch = "wasm32") { 0 } else { std::thread::available_parallelism().map(|n| n.get().min(8) as u16).unwrap_or(0) };
let img = slidecraft_render::render_slide(
&doc,
idx,
&RenderOpts {
scale: size.0 as f64 / doc.slide_size.width.max(1.0),
size: Some(size),
state: Some(&f),
threads,
..Default::default()
},
);
let ci = crate::textures::to_color_image(&img, false);
let tex = match show.tex.take() {
Some((_, mut h)) => {
h.set(ci, egui::TextureOptions::LINEAR);
h
}
None => ctx.load_texture("show", ci, egui::TextureOptions::LINEAR),
};
show.tex = Some((key, tex.clone()));
Some(tex)
};
let mut drew = false;
if let (Some(tr), Some(new)) = (&show.trans, &tex) {
let p = ((now - tr.start) / tr.dur.max(0.01)).clamp(0.0, 1.0);
if p < 1.0 {
for l in slidecraft_anim::transition_layers(&tr.kind, &tr.option, p) {
let mesh = match l.source {
Source::Old => match &tr.old {
Some(o) => quad_mesh(Some(o.id()), srect, &l, Color32::WHITE),
None => quad_mesh(None, srect, &l, Color32::BLACK),
},
Source::New => quad_mesh(Some(new.id()), srect, &l, Color32::WHITE),
Source::Black => quad_mesh(None, srect, &l, Color32::BLACK),
Source::White => quad_mesh(None, srect, &l, Color32::WHITE),
};
painter.add(egui::Shape::mesh(mesh));
}
drew = true;
ctx.request_repaint();
}
}
if !drew {
show.trans = None;
if let Some(tex) = &tex {
painter.image(tex.id(), srect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
}
}
if animating {
ctx.request_repaint();
}
}
if let Some(c) = show.blank {
painter.rect_filled(full, CornerRadius::ZERO, c);
}
// Pen.
let resp = ui.interact(full, ui.id().with("show_pen"), if show.pen { Sense::drag() } else { Sense::hover() });
if show.pen {
if resp.drag_started() {
show.strokes.push(vec![]);
}
if resp.dragged()
&& let (Some(p), Some(s)) = (resp.interact_pointer_pos(), show.strokes.last_mut())
{
s.push(pos2((p.x - srect.min.x) / srect.width(), (p.y - srect.min.y) / srect.height()));
}
ctx.set_cursor_icon(egui::CursorIcon::Crosshair);
} else if now - show.last_move > 2.5 && !show.reading {
ctx.set_cursor_icon(egui::CursorIcon::None);
ctx.request_repaint_after(std::time::Duration::from_millis(500));
}
let pen_col = theme::to_color32(doc.show.pen_color);
for s in &show.strokes {
let pts: Vec<Pos2> = s.iter().map(|q| pos2(srect.min.x + q.x * srect.width(), srect.min.y + q.y * srect.height())).collect();
painter.add(egui::Shape::line(pts, Stroke::new(3.0, pen_col)));
}
// Reading view bar / on-screen controls.
if show.reading {
let bar = Rect::from_min_max(pos2(full.min.x, full.max.y - 34.0), full.max);
painter.rect_filled(bar, CornerRadius::ZERO, Color32::from_gray(32));
painter.text(
pos2(bar.min.x + 14.0, bar.center().y),
Align2::LEFT_CENTER,
format!("Slide {} of {}", show.state.slide + 1, doc.slides.len()),
theme::font(12.0),
Color32::from_gray(200),
);
let mut child = ui.new_child(
egui::UiBuilder::new()
.max_rect(Rect::from_min_max(pos2(bar.max.x - 260.0, bar.min.y), bar.max))
.layout(egui::Layout::right_to_left(egui::Align::Center)),
);
if child.button("Close").clicked() {
keep = false;
}
if child.button("▶").clicked() {
let a = show.state.next(&doc);
if !show.apply(a, &doc, now) {
keep = false;
}
}
if child.button("◀").clicked() {
let a = show.state.prev(&doc);
show.apply(a, &doc, now);
}
} else if now - show.last_move < 2.5 {
let bar = Rect::from_min_size(pos2(full.min.x + 20.0, full.max.y - 50.0), vec2(250.0, 34.0));
painter.rect_filled(bar, CornerRadius::same(17), Color32::from_black_alpha(140));
let mut child =
ui.new_child(egui::UiBuilder::new().max_rect(bar.shrink2(vec2(10.0, 4.0))).layout(egui::Layout::left_to_right(egui::Align::Center)));
let b = |ui: &mut Ui, s: &str| ui.add(egui::Button::new(egui::RichText::new(s).color(Color32::WHITE).size(15.0)).frame(false));
if b(&mut child, "◀").clicked() {
let a = show.state.prev(&doc);
show.apply(a, &doc, now);
}
if b(&mut child, "▶").clicked() {
let a = show.state.next(&doc);
if !show.apply(a, &doc, now) {
keep = false;
}
}
if b(&mut child, if show.pen { "✏ on" } else { "✏" }).clicked() {
show.pen = !show.pen;
}
if b(&mut child, "⬛").clicked() {
show.blank = Some(Color32::BLACK);
}
if b(&mut child, "End").clicked() {
keep = false;
}
ctx.request_repaint_after(std::time::Duration::from_millis(300));
}
if show.rehearse {
let t = now - show.slide_start;
let total = now - show.show_start;
let r = Rect::from_min_size(pos2(full.min.x + 12.0, full.min.y + 12.0), vec2(220.0, 30.0));
painter.rect_filled(r, CornerRadius::same(6), Color32::from_black_alpha(160));
painter.text(
r.center(),
Align2::CENTER_CENTER,
format!("Slide {:02}:{:04.1} Total {:02}:{:02}", (t / 60.0) as u32, t % 60.0, (total / 60.0) as u32, (total % 60.0) as u32),
theme::font(13.0),
Color32::WHITE,
);
ctx.request_repaint_after(std::time::Duration::from_millis(100));
}
// Presenter view in a second window.
if show.presenter && !show.reading && !show.ended {
presenter_window(&ctx, &mut show, &doc, now, &mut keep);
}
if keep {
app.show = Some(show);
} else {
if !show.reading {
ctx.send_viewport_cmd(egui::ViewportCommand::Fullscreen(false));
}
if show.rehearse {
if let Some((_, tl)) = &show.timeline {
let _ = tl;
}
show.timings.push((show.state.slide, now - show.slide_start));
for (i, secs) in show.timings.clone() {
let _ = app.run("transition.timing", json!({"index": i, "after": (secs * 1000.0).round() as u64}));
}
app.set_status("Slide timings recorded.");
}
let last = show.state.slide;
let _ = app.run("slide.go", json!({"index": last}));
}
}
impl Show {
fn reading_bar_hit(&self, ctx: &egui::Context) -> bool {
// Clicks on the on-screen controls must not also advance.
ctx.input(|i| i.pointer.interact_pos()).is_some_and(|p| {
let r = ctx.content_rect();
(self.reading && p.y > r.max.y - 34.0) || (!self.reading && p.y > r.max.y - 56.0 && p.x < r.min.x + 280.0)
})
}
}
fn presenter_window(ctx: &egui::Context, show: &mut Show, doc: &Presentation, now: f64, keep: &mut bool) {
let cur = show.state.slide;
let next = slidecraft_anim::show_order(doc).into_iter().find(|i| *i > cur);
let cur_tex = show.tex.as_ref().map(|(_, t)| t.clone());
if let Some(n) = next
&& show.next_tex.as_ref().is_none_or(|(k, _)| *k != n)
{
let img = slidecraft_render::render_slide(
doc,
n,
&RenderOpts {
scale: 480.0 / doc.slide_size.width.max(1.0),
size: Some((480, (480.0 * doc.slide_size.height / doc.slide_size.width.max(1.0)) as u32)),
..Default::default()
},
);
show.next_tex = Some((n, ctx.load_texture("presenter-next", crate::textures::to_color_image(&img, false), egui::TextureOptions::LINEAR)));
}
let next_tex = show.next_tex.as_ref().filter(|(k, _)| Some(*k) == next).map(|(_, t)| t.clone());
let notes = doc.slides.get(cur).map(|s| s.notes_text()).unwrap_or_default();
let elapsed = now - show.show_start;
let mut actions = vec![];
ctx.show_viewport_immediate(
egui::ViewportId::from_hash_of("slidecraft_presenter"),
egui::ViewportBuilder::default().with_title("Presenter View — SlideCraft").with_inner_size([1100.0, 700.0]),
|ui, _| {
egui::CentralPanel::default().frame(egui::Frame::NONE.fill(Color32::from_gray(24)).inner_margin(egui::Margin::same(16))).show(ui, |ui| {
ui.horizontal(|ui| {
ui.label(
egui::RichText::new(format!(
"{:02}:{:02}:{:02}",
(elapsed / 3600.0) as u32,
(elapsed / 60.0) as u32 % 60,
elapsed as u32 % 60
))
.size(22.0)
.color(Color32::WHITE),
);
ui.add_space(20.0);
ui.label(egui::RichText::new(format!("Slide {} of {}", cur + 1, doc.slides.len())).size(16.0).color(Color32::from_gray(200)));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.button("End Show").clicked() {
*keep = false;
}
if ui.button("Next ▶").clicked() {
actions.push(show.state.next(doc));
}
if ui.button("◀ Previous").clicked() {
actions.push(show.state.prev(doc));
}
});
});
ui.add_space(10.0);
let avail = ui.available_rect_before_wrap();
let left = Rect::from_min_max(avail.min, pos2(avail.min.x + avail.width() * 0.62, avail.max.y));
let right = Rect::from_min_max(pos2(left.max.x + 16.0, avail.min.y), avail.max);
let cr = fit(Rect::from_min_max(left.min, pos2(left.max.x, left.min.y + left.height() * 0.8)), doc);
let cr = Rect::from_min_size(left.min, cr.size());
if let Some(t) = &cur_tex {
ui.painter().image(t.id(), cr, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
}
let nr = Rect::from_min_size(
right.min + vec2(0.0, 22.0),
vec2(right.width(), right.width() * (doc.slide_size.height / doc.slide_size.width.max(1.0)) as f32),
);
ui.painter().text(right.min, Align2::LEFT_TOP, "Next", theme::font(13.0), Color32::from_gray(170));
match &next_tex {
Some(t) => {
ui.painter().image(t.id(), nr, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
}
None => {
ui.painter().rect_filled(nr, CornerRadius::ZERO, Color32::from_gray(40));
ui.painter().text(nr.center(), Align2::CENTER_CENTER, "End of slide show", theme::font(13.0), Color32::from_gray(170));
}
}
let notes_rect = Rect::from_min_max(pos2(right.min.x, nr.max.y + 16.0), right.max);
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(notes_rect));
egui::ScrollArea::vertical().show(&mut child, |ui| {
ui.label(egui::RichText::new(if notes.is_empty() { "No notes." } else { &notes }).size(18.0).color(Color32::from_gray(230)));
});
});
if ui.input(|i| i.viewport().close_requested()) {
show.presenter = false;
}
},
);
for a in actions {
if !show.apply(a, doc, now) {
*keep = false;
}
}
}
+231
View File
@@ -0,0 +1,231 @@
//! Slide Sorter, Notes Page and the Outline pane.
use egui::{Align2, Color32, CornerRadius, Rect, Sense, Stroke, Ui, pos2, vec2};
use serde_json::json;
use crate::SlideApp;
use crate::theme::{self, Tokens};
/// Grid of slides; click selects, double-click opens in Normal view, drag reorders.
pub fn show(app: &mut SlideApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
let Some(st) = app.session.active() else { return };
let doc = st.doc.clone();
let cur = st.selection.slide;
let multi = st.selection.slides.clone();
let zoom = app.ui.sorter_zoom.clamp(0.5, 3.0);
egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| {
ui.add_space(16.0);
let tw = 200.0 * zoom;
let th = tw * (doc.slide_size.height / doc.slide_size.width.max(1.0)) as f32;
let ppp = ui.ctx().pixels_per_point();
let cols = ((ui.available_width() - 32.0) / (tw + 36.0)).floor().max(1.0) as usize;
let mut budget = 4u32;
let mut rects = vec![];
let mut need_more = false;
for row in 0..doc.slides.len().div_ceil(cols) {
ui.horizontal(|ui| {
ui.add_space(24.0);
for c in 0..cols {
let i = row * cols + c;
if i >= doc.slides.len() {
break;
}
let (cell, resp) = ui.allocate_exact_size(vec2(tw + 36.0, th + 36.0), Sense::click_and_drag());
let img = Rect::from_min_size(cell.min + vec2(18.0, 8.0), vec2(tw, th));
rects.push((i, cell));
let p = ui.painter();
match app.textures.thumb(ui.ctx(), &doc, i, (tw * ppp) as u32, &mut budget) {
Some(tex) => {
p.image(tex.id(), img, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
}
None => {
p.rect_filled(img, CornerRadius::ZERO, Color32::WHITE);
need_more = true;
}
}
let id = doc.slides.get(i).map(|s| s.id);
let selected = i == cur || id.is_some_and(|id| multi.contains(&id));
p.rect_stroke(
img.expand(if selected { 2.0 } else { 0.0 }),
CornerRadius::same(3),
Stroke::new(if selected { 3.0 } else { 1.0 }, if selected { t.accent } else { t.border }),
egui::StrokeKind::Outside,
);
p.text(pos2(img.min.x, img.max.y + 12.0), Align2::LEFT_CENTER, format!("{}", i + 1), theme::font(12.0), t.text_dim);
if let Some(tr) = doc.slides.get(i).and_then(|s| s.transition.as_ref()).filter(|t| t.kind != "none") {
p.text(pos2(img.max.x, img.max.y + 12.0), Align2::RIGHT_CENTER, format!("★ {}", tr.kind), theme::font(10.5), t.text_faint);
}
if resp.clicked() {
let add = ui.input(|i| i.modifiers.command);
if add {
if let Some(id) = id {
let _ = app.run("slide.selectSlides", json!({"ids": [id.0], "add": true}));
}
} else {
let _ = app.run("slide.go", json!({"index": i}));
}
}
if resp.double_clicked() {
let _ = app.run("slide.go", json!({"index": i}));
app.ui.view = crate::ViewMode::Normal;
}
if resp.drag_started() {
app.thumb_drag = Some((i, i));
}
resp.context_menu(|ui| crate::thumbs::slide_menu(app, ui, i));
}
});
}
if need_more {
ui.ctx().request_repaint();
}
if let Some((from, _)) = app.thumb_drag {
if let Some(pp) = ui.input(|i| i.pointer.interact_pos()) {
let to =
rects.iter().find(|(_, r)| r.contains(pp)).map(|(i, r)| if pp.x > r.center().x { i + 1 } else { *i }).unwrap_or(doc.slides.len());
app.thumb_drag = Some((from, to));
if let Some((_, r)) = rects.iter().find(|(i, _)| *i == to).or(rects.last()) {
let x = if to >= doc.slides.len() { r.max.x - 6.0 } else { r.min.x + 6.0 };
ui.painter().line_segment([pos2(x, r.min.y + 6.0), pos2(x, r.max.y - 20.0)], Stroke::new(3.0, t.accent));
}
}
if ui.input(|i| i.pointer.any_released()) {
if let Some((from, to)) = app.thumb_drag.take()
&& to != from
&& to != from + 1
{
let _ = app.run("slide.move", json!({"from": from, "to": if to > from { to - 1 } else { to }}));
}
app.thumb_drag = None;
}
}
// Ctrl/⌘ + wheel zooms the sorter.
let (zd, cmd) = ui.input(|i| (i.zoom_delta(), i.modifiers.command));
if (zd - 1.0).abs() > 1e-3 || cmd {
app.ui.sorter_zoom = (zoom * zd).clamp(0.5, 3.0);
}
});
}
/// Notes Page: the slide on top and its notes underneath, on a page.
pub fn notes_page(app: &mut SlideApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
let avail = ui.available_rect_before_wrap();
ui.painter().rect_filled(avail, CornerRadius::ZERO, t.canvas);
let Some(st) = app.session.active() else { return };
let doc = st.doc.clone();
let idx = st.selection.slide;
let page_h = avail.height() - 40.0;
let page_w = page_h * 8.5 / 11.0;
let page = Rect::from_center_size(avail.center(), vec2(page_w, page_h));
ui.painter().rect_filled(page.translate(vec2(2.0, 2.0)), CornerRadius::ZERO, t.shadow);
ui.painter().rect_filled(page, CornerRadius::ZERO, Color32::WHITE);
let sw = page_w * 0.78;
let sh = sw * (doc.slide_size.height / doc.slide_size.width.max(1.0)) as f32;
let srect = Rect::from_min_size(pos2(page.center().x - sw / 2.0, page.min.y + page_h * 0.08), vec2(sw, sh));
let ppp = ui.ctx().pixels_per_point();
let mut budget = 2;
if let Some(tex) = app.textures.thumb(ui.ctx(), &doc, idx, (sw * ppp) as u32, &mut budget) {
ui.painter().image(tex.id(), srect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
}
ui.painter().rect_stroke(srect, CornerRadius::ZERO, Stroke::new(1.0, Color32::GRAY), egui::StrokeKind::Outside);
let notes_rect = Rect::from_min_max(pos2(srect.min.x, srect.max.y + 24.0), pos2(srect.max.x, page.max.y - page_h * 0.06));
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(notes_rect));
let current = st.current_slide().map(|s| s.notes_text()).unwrap_or_default();
let id = egui::Id::new(("notespage", idx));
let mut buf: String = child.data_mut(|d| d.get_temp(id).unwrap_or_else(|| current.clone()));
let r = child.add(
egui::TextEdit::multiline(&mut buf)
.hint_text("Click to add text")
.desired_width(notes_rect.width())
.desired_rows(12)
.frame(egui::Frame::NONE)
.text_color(Color32::from_gray(30))
.font(theme::font(14.0)),
);
if r.lost_focus() && buf != current {
let _ = app.run("slide.notes", json!({"text": buf, "index": idx}));
}
child.data_mut(|d| d.insert_temp(id, buf));
let nav = ui.input(|i| (i.key_pressed(egui::Key::PageDown), i.key_pressed(egui::Key::PageUp)));
if nav.0 {
let _ = app.run("slide.next", json!({}));
}
if nav.1 {
let _ = app.run("slide.previous", json!({}));
}
}
/// Outline view: slide titles and body text as an editable outline.
pub fn outline_pane(app: &mut SlideApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
egui::Panel::left("outline").resizable(true).default_size(360.0).frame(egui::Frame::NONE.fill(t.card).inner_margin(egui::Margin::same(10))).show(
ui,
|ui| {
let Some(st) = app.session.active() else { return };
let doc = st.doc.clone();
let cur = st.selection.slide;
egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| {
for (i, s) in doc.slides.iter().enumerate() {
let title_shape = s.shapes.iter().find(|x| x.ph_type().is_some_and(|k| k.is_title())).map(|x| x.id.0);
ui.horizontal(|ui| {
ui.label(egui::RichText::new(format!("{}", i + 1)).color(t.text_dim));
let (r, _) = ui.allocate_exact_size(vec2(14.0, 10.0), Sense::hover());
ui.painter().rect_stroke(
r,
CornerRadius::same(1),
Stroke::new(1.0, if i == cur { t.accent } else { t.text_dim }),
egui::StrokeKind::Inside,
);
let id = egui::Id::new(("ol-title", s.id.0));
let cur_title = s.title();
let mut v: String = ui.data_mut(|d| d.get_temp(id).unwrap_or_else(|| cur_title.clone()));
let resp =
ui.add(egui::TextEdit::singleline(&mut v).font(theme::bold(14.0)).frame(egui::Frame::NONE).desired_width(f32::INFINITY));
if resp.gained_focus() {
let _ = app.run("slide.go", json!({"index": i}));
}
if resp.lost_focus() && v != cur_title {
if let Some(tid) = title_shape {
let _ = app.run("text.set", json!({"id": tid, "text": v}));
}
}
if !resp.has_focus() {
ui.data_mut(|d| d.remove::<String>(id));
} else {
ui.data_mut(|d| d.insert_temp(id, v));
}
});
for sh in s.shapes.iter().filter(|x| !x.ph_type().is_some_and(|k| k.is_title())) {
let Some(tb) = &sh.text else { continue };
if tb.is_empty() {
continue;
}
let id = egui::Id::new(("ol-body", s.id.0, sh.id.0));
let cur_text: String =
tb.paragraphs.iter().map(|p| format!("{}{}", "\t".repeat(p.level as usize), p.text())).collect::<Vec<_>>().join("\n");
let mut v: String = ui.data_mut(|d| d.get_temp(id).unwrap_or_else(|| cur_text.clone()));
ui.horizontal(|ui| {
ui.add_space(36.0);
let resp =
ui.add(egui::TextEdit::multiline(&mut v).frame(egui::Frame::NONE).desired_width(f32::INFINITY).desired_rows(1));
if resp.gained_focus() {
let _ = app.run("slide.go", json!({"index": i}));
}
if resp.lost_focus() && v != cur_text {
let _ = app.run("text.set", json!({"id": sh.id.0, "text": v}));
}
if !resp.has_focus() {
ui.data_mut(|d| d.remove::<String>(id));
} else {
ui.data_mut(|d| d.insert_temp(id, v.clone()));
}
});
}
ui.add_space(6.0);
}
});
},
);
}
+155
View File
@@ -0,0 +1,155 @@
//! Status bar (slide counter, language, notes/comments toggles, view buttons, zoom slider) and the
//! notes pane under the slide.
use egui::{Align2, CornerRadius, Rect, Sense, Stroke, Ui, pos2, vec2};
use serde_json::json;
use crate::icons::{self, Icon};
use crate::theme::{self, Tokens};
use crate::{SlideApp, ViewMode};
pub fn status_bar(app: &mut SlideApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
egui::Panel::bottom("status").exact_size(28.0).frame(egui::Frame::NONE.fill(t.chrome)).show(ui, |ui| {
let rect = ui.max_rect();
let Some(st) = app.session.active() else { return };
let n = st.doc.slides.len();
let left = match st.selection.target {
slidecraft_engine::Target::Slides => format!("Slide {} of {}", (st.selection.slide + 1).min(n.max(1)), n),
_ => "Slide Master".to_string(),
};
let p = ui.painter();
p.text(pos2(rect.min.x + 14.0, rect.center().y), Align2::LEFT_CENTER, &left, theme::font(12.0), t.text_dim);
p.text(pos2(rect.min.x + 120.0, rect.center().y), Align2::LEFT_CENTER, "English (United States)", theme::font(12.0), t.text_dim);
// Transient status message.
if let Some((msg, at)) = app.status.clone() {
if crate::now_ms() - at < 6000.0 {
p.text(pos2(rect.min.x + 290.0, rect.center().y), Align2::LEFT_CENTER, &msg, theme::font(12.0), t.accent);
ui.ctx().request_repaint_after(std::time::Duration::from_millis(500));
} else {
app.status = None;
}
}
let mut right = ui.new_child(
egui::UiBuilder::new()
.max_rect(Rect::from_min_max(pos2(rect.max.x - 560.0, rect.min.y), pos2(rect.max.x - 8.0, rect.max.y)))
.layout(egui::Layout::right_to_left(egui::Align::Center)),
);
right.spacing_mut().item_spacing.x = 4.0;
if small_icon(&mut right, Icon::FitSmall, "Fit slide to current window", false).clicked() {
app.ui.zoom = None;
}
let pct = (app.ui.zoom.unwrap_or(app.canvas_scale) * 100.0).round();
let lbl = right.add(egui::Label::new(egui::RichText::new(format!("{pct}%")).size(12.0).color(t.text_dim)).sense(Sense::click()));
if lbl.clicked() {
app.dialog = Some(crate::dialogs::Dialog::new("zoom"));
}
if small_icon(&mut right, Icon::ZoomIn, "Zoom In", false).clicked() {
let _ = app.run("view.zoomIn", json!({}));
}
// Slider 10%–400% (log scale).
let mut z = app.ui.zoom.unwrap_or(app.canvas_scale).clamp(0.1, 4.0);
let mut lz = z.ln();
let s = right.add_sized(vec2(110.0, 18.0), egui::Slider::new(&mut lz, (0.1f32).ln()..=(4.0f32).ln()).show_value(false));
if s.changed() {
z = lz.exp();
app.ui.zoom = Some(z);
}
if small_icon(&mut right, Icon::ZoomOut, "Zoom Out", false).clicked() {
let _ = app.run("view.zoomOut", json!({}));
}
right.add_space(8.0);
let view = app.ui.view;
if small_icon(&mut right, Icon::ViewShowSmall, "Slide Show", false).clicked() {
let i = app.session.active().map(|d| d.selection.slide).unwrap_or(0);
app.start_show(i, false);
}
if small_icon(&mut right, Icon::ViewReadingSmall, "Reading View", false).clicked() {
let _ = app.run("view.reading", json!({}));
}
if small_icon(&mut right, Icon::ViewSorterSmall, "Slide Sorter", view == ViewMode::Sorter).clicked() {
app.ui.view = ViewMode::Sorter;
}
if small_icon(&mut right, Icon::ViewNormalSmall, "Normal", view == ViewMode::Normal).clicked() {
app.ui.view = ViewMode::Normal;
}
right.add_space(8.0);
let c_on = app.ui.pane.as_deref() == Some("comments");
if labeled(&mut right, Icon::CommentsSmall, "Comments", c_on).clicked() {
let _ = app.run("view.pane", json!({"pane": "comments", "toggle": true}));
}
let n_on = app.ui.notes;
if labeled(&mut right, Icon::NotesSmall, "Notes", n_on).clicked() {
app.ui.notes = !n_on;
}
let _ = Stroke::NONE;
});
}
fn small_icon(ui: &mut Ui, icon: Icon, tip: &str, on: bool) -> egui::Response {
let t = Tokens::get(ui.ctx());
let (r, resp) = ui.allocate_exact_size(vec2(26.0, 20.0), Sense::click());
if on || resp.hovered() {
ui.painter().rect_filled(r, CornerRadius::same(4), if on { t.selected } else { t.hover });
}
icons::paint(ui.painter(), Rect::from_center_size(r.center(), vec2(16.0, 16.0)), icon, t.text_dim, false);
resp.on_hover_text(tip)
}
fn labeled(ui: &mut Ui, icon: Icon, label: &str, on: bool) -> egui::Response {
let t = Tokens::get(ui.ctx());
let w = 22.0 + ui.fonts_mut(|f| f.layout_no_wrap(label.into(), theme::font(12.0), t.text).size().x) + 8.0;
let (r, resp) = ui.allocate_exact_size(vec2(w, 20.0), Sense::click());
if on || resp.hovered() {
ui.painter().rect_filled(r, CornerRadius::same(4), if on { t.selected } else { t.hover });
}
icons::paint(ui.painter(), Rect::from_center_size(pos2(r.min.x + 11.0, r.center().y), vec2(15.0, 15.0)), icon, t.text_dim, false);
ui.painter().text(pos2(r.min.x + 22.0, r.center().y), Align2::LEFT_CENTER, label, theme::font(12.0), t.text_dim);
resp
}
/// The speaker notes editor under the slide.
pub fn notes_pane(app: &mut SlideApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
let Some(st) = app.session.active() else { return };
if st.selection.target != slidecraft_engine::Target::Slides {
return;
}
let idx = st.selection.slide;
let uid = st.uid;
let current = st.current_slide().map(|s| s.notes_text()).unwrap_or_default();
egui::Panel::bottom("notes")
.resizable(true)
.default_size(app.ui.notes_height)
.size_range(36.0..=400.0)
.frame(egui::Frame::NONE.fill(t.chrome).inner_margin(egui::Margin::symmetric(8, 6)))
.show(ui, |ui| {
app.ui.notes_height = ui.available_height() + 12.0;
let rect = ui.max_rect();
ui.painter()
.line_segment([pos2(rect.min.x - 8.0, rect.min.y - 6.0), pos2(rect.max.x + 8.0, rect.min.y - 6.0)], Stroke::new(1.0, t.separator));
// Keep a local buffer per slide; commit when it changes and the field loses focus.
if app.notes_buf.0 != uid || app.notes_buf.1 != idx {
app.notes_buf = (uid, idx, current.clone());
}
let id = egui::Id::new("notes_edit");
let focused = ui.ctx().memory(|m| m.has_focus(id));
if !focused && app.notes_buf.2 != current {
app.notes_buf.2 = current.clone();
}
let mut buf = app.notes_buf.2.clone();
let r = ui.add(
egui::TextEdit::multiline(&mut buf)
.id(id)
.hint_text("Click to add notes")
.frame(egui::Frame::NONE)
.desired_width(f32::INFINITY)
.desired_rows(2)
.font(theme::font(13.0)),
);
app.notes_buf.2 = buf.clone();
if r.lost_focus() && buf != current {
let _ = app.run("slide.notes", json!({"text": buf, "index": idx}));
}
});
}
+150
View File
@@ -0,0 +1,150 @@
//! Rendered slides as GPU textures: the canvas slide and thumbnails, cached by the slide's and its
//! master's `Arc` identity (an unchanged slide keeps its `Arc`, so it never re-renders).
use std::collections::HashMap;
use std::sync::Arc;
use egui::{ColorImage, TextureHandle, TextureOptions};
use slidecraft_model::{Master, Presentation, Slide};
use slidecraft_render::{Image, RenderOpts};
#[derive(Clone, PartialEq)]
struct Key {
slide: usize,
master: usize,
size: (u32, u32),
edit: bool,
extra: u64,
}
struct Entry {
key: Key,
tex: TextureHandle,
// Keep the Arcs alive so their addresses can't be reused while cached.
_slide: Arc<Slide>,
_master: Option<Arc<Master>>,
stamp: u64,
}
#[derive(Default)]
pub struct Textures {
canvas: Option<Entry>,
thumbs: HashMap<usize, Entry>,
clock: u64,
pub last_render_ms: f64,
}
pub fn to_color_image(img: &Image, grayscale: bool) -> ColorImage {
let w = img.width as usize;
let h = img.height as usize;
let mut px = Vec::with_capacity(w * h);
for c in img.pixels.chunks_exact(4) {
let (r, g, b, a) = (c[0], c[1], c[2], c[3]);
if grayscale {
let y = ((r as u32 * 77 + g as u32 * 150 + b as u32 * 29) >> 8) as u8;
px.push(egui::Color32::from_rgba_premultiplied(y, y, y, a));
} else {
px.push(egui::Color32::from_rgba_premultiplied(r, g, b, a));
}
}
px.resize(w * h, egui::Color32::TRANSPARENT);
ColorImage::new([w, h], px)
}
fn master_of(p: &Presentation, slide: &Slide) -> Option<Arc<Master>> {
p.masters.iter().find(|m| m.layout(slide.layout).is_some()).cloned()
}
fn threads() -> u16 {
if cfg!(target_arch = "wasm32") { 0 } else { std::thread::available_parallelism().map(|n| n.get().min(8) as u16).unwrap_or(0) }
}
impl Textures {
/// The canvas texture for slide `index` at `size` pixels.
pub fn slide(
&mut self,
ctx: &egui::Context,
p: &Presentation,
index: usize,
size: (u32, u32),
edit: bool,
extra: u64,
grayscale: bool,
) -> Option<TextureHandle> {
let slide = p.slides.get(index)?.clone();
let master = master_of(p, &slide);
let key = Key {
slide: Arc::as_ptr(&slide) as usize,
master: master.as_ref().map(|m| Arc::as_ptr(m) as usize).unwrap_or(0),
size,
edit,
extra: extra ^ grayscale as u64,
};
if let Some(e) = &self.canvas
&& e.key == key
{
return Some(e.tex.clone());
}
let t0 = crate::now_ms();
let scale = size.0 as f64 / p.slide_size.width.max(1.0);
let img = slidecraft_render::render_slide(p, index, &RenderOpts { scale, edit, threads: threads(), size: Some(size), ..Default::default() });
self.last_render_ms = crate::now_ms() - t0;
let ci = to_color_image(&img, grayscale);
let tex = match self.canvas.take() {
Some(mut e) => {
e.tex.set(ci, TextureOptions::LINEAR);
e.tex
}
None => ctx.load_texture("slide-canvas", ci, TextureOptions::LINEAR),
};
self.canvas = Some(Entry { key, tex: tex.clone(), _slide: slide, _master: master, stamp: 0 });
Some(tex)
}
/// Force the canvas to re-render next time (fonts loaded, theme changed in place…).
pub fn invalidate(&mut self) {
self.canvas = None;
self.thumbs.clear();
}
/// A thumbnail texture; renders at most `budget` new thumbnails per frame (returns None when
/// it is over budget, so the caller draws a placeholder and asks for another frame).
pub fn thumb(&mut self, ctx: &egui::Context, p: &Presentation, index: usize, width_px: u32, budget: &mut u32) -> Option<TextureHandle> {
let slide = p.slides.get(index)?.clone();
let master = master_of(p, &slide);
let h = (width_px as f64 * p.slide_size.height / p.slide_size.width.max(1.0)).round().max(1.0) as u32;
let key = Key {
slide: Arc::as_ptr(&slide) as usize,
master: master.as_ref().map(|m| Arc::as_ptr(m) as usize).unwrap_or(0),
size: (width_px, h),
edit: false,
extra: 0,
};
self.clock += 1;
let clock = self.clock;
if let Some(e) = self.thumbs.get_mut(&key.slide)
&& e.key == key
{
e.stamp = clock;
return Some(e.tex.clone());
}
if *budget == 0 {
// Keep showing the stale thumbnail while the new one waits.
return self.thumbs.get(&key.slide).map(|e| e.tex.clone());
}
*budget -= 1;
let scale = width_px as f64 / p.slide_size.width.max(1.0);
let img = slidecraft_render::render_slide(p, index, &RenderOpts { scale, size: Some((width_px, h)), threads: 0, ..Default::default() });
let tex = ctx.load_texture(format!("thumb-{index}"), to_color_image(&img, false), TextureOptions::LINEAR);
// Drop the old entry for this slide address; entries for slides that no longer exist age out.
self.thumbs.insert(key.slide, Entry { key, tex: tex.clone(), _slide: slide, _master: master, stamp: clock });
if self.thumbs.len() > 600 {
let mut v: Vec<(usize, u64)> = self.thumbs.iter().map(|(k, e)| (*k, e.stamp)).collect();
v.sort_by_key(|x| x.1);
for (k, _) in v.into_iter().take(200) {
self.thumbs.remove(&k);
}
}
Some(tex)
}
}
+194
View File
@@ -0,0 +1,194 @@
//! Design tokens (light, PowerPoint-like; and dark) and fonts for the UI.
use egui::{Color32, CornerRadius, FontData, FontDefinitions, FontFamily, Stroke, Visuals};
use serde::{Deserialize, Serialize};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Brightness {
#[default]
Light,
Dark,
}
/// Every colour the chrome uses.
#[derive(Clone, Copy, Debug)]
pub struct Tokens {
pub dark: bool,
/// Window background (title bar, tab row, side panes).
pub chrome: Color32,
/// The ribbon card and popovers.
pub card: Color32,
pub card_border: Color32,
/// Canvas around the slide.
pub canvas: Color32,
pub text: Color32,
pub text_dim: Color32,
pub text_faint: Color32,
pub border: Color32,
pub separator: Color32,
pub hover: Color32,
pub pressed: Color32,
pub selected: Color32,
/// SlideCraft's accent (tabs underline, selected thumbnail, primary buttons).
pub accent: Color32,
pub accent_text: Color32,
pub contextual: Color32,
pub selection: Color32,
pub handle_fill: Color32,
pub handle_stroke: Color32,
pub guide: Color32,
pub caret: Color32,
pub text_selection: Color32,
pub field_bg: Color32,
pub shadow: Color32,
}
impl Tokens {
pub fn light() -> Self {
Tokens {
dark: false,
chrome: Color32::from_rgb(0xF5, 0xF5, 0xF5),
card: Color32::WHITE,
card_border: Color32::from_rgb(0xE3, 0xE3, 0xE3),
canvas: Color32::from_rgb(0xF0, 0xF0, 0xF0),
text: Color32::from_rgb(0x24, 0x24, 0x24),
text_dim: Color32::from_rgb(0x5C, 0x5C, 0x5C),
text_faint: Color32::from_rgb(0x99, 0x99, 0x99),
border: Color32::from_rgb(0xD6, 0xD6, 0xD6),
separator: Color32::from_rgb(0xE1, 0xE1, 0xE1),
hover: Color32::from_rgb(0xEB, 0xEB, 0xEB),
pressed: Color32::from_rgb(0xDE, 0xDE, 0xDE),
selected: Color32::from_rgb(0xE6, 0xE6, 0xE6),
accent: Color32::from_rgb(0xD9, 0x5A, 0x1A),
accent_text: Color32::WHITE,
contextual: Color32::from_rgb(0xC2, 0x4E, 0x14),
selection: Color32::from_rgb(0x7A, 0x7A, 0x7A),
handle_fill: Color32::WHITE,
handle_stroke: Color32::from_rgb(0x6E, 0x6E, 0x6E),
guide: Color32::from_rgb(0xE8, 0x5D, 0x2A),
caret: Color32::BLACK,
text_selection: Color32::from_rgba_unmultiplied(0x5A, 0x8F, 0xE0, 90),
field_bg: Color32::WHITE,
shadow: Color32::from_black_alpha(28),
}
}
pub fn dark() -> Self {
Tokens {
dark: true,
chrome: Color32::from_rgb(0x26, 0x26, 0x26),
card: Color32::from_rgb(0x33, 0x33, 0x33),
card_border: Color32::from_rgb(0x44, 0x44, 0x44),
canvas: Color32::from_rgb(0x1E, 0x1E, 0x1E),
text: Color32::from_rgb(0xEE, 0xEE, 0xEE),
text_dim: Color32::from_rgb(0xBB, 0xBB, 0xBB),
text_faint: Color32::from_rgb(0x88, 0x88, 0x88),
border: Color32::from_rgb(0x4A, 0x4A, 0x4A),
separator: Color32::from_rgb(0x48, 0x48, 0x48),
hover: Color32::from_rgb(0x40, 0x40, 0x40),
pressed: Color32::from_rgb(0x4C, 0x4C, 0x4C),
selected: Color32::from_rgb(0x47, 0x47, 0x47),
accent: Color32::from_rgb(0xF2, 0x6B, 0x1D),
accent_text: Color32::WHITE,
contextual: Color32::from_rgb(0xF5, 0x8A, 0x4B),
selection: Color32::from_rgb(0xAA, 0xAA, 0xAA),
handle_fill: Color32::WHITE,
handle_stroke: Color32::from_rgb(0x6E, 0x6E, 0x6E),
guide: Color32::from_rgb(0xF2, 0x8A, 0x4B),
caret: Color32::BLACK,
text_selection: Color32::from_rgba_unmultiplied(0x5A, 0x8F, 0xE0, 90),
field_bg: Color32::from_rgb(0x2A, 0x2A, 0x2A),
shadow: Color32::from_black_alpha(90),
}
}
pub fn for_brightness(b: Brightness) -> Self {
match b {
Brightness::Light => Self::light(),
Brightness::Dark => Self::dark(),
}
}
pub fn get(ctx: &egui::Context) -> Self {
if ctx.global_style().visuals.dark_mode { Self::dark() } else { Self::light() }
}
}
/// Apply tokens to egui's style.
pub fn apply(ctx: &egui::Context, t: &Tokens) {
let mut v = if t.dark { Visuals::dark() } else { Visuals::light() };
v.panel_fill = t.chrome;
v.window_fill = t.card;
v.extreme_bg_color = t.field_bg;
v.faint_bg_color = t.hover;
v.window_stroke = Stroke::new(1.0, t.card_border);
v.window_corner_radius = CornerRadius::same(10);
v.menu_corner_radius = CornerRadius::same(8);
v.selection.bg_fill = t.accent.gamma_multiply(0.85);
v.selection.stroke = Stroke::new(1.0, t.accent_text);
v.hyperlink_color = t.accent;
v.override_text_color = Some(t.text);
for w in [&mut v.widgets.noninteractive, &mut v.widgets.inactive, &mut v.widgets.hovered, &mut v.widgets.active, &mut v.widgets.open] {
w.corner_radius = CornerRadius::same(5);
}
v.widgets.noninteractive.bg_stroke = Stroke::new(1.0, t.separator);
v.widgets.inactive.weak_bg_fill = Color32::TRANSPARENT;
v.widgets.inactive.bg_fill = t.field_bg;
v.widgets.inactive.bg_stroke = Stroke::NONE;
v.widgets.hovered.weak_bg_fill = t.hover;
v.widgets.hovered.bg_fill = t.hover;
v.widgets.hovered.bg_stroke = Stroke::NONE;
v.widgets.active.weak_bg_fill = t.pressed;
v.widgets.active.bg_fill = t.pressed;
v.widgets.open.weak_bg_fill = t.pressed;
v.window_shadow = egui::epaint::Shadow { offset: [0, 4], blur: 18, spread: 0, color: t.shadow };
v.popup_shadow = egui::epaint::Shadow { offset: [0, 3], blur: 12, spread: 0, color: t.shadow };
ctx.set_visuals(v);
ctx.global_style_mut(|s| {
s.spacing.item_spacing = egui::vec2(6.0, 4.0);
s.spacing.button_padding = egui::vec2(6.0, 3.0);
s.spacing.interact_size.y = 22.0;
s.text_styles.insert(egui::TextStyle::Body, egui::FontId::proportional(13.0));
s.text_styles.insert(egui::TextStyle::Button, egui::FontId::proportional(13.0));
s.text_styles.insert(egui::TextStyle::Small, egui::FontId::proportional(11.0));
s.text_styles.insert(egui::TextStyle::Heading, egui::FontId::proportional(18.0));
});
}
/// UI fonts: Inter (from craft-fonts) when embedded, a Japanese UI face for CJK, then egui's.
pub fn install_fonts(ctx: &egui::Context) {
let mut defs = FontDefinitions::default();
let mut names = vec![];
for (i, f) in slidecraft_fonts::CRAFT_FONTS.iter().enumerate() {
let wanted = (f.family == "Inter" && (f.style == "Regular" || f.style == "SemiBold" || f.style == "Bold"))
|| (f.family == "BIZ UDPGothic" && f.style == "Regular")
|| (f.family == "Noto Sans Arabic");
if !wanted {
continue;
}
let name = format!("craft-{i}-{}-{}", f.family, f.style);
defs.font_data.insert(name.clone(), std::sync::Arc::new(FontData::from_static(f.bytes)));
names.push((f.family, f.style, name));
}
let regular: Vec<String> = names.iter().filter(|(fam, st, _)| !(*fam == "Inter" && *st != "Regular")).map(|(_, _, n)| n.clone()).collect();
if let Some(list) = defs.families.get_mut(&FontFamily::Proportional) {
for (k, n) in regular.iter().enumerate() {
list.insert(k, n.clone());
}
}
// A bold family for headings and selected tabs.
let mut bold: Vec<String> = names.iter().filter(|(fam, st, _)| *fam == "Inter" && *st == "SemiBold").map(|(_, _, n)| n.clone()).collect();
bold.extend(defs.families.get(&FontFamily::Proportional).cloned().unwrap_or_default());
defs.families.insert(FontFamily::Name("bold".into()), bold);
ctx.set_fonts(defs);
}
pub fn bold(size: f32) -> egui::FontId {
egui::FontId::new(size, FontFamily::Name("bold".into()))
}
pub fn font(size: f32) -> egui::FontId {
egui::FontId::proportional(size)
}
pub fn to_color32(c: slidecraft_color::Rgba) -> Color32 {
Color32::from_rgba_unmultiplied(c.r, c.g, c.b, c.a)
}
+314
View File
@@ -0,0 +1,314 @@
//! The slide thumbnail pane (Normal view): numbered thumbnails grouped by section, selection,
//! drag-to-reorder, and the slide context menu.
use egui::{Align2, Color32, CornerRadius, Rect, Sense, Stroke, Ui, pos2, vec2};
use serde_json::json;
use slidecraft_engine::Target;
use crate::SlideApp;
use crate::theme::{self, Tokens};
pub fn show(app: &mut SlideApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
let in_master = app.session.active().is_some_and(|d| d.selection.target != Target::Slides);
egui::Panel::left("thumbs")
.resizable(true)
.default_size(app.ui.thumbs_width)
.size_range(150.0..=420.0)
.frame(egui::Frame::NONE.fill(t.chrome))
.show(ui, |ui| {
app.ui.thumbs_width = ui.available_width();
if in_master {
master_list(app, ui);
return;
}
egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| {
ui.add_space(8.0);
list(app, ui);
ui.add_space(20.0);
});
});
}
fn list(app: &mut SlideApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
let Some(st) = app.session.active() else { return };
let doc = st.doc.clone();
let cur = st.selection.slide;
let multi = st.selection.slides.clone();
let width = ui.available_width();
let tw = (width - 40.0).max(60.0);
let th = tw * (doc.slide_size.height / doc.slide_size.width.max(1.0)) as f32;
let ppp = ui.ctx().pixels_per_point();
let mut budget = 3u32;
let mut rects = vec![];
let section_starts: Vec<(usize, usize)> =
doc.sections.iter().enumerate().filter_map(|(si, s)| s.slides.first().and_then(|id| doc.slide_index(*id)).map(|i| (i, si))).collect();
let mut need_more = false;
for i in 0..doc.slides.len() {
if let Some((_, si)) = section_starts.iter().find(|(start, _)| *start == i)
&& let Some(sec) = doc.sections.get(*si)
{
ui.add_space(4.0);
let (r, resp) = ui.allocate_exact_size(vec2(width, 22.0), Sense::click());
let p = ui.painter();
crate::widgets::chevron(p, pos2(r.min.x + 14.0, r.center().y), 3.0, t.text_dim);
p.text(
pos2(r.min.x + 24.0, r.center().y),
Align2::LEFT_CENTER,
format!("{} ({})", sec.name, sec.slides.len()),
theme::font(12.0),
t.text_dim,
);
resp.context_menu(|ui| {
if ui.button("Rename Section…").clicked() {
let mut d = crate::dialogs::Dialog::new("renameSection");
d.params = json!({"index": si, "name": sec.name});
app.dialog = Some(d);
ui.close();
}
if ui.button("Remove Section").clicked() {
let _ = app.run("section.remove", json!({"index": si}));
ui.close();
}
if ui.button("Remove Section & Slides").clicked() {
let _ = app.run("section.remove", json!({"index": si, "slides": true}));
ui.close();
}
if ui.button("Move Section Up").clicked() {
let _ = app.run("section.move", json!({"index": si, "to": si.saturating_sub(1)}));
ui.close();
}
if ui.button("Move Section Down").clicked() {
let _ = app.run("section.move", json!({"index": si, "to": si + 1}));
ui.close();
}
});
}
let (row, resp) = ui.allocate_exact_size(vec2(width, th + 16.0), Sense::click_and_drag());
let img = Rect::from_min_size(pos2(row.min.x + 30.0, row.min.y + 6.0), vec2(tw, th));
rects.push((i, row));
let p = ui.painter();
let id = doc.slides.get(i).map(|s| s.id);
let selected = i == cur || id.is_some_and(|id| multi.contains(&id));
let hidden = doc.slides.get(i).is_some_and(|s| s.hidden);
p.text(
pos2(row.min.x + 14.0, img.min.y + 2.0),
Align2::CENTER_TOP,
format!("{}", i + 1),
theme::font(12.0),
if selected { t.text } else { t.text_dim },
);
if hidden {
p.line_segment([pos2(row.min.x + 8.0, img.min.y + 18.0), pos2(row.min.x + 20.0, img.min.y + 6.0)], Stroke::new(1.0, t.text_dim));
}
let has_anim = doc.slides.get(i).is_some_and(|s| !s.animations.is_empty() || s.transition.as_ref().is_some_and(|tr| tr.kind != "none"));
if has_anim {
p.text(pos2(row.min.x + 14.0, img.min.y + 20.0), Align2::CENTER_TOP, "★", theme::font(10.0), t.text_faint);
}
let wpx = (tw * ppp).round() as u32;
match app.textures.thumb(ui.ctx(), &doc, i, wpx, &mut budget) {
Some(tex) => {
p.image(
tex.id(),
img,
Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)),
if hidden { Color32::from_white_alpha(140) } else { Color32::WHITE },
);
}
None => {
p.rect_filled(img, CornerRadius::ZERO, Color32::WHITE);
need_more = true;
}
}
if selected {
p.rect_stroke(img.expand(2.0), CornerRadius::same(4), Stroke::new(3.0, t.accent), egui::StrokeKind::Outside);
} else {
p.rect_stroke(
img,
CornerRadius::ZERO,
Stroke::new(if resp.hovered() { 2.0 } else { 1.0 }, if resp.hovered() { t.text_faint } else { t.border }),
egui::StrokeKind::Outside,
);
}
if resp.clicked() {
let (shift, cmd) = ui.input(|i| (i.modifiers.shift, i.modifiers.command));
if let Some(id) = id {
if cmd {
let _ = app.run("slide.selectSlides", json!({"ids": [id.0], "add": true}));
} else if shift {
let (a, b) = (cur.min(i), cur.max(i));
let ids: Vec<u32> = doc.slides.iter().skip(a).take(b - a + 1).map(|s| s.id.0).collect();
let _ = app.run("slide.selectSlides", json!({"ids": ids}));
let _ = app.run("slide.go", json!({"index": i}));
let _ = app.run("slide.selectSlides", json!({"ids": ids}));
} else {
let _ = app.run("slide.go", json!({"index": i}));
}
}
}
if resp.double_clicked() {
app.start_show(i, false);
}
if resp.drag_started() {
app.thumb_drag = Some((i, i));
}
resp.context_menu(|ui| slide_menu(app, ui, i));
}
if need_more {
ui.ctx().request_repaint();
}
// Drag reorder: insertion line.
if let Some((from, _)) = app.thumb_drag {
if let Some(pp) = ui.input(|i| i.pointer.interact_pos()) {
let mut to = doc.slides.len();
for (i, r) in &rects {
if pp.y < r.center().y {
to = *i;
break;
}
}
app.thumb_drag = Some((from, to));
let y = rects.iter().find(|(i, _)| *i == to).map(|(_, r)| r.min.y + 2.0).or_else(|| rects.last().map(|(_, r)| r.max.y)).unwrap_or(0.0);
ui.painter().line_segment([pos2(26.0, y), pos2(width, y)], Stroke::new(2.0, t.accent));
}
if ui.input(|i| i.pointer.any_released()) {
if let Some((from, to)) = app.thumb_drag.take()
&& to != from
&& to != from + 1
{
let dest = if to > from { to - 1 } else { to };
let _ = app.run("slide.move", json!({"from": from, "to": dest}));
}
app.thumb_drag = None;
}
}
// Click on empty space below: new slide prompt.
let (r, resp) = ui.allocate_exact_size(vec2(width, 40.0), Sense::click());
if resp.hovered() {
ui.painter().text(r.center(), Align2::CENTER_CENTER, "+ New Slide", theme::font(12.0), t.text_faint);
}
if resp.clicked() {
let _ = app.run("slide.new", json!({}));
}
}
pub fn slide_menu(app: &mut SlideApp, ui: &mut Ui, i: usize) {
if app.session.active().is_some_and(|d| d.selection.slide != i && !d.selection.slides.iter().any(|id| d.doc.slide_index(*id) == Some(i))) {
let _ = app.run("slide.go", json!({"index": i}));
}
for (l, id, p) in [
("Cut", "edit.cut", json!({"scope": "slides"})),
("Copy", "edit.copy", json!({"scope": "slides"})),
("Paste", "edit.paste", json!({"scope": "slides"})),
] {
if ui.button(l).clicked() {
let _ = app.run(id, p);
ui.close();
}
}
ui.separator();
if ui.button("New Slide").clicked() {
let _ = app.run("slide.new", json!({}));
ui.close();
}
if ui.button("Duplicate Slide").clicked() {
let _ = app.run("slide.duplicate", json!({}));
ui.close();
}
if ui.button("Delete Slide").clicked() {
let _ = app.run("slide.delete", json!({}));
ui.close();
}
ui.separator();
if ui.button("Add Section").clicked() {
let _ = app.run("section.add", json!({"at": i}));
ui.close();
}
ui.menu_button("Layout", |ui| {
let layouts: Vec<(String, u32)> = app
.session
.active()
.and_then(|d| d.doc.masters.first().map(|m| m.layouts.iter().map(|l| (l.name.clone(), l.id.0)).collect()))
.unwrap_or_default();
for (n, id) in layouts {
if ui.button(n).clicked() {
let _ = app.run("slide.layout", json!({"layout": id}));
ui.close();
}
}
});
if ui.button("Reset Slide").clicked() {
let _ = app.run("slide.reset", json!({}));
ui.close();
}
if ui.button("Format Background…").clicked() {
let _ = app.run("view.pane", json!({"pane": "background"}));
ui.close();
}
let hidden = app.session.active().and_then(|d| d.doc.slides.get(i).map(|s| s.hidden)).unwrap_or(false);
if ui.button(if hidden { "Show Slide" } else { "Hide Slide" }).clicked() {
let _ = app.run("slide.hide", json!({"index": i}));
ui.close();
}
}
fn master_list(app: &mut SlideApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
let Some(st) = app.session.active() else { return };
let doc = st.doc.clone();
let target = st.selection.target;
egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| {
ui.add_space(8.0);
let width = ui.available_width();
for (mi, m) in doc.masters.iter().enumerate() {
let items: Vec<Option<usize>> = std::iter::once(None).chain((0..m.layouts.len()).map(Some)).collect();
for li in items {
let indent = if li.is_some() { 30.0 } else { 14.0 };
let tw = (width - indent - 14.0).max(40.0) * if li.is_some() { 0.8 } else { 1.0 };
let th = tw * (doc.slide_size.height / doc.slide_size.width.max(1.0)) as f32;
let (row, resp) = ui.allocate_exact_size(vec2(width, th + 12.0), Sense::click());
let img = Rect::from_min_size(pos2(row.min.x + indent, row.min.y + 4.0), vec2(tw, th));
let ppp = ui.ctx().pixels_per_point();
let px = ((tw * ppp) as u32).max(1);
let pic = slidecraft_render::render_layout(
&doc,
mi,
li,
&slidecraft_render::RenderOpts {
scale: px as f64 / doc.slide_size.width.max(1.0),
edit: true,
size: Some((px, (px as f64 * doc.slide_size.height / doc.slide_size.width.max(1.0)) as u32)),
..Default::default()
},
);
let tex =
ui.ctx().load_texture(format!("ml-{mi}-{li:?}"), crate::textures::to_color_image(&pic, false), egui::TextureOptions::LINEAR);
ui.painter().image(tex.id(), img, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
let selected = match (target, li) {
(Target::Master { master }, None) => master == mi,
(Target::Layout { master, layout }, Some(l)) => master == mi && layout == l,
_ => false,
};
ui.painter().rect_stroke(
img.expand(if selected { 2.0 } else { 0.0 }),
CornerRadius::same(2),
Stroke::new(if selected { 3.0 } else { 1.0 }, if selected { t.accent } else { t.border }),
egui::StrokeKind::Outside,
);
let name = match li {
Some(l) => m.layouts.get(l).map(|x| x.name.clone()).unwrap_or_default(),
None => format!("{} Slide Master", m.name),
};
let resp = resp.on_hover_text(name);
if resp.clicked() {
let p = match li {
Some(l) => json!({"master": mi, "layout": l}),
None => json!({"master": mi}),
};
let _ = app.run("view.slideMaster", p);
}
}
}
});
}
+433
View File
@@ -0,0 +1,433 @@
//! Ribbon widgets: large and small buttons, split buttons, groups, dropdowns, spinners, the colour
//! picker and galleries.
use egui::{Align2, Color32, CornerRadius, Rect, Response, Sense, Stroke, Ui, Vec2, pos2, vec2};
use slidecraft_color::{ColorScheme, Rgba, SchemeSlot};
use slidecraft_model::ColorRef;
use crate::icons::{self, Icon};
use crate::theme::{self, Tokens};
/// Height of the ribbon's content area.
pub const RIBBON_H: f32 = 66.0;
fn bg(ui: &Ui, r: &Response, selected: bool, t: &Tokens) -> Option<Color32> {
if !r.sense.senses_click() && !r.sense.senses_drag() {
return None;
}
if selected {
Some(t.selected)
} else if r.is_pointer_button_down_on() {
Some(t.pressed)
} else if r.hovered() && ui.is_enabled() {
Some(t.hover)
} else {
None
}
}
/// A large ribbon button: 32 px icon over a one- or two-line label.
pub fn big_button(ui: &mut Ui, icon: Icon, label: &str, enabled: bool) -> Response {
big_button_ex(ui, icon, label, enabled, false, false).0
}
/// Large button; with `split`, the label row is a separate dropdown target (returns its response).
pub fn big_button_ex(ui: &mut Ui, icon: Icon, label: &str, enabled: bool, split: bool, selected: bool) -> (Response, Option<Response>) {
let t = Tokens::get(ui.ctx());
let font = theme::font(11.0);
let lines: Vec<&str> = wrap_label(label);
let text_w = lines.iter().map(|l| ui.fonts_mut(|f| f.layout_no_wrap(l.to_string(), font.clone(), t.text).size().x)).fold(0.0f32, f32::max);
let w = (text_w + 10.0 + if split && lines.len() == 1 { 10.0 } else { 0.0 }).max(40.0);
let (rect, resp) = ui.allocate_exact_size(vec2(w, RIBBON_H - 2.0), if enabled { Sense::click() } else { Sense::hover() });
let painter = ui.painter_at(rect.expand(1.0));
let (main_rect, arrow_rect) = if split {
(Rect::from_min_max(rect.min, pos2(rect.max.x, rect.min.y + 38.0)), Some(Rect::from_min_max(pos2(rect.min.x, rect.min.y + 38.0), rect.max)))
} else {
(rect, None)
};
let main_resp =
if split { ui.interact(main_rect, resp.id.with("main"), if enabled { Sense::click() } else { Sense::hover() }) } else { resp.clone() };
let arrow_resp = arrow_rect.map(|r| ui.interact(r, resp.id.with("arrow"), if enabled { Sense::click() } else { Sense::hover() }));
if let Some(c) = bg(ui, &main_resp, selected, &t) {
painter.rect_filled(if split { main_rect } else { rect }, CornerRadius::same(4), c);
}
if let (Some(ar), Some(a)) = (arrow_rect, &arrow_resp)
&& let Some(c) = bg(ui, a, false, &t)
{
painter.rect_filled(ar, CornerRadius::same(4), c);
}
let icon_rect = Rect::from_center_size(pos2(rect.center().x, rect.min.y + 19.0), vec2(30.0, 30.0));
icons::paint(&painter, icon_rect, icon, t.text, !enabled);
let col = if enabled { t.text } else { t.text_faint };
let mut y = rect.min.y + 39.0;
for (i, l) in lines.iter().enumerate() {
let last = i + 1 == lines.len();
let label_w = ui.fonts_mut(|f| f.layout_no_wrap(l.to_string(), font.clone(), col).size().x);
let extra = if split && last { 9.0 } else { 0.0 };
let x = rect.center().x - (label_w + extra) / 2.0;
painter.text(pos2(x, y), Align2::LEFT_TOP, l, font.clone(), col);
if split && last {
chevron(&painter, pos2(x + label_w + 5.0, y + 7.0), 3.0, col);
}
y += 12.5;
}
(if split { main_resp } else { resp }, arrow_resp)
}
fn wrap_label(label: &str) -> Vec<&str> {
if let Some((a, b)) = label.split_once('\n') {
return vec![a, b];
}
let words: Vec<&str> = label.split(' ').collect();
if words.len() >= 2 && label.chars().count() > 8 {
// Break near the middle.
let mut best = 1;
let mut best_diff = usize::MAX;
for k in 1..words.len() {
let a: usize = words.iter().take(k).map(|w| w.chars().count() + 1).sum();
let b: usize = words.iter().skip(k).map(|w| w.chars().count() + 1).sum();
let d = a.abs_diff(b);
if d < best_diff {
best_diff = d;
best = k;
}
}
let split_at: usize = words.iter().take(best).map(|w| w.len() + 1).sum::<usize>().saturating_sub(1);
if let (Some(a), Some(b)) = (label.get(..split_at), label.get(split_at + 1..)) {
return vec![a, b];
}
}
vec![label]
}
pub fn chevron(p: &egui::Painter, c: egui::Pos2, s: f32, col: Color32) {
p.line_segment([pos2(c.x - s, c.y - s * 0.5), pos2(c.x, c.y + s * 0.5)], Stroke::new(1.2, col));
p.line_segment([pos2(c.x, c.y + s * 0.5), pos2(c.x + s, c.y - s * 0.5)], Stroke::new(1.2, col));
}
/// A small ribbon button: 16 px icon + label in one row (label may be empty).
pub fn small_button(ui: &mut Ui, icon: Icon, label: &str, enabled: bool, selected: bool) -> Response {
small_button_ex(ui, icon, label, enabled, selected, false).0
}
pub fn small_button_ex(ui: &mut Ui, icon: Icon, label: &str, enabled: bool, selected: bool, split: bool) -> (Response, Option<Response>) {
let t = Tokens::get(ui.ctx());
let font = theme::font(12.0);
let tw = if label.is_empty() { 0.0 } else { ui.fonts_mut(|f| f.layout_no_wrap(label.to_string(), font.clone(), t.text).size().x) + 5.0 };
let arrow_w = if split { 12.0 } else { 0.0 };
let w = 24.0 + tw + arrow_w;
let (rect, resp) = ui.allocate_exact_size(vec2(w, 22.0), if enabled { Sense::click() } else { Sense::hover() });
let painter = ui.painter_at(rect);
let main_rect = Rect::from_min_max(rect.min, pos2(rect.max.x - arrow_w, rect.max.y));
let main_resp =
if split { ui.interact(main_rect, resp.id.with("m"), if enabled { Sense::click() } else { Sense::hover() }) } else { resp.clone() };
let arrow_resp = split.then(|| {
ui.interact(
Rect::from_min_max(pos2(rect.max.x - arrow_w, rect.min.y), rect.max),
resp.id.with("a"),
if enabled { Sense::click() } else { Sense::hover() },
)
});
if let Some(c) = bg(ui, &main_resp, selected, &t) {
painter.rect_filled(main_rect, CornerRadius::same(4), c);
}
if let Some(a) = &arrow_resp
&& let Some(c) = bg(ui, a, false, &t)
{
painter.rect_filled(Rect::from_min_max(pos2(rect.max.x - arrow_w, rect.min.y), rect.max), CornerRadius::same(4), c);
}
icons::paint(&painter, Rect::from_center_size(pos2(rect.min.x + 12.0, rect.center().y), vec2(17.0, 17.0)), icon, t.text, !enabled);
if !label.is_empty() {
painter.text(pos2(rect.min.x + 24.0, rect.center().y), Align2::LEFT_CENTER, label, font, if enabled { t.text } else { t.text_faint });
}
if split {
chevron(&painter, pos2(rect.max.x - 6.0, rect.center().y), 2.8, t.text_dim);
}
(if split { main_resp } else { resp }, arrow_resp)
}
/// A square icon toggle (B, I, U, alignment…).
pub fn icon_toggle(ui: &mut Ui, icon: Icon, tip: &str, enabled: bool, on: bool) -> Response {
let t = Tokens::get(ui.ctx());
let (rect, resp) = ui.allocate_exact_size(vec2(24.0, 22.0), if enabled { Sense::click() } else { Sense::hover() });
let painter = ui.painter_at(rect);
if let Some(c) = bg(ui, &resp, on, &t) {
painter.rect_filled(rect, CornerRadius::same(4), c);
}
icons::paint(&painter, rect.shrink(3.0), icon, t.text, !enabled);
resp.on_hover_text(tip)
}
/// Icon with a colour bar under it and a dropdown arrow (Font Color, Highlight, Shape Fill…).
pub fn color_split(ui: &mut Ui, icon: Icon, tip: &str, color: Color32, enabled: bool) -> (Response, Response) {
let t = Tokens::get(ui.ctx());
let (rect, resp) = ui.allocate_exact_size(vec2(34.0, 22.0), Sense::hover());
let main = ui.interact(Rect::from_min_size(rect.min, vec2(23.0, 22.0)), resp.id.with("m"), if enabled { Sense::click() } else { Sense::hover() });
let arrow = ui.interact(
Rect::from_min_max(pos2(rect.min.x + 23.0, rect.min.y), rect.max),
resp.id.with("a"),
if enabled { Sense::click() } else { Sense::hover() },
);
let painter = ui.painter_at(rect);
if let Some(c) = bg(ui, &main, false, &t) {
painter.rect_filled(main.rect, CornerRadius::same(4), c);
}
if let Some(c) = bg(ui, &arrow, false, &t) {
painter.rect_filled(arrow.rect, CornerRadius::same(4), c);
}
icons::paint(&painter, Rect::from_min_size(rect.min + vec2(3.0, 1.0), vec2(17.0, 15.0)), icon, t.text, !enabled);
painter.rect_filled(
Rect::from_min_size(rect.min + vec2(4.0, 17.0), vec2(15.0, 3.5)),
CornerRadius::ZERO,
if enabled { color } else { t.text_faint },
);
chevron(&painter, pos2(rect.max.x - 5.5, rect.center().y), 2.6, t.text_dim);
(main.on_hover_text(tip), arrow)
}
/// A ribbon group: contents, then a thin vertical separator.
pub fn group<R>(ui: &mut Ui, add: impl FnOnce(&mut Ui) -> R) -> R {
let r = ui.horizontal_top(|ui| {
ui.spacing_mut().item_spacing = vec2(1.0, 1.0);
add(ui)
});
separator(ui);
r.inner
}
pub fn separator(ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
let (rect, _) = ui.allocate_exact_size(vec2(13.0, RIBBON_H - 6.0), Sense::hover());
ui.painter().line_segment([pos2(rect.center().x, rect.min.y + 6.0), pos2(rect.center().x, rect.max.y - 4.0)], Stroke::new(1.0, t.separator));
}
/// Two or three rows of small controls stacked in one ribbon column.
pub fn rows<R>(ui: &mut Ui, add: impl FnOnce(&mut Ui) -> R) -> R {
ui.vertical(|ui| {
ui.spacing_mut().item_spacing = vec2(1.0, 0.0);
ui.add_space(0.0);
add(ui)
})
.inner
}
/// A text dropdown field (font name, size): shows `current`, opens a popup with `add_items`.
pub fn dropdown(ui: &mut Ui, id: &str, current: &str, width: f32, enabled: bool, add_items: impl FnOnce(&mut Ui)) -> Response {
let t = Tokens::get(ui.ctx());
let (rect, resp) = ui.allocate_exact_size(vec2(width, 22.0), if enabled { Sense::click() } else { Sense::hover() });
let painter = ui.painter_at(rect);
painter.rect(
rect,
CornerRadius::same(4),
t.field_bg,
Stroke::new(1.0, if resp.hovered() { t.text_faint } else { t.border }),
egui::StrokeKind::Inside,
);
let clip = rect.shrink2(vec2(6.0, 0.0));
painter.with_clip_rect(Rect::from_min_max(clip.min, pos2(clip.max.x - 12.0, clip.max.y))).text(
pos2(rect.min.x + 6.0, rect.center().y),
Align2::LEFT_CENTER,
current,
theme::font(12.0),
if enabled { t.text } else { t.text_faint },
);
chevron(&painter, pos2(rect.max.x - 9.0, rect.center().y), 3.0, t.text_dim);
let _ = id;
egui::Popup::menu(&resp).width(width.max(140.0)).show(|ui| {
egui::ScrollArea::vertical().max_height(360.0).show(ui, add_items);
});
resp
}
/// The colour grid popup body: theme colours with tints/shades, standard colours, extra entries.
/// Returns the picked colour (`Some(None)` = "No Fill"/"Automatic" when `none_label` is given).
pub fn color_grid(ui: &mut Ui, scheme: &ColorScheme, none_label: Option<&str>) -> Option<Option<ColorRef>> {
let t = Tokens::get(ui.ctx());
let mut out = None;
ui.set_min_width(220.0);
ui.label(egui::RichText::new("Theme Colors").font(theme::bold(12.0)));
let slots = [
SchemeSlot::Lt1,
SchemeSlot::Dk1,
SchemeSlot::Lt2,
SchemeSlot::Dk2,
SchemeSlot::Accent1,
SchemeSlot::Accent2,
SchemeSlot::Accent3,
SchemeSlot::Accent4,
SchemeSlot::Accent5,
SchemeSlot::Accent6,
];
let cell = 18.0;
let sw = |ui: &mut Ui, c: Rgba, tip: &str| -> bool {
let (r, resp) = ui.allocate_exact_size(vec2(cell, cell), Sense::click());
ui.painter().rect(
r.shrink(1.0),
CornerRadius::same(2),
theme::to_color32(c),
Stroke::new(if resp.hovered() { 2.0 } else { 1.0 }, if resp.hovered() { t.accent } else { t.border }),
egui::StrokeKind::Inside,
);
resp.on_hover_text(tip).clicked()
};
egui::Grid::new("theme_colors").spacing(vec2(3.0, 1.0)).show(ui, |ui| {
for slot in slots {
let alias = match slot {
SchemeSlot::Lt1 => SchemeSlot::Bg1,
SchemeSlot::Dk1 => SchemeSlot::Tx1,
SchemeSlot::Lt2 => SchemeSlot::Bg2,
SchemeSlot::Dk2 => SchemeSlot::Tx2,
s => s,
};
if sw(ui, scheme.get(slot), slot.label()) {
out = Some(Some(ColorRef::scheme(alias)));
}
}
ui.end_row();
for row in 0..5 {
for slot in slots {
let base = scheme.get(slot);
let vars = slidecraft_color::theme_variants(base);
let Some((mods, label)) = vars.get(row) else { continue };
let c = slidecraft_color::apply(base, mods);
if sw(ui, c, label) {
let alias = match slot {
SchemeSlot::Lt1 => SchemeSlot::Bg1,
SchemeSlot::Dk1 => SchemeSlot::Tx1,
SchemeSlot::Lt2 => SchemeSlot::Bg2,
SchemeSlot::Dk2 => SchemeSlot::Tx2,
s => s,
};
let mut r = ColorRef::scheme(alias);
r.mods = mods.clone();
out = Some(Some(r));
}
}
ui.end_row();
}
});
ui.add_space(4.0);
ui.label(egui::RichText::new("Standard Colors").font(theme::bold(12.0)));
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 3.0;
for (c, name) in slidecraft_color::STANDARD_COLORS {
if sw(ui, c, name) {
out = Some(Some(ColorRef::rgb(c)));
}
}
});
ui.separator();
if let Some(n) = none_label
&& ui.button(n).clicked()
{
out = Some(None);
}
// "More Colors…": a hex field.
let id = ui.id().with("hex");
let mut hex: String = ui.data_mut(|d| d.get_temp::<String>(id).unwrap_or_default());
ui.horizontal(|ui| {
ui.label("More Colors… #");
let r = ui.add(egui::TextEdit::singleline(&mut hex).desired_width(70.0).hint_text("RRGGBB"));
if r.lost_focus()
&& ui.input(|i| i.key_pressed(egui::Key::Enter))
&& let Some(c) = Rgba::from_hex(&hex)
{
out = Some(Some(ColorRef::rgb(c)));
}
});
ui.data_mut(|d| d.insert_temp(id, hex));
if out.is_some() {
ui.close();
}
out
}
/// A row of gallery tiles drawn by `paint_tile`; returns the clicked index.
pub fn gallery(
ui: &mut Ui,
n: usize,
tile: Vec2,
selected: Option<usize>,
mut paint_tile: impl FnMut(&egui::Painter, Rect, usize),
mut tip: impl FnMut(usize) -> String,
) -> Option<usize> {
let t = Tokens::get(ui.ctx());
let mut clicked = None;
for i in 0..n {
let (rect, resp) = ui.allocate_exact_size(tile, Sense::click());
let painter = ui.painter_at(rect.expand(2.0));
let sel = selected == Some(i);
if sel || resp.hovered() {
painter.rect_filled(rect.expand(1.0), CornerRadius::same(4), if sel { t.selected } else { t.hover });
}
paint_tile(&painter, rect.shrink(3.0), i);
if sel {
painter.rect_stroke(rect.expand(1.0), CornerRadius::same(4), Stroke::new(1.5, t.accent), egui::StrokeKind::Inside);
}
if resp.on_hover_text(tip(i)).clicked() {
clicked = Some(i);
}
}
clicked
}
/// A labelled number field with up/down steppers. Returns the new value when committed.
pub fn spinner(ui: &mut Ui, id: &str, value: f64, step: f64, suffix: &str, width: f32) -> Option<f64> {
let t = Tokens::get(ui.ctx());
let key = ui.id().with(id);
let mut text: String = ui.data_mut(|d| d.get_temp::<String>(key)).unwrap_or_else(|| fmt_num(value, suffix));
let mut out = None;
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 0.0;
let r = ui.add(egui::TextEdit::singleline(&mut text).desired_width(width).font(theme::font(12.0)));
if r.has_focus() {
ui.data_mut(|d| d.insert_temp(key, text.clone()));
} else {
ui.data_mut(|d| d.remove::<String>(key));
}
if r.lost_focus() {
let num: String = text.chars().filter(|c| c.is_ascii_digit() || *c == '.' || *c == '-').collect();
if let Ok(v) = num.parse::<f64>() {
out = Some(v);
}
}
let (rect, _) = ui.allocate_exact_size(vec2(12.0, 22.0), Sense::hover());
let up = ui.interact(Rect::from_min_max(rect.min, pos2(rect.max.x, rect.center().y)), key.with("up"), Sense::click());
let down = ui.interact(Rect::from_min_max(pos2(rect.min.x, rect.center().y), rect.max), key.with("dn"), Sense::click());
let p = ui.painter();
let c = rect.center();
p.line_segment([pos2(c.x - 3.0, c.y - 3.0), pos2(c.x, c.y - 6.0)], Stroke::new(1.0, t.text_dim));
p.line_segment([pos2(c.x, c.y - 6.0), pos2(c.x + 3.0, c.y - 3.0)], Stroke::new(1.0, t.text_dim));
p.line_segment([pos2(c.x - 3.0, c.y + 3.0), pos2(c.x, c.y + 6.0)], Stroke::new(1.0, t.text_dim));
p.line_segment([pos2(c.x, c.y + 6.0), pos2(c.x + 3.0, c.y + 3.0)], Stroke::new(1.0, t.text_dim));
if up.clicked() {
out = Some(value + step);
}
if down.clicked() {
out = Some(value - step);
}
});
out
}
pub fn fmt_num(v: f64, suffix: &str) -> String {
let s = format!("{v:.2}");
let s = s.trim_end_matches('0').trim_end_matches('.').to_string();
format!("{s}{suffix}")
}
/// Section header inside panes.
pub fn section(ui: &mut Ui, title: &str, open: bool) -> bool {
let t = Tokens::get(ui.ctx());
let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 26.0), Sense::click());
let p = ui.painter();
let c = pos2(rect.min.x + 8.0, rect.center().y);
if open {
chevron(p, c, 3.5, t.text);
} else {
p.line_segment([pos2(c.x - 2.0, c.y - 4.0), pos2(c.x + 2.0, c.y)], Stroke::new(1.2, t.text));
p.line_segment([pos2(c.x + 2.0, c.y), pos2(c.x - 2.0, c.y + 4.0)], Stroke::new(1.2, t.text));
}
p.text(pos2(rect.min.x + 18.0, rect.center().y), Align2::LEFT_CENTER, title, theme::bold(13.0), t.text);
if resp.clicked() { !open } else { open }
}