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>
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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"] }
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)))
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 818 B |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 9.1 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 36 KiB |
@@ -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 }
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)),
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
@@ -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 },
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
|
||||
@@ -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"] }
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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"}),
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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, ¶ms)
|
||||
} else {
|
||||
self.session.execute(id, ¶ms).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
|
||||
}
|
||||
@@ -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")]),
|
||||
]
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 { ¬es }).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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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}));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||