mirror of
https://github.com/storytold/deckcraft.git
synced 2026-11-02 16:30:36 -06:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d45a64db2 | ||
|
|
e832a373f5 | ||
|
|
6c63345615 | ||
|
|
1f2c75a288 | ||
|
|
fd4c533425 | ||
|
|
d4d99c7627 | ||
|
|
9fce04f0b4 | ||
|
|
ffd77c7db0 | ||
|
|
1f6587b60d | ||
|
|
d09f4c1284 | ||
|
|
37a6aed679 | ||
|
|
02ea528e94 | ||
|
|
bcc4fc97e6 | ||
|
|
f4fcac9f2d | ||
|
|
babc950f81 | ||
|
|
468394b3c8 | ||
|
|
5eb0b7d2c0 | ||
|
|
01220df579 | ||
|
|
b0b87c5cad | ||
|
|
befa4cd69f | ||
|
|
cc00a17dfe | ||
|
|
90058395b7 | ||
|
|
14cc73101b | ||
|
|
650c4f9497 | ||
|
|
9f952550b2 | ||
|
|
7f742c9d4f | ||
|
|
b5033b6213 | ||
|
|
b6c1d87cf0 | ||
|
|
c37b150950 | ||
|
|
f3d8ba85a6 | ||
|
|
c0468b59cd | ||
|
|
5d27461fa6 | ||
|
|
773133ec42 | ||
|
|
8ec4f29059 | ||
|
|
87a10fc8e2 | ||
|
|
3fb6c43136 | ||
|
|
d990678b32 | ||
|
|
cd19752bb5 | ||
|
|
2ed2ce18d8 | ||
|
|
c5ac1dd44b | ||
|
|
84ac51a439 | ||
|
|
c3e9bd07e5 | ||
|
|
e747bff425 | ||
|
|
68dda6f7d8 | ||
|
|
9aef8cc5ab | ||
|
|
77e3a444b8 | ||
|
|
09bbfc7217 | ||
|
|
e812da58df | ||
|
|
82adf7326e | ||
|
|
32ef926c46 | ||
|
|
4c1916466b | ||
|
|
48c5ff67bf | ||
|
|
78ab8f0626 |
+3
-1
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
**Stage: alpha** · next: beta, ~20 points and ~120–210 h away
|
**Stage: alpha** · next: beta, ~20 points and ~120–210 h away
|
||||||
|
|
||||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-30 · **Change:** trivial (print-preview tests use egui's emitted URL, and macOS removes its previews before exit; parity scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-11-01 · **Change:** trivial (locked No Fill and no-outline click-through regression coverage added; No Fill hit-testing follows resolved fill instead of the presence of a style (#1621); scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-30 · **Change:** trivial (progress log: read-only `slide.actions` lists shape and table text actions; parity scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-30 · **Change:** trivial (progress log: read-only `slide.actions` lists shape and table text actions; parity scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-27 · **Change:** trivial (progress log: Set Up Slide Show ▸ Keep mouse cursor visible, #1445; stage and scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-27 · **Change:** trivial (progress log: Set Up Slide Show ▸ Keep mouse cursor visible, #1445; stage and scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-26 · **Change:** trivial (progress log: dragging several selected slides moves them all; previous: trivial, progress log: Edit Points handle drags with Shift, Ctrl/Cmd or Alt/Option set the point type; previous: trivial, progress log: scroll bars on the zoomed slide pane, #1157; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-26 · **Change:** trivial (progress log: dragging several selected slides moves them all; previous: trivial, progress log: Edit Points handle drags with Shift, Ctrl/Cmd or Alt/Option set the point type; previous: trivial, progress log: scroll bars on the zoomed slide pane, #1157; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||||
@@ -148,6 +148,7 @@ app's JSON control channel (`deckcraft --control PORT`).
|
|||||||
|
|
||||||
| Date | What landed |
|
| Date | What landed |
|
||||||
|---|---|
|
|---|---|
|
||||||
|
| 2026-10-31 | No Fill shape interiors allow selection and editing of underlying objects, even when a quick style remains; hit-testing uses the renderer’s fill resolution. Outlines, rotation, text targets and actual fills keep their hit behaviour (#1621) |
|
||||||
| 2026-10-30 | Print-preview tests use the URL emitted by egui and its exact file path; previews are explicitly removed before the macOS process exits |
|
| 2026-10-30 | Print-preview tests use the URL emitted by egui and its exact file path; previews are explicitly removed before the macOS process exits |
|
||||||
| 2026-10-30 | `slide.actions` lists shape click and hover actions and text-run hyperlinks (including table cells and grouped shapes) for audit; listing never runs an action. The UI pane is a follow-up |
|
| 2026-10-30 | `slide.actions` lists shape click and hover actions and text-run hyperlinks (including table cells and grouped shapes) for audit; listing never runs an action. The UI pane is a follow-up |
|
||||||
| 2026-10-29 | Slide Show: going back to the previous slide plays the transition of the slide being left, reversed, as PowerPoint does, instead of the previous slide's own entrance transition (#1531); a jump (Go to Slide, links) still plays the target slide's transition |
|
| 2026-10-29 | Slide Show: going back to the previous slide plays the transition of the slide being left, reversed, as PowerPoint does, instead of the previous slide's own entrance transition (#1531); a jump (Go to Slide, links) still plays the target slide's transition |
|
||||||
@@ -318,6 +319,7 @@ app's JSON control channel (`deckcraft --control PORT`).
|
|||||||
|
|
||||||
| Date | Change | Summary |
|
| Date | Change | Summary |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
| 2026-10-31 | trivial | No Fill hit-testing follows resolved fill instead of the presence of a style (#1621); scores unchanged |
|
||||||
| 2026-10-30 | trivial | Progress log: read-only slide action inspection; parity scores unchanged |
|
| 2026-10-30 | trivial | Progress log: read-only slide action inspection; parity scores unchanged |
|
||||||
| 2026-10-29 | trivial | Progress log: going back in the slide show reverses the left slide's transition (#1531). Scores and milestone names unchanged |
|
| 2026-10-29 | trivial | Progress log: going back in the slide show reverses the left slide's transition (#1531). Scores and milestone names unchanged |
|
||||||
| 2026-10-29 | trivial | Progress log: the Picture Styles row leaves room for Crop and Size (#1232). Scores and milestone names unchanged |
|
| 2026-10-29 | trivial | Progress log: the Picture Styles row leaves room for Crop and Size (#1232). Scores and milestone names unchanged |
|
||||||
|
|||||||
@@ -27,4 +27,4 @@ rfd = { workspace = true }
|
|||||||
wasm-bindgen = "0.2"
|
wasm-bindgen = "0.2"
|
||||||
wasm-bindgen-futures = "0.4"
|
wasm-bindgen-futures = "0.4"
|
||||||
js-sys = "0.3"
|
js-sys = "0.3"
|
||||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration"] }
|
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "MessageEvent", "Event", "EventTarget"] }
|
||||||
|
|||||||
@@ -18,6 +18,8 @@
|
|||||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||||
#![forbid(unsafe_code)]
|
#![forbid(unsafe_code)]
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
mod presenter;
|
||||||
#[cfg(target_arch = "wasm32")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
mod web;
|
mod web;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,303 @@
|
|||||||
|
//! Same-origin popup transport. Each window starts its own eframe/WASM runner.
|
||||||
|
use deckcraft_ui_egui::presenter::{Command, DeltaCache, Host, Snapshot, View};
|
||||||
|
use std::{
|
||||||
|
cell::RefCell,
|
||||||
|
collections::VecDeque,
|
||||||
|
rc::{Rc, Weak},
|
||||||
|
};
|
||||||
|
use wasm_bindgen::{JsCast, closure::Closure};
|
||||||
|
|
||||||
|
const MAX_MESSAGE: usize = 16 * 1024 * 1024;
|
||||||
|
const MAX_COMMANDS: usize = 64;
|
||||||
|
const PREFIX: &str = "#deckcraft-presenter=";
|
||||||
|
fn error(e: wasm_bindgen::JsValue) -> String {
|
||||||
|
format!("{e:?}")
|
||||||
|
}
|
||||||
|
fn origin() -> Result<String, String> {
|
||||||
|
web_sys::window().ok_or("No browser window")?.location().origin().map_err(error)
|
||||||
|
}
|
||||||
|
pub fn token() -> Option<String> {
|
||||||
|
let hash = web_sys::window()?.location().hash().ok()?;
|
||||||
|
let token = hash.strip_prefix(PREFIX)?;
|
||||||
|
(!token.is_empty() && token.len() < 128).then(|| token.to_owned())
|
||||||
|
}
|
||||||
|
fn same_source(event: &web_sys::MessageEvent, window: &web_sys::Window, expected_origin: &str) -> bool {
|
||||||
|
event.origin() == expected_origin && event.source().is_some_and(|source| js_sys::Object::is(source.as_ref(), window.as_ref()))
|
||||||
|
}
|
||||||
|
fn send(window: &web_sys::Window, origin: &str, value: serde_json::Value) -> Result<(), String> {
|
||||||
|
let text = serde_json::to_string(&value).map_err(|e| e.to_string())?;
|
||||||
|
if text.len() > MAX_MESSAGE {
|
||||||
|
return Err("Presenter message is too large".into());
|
||||||
|
}
|
||||||
|
window.post_message(&wasm_bindgen::JsValue::from_str(&text), origin).map_err(error)
|
||||||
|
}
|
||||||
|
struct State {
|
||||||
|
popup: Option<web_sys::Window>,
|
||||||
|
token: String,
|
||||||
|
commands: VecDeque<Command>,
|
||||||
|
ready: bool,
|
||||||
|
refresh_images: bool,
|
||||||
|
}
|
||||||
|
pub struct BrowserHost {
|
||||||
|
state: Rc<RefCell<State>>,
|
||||||
|
origin: String,
|
||||||
|
listener: Closure<dyn FnMut(web_sys::MessageEvent)>,
|
||||||
|
button: Option<(web_sys::HtmlElement, Closure<dyn FnMut(web_sys::Event)>)>,
|
||||||
|
images: DeltaCache,
|
||||||
|
}
|
||||||
|
impl BrowserHost {
|
||||||
|
pub fn new(ctx: egui::Context) -> Result<Self, String> {
|
||||||
|
let window = web_sys::window().ok_or("No browser window")?;
|
||||||
|
let origin = origin()?;
|
||||||
|
let state = Rc::new(RefCell::new(State { popup: None, token: String::new(), commands: VecDeque::new(), ready: false, refresh_images: true }));
|
||||||
|
let weak = Rc::downgrade(&state);
|
||||||
|
let expected = origin.clone();
|
||||||
|
let listener = Closure::new(move |event: web_sys::MessageEvent| {
|
||||||
|
let Some(state) = weak.upgrade() else { return };
|
||||||
|
let Ok(mut state) = state.try_borrow_mut() else { return };
|
||||||
|
let Some(popup) = state.popup.as_ref() else { return };
|
||||||
|
if !same_source(&event, popup, &expected) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Some(text) = event.data().as_string().filter(|s| s.len() <= 8192) else { return };
|
||||||
|
let Ok(value) = serde_json::from_str::<serde_json::Value>(&text) else { return };
|
||||||
|
if value.get("token").and_then(|v| v.as_str()) != Some(state.token.as_str()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
match value.get("kind").and_then(|v| v.as_str()) {
|
||||||
|
Some("ready") => {
|
||||||
|
state.ready = true;
|
||||||
|
state.refresh_images = true;
|
||||||
|
}
|
||||||
|
Some("command") if state.commands.len() < MAX_COMMANDS => {
|
||||||
|
if let Some(command) = value.get("command").and_then(|v| serde_json::from_value(v.clone()).ok()) {
|
||||||
|
state.commands.push_back(command);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
ctx.request_repaint();
|
||||||
|
});
|
||||||
|
window.add_event_listener_with_callback("message", listener.as_ref().unchecked_ref()).map_err(error)?;
|
||||||
|
Ok(Self { state, origin, listener, button: None, images: DeltaCache::default() })
|
||||||
|
}
|
||||||
|
fn remove_button(&mut self) {
|
||||||
|
if let Some((button, callback)) = self.button.take() {
|
||||||
|
let _ = button.remove_event_listener_with_callback("click", callback.as_ref().unchecked_ref());
|
||||||
|
button.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn recovery_button(&mut self) {
|
||||||
|
if self.button.is_some() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Some(document) = web_sys::window().and_then(|w| w.document()) else { return };
|
||||||
|
let Some(body) = document.body() else { return };
|
||||||
|
let Ok(element) = document.create_element("button") else { return };
|
||||||
|
let Ok(button) = element.dyn_into::<web_sys::HtmlElement>() else { return };
|
||||||
|
button.set_text_content(Some("Open presenter window"));
|
||||||
|
let _ = button.set_attribute("style", "position:fixed;top:12px;right:12px;z-index:9999;padding:12px;color:white;background:#333;border:1px solid #aaa;border-radius:4px;cursor:pointer");
|
||||||
|
let weak: Weak<RefCell<State>> = Rc::downgrade(&self.state);
|
||||||
|
let button_copy = button.clone();
|
||||||
|
let callback = Closure::new(move |_event: web_sys::Event| {
|
||||||
|
if let Some(state) = weak.upgrade() {
|
||||||
|
match open_popup(&state) {
|
||||||
|
Ok(()) => {
|
||||||
|
button_copy.set_text_content(Some("Opening presenter window…"));
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
button_copy.set_text_content(Some("Popup blocked. Allow popups, then click to retry."));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if button.add_event_listener_with_callback("click", callback.as_ref().unchecked_ref()).is_ok() && body.append_child(&button).is_ok() {
|
||||||
|
self.button = Some((button, callback));
|
||||||
|
} else {
|
||||||
|
let _ = button.remove_event_listener_with_callback("click", callback.as_ref().unchecked_ref());
|
||||||
|
button.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn open_popup(state: &Rc<RefCell<State>>) -> Result<(), String> {
|
||||||
|
let window = web_sys::window().ok_or("No browser window")?;
|
||||||
|
let mut state = state.try_borrow_mut().map_err(|_| "Presenter is busy")?;
|
||||||
|
if let Some(popup) = &state.popup
|
||||||
|
&& !popup.closed().unwrap_or(true)
|
||||||
|
{
|
||||||
|
let _ = popup.focus();
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let token = format!("{}-{}", js_sys::Date::now(), js_sys::Math::random());
|
||||||
|
let url = web_sys::Url::new(&window.location().href().map_err(error)?).map_err(error)?;
|
||||||
|
url.set_hash(&format!("{PREFIX}{token}"));
|
||||||
|
let popup = window
|
||||||
|
.open_with_url_and_target_and_features(&url.href(), "_blank", "popup,width=1100,height=700")
|
||||||
|
.map_err(error)?
|
||||||
|
.ok_or("Presenter popup was blocked. Use Open presenter window to retry.")?;
|
||||||
|
if popup.closed().unwrap_or(true) {
|
||||||
|
return Err("Browser isolation prevented connecting the presenter window".into());
|
||||||
|
}
|
||||||
|
state.popup = Some(popup);
|
||||||
|
state.token = token;
|
||||||
|
state.commands.clear();
|
||||||
|
state.ready = false;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
impl Host for BrowserHost {
|
||||||
|
fn open(&mut self) -> Result<(), String> {
|
||||||
|
self.close();
|
||||||
|
let result = open_popup(&self.state);
|
||||||
|
if result.is_err() {
|
||||||
|
self.recovery_button();
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
fn is_open(&self) -> bool {
|
||||||
|
self.state.borrow().popup.as_ref().is_some_and(|w| !w.closed().unwrap_or(true))
|
||||||
|
}
|
||||||
|
fn commands(&mut self) -> Vec<Command> {
|
||||||
|
self.state.borrow_mut().commands.drain(..).collect()
|
||||||
|
}
|
||||||
|
fn update(&mut self, snapshot: Snapshot) {
|
||||||
|
if !self.is_open() {
|
||||||
|
self.recovery_button();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.remove_button();
|
||||||
|
let mut state = self.state.borrow_mut();
|
||||||
|
if !state.ready {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Some(popup) = state.popup.clone() {
|
||||||
|
let token = state.token.clone();
|
||||||
|
let sent = self.images.send(snapshot, &token, state.refresh_images, |delta, current_changed, next_changed| {
|
||||||
|
send(
|
||||||
|
&popup,
|
||||||
|
&self.origin,
|
||||||
|
serde_json::json!({"kind":"snapshot", "token":token, "snapshot":delta,
|
||||||
|
"currentChanged":current_changed, "nextChanged":next_changed}),
|
||||||
|
)
|
||||||
|
});
|
||||||
|
if sent.is_ok() {
|
||||||
|
state.refresh_images = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn close(&mut self) {
|
||||||
|
self.remove_button();
|
||||||
|
let mut state = self.state.borrow_mut();
|
||||||
|
if let Some(window) = state.popup.take() {
|
||||||
|
let _ = window.close();
|
||||||
|
}
|
||||||
|
state.commands.clear();
|
||||||
|
state.ready = false;
|
||||||
|
state.token.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl Drop for BrowserHost {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.close();
|
||||||
|
if let Some(window) = web_sys::window() {
|
||||||
|
let _ = window.remove_event_listener_with_callback("message", self.listener.as_ref().unchecked_ref());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Popup {
|
||||||
|
token: String,
|
||||||
|
origin: String,
|
||||||
|
opener: web_sys::Window,
|
||||||
|
snapshot: Rc<RefCell<Option<Snapshot>>>,
|
||||||
|
last_received: Rc<RefCell<f64>>,
|
||||||
|
listener: Closure<dyn FnMut(web_sys::MessageEvent)>,
|
||||||
|
view: View,
|
||||||
|
}
|
||||||
|
impl Popup {
|
||||||
|
pub fn new(token: String, ctx: egui::Context) -> Result<Self, String> {
|
||||||
|
let window = web_sys::window().ok_or("No browser window")?;
|
||||||
|
// `opener` is a WindowProxy from another realm; instanceof Window can fail across
|
||||||
|
// realms. The browser API supplies a WindowProxy or null, and messages still verify
|
||||||
|
// exact source identity and origin before any data is accepted.
|
||||||
|
let opener = window.opener().map_err(error)?;
|
||||||
|
if opener.is_null() || opener.is_undefined() {
|
||||||
|
return Err("The audience window is unavailable".into());
|
||||||
|
}
|
||||||
|
let opener: web_sys::Window = opener.unchecked_into();
|
||||||
|
let origin = origin()?;
|
||||||
|
let snapshot: Rc<RefCell<Option<Snapshot>>> = Rc::new(RefCell::new(None));
|
||||||
|
let last_received = Rc::new(RefCell::new(js_sys::Date::now()));
|
||||||
|
let received = last_received.clone();
|
||||||
|
let target = snapshot.clone();
|
||||||
|
let expected_origin = origin.clone();
|
||||||
|
let expected_opener = opener.clone();
|
||||||
|
let expected_token = token.clone();
|
||||||
|
let listener = Closure::new(move |event: web_sys::MessageEvent| {
|
||||||
|
if !same_source(&event, &expected_opener, &expected_origin) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Some(text) = event.data().as_string().filter(|s| s.len() <= MAX_MESSAGE) else { return };
|
||||||
|
let Ok(value) = serde_json::from_str::<serde_json::Value>(&text) else { return };
|
||||||
|
if value.get("token").and_then(|v| v.as_str()) != Some(expected_token.as_str())
|
||||||
|
|| value.get("kind").and_then(|v| v.as_str()) != Some("snapshot")
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Some(mut data) = value.get("snapshot").and_then(|v| serde_json::from_value::<Snapshot>(v.clone()).ok()) {
|
||||||
|
if let Some(previous) = target.borrow().as_ref() {
|
||||||
|
if value.get("currentChanged").and_then(|v| v.as_bool()) == Some(false) {
|
||||||
|
data.current = previous.current.clone();
|
||||||
|
}
|
||||||
|
if value.get("nextChanged").and_then(|v| v.as_bool()) == Some(false) {
|
||||||
|
data.next = previous.next.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*target.borrow_mut() = Some(data);
|
||||||
|
*received.borrow_mut() = js_sys::Date::now();
|
||||||
|
ctx.request_repaint();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
window.add_event_listener_with_callback("message", listener.as_ref().unchecked_ref()).map_err(error)?;
|
||||||
|
if let Some(document) = window.document() {
|
||||||
|
document.set_title("Presenter View — DeckCraft");
|
||||||
|
}
|
||||||
|
let popup = Self { token, origin, opener, snapshot, last_received, listener, view: View::default() };
|
||||||
|
send(&popup.opener, &popup.origin, serde_json::json!({"kind":"ready","token":popup.token}))?;
|
||||||
|
Ok(popup)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl eframe::App for Popup {
|
||||||
|
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
||||||
|
let snapshot = self.snapshot.borrow();
|
||||||
|
let notes = snapshot.as_ref().map_or("", |snapshot| snapshot.notes.as_str());
|
||||||
|
if !self.view.prepare(ui.ctx(), notes) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ui.ctx().request_repaint_after(std::time::Duration::from_millis(500));
|
||||||
|
if self.opener.closed().unwrap_or(true) {
|
||||||
|
ui.heading("The audience window has closed.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if js_sys::Date::now() - *self.last_received.borrow() > 10_000.0 {
|
||||||
|
ui.heading("The audience window is not responding.");
|
||||||
|
ui.label("Return to the audience window to resume the connection.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
egui::CentralPanel::default().show(ui, |ui| {
|
||||||
|
if let Some(snapshot) = snapshot.as_ref() {
|
||||||
|
for command in self.view.ui(ui, snapshot) {
|
||||||
|
let _ = send(&self.opener, &self.origin, serde_json::json!({"kind":"command","token":self.token,"command":command}));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ui.label("Waiting for the audience window…");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl Drop for Popup {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
if let Some(window) = web_sys::window() {
|
||||||
|
let _ = window.remove_event_listener_with_callback("message", self.listener.as_ref().unchecked_ref());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -39,6 +39,10 @@ pub fn start() {
|
|||||||
log::info!("deckcraft-web {}: wgpu backend {:?}", env!("CARGO_PKG_VERSION"), rs.adapter.get_info().backend);
|
log::info!("deckcraft-web {}: wgpu backend {:?}", env!("CARGO_PKG_VERSION"), rs.adapter.get_info().backend);
|
||||||
}
|
}
|
||||||
deckcraft_ui_egui::install_ime_pass_hooks(&cc.egui_ctx);
|
deckcraft_ui_egui::install_ime_pass_hooks(&cc.egui_ctx);
|
||||||
|
if let Some(token) = crate::presenter::token() {
|
||||||
|
let presenter = crate::presenter::Popup::new(token, cc.egui_ctx.clone()).map_err(std::io::Error::other)?;
|
||||||
|
return Ok(Box::new(presenter) as Box<dyn eframe::App>);
|
||||||
|
}
|
||||||
let inbox: Inbox = Inbox::default();
|
let inbox: Inbox = Inbox::default();
|
||||||
let mut app = SlideApp::new(Session::new(), services(inbox.clone(), cc.egui_ctx.clone()));
|
let mut app = SlideApp::new(Session::new(), services(inbox.clone(), cc.egui_ctx.clone()));
|
||||||
let q = query();
|
let q = query();
|
||||||
@@ -80,6 +84,11 @@ impl eframe::App for WebShell {
|
|||||||
/// Delegate one eframe logic pass to the wrapped application.
|
/// Delegate one eframe logic pass to the wrapped application.
|
||||||
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||||
self.app.logic(ctx);
|
self.app.logic(ctx);
|
||||||
|
if self.app.show.is_none()
|
||||||
|
&& let Some(host) = self.app.services.presenter.as_mut()
|
||||||
|
{
|
||||||
|
host.close();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Consume browser drops with fresh input, capture the identity before the asynchronous read,
|
/// Consume browser drops with fresh input, capture the identity before the asynchronous read,
|
||||||
@@ -118,7 +127,15 @@ impl eframe::App for WebShell {
|
|||||||
/// back to `open_async` (browser picker → inbox) and `download`.
|
/// back to `open_async` (browser picker → inbox) and `download`.
|
||||||
fn services(inbox: Inbox, ctx: egui::Context) -> Services {
|
fn services(inbox: Inbox, ctx: egui::Context) -> Services {
|
||||||
let open_inbox = inbox.clone();
|
let open_inbox = inbox.clone();
|
||||||
|
let presenter = match crate::presenter::BrowserHost::new(ctx.clone()) {
|
||||||
|
Ok(host) => Some(Box::new(host) as Box<dyn deckcraft_ui_egui::presenter::Host>),
|
||||||
|
Err(error) => {
|
||||||
|
log::error!("Presenter window: {error}");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
};
|
||||||
Services {
|
Services {
|
||||||
|
presenter,
|
||||||
open_async: Some(Box::new(move |purpose: &str, request: u64| {
|
open_async: Some(Box::new(move |purpose: &str, request: u64| {
|
||||||
let inbox = open_inbox.clone();
|
let inbox = open_inbox.clone();
|
||||||
let ctx = ctx.clone();
|
let ctx = ctx.clone();
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
use super::{
|
use super::{
|
||||||
Arc, BTreeSet, Background, Fill, NO_ID_SPACE, Presentation, Result, Session, Target, Value, bad, bool_or, bool_param, color_param, color_value,
|
Arc, BTreeSet, Background, Fill, NO_ID_SPACE, Presentation, Result, Session, Target, Value, bad, bool_or, bool_param, color_param, color_value,
|
||||||
f64_param, ok, usize_param, with_param,
|
f64_param, ok, opt_usize_param, usize_param, with_param,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub(super) fn background(s: &mut Session, p: &Value) -> Result<Value> {
|
pub(super) fn background(s: &mut Session, p: &Value) -> Result<Value> {
|
||||||
if bool_or(p, "copy", false) {
|
if bool_or(p, "copy", false) {
|
||||||
return copy_slide_background(s);
|
return copy_slide_background(s);
|
||||||
}
|
}
|
||||||
|
// Check the index before anything is edited: a picture is added to the media first.
|
||||||
|
let index = opt_usize_param(p, "index", "design.background")?;
|
||||||
let props = p.get("pictureProps").filter(|v| v.is_object()).cloned();
|
let props = p.get("pictureProps").filter(|v| v.is_object()).cloned();
|
||||||
let transparency = f64_param(p, "transparency");
|
let transparency = f64_param(p, "transparency");
|
||||||
let reset = bool_or(p, "reset", false);
|
let reset = bool_or(p, "reset", false);
|
||||||
@@ -62,7 +64,6 @@ pub(super) fn background(s: &mut Session, p: &Value) -> Result<Value> {
|
|||||||
));
|
));
|
||||||
};
|
};
|
||||||
let all = bool_or(p, "all", false);
|
let all = bool_or(p, "all", false);
|
||||||
let index = usize_param(p, "index");
|
|
||||||
if matches!(op, BgOp::Adjust) {
|
if matches!(op, BgOp::Adjust) {
|
||||||
let next = {
|
let next = {
|
||||||
let st = s.doc()?;
|
let st = s.doc()?;
|
||||||
|
|||||||
@@ -485,3 +485,16 @@ fn background_all_clears_layout_overrides_even_if_slides_and_master_match() {
|
|||||||
s.execute("design.background", &json!({"color": "#AA0000", "all": true})).unwrap();
|
s.execute("design.background", &json!({"color": "#AA0000", "all": true})).unwrap();
|
||||||
assert_eq!(undo_len(&s), n + 1, "now it is truly a no-op");
|
assert_eq!(undo_len(&s), n + 1, "now it is truly a no-op");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn background_rejects_invalid_index() {
|
||||||
|
let mut s = Session::with_new();
|
||||||
|
s.execute("slide.new", &json!({})).unwrap();
|
||||||
|
s.execute("slide.go", &json!({"index": 0})).unwrap();
|
||||||
|
let before = s.doc().unwrap().doc.slides[0].background.clone();
|
||||||
|
for bad in [json!(-1), json!(1.5)] {
|
||||||
|
assert!(s.execute("design.background", &json!({"color": "#0000C8", "index": bad})).is_err());
|
||||||
|
assert!(s.execute("design.background", &json!({"transparency": 0.5, "index": bad})).is_err());
|
||||||
|
assert_eq!(s.doc().unwrap().doc.slides[0].background, before);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||||||
"Shape Fill",
|
"Shape Fill",
|
||||||
["Shape Format", "Shape Styles"],
|
["Shape Format", "Shape Styles"],
|
||||||
None,
|
None,
|
||||||
"{color? | none?: true | background?: true (the slide background shows through; shapes and text boxes only) | gradient?: {stops: [[pos, color]], angle?, kind?: linear|radial} | picture?: base64 | pattern?: {preset, fg, bg} | transparency?: 0..1 | reset?: true, ids?}; pictureProps?: {tile?: bool, offsetX?, offsetY?: pt, scaleX?, scaleY?: 0..10, align?: tl|t|tr|l|ctr|r|bl|b|br, mirror?: none|x|y|xy, stretch?: [l, t, r, b] fractions, rotateWithShape?: bool} adjusts a picture fill; a group also formats its members",
|
"{color? | none?: true | background?: true (the slide background shows through; shapes and text boxes only) | gradient?: {stops: [[pos 0..1, color]], kind?: linear|radial|rectangular|path, angle?: deg (linear), focus?: [l, t, r, b] fractions (radial, rectangular and path: where the first stop sits), rotateWithShape?: bool} | picture?: base64 | pattern?: {preset, fg, bg} | transparency?: 0..1 | reset?: true, ids?}; pictureProps?: {tile?: bool, offsetX?, offsetY?: pt, scaleX?, scaleY?: 0..10, align?: tl|t|tr|l|ctr|r|bl|b|br, mirror?: none|x|y|xy, stretch?: [l, t, r, b] fractions, rotateWithShape?: bool} adjusts a picture fill; a group also formats its members",
|
||||||
has_selection,
|
has_selection,
|
||||||
fill
|
fill
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -259,6 +259,16 @@ mod tests {
|
|||||||
dir
|
dir
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn instance_identity_is_stable_nonzero_and_json_safe() {
|
||||||
|
let id = instance_id();
|
||||||
|
assert_eq!(instance_id(), id);
|
||||||
|
assert!(id != 0 && id <= UID_MASK);
|
||||||
|
assert_eq!(id & 0xFF_FFFF, 0);
|
||||||
|
let encoded = serde_json::to_value(id).unwrap();
|
||||||
|
assert_eq!(encoded.as_f64().unwrap() as u64, id);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn recovers_unsaved_presentations() {
|
fn recovers_unsaved_presentations() {
|
||||||
let dir = temp("recovery");
|
let dir = temp("recovery");
|
||||||
|
|||||||
@@ -709,7 +709,8 @@ pub fn paragraph_words(p: &deckcraft_model::Paragraph, default: &str) -> Vec<Wor
|
|||||||
chars.extend(r.content().chars().map(|c| (c, ri)));
|
chars.extend(r.content().chars().map(|c| (c, ri)));
|
||||||
}
|
}
|
||||||
let lang_of = |ri: usize| p.runs.get(ri).and_then(|r| r.props.lang.clone()).filter(|l| !l.is_empty()).unwrap_or_else(|| default.to_string());
|
let lang_of = |ri: usize| p.runs.get(ri).and_then(|r| r.props.lang.clone()).filter(|l| !l.is_empty()).unwrap_or_else(|| default.to_string());
|
||||||
let inside = |c: char| c.is_alphanumeric() || c == '\'' || c == '’' || c == '-';
|
// Combining marks (decomposed accents) belong to the letter before them.
|
||||||
|
let inside = |c: char| c.is_alphanumeric() || deckcraft_model::words::is_combining_mark(c) || c == '\'' || c == '’' || c == '-';
|
||||||
let mut out = vec![];
|
let mut out = vec![];
|
||||||
let mut i = 0;
|
let mut i = 0;
|
||||||
while i < chars.len() {
|
while i < chars.len() {
|
||||||
@@ -725,8 +726,14 @@ pub fn paragraph_words(p: &deckcraft_model::Paragraph, default: &str) -> Vec<Wor
|
|||||||
while a < b && !chars[a].0.is_alphanumeric() {
|
while a < b && !chars[a].0.is_alphanumeric() {
|
||||||
a += 1;
|
a += 1;
|
||||||
}
|
}
|
||||||
while b > a && !chars[b - 1].0.is_alphanumeric() {
|
// Trim back to the last letter or digit, keeping only the marks that sit on it
|
||||||
b -= 1;
|
// (a mark on a trailing apostrophe goes with the apostrophe).
|
||||||
|
while b > a {
|
||||||
|
let k = (a..b).rev().find(|&k| !deckcraft_model::words::is_combining_mark(chars[k].0)).unwrap_or(a);
|
||||||
|
if chars[k].0.is_alphanumeric() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
b = k;
|
||||||
}
|
}
|
||||||
let text: String = chars[a..b].iter().map(|c| c.0).collect();
|
let text: String = chars[a..b].iter().map(|c| c.0).collect();
|
||||||
if text.is_empty() || text.chars().any(|c| c.is_ascii_digit()) || !text.chars().any(char::is_alphabetic) {
|
if text.is_empty() || text.chars().any(|c| c.is_ascii_digit()) || !text.chars().any(char::is_alphabetic) {
|
||||||
@@ -819,6 +826,20 @@ SFX D Y 1\nSFX D 0 ed/S .\n";
|
|||||||
Dictionary::parse(AFF.as_bytes(), DIC.as_bytes())
|
Dictionary::parse(AFF.as_bytes(), DIC.as_bytes())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nfd_word_not_split() {
|
||||||
|
let p = deckcraft_model::Paragraph::new("Das na\u{ef}ve Caf\u{e9} und cafe\u{301} na\u{308}ive");
|
||||||
|
let w: Vec<String> = paragraph_words(&p, "en-US").into_iter().map(|w| w.text).collect();
|
||||||
|
assert_eq!(w, ["Das", "na\u{ef}ve", "Caf\u{e9}", "und", "cafe\u{301}", "na\u{308}ive"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_mark_on_trailing_punctuation_is_trimmed_with_it() {
|
||||||
|
let p = deckcraft_model::Paragraph::new("word'\u{301} cafe\u{301}' x");
|
||||||
|
let w: Vec<String> = paragraph_words(&p, "en-US").into_iter().map(|w| w.text).collect();
|
||||||
|
assert_eq!(w, ["word", "cafe\u{301}", "x"]);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn curly_apostrophe_matches_ascii_entry() {
|
fn curly_apostrophe_matches_ascii_entry() {
|
||||||
let d = Dictionary::parse(AFF.as_bytes(), "2\ndon't\nwork\n".as_bytes());
|
let d = Dictionary::parse(AFF.as_bytes(), "2\ndon't\nwork\n".as_bytes());
|
||||||
|
|||||||
@@ -4294,6 +4294,38 @@ fn a_params_doc_spells_out_each_parameter() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `shape.fill`'s params doc names each key and kind its `gradient` takes.
|
||||||
|
#[test]
|
||||||
|
fn shape_fill_documents_each_gradient_key_and_kind() {
|
||||||
|
use deckcraft_model::style::GradientShape;
|
||||||
|
let params = cmd::parse_params(find_command("shape.fill").unwrap().params).unwrap();
|
||||||
|
let about = params.iter().find(|p| p.name == "gradient").unwrap().about;
|
||||||
|
assert_eq!(cmd::param_keys(about).unwrap(), ["stops", "kind", "angle", "focus", "rotateWithShape"]);
|
||||||
|
for kind in ["linear", "radial", "rectangular", "path"] {
|
||||||
|
assert!(about.contains(kind), "{kind}: {about}");
|
||||||
|
let g = cmd::shape::gradient_param(&json!({
|
||||||
|
"stops": [[0, "#ffffff"], [1, "#000000"]],
|
||||||
|
"kind": kind,
|
||||||
|
"angle": 30,
|
||||||
|
"focus": [0, 0, 1, 1],
|
||||||
|
"rotateWithShape": false
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
assert!(!g.rotate_with_shape, "{kind}");
|
||||||
|
match g.shape {
|
||||||
|
GradientShape::Linear { angle, .. } => assert_eq!((kind, angle), ("linear", 30.0)),
|
||||||
|
GradientShape::Path { path, focus } => {
|
||||||
|
let expected = match kind {
|
||||||
|
"radial" => "circle",
|
||||||
|
"rectangular" => "rect",
|
||||||
|
_ => "shape",
|
||||||
|
};
|
||||||
|
assert_eq!((path.as_str(), focus), (expected, [0.0, 0.0, 1.0, 1.0]), "{kind}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn what_a_command_acts_on_names_only_keys_it_takes() {
|
fn what_a_command_acts_on_names_only_keys_it_takes() {
|
||||||
for c in command_specs() {
|
for c in command_specs() {
|
||||||
@@ -5572,6 +5604,170 @@ fn edit_points_conversion_is_one_undo_step() {
|
|||||||
assert!(preset(&s), "the only step was the conversion");
|
assert!(preset(&s), "the only step was the conversion");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// #1621: build overlapping objects with an ordinary quick style on the foreground shape.
|
||||||
|
fn no_fill_overlap(s: &mut Session, text: bool) -> (ShapeId, ShapeId) {
|
||||||
|
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
|
||||||
|
let under = s
|
||||||
|
.execute(
|
||||||
|
if text { "insert.textBox" } else { "shape.insert" },
|
||||||
|
&json!({"preset": "rect", "rect": [100, 300, 300, 60], "text": if text { "UNDERLYING TEXT" } else { "" }}),
|
||||||
|
)
|
||||||
|
.unwrap()["id"]
|
||||||
|
.as_u64()
|
||||||
|
.unwrap();
|
||||||
|
let over = s.execute("shape.insert", &json!({"preset": "rect", "rect": [80, 270, 350, 140]})).unwrap()["id"].as_u64().unwrap();
|
||||||
|
(ShapeId(under as u32), ShapeId(over as u32))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// No Fill passes interior clicks through while preserving outline and actual-fill targets.
|
||||||
|
#[test]
|
||||||
|
fn no_fill_shape_allows_selecting_and_editing_underlying_text() {
|
||||||
|
let mut s = session();
|
||||||
|
let (under, over) = no_fill_overlap(&mut s, true);
|
||||||
|
s.execute("shape.fill", &json!({"none": true})).unwrap();
|
||||||
|
assert!(s.doc().unwrap().shape(over).unwrap().style.is_some(), "the ordinary quick style remains");
|
||||||
|
s.execute("edit.deselect", &json!({})).unwrap();
|
||||||
|
let p = deckcraft_geom::Point::new(200.0, 310.0);
|
||||||
|
assert!(matches!(s.hit_test(p, 3.0), Some(tools::Hit::Shape { id, .. }) if id == under));
|
||||||
|
for kind in [PointerKind::Down, PointerKind::Up] {
|
||||||
|
s.pointer(tev(kind, p.x, p.y)).unwrap();
|
||||||
|
}
|
||||||
|
assert_eq!(s.doc().unwrap().selection.shapes, vec![under]);
|
||||||
|
assert_eq!(text_sel(&s).unwrap().shape, under);
|
||||||
|
// The transparent foreground also leaves an already-active text editor's caret alone.
|
||||||
|
for kind in [PointerKind::Down, PointerKind::Up] {
|
||||||
|
s.pointer(tev(kind, p.x + 20.0, p.y)).unwrap();
|
||||||
|
}
|
||||||
|
assert_eq!(text_sel(&s).unwrap().shape, under);
|
||||||
|
s.execute("edit.deselect", &json!({})).unwrap();
|
||||||
|
// The rectangle's outline is still a target, and selecting it does not remove its style.
|
||||||
|
for kind in [PointerKind::Down, PointerKind::Up] {
|
||||||
|
s.pointer(tev(kind, 80.0, 330.0)).unwrap();
|
||||||
|
}
|
||||||
|
assert_eq!(s.doc().unwrap().selection.shapes, vec![over]);
|
||||||
|
assert!(s.doc().unwrap().shape(over).unwrap().style.is_some());
|
||||||
|
// A real fill still catches clicks, even with 100% transparency (distinct from No Fill).
|
||||||
|
for transparency in [0.0, 1.0] {
|
||||||
|
s.execute("edit.select", &json!({"ids": [over.0]})).unwrap();
|
||||||
|
s.execute("shape.fill", &json!({"color": "#336699", "transparency": transparency})).unwrap();
|
||||||
|
s.execute("edit.deselect", &json!({})).unwrap();
|
||||||
|
assert!(matches!(s.hit_test(p, 3.0), Some(tools::Hit::Shape { id, .. }) if id == over));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rotation and pointer tolerance must preserve both pass-through interiors and outline hits.
|
||||||
|
#[test]
|
||||||
|
fn no_fill_shape_hit_testing_respects_rotation_and_pointer_tolerance() {
|
||||||
|
let mut s = session();
|
||||||
|
let (under, over) = no_fill_overlap(&mut s, false);
|
||||||
|
s.execute("shape.fill", &json!({"none": true})).unwrap();
|
||||||
|
s.execute("shape.rotate", &json!({"deg": 30})).unwrap();
|
||||||
|
let x = s.doc().unwrap().shape(over).unwrap().xfrm.unwrap();
|
||||||
|
let p = x.affine() * deckcraft_geom::Point::new(x.w / 2.0, x.h / 2.0);
|
||||||
|
let edge = x.affine() * deckcraft_geom::Point::new(0.0, x.h / 4.0);
|
||||||
|
for tol in [0.5, 3.0, 8.0] {
|
||||||
|
s.execute("edit.deselect", &json!({})).unwrap();
|
||||||
|
assert!(matches!(s.hit_test(p, tol), Some(tools::Hit::Shape { id, .. }) if id == under));
|
||||||
|
assert!(matches!(s.hit_test(edge, tol), Some(tools::Hit::Shape { id, .. }) if id == over));
|
||||||
|
for kind in [PointerKind::Down, PointerKind::Up] {
|
||||||
|
s.pointer(PointerEvent { tol, ..tev(kind, p.x, p.y) }).unwrap();
|
||||||
|
}
|
||||||
|
assert_eq!(s.doc().unwrap().selection.shapes, vec![under]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Style, theme and absent fills follow rendering, with explicit fill taking precedence.
|
||||||
|
#[test]
|
||||||
|
fn no_fill_hit_testing_resolves_style_and_theme_fills() {
|
||||||
|
use std::sync::Arc;
|
||||||
|
for source in ["style", "theme", "absent"] {
|
||||||
|
let mut s = session();
|
||||||
|
let (under, over) = no_fill_overlap(&mut s, true);
|
||||||
|
{
|
||||||
|
let st = s.doc_mut().unwrap();
|
||||||
|
let doc = Arc::make_mut(&mut st.doc);
|
||||||
|
if source == "theme" {
|
||||||
|
Arc::make_mut(&mut doc.masters[0]).theme.format.fills[0] = model::Fill::None;
|
||||||
|
}
|
||||||
|
let sh = Arc::make_mut(&mut doc.slides[st.selection.slide]).shapes.iter_mut().find(|sh| sh.id == over).unwrap();
|
||||||
|
assert!(sh.fill.is_none(), "the shape has no explicit fill override");
|
||||||
|
if source == "absent" {
|
||||||
|
sh.style = None;
|
||||||
|
} else {
|
||||||
|
sh.style.as_mut().unwrap().fill_ref.0 = if source == "style" { 0 } else { 1 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.execute("edit.deselect", &json!({})).unwrap();
|
||||||
|
let p = deckcraft_geom::Point::new(200.0, 310.0);
|
||||||
|
assert!(matches!(s.hit_test(p, 3.0), Some(tools::Hit::Shape { id, .. }) if id == under), "{source}");
|
||||||
|
// An explicit fill takes precedence over the no-fill style/theme.
|
||||||
|
s.execute("edit.select", &json!({"ids": [over.0]})).unwrap();
|
||||||
|
s.execute("shape.fill", &json!({"color": "#336699"})).unwrap();
|
||||||
|
s.execute("edit.deselect", &json!({})).unwrap();
|
||||||
|
assert!(matches!(s.hit_test(p, 3.0), Some(tools::Hit::Shape { id, .. }) if id == over), "{source}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Text on a foreground No Fill shape retains its selection and editing target.
|
||||||
|
#[test]
|
||||||
|
fn no_fill_text_shapes_keep_their_interior_text_target() {
|
||||||
|
let mut s = session();
|
||||||
|
let (_, over) = no_fill_overlap(&mut s, true);
|
||||||
|
s.execute("text.set", &json!({"id": over.0, "text": "FOREGROUND TEXT"})).unwrap();
|
||||||
|
s.execute("shape.fill", &json!({"none": true})).unwrap();
|
||||||
|
s.execute("edit.deselect", &json!({})).unwrap();
|
||||||
|
let p = deckcraft_geom::Point::new(200.0, 310.0);
|
||||||
|
assert!(matches!(s.hit_test(p, 3.0), Some(tools::Hit::Shape { id, text: true }) if id == over));
|
||||||
|
s.pointer(tev(PointerKind::DoubleClick, p.x, p.y)).unwrap();
|
||||||
|
assert_eq!(text_sel(&s).unwrap().shape, over);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A locked No Fill shape remains selectable on its outline, but dragging cannot move it.
|
||||||
|
#[test]
|
||||||
|
fn no_fill_locked_shape_keeps_outline_selection_without_moving() {
|
||||||
|
let mut s = session();
|
||||||
|
let (under, over) = no_fill_overlap(&mut s, true);
|
||||||
|
s.execute("shape.fill", &json!({"none": true})).unwrap();
|
||||||
|
s.execute("shape.lock", &json!({"locked": true})).unwrap();
|
||||||
|
let before = s.doc().unwrap().shape(over).unwrap().xfrm;
|
||||||
|
s.execute("edit.deselect", &json!({})).unwrap();
|
||||||
|
let inside = deckcraft_geom::Point::new(200.0, 310.0);
|
||||||
|
assert!(matches!(s.hit_test(inside, 3.0), Some(tools::Hit::Shape { id, .. }) if id == under));
|
||||||
|
for kind in [PointerKind::Down, PointerKind::Up] {
|
||||||
|
s.pointer(tev(kind, inside.x, inside.y)).unwrap();
|
||||||
|
}
|
||||||
|
assert_eq!(text_sel(&s).unwrap().shape, under);
|
||||||
|
s.execute("edit.deselect", &json!({})).unwrap();
|
||||||
|
let edge = deckcraft_geom::Point::new(80.0, 330.0);
|
||||||
|
assert!(matches!(s.hit_test(edge, 3.0), Some(tools::Hit::Shape { id, .. }) if id == over));
|
||||||
|
s.pointer(tev(PointerKind::Down, edge.x, edge.y)).unwrap();
|
||||||
|
s.pointer(tev(PointerKind::Drag, edge.x + 40.0, edge.y + 20.0)).unwrap();
|
||||||
|
s.pointer(tev(PointerKind::Up, edge.x + 40.0, edge.y + 20.0)).unwrap();
|
||||||
|
let st = s.doc().unwrap();
|
||||||
|
assert_eq!(st.selection.shapes, vec![over]);
|
||||||
|
assert_eq!(st.shape(over).unwrap().xfrm, before);
|
||||||
|
assert!(st.shape(over).unwrap().locked);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removing both fill and outline leaves the empty interior transparent to clicks, locked or not.
|
||||||
|
#[test]
|
||||||
|
fn no_fill_shape_without_text_or_outline_passes_interior_clicks() {
|
||||||
|
for locked in [false, true] {
|
||||||
|
let mut s = session();
|
||||||
|
let (under, _) = no_fill_overlap(&mut s, false);
|
||||||
|
s.execute("shape.fill", &json!({"none": true})).unwrap();
|
||||||
|
s.execute("shape.line", &json!({"none": true})).unwrap();
|
||||||
|
s.execute("shape.lock", &json!({"locked": locked})).unwrap();
|
||||||
|
s.execute("edit.deselect", &json!({})).unwrap();
|
||||||
|
let p = deckcraft_geom::Point::new(200.0, 310.0);
|
||||||
|
assert!(matches!(s.hit_test(p, 3.0), Some(tools::Hit::Shape { id, .. }) if id == under));
|
||||||
|
for kind in [PointerKind::Down, PointerKind::Up] {
|
||||||
|
s.pointer(tev(kind, p.x, p.y)).unwrap();
|
||||||
|
}
|
||||||
|
assert_eq!(s.doc().unwrap().selection.shapes, vec![under]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// #931: while the text of a placeholder is being edited, a shape in front of it still takes a
|
/// #931: while the text of a placeholder is being edited, a shape in front of it still takes a
|
||||||
/// click (as in PowerPoint); a click on the text where nothing is in front still moves the caret.
|
/// click (as in PowerPoint); a click on the text where nothing is in front still moves the caret.
|
||||||
#[test]
|
#[test]
|
||||||
@@ -5645,6 +5841,204 @@ fn box_of(s: &mut Session, id: u64) -> serde_json::Value {
|
|||||||
s.execute("shape.inspect", &json!({"id": id})).unwrap()["box"].clone()
|
s.execute("shape.inspect", &json!({"id": id})).unwrap()["box"].clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Two selected boxes with different sizes and aligned right edges, on a blank slide.
|
||||||
|
fn resize_selection_scene() -> (Session, [u64; 2]) {
|
||||||
|
let mut s = session();
|
||||||
|
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
|
||||||
|
s.prefs.smart_guides = false;
|
||||||
|
s.prefs.snap_to_grid = false;
|
||||||
|
let ids = [[100, 100, 100, 80], [120, 300, 80, 100]]
|
||||||
|
.map(|rect| s.execute("shape.insert", &json!({"preset": "rect", "rect": rect, "text": "Resize test"})).unwrap()["id"].as_u64().unwrap());
|
||||||
|
s.execute("edit.select", &json!({"ids": ids})).unwrap();
|
||||||
|
(s, ids)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Simulate file-loaded shapes whose stored boxes have not yet been fitted to their text.
|
||||||
|
fn pending_resize_autofit(s: &mut Session, ids: &[u64]) {
|
||||||
|
s.edit(|doc, sel| {
|
||||||
|
let shapes = crate::shapes_mut(doc, sel).unwrap();
|
||||||
|
for id in ids {
|
||||||
|
let sh = deckcraft_model::find_shape_mut(shapes, ShapeId(*id as u32)).unwrap();
|
||||||
|
sh.text.as_mut().unwrap().body.autofit = Some(deckcraft_model::text::AutoFit::Shape);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resizing multiple selected shapes scales them proportionally around their anchors in one undo step.
|
||||||
|
#[test]
|
||||||
|
fn resizing_selected_shapes_is_one_undo_step() {
|
||||||
|
let (mut s, [a, b]) = resize_selection_scene();
|
||||||
|
let before = s.doc().unwrap().doc.clone();
|
||||||
|
let steps = s.doc().unwrap().history.undo.len();
|
||||||
|
let down = drag_gesture(&mut s, (200.0, 180.0), (250.0, 210.0));
|
||||||
|
assert_eq!(down["hit"]["kind"], "resize");
|
||||||
|
assert_eq!(down["hit"]["id"], a);
|
||||||
|
for (id, expected) in [(a, [100.0, 100.0, 150.0, 110.0]), (b, [120.0, 300.0, 120.0, 137.5])] {
|
||||||
|
let actual = box_of(&mut s, id);
|
||||||
|
for (key, value) in ["x", "y", "w", "h"].into_iter().zip(expected) {
|
||||||
|
assert_eq!(actual[key], value, "{id}: {key}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1);
|
||||||
|
let after = s.doc().unwrap().doc.clone();
|
||||||
|
s.execute("edit.undo", &json!({})).unwrap();
|
||||||
|
assert_eq!(s.doc().unwrap().doc, before, "one undo restores both shapes");
|
||||||
|
s.execute("edit.redo", &json!({})).unwrap();
|
||||||
|
assert_eq!(s.doc().unwrap().doc, after, "one redo resizes both shapes again");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resizing selected shapes refits their text boxes inside the same undo transaction.
|
||||||
|
#[test]
|
||||||
|
fn resizing_selected_shapes_refits_text_in_the_same_undo_step() {
|
||||||
|
let (mut s, ids) = resize_selection_scene();
|
||||||
|
pending_resize_autofit(&mut s, &ids);
|
||||||
|
let before = s.doc().unwrap().doc.clone();
|
||||||
|
let steps = s.doc().unwrap().history.undo.len();
|
||||||
|
// An east-handle drag changes width only; a height change must come from text fitting.
|
||||||
|
drag_gesture(&mut s, (200.0, 140.0), (250.0, 140.0));
|
||||||
|
for (id, old_height) in ids.into_iter().zip([80.0, 100.0]) {
|
||||||
|
assert_ne!(box_of(&mut s, id)["h"], old_height, "both shapes' text is refitted");
|
||||||
|
}
|
||||||
|
assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1);
|
||||||
|
let after = s.doc().unwrap().doc.clone();
|
||||||
|
s.execute("edit.undo", &json!({})).unwrap();
|
||||||
|
assert_eq!(s.doc().unwrap().doc, before);
|
||||||
|
s.execute("edit.redo", &json!({})).unwrap();
|
||||||
|
assert_eq!(s.doc().unwrap().doc, after, "redo includes the final text fit");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clicking a resize handle without dragging neither modifies shapes nor triggers text autofit.
|
||||||
|
#[test]
|
||||||
|
fn clicking_a_resize_handle_does_not_change_or_refit_selected_shapes() {
|
||||||
|
let (mut s, ids) = resize_selection_scene();
|
||||||
|
pending_resize_autofit(&mut s, &ids);
|
||||||
|
let before = s.doc().unwrap().doc.clone();
|
||||||
|
let steps = s.doc().unwrap().history.undo.len();
|
||||||
|
let revision = s.doc().unwrap().revision;
|
||||||
|
for kind in [PointerKind::Down, PointerKind::Up] {
|
||||||
|
s.pointer(PointerEvent { kind, x: 200.0, y: 180.0, tol: 3.0, ..Default::default() }).unwrap();
|
||||||
|
}
|
||||||
|
assert_eq!(s.doc().unwrap().doc, before);
|
||||||
|
assert_eq!(s.doc().unwrap().history.undo.len(), steps, "a click creates no undo entry");
|
||||||
|
assert_eq!(s.doc().unwrap().revision, revision);
|
||||||
|
assert!(std::sync::Arc::ptr_eq(&s.doc().unwrap().doc, &before), "a click does not dirty the document");
|
||||||
|
// Ensure this fixture would catch an accidental refit on release.
|
||||||
|
for id in ids {
|
||||||
|
cmd::text::fit_text_box(&mut s, ShapeId(id as u32)).unwrap();
|
||||||
|
}
|
||||||
|
assert_ne!(s.doc().unwrap().doc, before);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Locked shapes in a multi-selection are excluded from both resizing and post-resize text fitting.
|
||||||
|
#[test]
|
||||||
|
fn resizing_selected_shapes_skips_locked_shapes_and_their_text_fit() {
|
||||||
|
let (mut s, [a, b]) = resize_selection_scene();
|
||||||
|
pending_resize_autofit(&mut s, &[b]);
|
||||||
|
s.execute("shape.lock", &json!({"ids": [b], "locked": true})).unwrap();
|
||||||
|
s.execute("edit.select", &json!({"ids": [a, b]})).unwrap();
|
||||||
|
let locked = s.doc().unwrap().shape(ShapeId(b as u32)).unwrap().clone();
|
||||||
|
let before = s.doc().unwrap().doc.clone();
|
||||||
|
let steps = s.doc().unwrap().history.undo.len();
|
||||||
|
drag_gesture(&mut s, (200.0, 180.0), (250.0, 210.0));
|
||||||
|
assert_eq!(box_of(&mut s, a)["w"], 150.0);
|
||||||
|
assert_eq!(box_of(&mut s, a)["h"], 110.0);
|
||||||
|
assert_eq!(s.doc().unwrap().shape(ShapeId(b as u32)).unwrap(), &locked, "the locked shape is neither resized nor refitted");
|
||||||
|
assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1);
|
||||||
|
s.execute("edit.undo", &json!({})).unwrap();
|
||||||
|
assert_eq!(s.doc().unwrap().doc, before);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resizing selected shapes excludes other selected resize targets from smart-guide snapping.
|
||||||
|
#[test]
|
||||||
|
fn resizing_selected_shapes_does_not_snap_to_other_resize_targets() {
|
||||||
|
for reverse in [false, true] {
|
||||||
|
let (mut s, [a, b]) = resize_selection_scene();
|
||||||
|
s.prefs.smart_guides = true;
|
||||||
|
let ids = if reverse { [b, a] } else { [a, b] };
|
||||||
|
s.execute("edit.select", &json!({"ids": ids})).unwrap();
|
||||||
|
// Within snap tolerance of the other selected shape's original right edge.
|
||||||
|
drag_gesture(&mut s, (200.0, 140.0), (203.0, 140.0));
|
||||||
|
assert_eq!(box_of(&mut s, a)["w"], 103.0, "first shape must not snap back");
|
||||||
|
assert_eq!(box_of(&mut s, b)["w"], 82.4, "selection order must not affect snapping");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resizing selected shapes snaps the active shape against unselected stationary shapes and scales others proportionally.
|
||||||
|
#[test]
|
||||||
|
fn resizing_selected_shapes_still_snaps_to_stationary_shapes() {
|
||||||
|
let (mut s, [a, b]) = resize_selection_scene();
|
||||||
|
let fixed = s.execute("shape.insert", &json!({"preset": "rect", "rect": [207, 430, 100, 80]})).unwrap()["id"].as_u64().unwrap();
|
||||||
|
let fixed_before = box_of(&mut s, fixed);
|
||||||
|
s.execute("edit.select", &json!({"ids": [a, b]})).unwrap();
|
||||||
|
s.prefs.smart_guides = true;
|
||||||
|
drag_gesture(&mut s, (200.0, 140.0), (203.0, 140.0));
|
||||||
|
assert_eq!(box_of(&mut s, a)["w"], 107.0);
|
||||||
|
assert!((box_of(&mut s, b)["w"].as_f64().unwrap() - 85.6).abs() < 1e-9);
|
||||||
|
assert_eq!(box_of(&mut s, fixed), fixed_before);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Secondary pictures resize uniformly around their anchor, preserving independent flips
|
||||||
|
/// and keeping the complete multi-selection resize in one undo step.
|
||||||
|
#[test]
|
||||||
|
fn resizing_selected_shapes_resizes_secondary_pictures() {
|
||||||
|
// Pointer destination, Alt, expected rectangle, expected picture, expected flips.
|
||||||
|
let cases = [
|
||||||
|
((180.0, 200.0), false, [100.0, 100.0, 80.0, 100.0], [300.0, 300.0, 80.0, 80.0], [false, false]),
|
||||||
|
((200.0, 180.0), false, [100.0, 100.0, 100.0, 80.0], [300.0, 300.0, 80.0, 80.0], [false, false]),
|
||||||
|
((220.0, 200.0), false, [100.0, 100.0, 120.0, 100.0], [300.0, 300.0, 120.0, 120.0], [false, false]),
|
||||||
|
((200.0, 220.0), false, [100.0, 100.0, 100.0, 120.0], [300.0, 300.0, 120.0, 120.0], [false, false]),
|
||||||
|
((180.0, 200.0), true, [120.0, 100.0, 60.0, 100.0], [320.0, 320.0, 60.0, 60.0], [false, false]),
|
||||||
|
((200.0, 180.0), true, [100.0, 120.0, 100.0, 60.0], [320.0, 320.0, 60.0, 60.0], [false, false]),
|
||||||
|
((80.0, 200.0), false, [80.0, 100.0, 20.0, 100.0], [280.0, 300.0, 20.0, 20.0], [true, false]),
|
||||||
|
((200.0, 80.0), false, [100.0, 80.0, 100.0, 20.0], [300.0, 280.0, 20.0, 20.0], [false, true]),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (to, alt, expected_rect, expected_picture, flips) in cases {
|
||||||
|
let mut s = session();
|
||||||
|
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
|
||||||
|
s.prefs.smart_guides = false;
|
||||||
|
s.prefs.snap_to_grid = false;
|
||||||
|
|
||||||
|
let rect = s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 100, 100]})).unwrap()["id"].as_u64().unwrap();
|
||||||
|
let picture = ramp_picture(&mut s, 1, [300, 300, 100, 100]).as_u64().unwrap();
|
||||||
|
s.execute("edit.select", &json!({"ids": [rect, picture]})).unwrap();
|
||||||
|
|
||||||
|
let before = s.doc().unwrap().doc.clone();
|
||||||
|
let steps = s.doc().unwrap().history.undo.len();
|
||||||
|
let mods = Mods { alt, ..Default::default() };
|
||||||
|
let ev = |kind, (x, y): (f64, f64)| PointerEvent { kind, x, y, mods, tol: 3.0, pressure: None };
|
||||||
|
|
||||||
|
let down = s.pointer(ev(PointerKind::Down, (200.0, 200.0))).unwrap();
|
||||||
|
assert_eq!(down["hit"]["kind"], "resize");
|
||||||
|
assert_eq!(down["hit"]["id"], rect);
|
||||||
|
assert_eq!(down["hit"]["handle"], 4);
|
||||||
|
|
||||||
|
s.pointer(ev(PointerKind::Drag, ((200.0 + to.0) / 2.0, (200.0 + to.1) / 2.0))).unwrap();
|
||||||
|
s.pointer(ev(PointerKind::Drag, to)).unwrap();
|
||||||
|
s.pointer(ev(PointerKind::Up, to)).unwrap();
|
||||||
|
|
||||||
|
for (id, expected) in [(rect, expected_rect), (picture, expected_picture)] {
|
||||||
|
let actual = box_of(&mut s, id);
|
||||||
|
for (key, value) in ["x", "y", "w", "h"].into_iter().zip(expected) {
|
||||||
|
let got = actual[key].as_f64().unwrap();
|
||||||
|
assert!((got - value).abs() < 1e-9, "{to:?}, alt={alt}, shape={id}, {key}: expected {value}, got {got}");
|
||||||
|
}
|
||||||
|
|
||||||
|
let xfrm = s.doc().unwrap().shape(ShapeId(id as u32)).unwrap().xfrm.unwrap();
|
||||||
|
assert_eq!([xfrm.flip_h, xfrm.flip_v], flips, "{to:?}, alt={alt}, shape={id}");
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1);
|
||||||
|
let after = s.doc().unwrap().doc.clone();
|
||||||
|
s.execute("edit.undo", &json!({})).unwrap();
|
||||||
|
assert_eq!(s.doc().unwrap().doc, before);
|
||||||
|
s.execute("edit.redo", &json!({})).unwrap();
|
||||||
|
assert_eq!(s.doc().unwrap().doc, after);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// #961: dragging the east handle snaps the dragged (right) edge, even when the fixed left edge
|
/// #961: dragging the east handle snaps the dragged (right) edge, even when the fixed left edge
|
||||||
/// already lines up with another shape. The nearest line used to be the fixed edge's, at distance 0.
|
/// already lines up with another shape. The nearest line used to be the fixed edge's, at distance 0.
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -150,8 +150,10 @@ enum Drag {
|
|||||||
},
|
},
|
||||||
Resize {
|
Resize {
|
||||||
id: ShapeId,
|
id: ShapeId,
|
||||||
handle: u8,
|
|
||||||
orig: Xfrm,
|
orig: Xfrm,
|
||||||
|
ids: Vec<ShapeId>,
|
||||||
|
handle: u8,
|
||||||
|
origs: Vec<(ShapeId, Xfrm)>,
|
||||||
start: Point,
|
start: Point,
|
||||||
},
|
},
|
||||||
LineEnd {
|
LineEnd {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ struct MoveDrag {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Session {
|
impl Session {
|
||||||
|
/// Update the active drag interaction using the pointer position and modifiers.
|
||||||
pub(super) fn drag(&mut self, p: Point, mods: Mods, tol: f64) -> Result<Value> {
|
pub(super) fn drag(&mut self, p: Point, mods: Mods, tol: f64) -> Result<Value> {
|
||||||
let Some(drag) = self.tool.drag.clone() else { return Ok(Value::Null) };
|
let Some(drag) = self.tool.drag.clone() else { return Ok(Value::Null) };
|
||||||
match drag {
|
match drag {
|
||||||
@@ -26,10 +27,48 @@ impl Session {
|
|||||||
Drag::Move { ids, start, origs, moved, duplicate, enter_text } => {
|
Drag::Move { ids, start, origs, moved, duplicate, enter_text } => {
|
||||||
return self.drag_move(MoveDrag { ids, start, origs, moved, duplicate, enter_text }, p, mods, tol);
|
return self.drag_move(MoveDrag { ids, start, origs, moved, duplicate, enter_text }, p, mods, tol);
|
||||||
}
|
}
|
||||||
Drag::Resize { id, handle, orig, start } => {
|
Drag::Resize { id, orig, ids, handle, origs, start } => {
|
||||||
let nx = resize_box(&orig, handle, p - start, mods.shift || self.is_picture(id), mods.alt);
|
let keep_aspect = mods.shift || self.is_picture(id);
|
||||||
let nx = self.snap_resize(nx, &orig, handle, id, tol);
|
let nx = resize_box(&orig, handle, p - start, keep_aspect, mods.alt);
|
||||||
self.set_xfrm(id, nx)?;
|
let nx = self.snap_resize(nx, handle, &ids, tol);
|
||||||
|
let sx = if orig.w > 1e-9 { (nx.w / orig.w) * if nx.flip_h != orig.flip_h { -1.0 } else { 1.0 } - 1.0 } else { 0.0 };
|
||||||
|
let sy = if orig.h > 1e-9 { (nx.h / orig.h) * if nx.flip_v != orig.flip_v { -1.0 } else { 1.0 } - 1.0 } else { 0.0 };
|
||||||
|
let div = if mods.alt { 2.0 } else { 1.0 };
|
||||||
|
let hx = match handle {
|
||||||
|
0 | 6 | 7 => -1.0,
|
||||||
|
2..=4 => 1.0,
|
||||||
|
_ => 0.0,
|
||||||
|
};
|
||||||
|
let hy = match handle {
|
||||||
|
0..=2 => -1.0,
|
||||||
|
4..=6 => 1.0,
|
||||||
|
_ => 0.0,
|
||||||
|
};
|
||||||
|
for (tid, o) in &origs {
|
||||||
|
let target_nx = if *tid == id {
|
||||||
|
nx
|
||||||
|
} else {
|
||||||
|
let picture = self.is_picture(*tid);
|
||||||
|
let (sx, sy) = if picture && matches!(handle, 0 | 2 | 4 | 6) {
|
||||||
|
// Use the axis with the larger relative change so an unchanged
|
||||||
|
// dimension cannot cancel shrinking. Preserve each axis's flip.
|
||||||
|
let scale_x = 1.0 + sx;
|
||||||
|
let scale_y = 1.0 + sy;
|
||||||
|
let scale = if sx.abs() >= sy.abs() { scale_x.abs() } else { scale_y.abs() };
|
||||||
|
(scale.copysign(scale_x) - 1.0, scale.copysign(scale_y) - 1.0)
|
||||||
|
} else {
|
||||||
|
(sx, sy)
|
||||||
|
};
|
||||||
|
|
||||||
|
let lp = Affine::rotate(o.rot.to_radians())
|
||||||
|
* Point::new(
|
||||||
|
hx * sx * o.w / div * if o.flip_h { -1.0 } else { 1.0 },
|
||||||
|
hy * sy * o.h / div * if o.flip_v { -1.0 } else { 1.0 },
|
||||||
|
);
|
||||||
|
resize_box(o, handle, Vec2::new(lp.x, lp.y), picture, mods.alt)
|
||||||
|
};
|
||||||
|
self.set_xfrm(*tid, target_nx)?;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Drag::LineEnd { id, end, orig } => {
|
Drag::LineEnd { id, end, orig } => {
|
||||||
let a = orig.affine();
|
let a = orig.affine();
|
||||||
|
|||||||
@@ -228,7 +228,7 @@ impl Session {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Snap the edges that resize handle `handle` drags in the resized box `x` to the smart guides.
|
/// Snap the edges that resize handle `handle` drags in the resized box `x` to the smart guides.
|
||||||
pub(super) fn snap_resize(&mut self, x: Xfrm, _orig: &Xfrm, handle: u8, id: ShapeId, tol: f64) -> Xfrm {
|
pub(super) fn snap_resize(&mut self, x: Xfrm, handle: u8, exclude: &[ShapeId], tol: f64) -> Xfrm {
|
||||||
if x.rot != 0.0 {
|
if x.rot != 0.0 {
|
||||||
self.tool.guides.clear();
|
self.tool.guides.clear();
|
||||||
return x;
|
return x;
|
||||||
@@ -241,7 +241,7 @@ impl Session {
|
|||||||
let (top, bottom) = (matches!(handle, 0..=2), matches!(handle, 4..=6));
|
let (top, bottom) = (matches!(handle, 0..=2), matches!(handle, 4..=6));
|
||||||
let (moves_left, moves_right) = if x.flip_h { (right, left) } else { (left, right) };
|
let (moves_left, moves_right) = if x.flip_h { (right, left) } else { (left, right) };
|
||||||
let (moves_top, moves_bottom) = if x.flip_v { (bottom, top) } else { (top, bottom) };
|
let (moves_top, moves_bottom) = if x.flip_v { (bottom, top) } else { (top, bottom) };
|
||||||
let (off, guides) = self.snap_edges(r, &[id], tol * 1.5, true, [moves_left, false, moves_right], [moves_top, false, moves_bottom]);
|
let (off, guides) = self.snap_edges(r, exclude, tol * 1.5, true, [moves_left, false, moves_right], [moves_top, false, moves_bottom]);
|
||||||
let mut nx = x;
|
let mut nx = x;
|
||||||
if moves_left {
|
if moves_left {
|
||||||
nx.x += off.x;
|
nx.x += off.x;
|
||||||
|
|||||||
@@ -216,6 +216,7 @@ pub(super) fn in_table_cell(st: &crate::DocState, id: ShapeId, p: Point) -> bool
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Resolve a shape's target at slide point `p`, using its effective fill for interior hits.
|
||||||
pub(super) fn hit_shape(st: &crate::DocState, sh: &Shape, p: Point, tol: f64) -> Option<Hit> {
|
pub(super) fn hit_shape(st: &crate::DocState, sh: &Shape, p: Point, tol: f64) -> Option<Hit> {
|
||||||
let x = xfrm_of(&st.doc, &st.selection, sh);
|
let x = xfrm_of(&st.doc, &st.selection, sh);
|
||||||
let inv = x.affine().inverse();
|
let inv = x.affine().inverse();
|
||||||
@@ -267,8 +268,11 @@ pub(super) fn hit_shape(st: &crate::DocState, sh: &Shape, p: Point, tol: f64) ->
|
|||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let has_text = matches!(sh.kind, ShapeKind::Shape) && (sh.text.as_ref().is_some_and(|t| !t.is_empty()) || sh.ph.is_some() || sh.text_box);
|
let has_text = matches!(sh.kind, ShapeKind::Shape) && (sh.text.as_ref().is_some_and(|t| !t.is_empty()) || sh.ph.is_some() || sh.text_box);
|
||||||
// Empty interiors of unfilled, text-less shapes don't catch clicks.
|
// Match rendering: an explicit No Fill overrides the style, and style/theme fills
|
||||||
let filled = !matches!(sh.fill, Some(deckcraft_model::Fill::None)) || sh.style.is_some();
|
// can themselves be No Fill. Empty, text-less interiors then let clicks through.
|
||||||
|
let fill =
|
||||||
|
cmd::text::ctx_for(&st.doc, &st.selection).map(|ctx| deckcraft_model::resolve::fill(&ctx, sh).0).unwrap_or_else(|| sh.fill.clone());
|
||||||
|
let filled = !matches!(fill, None | Some(deckcraft_model::Fill::None));
|
||||||
let geo = deckcraft_render::shape_geometry(sh, x.w, x.h);
|
let geo = deckcraft_render::shape_geometry(sh, x.w, x.h);
|
||||||
let in_geo = {
|
let in_geo = {
|
||||||
use deckcraft_geom::KurboShape;
|
use deckcraft_geom::KurboShape;
|
||||||
|
|||||||
@@ -220,6 +220,7 @@ impl Session {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Handle a pointer press and start the applicable tool interaction.
|
||||||
fn down(&mut self, p: Point, mods: Mods, tol: f64) -> Result<Value> {
|
fn down(&mut self, p: Point, mods: Mods, tol: f64) -> Result<Value> {
|
||||||
if let Some(Drag::Freeform { mode, mut points, .. }) = self.tool.drag.clone() {
|
if let Some(Drag::Freeform { mode, mut points, .. }) = self.tool.drag.clone() {
|
||||||
// Next click of a freeform: close on the start point, else add a vertex.
|
// Next click of a freeform: close on the start point, else add a vertex.
|
||||||
@@ -303,8 +304,12 @@ impl Session {
|
|||||||
Some(Hit::TableRow { id, row }) => self.begin_table_border(id, false, row, p)?,
|
Some(Hit::TableRow { id, row }) => self.begin_table_border(id, false, row, p)?,
|
||||||
Some(Hit::Resize { id, handle }) => {
|
Some(Hit::Resize { id, handle }) => {
|
||||||
let orig = st.shape(id).map(|s| xfrm_of(&st.doc, &st.selection, s)).unwrap_or_default();
|
let orig = st.shape(id).map(|s| xfrm_of(&st.doc, &st.selection, s)).unwrap_or_default();
|
||||||
|
let ids: Vec<ShapeId> = if st.selection.shapes.contains(&id) { st.selection.shapes.clone() } else { vec![id] };
|
||||||
|
let origs: Vec<(ShapeId, Xfrm)> =
|
||||||
|
ids.iter().filter_map(|i| st.shape(*i).filter(|s| !s.locked).map(|s| (*i, xfrm_of(&st.doc, &st.selection, s)))).collect();
|
||||||
|
let ids: Vec<ShapeId> = origs.iter().map(|(id, _)| *id).collect();
|
||||||
self.begin("Size");
|
self.begin("Size");
|
||||||
self.tool.drag = Some(Drag::Resize { id, handle, orig, start: p });
|
self.tool.drag = Some(Drag::Resize { id, orig, ids, handle, origs, start: p });
|
||||||
}
|
}
|
||||||
Some(Hit::LineEnd { id, end }) => {
|
Some(Hit::LineEnd { id, end }) => {
|
||||||
let orig = st.shape(id).map(|s| xfrm_of(&st.doc, &st.selection, s)).unwrap_or_default();
|
let orig = st.shape(id).map(|s| xfrm_of(&st.doc, &st.selection, s)).unwrap_or_default();
|
||||||
@@ -436,6 +441,7 @@ impl Session {
|
|||||||
Ok(json!({"hit": serde_json::to_value(&hit).unwrap_or_default()}))
|
Ok(json!({"hit": serde_json::to_value(&hit).unwrap_or_default()}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Handle a pointer release and finalize the active tool interaction.
|
||||||
fn up(&mut self, p: Point, mods: Mods) -> Result<Value> {
|
fn up(&mut self, p: Point, mods: Mods) -> Result<Value> {
|
||||||
let drag = self.tool.drag.take();
|
let drag = self.tool.drag.take();
|
||||||
self.tool.guides.clear();
|
self.tool.guides.clear();
|
||||||
@@ -558,10 +564,22 @@ impl Session {
|
|||||||
out = self.add_ink(points)?;
|
out = self.add_ink(points)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Some(Drag::Resize { id, .. }) => {
|
Some(Drag::Resize { origs, .. }) => {
|
||||||
|
let changed: Vec<ShapeId> = origs
|
||||||
|
.iter()
|
||||||
|
.filter_map(|(id, orig)| {
|
||||||
|
let current = self.active().and_then(|st| st.shape(*id).map(|s| xfrm_of(&st.doc, &st.selection, s)))?;
|
||||||
|
|
||||||
|
(current != *orig).then_some(*id)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
if !changed.is_empty() {
|
||||||
|
for id in changed {
|
||||||
|
let _ = cmd::text::fit_text_box(self, id);
|
||||||
|
self.refit_after_resize(id)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
self.commit();
|
self.commit();
|
||||||
let _ = cmd::text::fit_text_box(self, id);
|
|
||||||
self.refit_after_resize(id)?;
|
|
||||||
}
|
}
|
||||||
Some(Drag::TextSelect { before, .. }) => {
|
Some(Drag::TextSelect { before, .. }) => {
|
||||||
out = self.paint_if_selection_changed(before, out)?;
|
out = self.paint_if_selection_changed(before, out)?;
|
||||||
|
|||||||
@@ -30,12 +30,15 @@ pub fn bundled() -> Vec<&'static [u8]> {
|
|||||||
/// Open substitutes for families documents often ask for but we can't ship (metric-compatible
|
/// Open substitutes for families documents often ask for but we can't ship (metric-compatible
|
||||||
/// where one exists), tried in order after the family itself.
|
/// where one exists), tried in order after the family itself.
|
||||||
pub fn substitutes(family: &str) -> &'static [&'static str] {
|
pub fn substitutes(family: &str) -> &'static [&'static str] {
|
||||||
match family.to_ascii_lowercase().as_str() {
|
let f = family.to_ascii_lowercase();
|
||||||
|
if is_monospace_family(&f) {
|
||||||
|
return &["Liberation Mono", "Courier New", "Menlo", "Courier", "DejaVu Sans Mono"];
|
||||||
|
}
|
||||||
|
match f.as_str() {
|
||||||
"calibri" | "calibri light" => &["Carlito", "Liberation Sans", "Arial", "Inter"],
|
"calibri" | "calibri light" => &["Carlito", "Liberation Sans", "Arial", "Inter"],
|
||||||
"cambria" | "cambria math" => &["Caladea", "Liberation Serif", "Times New Roman", "Merriweather"],
|
"cambria" | "cambria math" => &["Caladea", "Liberation Serif", "Times New Roman", "Merriweather"],
|
||||||
"arial" | "helvetica" | "helvetica neue" | "arial nova" => &["Liberation Sans", "Arial", "Helvetica Neue", "Helvetica", "Inter"],
|
"arial" | "helvetica" | "helvetica neue" | "arial nova" => &["Liberation Sans", "Arial", "Helvetica Neue", "Helvetica", "Inter"],
|
||||||
"times new roman" | "times" => &["Liberation Serif", "Times New Roman", "Times", "Merriweather"],
|
"times new roman" | "times" => &["Liberation Serif", "Times New Roman", "Times", "Merriweather"],
|
||||||
"courier new" | "courier" | "consolas" | "menlo" => &["Liberation Mono", "Courier New", "Menlo", "Courier"],
|
|
||||||
"aptos" | "aptos display" | "aptos narrow" | "segoe ui" | "segoe ui light" | "segoe ui semibold" | "tahoma" | "verdana" => {
|
"aptos" | "aptos display" | "aptos narrow" | "segoe ui" | "segoe ui light" | "segoe ui semibold" | "tahoma" | "verdana" => {
|
||||||
&["Inter", "Helvetica Neue", "Arial", "Liberation Sans"]
|
&["Inter", "Helvetica Neue", "Arial", "Liberation Sans"]
|
||||||
}
|
}
|
||||||
@@ -52,12 +55,77 @@ pub fn substitutes(family: &str) -> &'static [&'static str] {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Families that are monospaced: when not installed, documents keep a monospaced substitute
|
||||||
|
/// ([`substitutes`]) so code blocks and columns stay aligned instead of falling back to
|
||||||
|
/// proportional Calibri.
|
||||||
|
pub fn is_monospace_family(family: &str) -> bool {
|
||||||
|
let f = family.to_ascii_lowercase();
|
||||||
|
let s = f.trim();
|
||||||
|
if matches!(
|
||||||
|
s,
|
||||||
|
"courier new"
|
||||||
|
| "courier"
|
||||||
|
| "cousine"
|
||||||
|
| "consolas"
|
||||||
|
| "menlo"
|
||||||
|
| "monaco"
|
||||||
|
| "source code pro"
|
||||||
|
| "jetbrains mono"
|
||||||
|
| "cascadia code"
|
||||||
|
| "cascadia code pl"
|
||||||
|
| "cascadia mono"
|
||||||
|
| "cascadia mono pl"
|
||||||
|
| "fira code"
|
||||||
|
| "fira mono"
|
||||||
|
| "inconsolata"
|
||||||
|
| "dejavu sans mono"
|
||||||
|
| "sf mono"
|
||||||
|
| "roboto mono"
|
||||||
|
| "hack"
|
||||||
|
| "ubuntu mono"
|
||||||
|
| "pt mono"
|
||||||
|
| "liberation mono"
|
||||||
|
| "andale mono"
|
||||||
|
| "lucida console"
|
||||||
|
| "lucida sans typewriter"
|
||||||
|
| "fixedsys"
|
||||||
|
| "terminal"
|
||||||
|
| "monospace"
|
||||||
|
| "iosevka"
|
||||||
|
| "monolisa"
|
||||||
|
| "envy code r"
|
||||||
|
| "hasklig"
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
s.starts_with("courier")
|
||||||
|
|| s.starts_with("consolas")
|
||||||
|
|| s.starts_with("inconsolata")
|
||||||
|
|| s.starts_with("iosevka")
|
||||||
|
|| s.ends_with(" mono")
|
||||||
|
|| s.ends_with(" code")
|
||||||
|
|| s.ends_with(" console")
|
||||||
|
|| s.ends_with(" typewriter")
|
||||||
|
|| s.ends_with(" monospace")
|
||||||
|
|| s.split_whitespace().any(|w| w == "mono" || w == "terminal" || w == "monospace")
|
||||||
|
}
|
||||||
|
|
||||||
/// Families whose open substitutes ([`substitutes`]) keep their metrics, so text drawn with
|
/// Families whose open substitutes ([`substitutes`]) keep their metrics, so text drawn with
|
||||||
/// them breaks into the same lines.
|
/// them breaks into the same lines.
|
||||||
fn has_metric_twin(family: &str) -> bool {
|
fn has_metric_twin(family: &str) -> bool {
|
||||||
matches!(
|
matches!(
|
||||||
family.to_ascii_lowercase().as_str(),
|
family.to_ascii_lowercase().as_str(),
|
||||||
"calibri" | "calibri light" | "cambria" | "cambria math" | "arial" | "helvetica" | "times new roman" | "times" | "courier new" | "courier"
|
"calibri"
|
||||||
|
| "calibri light"
|
||||||
|
| "cambria"
|
||||||
|
| "cambria math"
|
||||||
|
| "arial"
|
||||||
|
| "helvetica"
|
||||||
|
| "times new roman"
|
||||||
|
| "times"
|
||||||
|
| "courier new"
|
||||||
|
| "courier"
|
||||||
|
| "cousine"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1080,6 +1148,7 @@ impl FontDb {
|
|||||||
/// when it is installed or bundled, or a metric-compatible stand-in (Carlito for Calibri,
|
/// when it is installed or bundled, or a metric-compatible stand-in (Carlito for Calibri,
|
||||||
/// Liberation Sans for Arial…), or the family PowerPoint maps it to (Liberation Sans as Arial,
|
/// Liberation Sans for Arial…), or the family PowerPoint maps it to (Liberation Sans as Arial,
|
||||||
/// see `office_twin`); any other missing family is drawn in Calibri (or Carlito).
|
/// see `office_twin`); any other missing family is drawn in Calibri (or Carlito).
|
||||||
|
/// Monospaced families keep a monospaced substitute instead of falling back to Calibri.
|
||||||
/// DeckCraft's own default family keeps [`FontDb::face`]'s fallbacks.
|
/// DeckCraft's own default family keeps [`FontDb::face`]'s fallbacks.
|
||||||
pub fn document_face(&self, family: &str, style: &str) -> Arc<FontFace> {
|
pub fn document_face(&self, family: &str, style: &str) -> Arc<FontFace> {
|
||||||
if family.eq_ignore_ascii_case(FALLBACK_FAMILY) || has_metric_twin(family) || is_office_cloud_font(family) || self.has_family(family) {
|
if family.eq_ignore_ascii_case(FALLBACK_FAMILY) || has_metric_twin(family) || is_office_cloud_font(family) || self.has_family(family) {
|
||||||
@@ -1088,6 +1157,9 @@ impl FontDb {
|
|||||||
if let Some(twin) = office_twin(family) {
|
if let Some(twin) = office_twin(family) {
|
||||||
return self.face(twin, style);
|
return self.face(twin, style);
|
||||||
}
|
}
|
||||||
|
if is_monospace_family(family) {
|
||||||
|
return self.face(family, style);
|
||||||
|
}
|
||||||
self.face(MISSING_FONT_FAMILY, style)
|
self.face(MISSING_FONT_FAMILY, style)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ mod shape;
|
|||||||
|
|
||||||
pub use fallback::{bundled_families, covers_cluster, is_emoji_cluster, ligature_carets};
|
pub use fallback::{bundled_families, covers_cluster, is_emoji_cluster, ligature_carets};
|
||||||
pub use fontdb::{
|
pub use fontdb::{
|
||||||
FALLBACK_FAMILY, FaceRef, FontDb, FontFace, LAST_RESORT_FAMILY, MISSING_FONT_FAMILY, base_style, bundled, substitutes, system_font_dirs,
|
FALLBACK_FAMILY, FaceRef, FontDb, FontFace, LAST_RESORT_FAMILY, MISSING_FONT_FAMILY, base_style, bundled, is_monospace_family, substitutes,
|
||||||
|
system_font_dirs,
|
||||||
};
|
};
|
||||||
pub use harfrust::Feature;
|
pub use harfrust::Feature;
|
||||||
use harfrust::Tag;
|
use harfrust::Tag;
|
||||||
|
|||||||
@@ -216,9 +216,13 @@ fn missing_document_fonts_are_drawn_in_calibri() {
|
|||||||
assert_eq!(f.family, db.face(MISSING_FONT_FAMILY, "Bold").family, "{missing:?}");
|
assert_eq!(f.family, db.face(MISSING_FONT_FAMILY, "Bold").family, "{missing:?}");
|
||||||
}
|
}
|
||||||
// PowerPoint's own twins and Windows' substitutes, when the font itself is missing.
|
// PowerPoint's own twins and Windows' substitutes, when the font itself is missing.
|
||||||
for (missing, twin) in
|
for (missing, twin) in [
|
||||||
[("Liberation Sans", "Arial"), ("Liberation Serif", "Times New Roman"), ("Arial CE", "Arial"), ("Times New Roman CYR", "Times New Roman")]
|
("Liberation Sans", "Arial"),
|
||||||
{
|
("Liberation Serif", "Times New Roman"),
|
||||||
|
("Arial CE", "Arial"),
|
||||||
|
("Times New Roman CYR", "Times New Roman"),
|
||||||
|
("Courier New CE", "Courier New"),
|
||||||
|
] {
|
||||||
if !db.has_family(missing) {
|
if !db.has_family(missing) {
|
||||||
assert_eq!(db.document_face(missing, "Regular").family, db.face(twin, "Regular").family, "{missing}");
|
assert_eq!(db.document_face(missing, "Regular").family, db.face(twin, "Regular").family, "{missing}");
|
||||||
}
|
}
|
||||||
@@ -237,6 +241,73 @@ fn office_fonts_have_substitutes() {
|
|||||||
assert!(substitutes("Times New Roman").contains(&"Liberation Serif"));
|
assert!(substitutes("Times New Roman").contains(&"Liberation Serif"));
|
||||||
assert!(substitutes("Aptos").contains(&"Inter"));
|
assert!(substitutes("Aptos").contains(&"Inter"));
|
||||||
assert!(substitutes("zzz").is_empty());
|
assert!(substitutes("zzz").is_empty());
|
||||||
|
|
||||||
|
for mono in [
|
||||||
|
"Courier New",
|
||||||
|
"Cousine",
|
||||||
|
"Consolas",
|
||||||
|
"Menlo",
|
||||||
|
"Monaco",
|
||||||
|
"Source Code Pro",
|
||||||
|
"JetBrains Mono",
|
||||||
|
"Cascadia Code",
|
||||||
|
"Cascadia Code PL",
|
||||||
|
"Cascadia Mono",
|
||||||
|
"Fira Code",
|
||||||
|
"Inconsolata",
|
||||||
|
"DejaVu Sans Mono",
|
||||||
|
"Roboto Mono",
|
||||||
|
"Hack",
|
||||||
|
"Ubuntu Mono",
|
||||||
|
"Custom Monospace",
|
||||||
|
"Fantasque Sans Mono",
|
||||||
|
] {
|
||||||
|
assert!(substitutes(mono).contains(&"Liberation Mono"), "{mono} should have Liberation Mono substitute");
|
||||||
|
assert!(is_monospace_family(mono), "{mono} should be recognized as monospace");
|
||||||
|
}
|
||||||
|
|
||||||
|
for prop in ["Calibri", "Arial", "Times New Roman", "Inter", "Monotype Corsiva", "Monoton", "Monotype Baskerville", "Code Pro", "zzz"] {
|
||||||
|
assert!(!is_monospace_family(prop), "{prop} should not be recognized as monospace");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Explicit checks requested in review: Consolas, Courier New, Fira Code, Monotype Corsiva, Monoton,
|
||||||
|
// and ensuring proportional families maintain their unchanged substitutes.
|
||||||
|
assert!(is_monospace_family("Consolas"));
|
||||||
|
assert!(is_monospace_family("Courier New"));
|
||||||
|
assert!(is_monospace_family("Fira Code"));
|
||||||
|
assert!(!is_monospace_family("Monotype Corsiva"));
|
||||||
|
assert!(!is_monospace_family("Monoton"));
|
||||||
|
|
||||||
|
assert_eq!(substitutes("Calibri"), &["Carlito", "Liberation Sans", "Arial", "Inter"]);
|
||||||
|
assert_eq!(substitutes("Arial"), &["Liberation Sans", "Arial", "Helvetica Neue", "Helvetica", "Inter"]);
|
||||||
|
assert_eq!(substitutes("Times New Roman"), &["Liberation Serif", "Times New Roman", "Times", "Merriweather"]);
|
||||||
|
assert_eq!(substitutes("Georgia"), &["Merriweather", "Liberation Serif", "Georgia"]);
|
||||||
|
assert!(substitutes("Monotype Corsiva").is_empty());
|
||||||
|
assert!(substitutes("Monoton").is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_monospace_fonts_keep_monospace_fallback() {
|
||||||
|
let db = FontDb::global();
|
||||||
|
let mono_sub = db.face("Unavailable JetBrains Mono", "Regular").family.clone();
|
||||||
|
let calibri_target = db.face(MISSING_FONT_FAMILY, "Regular").family.clone();
|
||||||
|
|
||||||
|
assert!(is_monospace_family(&mono_sub), "substitute {mono_sub} must be monospace");
|
||||||
|
|
||||||
|
// Cousine is metric-compatible with Courier New / Liberation Mono and must not fall back to Calibri.
|
||||||
|
if !db.has_family("Cousine") {
|
||||||
|
assert_eq!(db.document_face("Cousine", "Regular").family, mono_sub);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Monospace fonts missing from the system must resolve to a monospace substitute.
|
||||||
|
for mono in ["Unavailable JetBrains Mono", "Custom App Code", "Unknown Space Mono", "Mock Terminal Font"] {
|
||||||
|
let f = db.document_face(mono, "Regular");
|
||||||
|
assert_eq!(f.family, mono_sub, "{mono}");
|
||||||
|
assert!(is_monospace_family(&f.family), "{mono} must resolve to a monospace family");
|
||||||
|
if mono_sub != calibri_target {
|
||||||
|
assert_ne!(f.family, calibri_target, "{mono} must not fall back to Calibri");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
+22
-3
@@ -71,7 +71,10 @@ impl Unit {
|
|||||||
/// `2.08"` style text for a length in points.
|
/// `2.08"` style text for a length in points.
|
||||||
pub fn format(self, pt: f64) -> String {
|
pub fn format(self, pt: f64) -> String {
|
||||||
let v = pt / self.points_per_unit();
|
let v = pt / self.points_per_unit();
|
||||||
let s = format!("{v:.2}");
|
let mut s = format!("{v:.2}");
|
||||||
|
if s.starts_with('-') && s[1..].bytes().all(|b| b == b'0' || b == b'.') {
|
||||||
|
s.remove(0);
|
||||||
|
}
|
||||||
format!("{s}{}", self.suffix())
|
format!("{s}{}", self.suffix())
|
||||||
}
|
}
|
||||||
/// Parses `2.5`, `2.5"`, `2.5 in`, `6 cm`, `30 pt`, `40px`, `5mm` into points. A bare number uses `self`.
|
/// Parses `2.5`, `2.5"`, `2.5 in`, `6 cm`, `30 pt`, `40px`, `5mm` into points. A bare number uses `self`.
|
||||||
@@ -153,8 +156,8 @@ impl Xfrm {
|
|||||||
}
|
}
|
||||||
/// Rotation normalised to 0..360.
|
/// Rotation normalised to 0..360.
|
||||||
pub fn rot_norm(&self) -> f64 {
|
pub fn rot_norm(&self) -> f64 {
|
||||||
let r = self.rot % 360.0;
|
let r = self.rot.rem_euclid(360.0);
|
||||||
if r < 0.0 { r + 360.0 } else { r }
|
if r >= 360.0 { 0.0 } else { r }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,6 +225,14 @@ pub fn preset_flatten(path: &BezPath, f: &mut dyn FnMut(PathEl)) {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn format_has_no_negative_zero() {
|
||||||
|
assert_eq!(Unit::Points.format(-0.001), "0.00 pt");
|
||||||
|
assert_eq!(Unit::Inches.format(-0.001), "0.00\"");
|
||||||
|
assert!(!Unit::Centimeters.format(-0.01).starts_with('-'));
|
||||||
|
assert_eq!(Unit::Points.format(-1.5), "-1.50 pt");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn emu_roundtrip_is_exact() {
|
fn emu_roundtrip_is_exact() {
|
||||||
for emu in [0i64, 1, 12_699, 12_700, 914_400, 12_192_000, 6_858_000, -5, 123_456_789] {
|
for emu in [0i64, 1, 12_699, 12_700, 914_400, 12_192_000, 6_858_000, -5, 123_456_789] {
|
||||||
@@ -266,6 +277,14 @@ mod tests {
|
|||||||
assert!((p.x - 300.0).abs() < 1e-9 && (p.y - 200.0).abs() < 1e-9);
|
assert!((p.x - 300.0).abs() < 1e-9 && (p.y - 200.0).abs() < 1e-9);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rot_norm_stays_below_360() {
|
||||||
|
for rot in [-1e-15, -1e-13, -f64::EPSILON, -0.0, 360.0, 720.0, -360.0] {
|
||||||
|
let n = Xfrm { rot, ..Default::default() }.rot_norm();
|
||||||
|
assert!((0.0..360.0).contains(&n), "rot {rot} -> {n}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn segment_distance() {
|
fn segment_distance() {
|
||||||
assert!((dist_to_segment(Point::new(5.0, 5.0), Point::new(0.0, 0.0), Point::new(10.0, 0.0)) - 5.0).abs() < 1e-9);
|
assert!((dist_to_segment(Point::new(5.0, 5.0), Point::new(0.0, 0.0), Point::new(10.0, 0.0)) - 5.0).abs() < 1e-9);
|
||||||
|
|||||||
@@ -566,6 +566,19 @@ impl<'a> SliceDecoder<'a> {
|
|||||||
(refs, mvs, false)
|
(refs, mvs, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Temporal direct mvs (8.4.1.2.3) from the co-located mv and `DistScaleFactor` (clamped
|
||||||
|
/// to ±1024 in `SliceDecoder`): mvL0 = clip_mv((mvCol · dsf + 128) >> 8), mvL1 =
|
||||||
|
/// clip_mv(mvL0 − mvCol), both clipped to the spec's motion-vector range −2^15..2^15−1.
|
||||||
|
/// The i32 maths cannot overflow; a truncating `as i16` used to wrap mvL0 past the range,
|
||||||
|
/// after which the mvL1 subtraction overflowed a debug build. mvL1 is scaled from the
|
||||||
|
/// unclamped mvL0 with only the results clipped, as the sibling SoundCraft decoder's
|
||||||
|
/// review established (soundcraft#994): dsf 512, mvCol 32767 keeps mvL1 at 32767.
|
||||||
|
fn temporal_direct_mvs(dsf: i32, mv_col: [i16; 2]) -> ([i16; 2], [i16; 2]) {
|
||||||
|
let clamp = |v: i32| v.clamp(-(1 << 15), (1 << 15) - 1) as i16;
|
||||||
|
let m0 = mv_col.map(|c| (dsf * i32::from(c) + 128) >> 8);
|
||||||
|
(m0.map(clamp), [clamp(m0[0] - i32::from(mv_col[0])), clamp(m0[1] - i32::from(mv_col[1]))])
|
||||||
|
}
|
||||||
|
|
||||||
/// Derive direct-mode motion for the 8x8 blocks in `b8_mask` of the current MB.
|
/// Derive direct-mode motion for the 8x8 blocks in `b8_mask` of the current MB.
|
||||||
fn derive_direct(&mut self, b8_mask: u8) -> Result<()> {
|
fn derive_direct(&mut self, b8_mask: u8) -> Result<()> {
|
||||||
let spatial = self.sh.direct_spatial_mv_pred;
|
let spatial = self.sh.direct_spatial_mv_pred;
|
||||||
@@ -600,13 +613,7 @@ impl<'a> SliceDecoder<'a> {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
let (dsf, ltr) = self.tdirect.get(ref0 as usize).copied().unwrap_or((256, true));
|
let (dsf, ltr) = self.tdirect.get(ref0 as usize).copied().unwrap_or((256, true));
|
||||||
let (mv0, mv1) = if ltr {
|
let (mv0, mv1) = if ltr { (mv_col, [0, 0]) } else { Self::temporal_direct_mvs(dsf, mv_col) };
|
||||||
(mv_col, [0, 0])
|
|
||||||
} else {
|
|
||||||
let s = |c: i16| ((dsf * c as i32 + 128) >> 8) as i16;
|
|
||||||
let m0 = [s(mv_col[0]), s(mv_col[1])];
|
|
||||||
(m0, [m0[0] - mv_col[0], m0[1] - mv_col[1]])
|
|
||||||
};
|
|
||||||
([ref0, 0], [mv0, mv1])
|
([ref0, 0], [mv0, mv1])
|
||||||
};
|
};
|
||||||
self.set_motion(x, y, unit, unit, refs, mvs);
|
self.set_motion(x, y, unit, unit, refs, mvs);
|
||||||
@@ -1481,3 +1488,33 @@ impl<'a> SliceDecoder<'a> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::SliceDecoder;
|
||||||
|
|
||||||
|
/// Temporal direct mvs stay inside the spec's motion-vector range −2^15..2^15−1 with a
|
||||||
|
/// large `DistScaleFactor` and a co-located mv at an i16 extreme: the scale used to
|
||||||
|
/// truncate past the range with `as i16`, and mvL0 − mvCol then overflowed a debug build
|
||||||
|
/// ("attempt to subtract with overflow"). `dsf` 256 is the identity scale.
|
||||||
|
#[test]
|
||||||
|
fn temporal_direct_mvs_at_i16_extremes_stay_in_the_spec_range() {
|
||||||
|
for dsf in [-1024i32, -1, 256, 1023] {
|
||||||
|
for col in [[i16::MIN, i16::MIN], [i16::MAX, i16::MAX], [i16::MIN, i16::MAX]] {
|
||||||
|
let (mv0, mv1) = SliceDecoder::temporal_direct_mvs(dsf, col);
|
||||||
|
for mv in [mv0, mv1] {
|
||||||
|
for c in mv {
|
||||||
|
assert!((-(1 << 15)..(1 << 15)).contains(&i32::from(c)), "dsf {dsf}, col {col:?}: {mv:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if dsf == 256 {
|
||||||
|
assert_eq!((mv0, mv1), (col, [0, 0]), "dsf 256 is the identity scale");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// soundcraft#994: mvL1 is scaled from the unclamped mvL0 with only the results clipped;
|
||||||
|
// deriving it from the clipped mvL0 gave [0, 0] here.
|
||||||
|
let (mv0, mv1) = SliceDecoder::temporal_direct_mvs(512, [i16::MAX, 0]);
|
||||||
|
assert_eq!((mv0, mv1), ([i16::MAX, 0], [i16::MAX, 0]), "mvL1 keeps mvL0's off-range part");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -469,6 +469,9 @@ pub fn change_case(s: &str, mode: &str) -> String {
|
|||||||
/// piece is the changed form of the matching input piece, even where a character changes length.
|
/// piece is the changed form of the matching input piece, even where a character changes length.
|
||||||
pub fn change_case_parts(parts: &[&str], mode: &str) -> Vec<String> {
|
pub fn change_case_parts(parts: &[&str], mode: &str) -> Vec<String> {
|
||||||
let mut start = true;
|
let mut start = true;
|
||||||
|
// All characters of all pieces, so word-final sigma sees neighbours across piece boundaries.
|
||||||
|
let all: Vec<char> = parts.iter().flat_map(|s| s.chars()).collect();
|
||||||
|
let mut base = 0;
|
||||||
parts
|
parts
|
||||||
.iter()
|
.iter()
|
||||||
.map(|s| match mode {
|
.map(|s| match mode {
|
||||||
@@ -479,10 +482,14 @@ pub fn change_case_parts(parts: &[&str], mode: &str) -> Vec<String> {
|
|||||||
// `title` restarts after whitespace, sentence case after sentence punctuation.
|
// `title` restarts after whitespace, sentence case after sentence punctuation.
|
||||||
let title = mode == "title";
|
let title = mode == "title";
|
||||||
let mut out = String::new();
|
let mut out = String::new();
|
||||||
for c in s.chars() {
|
for (j, c) in s.chars().enumerate() {
|
||||||
|
let i = base + j;
|
||||||
if start && c.is_alphabetic() {
|
if start && c.is_alphabetic() {
|
||||||
out.extend(c.to_uppercase());
|
out.extend(c.to_uppercase());
|
||||||
start = false;
|
start = false;
|
||||||
|
} else if c == 'Σ' && i > 0 && all[i - 1].is_alphabetic() && !all.get(i + 1).is_some_and(|n| n.is_alphabetic()) {
|
||||||
|
// Word-final capital sigma lowercases to ς, as `str::to_lowercase` does.
|
||||||
|
out.push('ς');
|
||||||
} else {
|
} else {
|
||||||
out.extend(c.to_lowercase());
|
out.extend(c.to_lowercase());
|
||||||
}
|
}
|
||||||
@@ -490,6 +497,7 @@ pub fn change_case_parts(parts: &[&str], mode: &str) -> Vec<String> {
|
|||||||
start = true;
|
start = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
base += s.chars().count();
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -504,6 +512,20 @@ mod tests {
|
|||||||
TextBody::from_text(t)
|
TextBody::from_text(t)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn title_and_sentence_final_sigma() {
|
||||||
|
assert_eq!(change_case("ΟΔΟΣ", "title"), "Οδος");
|
||||||
|
assert_eq!(change_case("ΟΔΟΣ ΜΟΥ.", "sentence"), "Οδος μου.");
|
||||||
|
assert_eq!(change_case("ΟΔΟΣ", "lower"), "οδος");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn title_sigma_across_runs_is_not_final() {
|
||||||
|
assert_eq!(change_case_parts(&["ΟΔΟΣ", "ΑΣ"], "title"), ["Οδοσ", "ας"]);
|
||||||
|
assert_eq!(change_case_parts(&["ΟΔΟ", "ΣΑ"], "title"), ["Οδο", "σα"]);
|
||||||
|
assert_eq!(change_case_parts(&["ΟΔΟΣ", " ΜΟΥ"], "title"), ["Οδος", " Μου"]);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn insert_and_split() {
|
fn insert_and_split() {
|
||||||
let mut b = body("Hello");
|
let mut b = body("Hello");
|
||||||
|
|||||||
@@ -55,10 +55,12 @@ impl ColorRef {
|
|||||||
};
|
};
|
||||||
deckcraft_color::apply(base, &self.mods)
|
deckcraft_color::apply(base, &self.mods)
|
||||||
}
|
}
|
||||||
/// Alpha (0–1) from an `alpha` transform, if any.
|
/// Alpha (0–1) from the `alpha`, `alphaMod` and `alphaOff` transforms, applied in order.
|
||||||
pub fn alpha(&self) -> f64 {
|
pub fn alpha(&self) -> f64 {
|
||||||
self.mods.iter().fold(1.0, |a, m| match m {
|
self.mods.iter().fold(1.0, |a, m| match m {
|
||||||
ColorTransform::Alpha(v) => (*v as f64 / 100_000.0).clamp(0.0, 1.0),
|
ColorTransform::Alpha(v) => (*v as f64 / 100_000.0).clamp(0.0, 1.0),
|
||||||
|
ColorTransform::AlphaMod(v) => (a * *v as f64 / 100_000.0).clamp(0.0, 1.0),
|
||||||
|
ColorTransform::AlphaOff(v) => (a + *v as f64 / 100_000.0).clamp(0.0, 1.0),
|
||||||
_ => a,
|
_ => a,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -327,3 +327,15 @@ fn only_web_and_mail_links_are_safe_to_open() {
|
|||||||
assert!(!safe_url(url), "{url}");
|
assert!(!safe_url(url), "{url}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn colour_alpha_follows_alpha_mod_and_off() {
|
||||||
|
use deckcraft_color::{ColorTransform, Rgba};
|
||||||
|
let base = || ColorRef::rgb(Rgba::rgb(1, 2, 3));
|
||||||
|
let mod_half = base().with(ColorTransform::AlphaMod(50000));
|
||||||
|
assert!((mod_half.alpha() - 0.5).abs() < 1e-9, "{}", mod_half.alpha());
|
||||||
|
let off = base().with(ColorTransform::AlphaOff(-50000));
|
||||||
|
assert!((off.alpha() - 0.5).abs() < 1e-9, "{}", off.alpha());
|
||||||
|
let both = base().with(ColorTransform::Alpha(40000)).with(ColorTransform::AlphaMod(50000));
|
||||||
|
assert!((both.alpha() - 0.2).abs() < 1e-9, "{}", both.alpha());
|
||||||
|
}
|
||||||
|
|||||||
@@ -90,11 +90,16 @@ fn base_class(c: char) -> Class {
|
|||||||
c if c.is_alphanumeric() => Class::Word,
|
c if c.is_alphanumeric() => Class::Word,
|
||||||
c if is_symbol(c) => Class::Symbol,
|
c if is_symbol(c) => Class::Symbol,
|
||||||
// A combining mark with no base (or any other mark) stays with letters.
|
// A combining mark with no base (or any other mark) stays with letters.
|
||||||
'\u{300}'..='\u{36f}' | '\u{1ab0}'..='\u{1aff}' | '\u{1dc0}'..='\u{1dff}' | '\u{20d0}'..='\u{20ff}' | '\u{fe20}'..='\u{fe2f}' => Class::Word,
|
c if is_combining_mark(c) => Class::Word,
|
||||||
_ => Class::Punct,
|
_ => Class::Punct,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether `c` is a combining mark (a decomposed accent), which belongs to the letter before it.
|
||||||
|
pub fn is_combining_mark(c: char) -> bool {
|
||||||
|
matches!(c, '\u{300}'..='\u{36f}' | '\u{1ab0}'..='\u{1aff}' | '\u{1dc0}'..='\u{1dff}' | '\u{20d0}'..='\u{20ff}' | '\u{fe20}'..='\u{fe2f}')
|
||||||
|
}
|
||||||
|
|
||||||
fn clusters(text: &str) -> Vec<Cluster> {
|
fn clusters(text: &str) -> Vec<Cluster> {
|
||||||
let mut out: Vec<Cluster> = Vec::new();
|
let mut out: Vec<Cluster> = Vec::new();
|
||||||
let mut firsts: Vec<char> = Vec::new();
|
let mut firsts: Vec<char> = Vec::new();
|
||||||
|
|||||||
@@ -202,7 +202,8 @@ fn unsafe_url_links_are_not_exported() {
|
|||||||
/// memory. Its naming/shaping tables still parse, but its missing head table prevents PDF
|
/// memory. Its naming/shaping tables still parse, but its missing head table prevents PDF
|
||||||
/// embedding. No font bytes or vendor assets are stored as test fixtures.
|
/// embedding. No font bytes or vendor assets are stored as test fixtures.
|
||||||
fn font_without_head() -> Vec<u8> {
|
fn font_without_head() -> Vec<u8> {
|
||||||
let mut bytes = FontDb::global().face("Ubuntu", "Regular").data().to_vec();
|
// The final bundled face is always Ubuntu Light; a system lookup may select a variable font.
|
||||||
|
let mut bytes = deckcraft_fonts::bundled().last().unwrap().to_vec();
|
||||||
for (old, new) in [(b"Ubuntu".as_slice(), b"BadPDF".as_slice()), (b"\0U\0b\0u\0n\0t\0u".as_slice(), b"\0B\0a\0d\0P\0D\0F".as_slice())] {
|
for (old, new) in [(b"Ubuntu".as_slice(), b"BadPDF".as_slice()), (b"\0U\0b\0u\0n\0t\0u".as_slice(), b"\0B\0a\0d\0P\0D\0F".as_slice())] {
|
||||||
let mut offset = 0;
|
let mut offset = 0;
|
||||||
while offset + old.len() <= bytes.len() {
|
while offset + old.len() <= bytes.len() {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ mod maps;
|
|||||||
mod opc;
|
mod opc;
|
||||||
mod png;
|
mod png;
|
||||||
pub(crate) mod read;
|
pub(crate) mod read;
|
||||||
|
pub use read::MAX_TAG_BYTES;
|
||||||
mod tables;
|
mod tables;
|
||||||
mod write;
|
mod write;
|
||||||
mod xml;
|
mod xml;
|
||||||
|
|||||||
+66
-6
@@ -40,6 +40,12 @@ pub struct Imp<'a> {
|
|||||||
pub kept_sidecar_bytes: usize,
|
pub kept_sidecar_bytes: usize,
|
||||||
/// Cached kept sidecars by normalized chart part name.
|
/// Cached kept sidecars by normalized chart part name.
|
||||||
pub kept_charts: HashMap<String, deckcraft_model::chart::ChartKept>,
|
pub kept_charts: HashMap<String, deckcraft_model::chart::ChartKept>,
|
||||||
|
/// Add-in tags parts, parsed once each, by normalized part name.
|
||||||
|
pub tags_by_part: HashMap<String, Arc<[(String, String)]>>,
|
||||||
|
/// Bytes of tags copied into the model so far (see [`MAX_TAG_BYTES`]).
|
||||||
|
pub tag_bytes: usize,
|
||||||
|
/// The tag budget ran out and the warning was given.
|
||||||
|
pub tags_capped: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Imp<'_> {
|
impl Imp<'_> {
|
||||||
@@ -196,16 +202,64 @@ fn choice_has_model3d(el: &El, rels: &Rels) -> bool {
|
|||||||
rec(el, rels, 0)
|
rec(el, rels, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Bytes of tags one import copies into the model, every tags part and reference together: a
|
||||||
|
/// small compressed tags part listed by many shapes, or many times by one, must not grow without
|
||||||
|
/// bound (#1164). The package budgets (`opc::MAX_XML_PART`, `MAX_TOTAL`, #1283) bound what is
|
||||||
|
/// parsed; this bounds what is copied from it.
|
||||||
|
pub const MAX_TAG_BYTES: usize = 64 << 20;
|
||||||
|
/// What each copied pair costs besides its text, so empty pairs count too.
|
||||||
|
const TAG_PAIR_COST: usize = std::mem::size_of::<(String, String)>();
|
||||||
|
|
||||||
/// Add-in tags named by `holder`'s `p:custDataLst`: the name/value pairs of each tags part.
|
/// Add-in tags named by `holder`'s `p:custDataLst`: the name/value pairs of each tags part.
|
||||||
pub fn tags(imp: &Imp, part: &Part, holder: &El) -> Vec<(String, String)> {
|
/// Each part is parsed once per import and counted once per holder. Once [`MAX_TAG_BYTES`] is
|
||||||
|
/// reached, no further tag parts are parsed or cached (#1164).
|
||||||
|
pub fn tags(imp: &mut Imp, part: &Part, holder: &El) -> Vec<(String, String)> {
|
||||||
|
if imp.tags_capped {
|
||||||
|
return vec![];
|
||||||
|
}
|
||||||
let Some(list) = holder.child("custDataLst") else { return vec![] };
|
let Some(list) = holder.child("custDataLst") else { return vec![] };
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
|
let mut seen = HashSet::new();
|
||||||
for t in list.children_named("tags").take(100) {
|
for t in list.children_named("tags").take(100) {
|
||||||
let Some(doc) = t.attr("r:id").and_then(|id| part.rels.get(id)).and_then(|r| imp.pkg.xml(&r.target)) else { continue };
|
if imp.tags_capped {
|
||||||
for tag in doc.root.children_named("tag").take(10_000) {
|
return out;
|
||||||
if let Some(name) = tag.attr("name") {
|
}
|
||||||
out.push((name.to_string(), tag.attr("val").unwrap_or("").to_string()));
|
let Some(target) = t.attr("r:id").and_then(|id| part.rels.get(id)).map(|r| opc::norm(&r.target)) else { continue };
|
||||||
|
if !seen.insert(target.clone()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let parsed = match imp.tags_by_part.get(&target) {
|
||||||
|
Some(p) => p.clone(),
|
||||||
|
None => {
|
||||||
|
if imp.tags_capped {
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
let p: Arc<[(String, String)]> = imp
|
||||||
|
.pkg
|
||||||
|
.xml(&target)
|
||||||
|
.map(|doc| {
|
||||||
|
doc.root
|
||||||
|
.children_named("tag")
|
||||||
|
.take(10_000)
|
||||||
|
.filter_map(|tag| Some((tag.attr("name")?.to_string(), tag.attr("val").unwrap_or("").to_string())))
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
imp.tags_by_part.insert(target, p.clone());
|
||||||
|
p
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
for (name, val) in parsed.iter() {
|
||||||
|
let n = imp.tag_bytes.saturating_add(TAG_PAIR_COST + name.len() + val.len());
|
||||||
|
if n > MAX_TAG_BYTES {
|
||||||
|
if !imp.tags_capped {
|
||||||
|
log::warn!("pptx: add-in tags over {MAX_TAG_BYTES} bytes; the rest are dropped");
|
||||||
|
imp.tags_capped = true;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
imp.tag_bytes = n;
|
||||||
|
out.push((name.clone(), val.clone()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
out
|
out
|
||||||
@@ -389,6 +443,9 @@ pub fn import_theme(bytes: &[u8]) -> Result<(deckcraft_model::theme::Theme, Vec<
|
|||||||
vml_previews: HashMap::new(),
|
vml_previews: HashMap::new(),
|
||||||
kept_sidecar_bytes: 0,
|
kept_sidecar_bytes: 0,
|
||||||
kept_charts: HashMap::new(),
|
kept_charts: HashMap::new(),
|
||||||
|
tags_by_part: HashMap::new(),
|
||||||
|
tag_bytes: 0,
|
||||||
|
tags_capped: false,
|
||||||
};
|
};
|
||||||
let part = Part::new(&pkg, &theme_path, &doc);
|
let part = Part::new(&pkg, &theme_path, &doc);
|
||||||
let theme = theme::read_theme(&mut imp, &part, &doc.root);
|
let theme = theme::read_theme(&mut imp, &part, &doc.root);
|
||||||
@@ -427,6 +484,9 @@ pub fn import_checked(bytes: &[u8]) -> Result<(Presentation, Vec<String>), PptxE
|
|||||||
vml_previews: HashMap::new(),
|
vml_previews: HashMap::new(),
|
||||||
kept_sidecar_bytes: 0,
|
kept_sidecar_bytes: 0,
|
||||||
kept_charts: HashMap::new(),
|
kept_charts: HashMap::new(),
|
||||||
|
tags_by_part: HashMap::new(),
|
||||||
|
tag_bytes: 0,
|
||||||
|
tags_capped: false,
|
||||||
};
|
};
|
||||||
// Slide ids first so hyperlinks anywhere can point at slides.
|
// Slide ids first so hyperlinks anywhere can point at slides.
|
||||||
let slide_ids = register_slides(&mut imp, &docs, &slide_entries, &mut lost_slides);
|
let slide_ids = register_slides(&mut imp, &docs, &slide_entries, &mut lost_slides);
|
||||||
@@ -441,7 +501,7 @@ pub fn import_checked(bytes: &[u8]) -> Result<(Presentation, Vec<String>), PptxE
|
|||||||
let layout_by_part = read_masters(&mut imp, &mut p, &master_paths, &docs);
|
let layout_by_part = read_masters(&mut imp, &mut p, &master_paths, &docs);
|
||||||
let file_slide_ids = read_slides(&mut imp, &mut p, &pres_part, &docs, &slide_ids, &layout_by_part);
|
let file_slide_ids = read_slides(&mut imp, &mut p, &pres_part, &docs, &slide_ids, &layout_by_part);
|
||||||
read_sections(&mut p, &pres_part, pres_el, &file_slide_ids);
|
read_sections(&mut p, &pres_part, pres_el, &file_slide_ids);
|
||||||
p.tags = tags(&imp, &pres_part, pres_el);
|
p.tags = tags(&mut imp, &pres_part, pres_el);
|
||||||
read_custom_shows(&mut p, &imp, &pres_part, pres_el);
|
read_custom_shows(&mut p, &imp, &pres_part, pres_el);
|
||||||
read_guides_and_show(&mut p, &imp, &pres_part);
|
read_guides_and_show(&mut p, &imp, &pres_part);
|
||||||
read_notes_master(&mut imp, &mut p, &pres_part);
|
read_notes_master(&mut imp, &mut p, &pres_part);
|
||||||
|
|||||||
@@ -36,3 +36,111 @@ fn tags_survive_round_trip() {
|
|||||||
assert_eq!(first.tags, vec![t("THINKCELLSHAPEDONOTDELETE", "t!abc")]);
|
assert_eq!(first.tags, vec![t("THINKCELLSHAPEDONOTDELETE", "t!abc")]);
|
||||||
assert!(back.slides.iter().skip(1).all(|s| s.tags.is_empty()));
|
assert!(back.slides.iter().skip(1).all(|s| s.tags.is_empty()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// #1164: the same tags part listed over and over is read once, and the tags one import copies
|
||||||
|
/// into the model are capped however often a shape lists them.
|
||||||
|
#[test]
|
||||||
|
fn a_tags_part_listed_many_times_is_copied_once_and_capped() {
|
||||||
|
let mut s = Session::new();
|
||||||
|
deckcraft_engine::sample::open_sample(&mut s).expect("sample");
|
||||||
|
for _ in 0..6 {
|
||||||
|
s.execute("shape.insert", &serde_json::json!({"preset": "rect", "rect": [10, 10, 50, 50]})).expect("shape");
|
||||||
|
}
|
||||||
|
let mut p = s.doc().expect("doc").doc.as_ref().clone();
|
||||||
|
let slide = Arc::make_mut(&mut p.slides[0]);
|
||||||
|
// A quarter of the budget with its name and pair cost: the fifth shape listing it goes over.
|
||||||
|
let pair = std::mem::size_of::<(String, String)>();
|
||||||
|
slide.shapes[0].tags = vec![t("BIG", &"x".repeat(deckcraft_pptx::MAX_TAG_BYTES / 4 - 3 - pair))];
|
||||||
|
let bytes = deckcraft_pptx::export(&p).expect("export");
|
||||||
|
|
||||||
|
// Every shape on the first slide lists that one tags part 100 times.
|
||||||
|
let mut zip = zip::ZipArchive::new(Cursor::new(bytes)).expect("zip");
|
||||||
|
let mut xml = String::new();
|
||||||
|
zip.by_name("ppt/slides/slide1.xml").expect("slide").read_to_string(&mut xml).expect("read");
|
||||||
|
let start = xml.find("<p:custDataLst>").expect("tags listed");
|
||||||
|
let end = xml[start..].find("</p:custDataLst>").expect("list ends") + start + "</p:custDataLst>".len();
|
||||||
|
let list = &xml[start..end];
|
||||||
|
let inner = list.trim_start_matches("<p:custDataLst>").trim_end_matches("</p:custDataLst>");
|
||||||
|
let many = format!("<p:custDataLst>{}</p:custDataLst>", inner.repeat(100));
|
||||||
|
let xml = xml.replace(list, "").replace("<p:nvPr/>", "<p:nvPr></p:nvPr>").replace("<p:nvPr>", &format!("<p:nvPr>{many}"));
|
||||||
|
assert!(xml.matches(&many).count() >= 5, "enough shapes to go over the budget");
|
||||||
|
let mut out = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||||
|
for i in 0..zip.len() {
|
||||||
|
let mut f = zip.by_index(i).expect("entry");
|
||||||
|
let name = f.name().to_string();
|
||||||
|
let mut data = vec![];
|
||||||
|
f.read_to_end(&mut data).expect("read");
|
||||||
|
out.start_file(&name, zip::write::SimpleFileOptions::default()).expect("start");
|
||||||
|
std::io::Write::write_all(&mut out, if name == "ppt/slides/slide1.xml" { xml.as_bytes() } else { &data }).expect("write");
|
||||||
|
}
|
||||||
|
let bytes = out.finish().expect("finish").into_inner();
|
||||||
|
|
||||||
|
let back = deckcraft_pptx::import(&bytes).expect("import");
|
||||||
|
let tagged: Vec<_> = back.slides[0].shapes.iter().filter(|x| !x.tags.is_empty()).collect();
|
||||||
|
assert_eq!(tagged.len(), 4, "four copies fit the budget");
|
||||||
|
assert!(tagged.iter().all(|x| x.tags.len() == 1), "each shape keeps the part's tag once, not 100 times");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// #1164: empty tags cost something too, or a part of 10,000 empty pairs listed by every shape
|
||||||
|
/// would be copied without ever reaching the budget.
|
||||||
|
#[test]
|
||||||
|
fn empty_tags_count_against_the_budget() {
|
||||||
|
let pair = std::mem::size_of::<(String, String)>();
|
||||||
|
let per_shape = 10_000 * pair;
|
||||||
|
let shapes = deckcraft_pptx::MAX_TAG_BYTES / per_shape + 2;
|
||||||
|
let mut s = Session::new();
|
||||||
|
deckcraft_engine::sample::open_sample(&mut s).expect("sample");
|
||||||
|
let mut p = s.doc().expect("doc").doc.as_ref().clone();
|
||||||
|
let slide = Arc::make_mut(&mut p.slides[0]);
|
||||||
|
let template = slide.shapes[0].clone();
|
||||||
|
slide.shapes.clear();
|
||||||
|
for i in 0..shapes {
|
||||||
|
let mut sh = template.clone();
|
||||||
|
sh.id = deckcraft_model::ShapeId(10_000 + i as u32);
|
||||||
|
sh.ph = None;
|
||||||
|
sh.tags = vec![t("", ""); 10_000];
|
||||||
|
slide.shapes.push(sh);
|
||||||
|
}
|
||||||
|
let bytes = deckcraft_pptx::export(&p).expect("export");
|
||||||
|
let back = deckcraft_pptx::import(&bytes).expect("import");
|
||||||
|
let pairs: usize = back.slides[0].shapes.iter().map(|x| x.tags.len()).sum();
|
||||||
|
assert!(pairs * pair <= deckcraft_pptx::MAX_TAG_BYTES, "{pairs} pairs copied");
|
||||||
|
assert!(pairs < shapes * 10_000, "the budget stopped the copying");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// #1164: once the budget is exhausted, further tags parts are not parsed or cached into
|
||||||
|
/// `tags_by_part`.
|
||||||
|
#[test]
|
||||||
|
fn new_tag_parts_are_not_parsed_or_cached_after_the_budget_is_exhausted() {
|
||||||
|
let mut s = Session::new();
|
||||||
|
deckcraft_engine::sample::open_sample(&mut s).expect("sample");
|
||||||
|
s.execute("shape.insert", &serde_json::json!({"preset": "rect", "rect": [10, 10, 50, 50]})).expect("shape");
|
||||||
|
let mut p = s.doc().expect("doc").doc.as_ref().clone();
|
||||||
|
let slide = Arc::make_mut(&mut p.slides[0]);
|
||||||
|
assert!(slide.shapes.len() >= 2);
|
||||||
|
// The first shape's tag fits the budget, and a second pair pushes it over the cap.
|
||||||
|
let pair = std::mem::size_of::<(String, String)>();
|
||||||
|
slide.shapes[0].tags = vec![t("BIG", &"x".repeat(deckcraft_pptx::MAX_TAG_BYTES - 3 - pair)), t("OVER", "1")];
|
||||||
|
// The second shape has its own separate tag part.
|
||||||
|
slide.shapes[1].tags = vec![t("SHAPE2_TAG", "dropped")];
|
||||||
|
let bytes = deckcraft_pptx::export(&p).expect("export");
|
||||||
|
|
||||||
|
// Corrupt the second shape's tag part (`tag2.xml`) with invalid XML in the zip.
|
||||||
|
// If it were parsed, XML loading would fail. Because the budget was capped, it is never read.
|
||||||
|
let mut zip = zip::ZipArchive::new(Cursor::new(bytes)).expect("zip");
|
||||||
|
let mut out = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||||
|
for i in 0..zip.len() {
|
||||||
|
let mut f = zip.by_index(i).expect("entry");
|
||||||
|
let name = f.name().to_string();
|
||||||
|
let mut data = vec![];
|
||||||
|
f.read_to_end(&mut data).expect("read");
|
||||||
|
out.start_file(&name, zip::write::SimpleFileOptions::default()).expect("start");
|
||||||
|
let payload = if name == "ppt/tags/tag2.xml" { b"<not valid xml<<<<" as &[u8] } else { &data };
|
||||||
|
std::io::Write::write_all(&mut out, payload).expect("write");
|
||||||
|
}
|
||||||
|
let corrupted = out.finish().expect("finish").into_inner();
|
||||||
|
|
||||||
|
let back = deckcraft_pptx::import(&corrupted).expect("import succeeds because tag2.xml is not parsed");
|
||||||
|
assert!(!back.slides[0].shapes[0].tags.is_empty(), "first shape tag was loaded");
|
||||||
|
assert!(back.slides[0].shapes[1].tags.is_empty(), "second shape tag was dropped and never parsed");
|
||||||
|
}
|
||||||
|
|||||||
@@ -94,6 +94,9 @@ mod tests;
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests_wild;
|
mod tests_wild;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests_effect_holes;
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod mt_tests {
|
mod mt_tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -1125,12 +1125,15 @@ impl Renderer {
|
|||||||
// see-through as its fill (none for an outline-only shape) …
|
// see-through as its fill (none for an outline-only shape) …
|
||||||
None => {
|
None => {
|
||||||
if let Some(a) = fill_alpha {
|
if let Some(a) = fill_alpha {
|
||||||
let mut filled = BezPath::new();
|
|
||||||
for sp in geo.paths.iter().filter(|sp| sp.fill != FillMode::None) {
|
|
||||||
filled.extend(sp.path.iter());
|
|
||||||
}
|
|
||||||
ctx.set_paint(color(c.with_alpha(255), a));
|
ctx.set_paint(color(c.with_alpha(255), a));
|
||||||
ctx.fill_path(&filled);
|
// Each subpath under its own fill rule, as the shape itself is
|
||||||
|
// drawn: one merged path filled NonZero fills the hole of an
|
||||||
|
// even-odd shape (a donut, a frame) (#1596).
|
||||||
|
for sp in geo.paths.iter().filter(|sp| sp.fill != FillMode::None) {
|
||||||
|
ctx.set_fill_rule(if sp.even_odd { peniko::Fill::EvenOdd } else { peniko::Fill::NonZero });
|
||||||
|
ctx.fill_path(&sp.path);
|
||||||
|
}
|
||||||
|
ctx.set_fill_rule(peniko::Fill::NonZero);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1161,7 +1164,19 @@ impl Renderer {
|
|||||||
ctx.stroke_path(&silhouette);
|
ctx.stroke_path(&silhouette);
|
||||||
// A shape without a fill (an outline, a frame) glows along its line only (#225).
|
// A shape without a fill (an outline, a frame) glows along its line only (#225).
|
||||||
if fill_alpha.is_some() || content.is_some() {
|
if fill_alpha.is_some() || content.is_some() {
|
||||||
ctx.fill_path(&silhouette);
|
// Per subpath under its own fill rule, as in the shadow above: the
|
||||||
|
// merged silhouette filled NonZero fills an even-odd hole (#1596).
|
||||||
|
// When no subpath takes part the silhouette is the outline fallback.
|
||||||
|
let mut filled_any = false;
|
||||||
|
for sp in geo.paths.iter().filter(|sp| sp.fill != FillMode::None || fill.is_none()) {
|
||||||
|
ctx.set_fill_rule(if sp.even_odd { peniko::Fill::EvenOdd } else { peniko::Fill::NonZero });
|
||||||
|
ctx.fill_path(&sp.path);
|
||||||
|
filled_any = true;
|
||||||
|
}
|
||||||
|
if !filled_any {
|
||||||
|
ctx.fill_path(&silhouette);
|
||||||
|
}
|
||||||
|
ctx.set_fill_rule(peniko::Fill::NonZero);
|
||||||
}
|
}
|
||||||
ctx.pop_layer();
|
ctx.pop_layer();
|
||||||
}
|
}
|
||||||
@@ -1180,7 +1195,20 @@ impl Renderer {
|
|||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
if let Some(fl) = fill {
|
if let Some(fl) = fill {
|
||||||
paint::fill_path(ctx, rctx, fl, ph, &silhouette, Rect::new(0.0, 0.0, w, h), mm, FillMode::Norm, 1.0, rctx.pres);
|
// Per subpath under its own fill rule, as in the shadow above:
|
||||||
|
// the merged silhouette filled NonZero fills an even-odd hole
|
||||||
|
// (#1596). The outline fallback covers shapes with no
|
||||||
|
// participating subpath.
|
||||||
|
let mut filled_any = false;
|
||||||
|
for sp in geo.paths.iter().filter(|sp| sp.fill != FillMode::None || fill.is_none()) {
|
||||||
|
ctx.set_fill_rule(if sp.even_odd { peniko::Fill::EvenOdd } else { peniko::Fill::NonZero });
|
||||||
|
paint::fill_path(ctx, rctx, fl, ph, &sp.path, Rect::new(0.0, 0.0, w, h), mm, FillMode::Norm, 1.0, rctx.pres);
|
||||||
|
filled_any = true;
|
||||||
|
}
|
||||||
|
if !filled_any {
|
||||||
|
paint::fill_path(ctx, rctx, fl, ph, &silhouette, Rect::new(0.0, 0.0, w, h), mm, FillMode::Norm, 1.0, rctx.pres);
|
||||||
|
}
|
||||||
|
ctx.set_fill_rule(peniko::Fill::NonZero);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -515,6 +515,56 @@ fn glow_of_a_shape_with_no_fill_and_no_line_is_not_drawn() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shadow_and_glow_keep_an_even_odd_shapes_hole() {
|
||||||
|
// #1596: a donut is one even-odd subpath — its hole exists only under the
|
||||||
|
// even-odd rule. The effects were cast from a merged silhouette filled
|
||||||
|
// NonZero, so the shadow and the glow filled the hole solid. The hole's
|
||||||
|
// centre must stay the slide's white in both, while the effects still draw.
|
||||||
|
let donut = |effects: deckcraft_model::Effects| {
|
||||||
|
let mut sh = Shape {
|
||||||
|
id: ShapeId(612),
|
||||||
|
xfrm: Some(Xfrm::new(50.0, 50.0, 200.0, 200.0)),
|
||||||
|
geom: Geom::preset("donut"),
|
||||||
|
fill: Some(Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(255, 160, 0)))),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
sh.effects = Some(effects);
|
||||||
|
render_slide(&deck_with(vec![sh]), 0, &RenderOpts { scale: 1.0, ..Default::default() })
|
||||||
|
};
|
||||||
|
let img = donut(deckcraft_model::Effects {
|
||||||
|
outer_shadow: Some(deckcraft_model::style::Shadow {
|
||||||
|
color: ColorRef::rgb(deckcraft_color::Rgba::BLACK),
|
||||||
|
blur: 8.0,
|
||||||
|
dist: 0.0,
|
||||||
|
dir: 45.0,
|
||||||
|
inner: false,
|
||||||
|
sx: 1.0,
|
||||||
|
sy: 1.0,
|
||||||
|
kx: 0.0,
|
||||||
|
ky: 0.0,
|
||||||
|
align: String::new(),
|
||||||
|
rotate_with_shape: false,
|
||||||
|
}),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
let hole = img.pixel(150, 150);
|
||||||
|
assert!(hole[0] > 245 && hole[1] > 245 && hole[2] > 245, "shadow in the hole: {hole:?}");
|
||||||
|
// The shadow still falls around the shape (just below its bottom edge).
|
||||||
|
let below = img.pixel(150, 251);
|
||||||
|
assert!(below[0] < 200 && below[0] == below[2], "no shadow below: {below:?}");
|
||||||
|
|
||||||
|
let img = donut(deckcraft_model::Effects {
|
||||||
|
glow: Some(deckcraft_model::style::Glow { color: ColorRef::rgb(deckcraft_color::Rgba::rgb(255, 0, 0)), radius: 8.0 }),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
let hole = img.pixel(150, 150);
|
||||||
|
assert!(hole[0] > 245 && hole[1] > 245 && hole[2] > 245, "glow in the hole: {hole:?}");
|
||||||
|
// The ring itself is the shape's orange.
|
||||||
|
let ring = img.pixel(150, 75);
|
||||||
|
assert!(ring[0] > 200 && ring[1] > 100 && ring[2] < 100, "ring: {ring:?}");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn placeholder_prompts_only_in_edit_view() {
|
fn placeholder_prompts_only_in_edit_view() {
|
||||||
let p = Presentation::default();
|
let p = Presentation::default();
|
||||||
|
|||||||
@@ -0,0 +1,196 @@
|
|||||||
|
//! Contributor-original pixel goldens for #1596 and the review of #1626.
|
||||||
|
//! The fixed samples cover background, hole/centre, ring, visible halos and reflected coverage.
|
||||||
|
|
||||||
|
use super::{Fill, Image, RenderOpts, Rgba, render_slide};
|
||||||
|
use deckcraft_model::style::{ColorRef, Effects, Glow, Line, Reflection, Shadow};
|
||||||
|
use deckcraft_model::{Geom, Presentation, Shape, ShapeId, Xfrm};
|
||||||
|
|
||||||
|
const PROBES: [(u32, u32); 9] = [
|
||||||
|
(20, 20), // Clear background.
|
||||||
|
(130, 130), // Clear donut hole; filled ellipse centre.
|
||||||
|
(130, 70), // Original ring/ellipse fill.
|
||||||
|
(130, 47), // Visible shadow/glow above the outer edge.
|
||||||
|
(130, 213), // Visible shadow/glow below the outer edge.
|
||||||
|
(130, 93), // Shadow/glow along the inner rim, inside the hole.
|
||||||
|
(130, 230), // Reflected upper ring.
|
||||||
|
(130, 300), // Reflected hole/ellipse centre.
|
||||||
|
(70, 300), // Reflected ring at the same fade as the hole.
|
||||||
|
];
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug)]
|
||||||
|
enum Effect {
|
||||||
|
Shadow,
|
||||||
|
Glow,
|
||||||
|
Reflection,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn scene(shape: &str, effect: Effect) -> Presentation {
|
||||||
|
let mut p = Presentation::default();
|
||||||
|
let mut effects = Effects::default();
|
||||||
|
match effect {
|
||||||
|
Effect::Shadow => {
|
||||||
|
effects.outer_shadow = Some(Shadow {
|
||||||
|
color: ColorRef::rgb(Rgba::BLACK),
|
||||||
|
blur: 8.0,
|
||||||
|
dist: 0.0,
|
||||||
|
dir: 45.0,
|
||||||
|
inner: false,
|
||||||
|
sx: 1.0,
|
||||||
|
sy: 1.0,
|
||||||
|
kx: 0.0,
|
||||||
|
ky: 0.0,
|
||||||
|
align: String::new(),
|
||||||
|
rotate_with_shape: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
Effect::Glow => effects.glow = Some(Glow { color: ColorRef::rgb(Rgba::rgb(255, 0, 0)), radius: 8.0 }),
|
||||||
|
Effect::Reflection => effects.reflection = Some(Reflection { blur: 0.0, start_alpha: 1.0, end_alpha: 0.0, end_pos: 1.0, dist: 10.0 }),
|
||||||
|
}
|
||||||
|
let slide = std::sync::Arc::make_mut(&mut p.slides[0]);
|
||||||
|
slide.background = Some(deckcraft_model::Background::Fill { fill: Fill::solid(ColorRef::rgb(Rgba::WHITE)) });
|
||||||
|
slide.shapes = vec![Shape {
|
||||||
|
id: ShapeId(1596),
|
||||||
|
xfrm: Some(Xfrm::new(50.0, 50.0, 160.0, 160.0)),
|
||||||
|
geom: Geom::preset(shape),
|
||||||
|
fill: Some(Fill::solid(ColorRef::rgb(Rgba::rgb(32, 96, 224)))),
|
||||||
|
line: Some(Line::none()),
|
||||||
|
effects: Some(effects),
|
||||||
|
..Default::default()
|
||||||
|
}];
|
||||||
|
p
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw(shape: &str, effect: Effect) -> Image {
|
||||||
|
let p = scene(shape, effect);
|
||||||
|
let image = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() });
|
||||||
|
if let Ok(dir) = std::env::var("DECKCRAFT_EFFECT_HOLES_QA") {
|
||||||
|
// Overwrite, so a QA directory can be reused across runs.
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
std::fs::write(std::path::Path::new(&dir).join(format!("{shape}-{effect:?}.png")), image.to_png()).unwrap();
|
||||||
|
}
|
||||||
|
image
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fixed contributor-original RGBA goldens; tolerate one final quantization step.
|
||||||
|
// The nonwhite halo/reflection samples also fail if an effect is disabled outright.
|
||||||
|
fn assert_golden(shape: &str, effect: Effect, expected: [[u8; 4]; 9]) {
|
||||||
|
let image = draw(shape, effect);
|
||||||
|
for ((x, y), want) in PROBES.into_iter().zip(expected) {
|
||||||
|
let got = image.pixel(x, y);
|
||||||
|
assert!(got.iter().zip(want).all(|(a, b)| (*a as i32 - b as i32).abs() <= 1), "{shape} {effect:?} at {x},{y}: {got:?} vs golden {want:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn donut_shadow_matches_golden() {
|
||||||
|
assert_golden(
|
||||||
|
"donut",
|
||||||
|
Effect::Shadow,
|
||||||
|
[
|
||||||
|
[255, 255, 255, 255],
|
||||||
|
[255, 255, 255, 255],
|
||||||
|
[32, 96, 224, 255],
|
||||||
|
[214, 214, 214, 255],
|
||||||
|
[233, 233, 233, 255],
|
||||||
|
[227, 227, 227, 255],
|
||||||
|
[255, 255, 255, 255],
|
||||||
|
[255, 255, 255, 255],
|
||||||
|
[255, 255, 255, 255],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn donut_glow_matches_golden() {
|
||||||
|
assert_golden(
|
||||||
|
"donut",
|
||||||
|
Effect::Glow,
|
||||||
|
[
|
||||||
|
[255, 255, 255, 255],
|
||||||
|
[255, 255, 255, 255],
|
||||||
|
[32, 96, 224, 255],
|
||||||
|
[255, 49, 49, 255],
|
||||||
|
[255, 111, 111, 255],
|
||||||
|
[255, 96, 96, 255],
|
||||||
|
[255, 255, 255, 255],
|
||||||
|
[255, 255, 255, 255],
|
||||||
|
[255, 255, 255, 255],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn donut_reflection_matches_golden() {
|
||||||
|
assert_golden(
|
||||||
|
"donut",
|
||||||
|
Effect::Reflection,
|
||||||
|
[
|
||||||
|
[255, 255, 255, 255],
|
||||||
|
[255, 255, 255, 255],
|
||||||
|
[32, 96, 224, 255],
|
||||||
|
[255, 255, 255, 255],
|
||||||
|
[255, 255, 255, 255],
|
||||||
|
[255, 255, 255, 255],
|
||||||
|
[130, 166, 238, 255],
|
||||||
|
[255, 255, 255, 255],
|
||||||
|
[189, 208, 246, 255],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ellipse_shadow_matches_golden() {
|
||||||
|
assert_golden(
|
||||||
|
"ellipse",
|
||||||
|
Effect::Shadow,
|
||||||
|
[
|
||||||
|
[255, 255, 255, 255],
|
||||||
|
[32, 96, 224, 255],
|
||||||
|
[32, 96, 224, 255],
|
||||||
|
[214, 214, 214, 255],
|
||||||
|
[233, 233, 233, 255],
|
||||||
|
[32, 96, 224, 255],
|
||||||
|
[255, 255, 255, 255],
|
||||||
|
[255, 255, 255, 255],
|
||||||
|
[255, 255, 255, 255],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ellipse_glow_matches_golden() {
|
||||||
|
assert_golden(
|
||||||
|
"ellipse",
|
||||||
|
Effect::Glow,
|
||||||
|
[
|
||||||
|
[255, 255, 255, 255],
|
||||||
|
[32, 96, 224, 255],
|
||||||
|
[32, 96, 224, 255],
|
||||||
|
[255, 49, 49, 255],
|
||||||
|
[255, 111, 111, 255],
|
||||||
|
[32, 96, 224, 255],
|
||||||
|
[255, 255, 255, 255],
|
||||||
|
[255, 255, 255, 255],
|
||||||
|
[255, 255, 255, 255],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ellipse_reflection_matches_golden() {
|
||||||
|
assert_golden(
|
||||||
|
"ellipse",
|
||||||
|
Effect::Reflection,
|
||||||
|
[
|
||||||
|
[255, 255, 255, 255],
|
||||||
|
[32, 96, 224, 255],
|
||||||
|
[32, 96, 224, 255],
|
||||||
|
[255, 255, 255, 255],
|
||||||
|
[255, 255, 255, 255],
|
||||||
|
[32, 96, 224, 255],
|
||||||
|
[130, 166, 238, 255],
|
||||||
|
[189, 208, 246, 255],
|
||||||
|
[189, 208, 246, 255],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -93,12 +93,24 @@ impl BoxGeom {
|
|||||||
} else {
|
} else {
|
||||||
text_rect
|
text_rect
|
||||||
};
|
};
|
||||||
let inner = Rect::new(
|
// Insets wider than the shape scale down proportionally to fit inside it (#1632).
|
||||||
r.x0 + bp.inset_l.unwrap_or(7.2),
|
let (mut ins_l, mut ins_r) = (bp.inset_l.unwrap_or(7.2), bp.inset_r.unwrap_or(7.2));
|
||||||
r.y0 + bp.inset_t.unwrap_or(3.6),
|
let (mut ins_t, mut ins_b) = (bp.inset_t.unwrap_or(3.6), bp.inset_b.unwrap_or(3.6));
|
||||||
(r.x1 - bp.inset_r.unwrap_or(7.2)).max(r.x0 + bp.inset_l.unwrap_or(7.2)),
|
let w = r.width().max(0.0);
|
||||||
(r.y1 - bp.inset_b.unwrap_or(3.6)).max(r.y0 + bp.inset_t.unwrap_or(3.6)),
|
let total_x = ins_l + ins_r;
|
||||||
);
|
if ins_l >= 0.0 && ins_r >= 0.0 && total_x > w && total_x > 0.0 {
|
||||||
|
let s = w / total_x;
|
||||||
|
ins_l *= s;
|
||||||
|
ins_r *= s;
|
||||||
|
}
|
||||||
|
let h = r.height().max(0.0);
|
||||||
|
let total_y = ins_t + ins_b;
|
||||||
|
if ins_t >= 0.0 && ins_b >= 0.0 && total_y > h && total_y > 0.0 {
|
||||||
|
let s = h / total_y;
|
||||||
|
ins_t *= s;
|
||||||
|
ins_b *= s;
|
||||||
|
}
|
||||||
|
let inner = Rect::new(r.x0 + ins_l, r.y0 + ins_t, (r.x1 - ins_r).max(r.x0 + ins_l), (r.y1 - ins_b).max(r.y0 + ins_t));
|
||||||
// Text that follows a WordArt arch or circle is set on one line per paragraph.
|
// Text that follows a WordArt arch or circle is set on one line per paragraph.
|
||||||
let follow = bp.warp.as_deref().is_some_and(deckcraft_geom::warp::is_follow);
|
let follow = bp.warp.as_deref().is_some_and(deckcraft_geom::warp::is_follow);
|
||||||
let wrap = bp.wrap.unwrap_or(true) && !follow && !matches!(bp.autofit, Some(AutoFit::Shape) if shape.text_box && !bp.wrap.unwrap_or(true));
|
let wrap = bp.wrap.unwrap_or(true) && !follow && !matches!(bp.autofit, Some(AutoFit::Shape) if shape.text_box && !bp.wrap.unwrap_or(true));
|
||||||
|
|||||||
@@ -1946,3 +1946,39 @@ fn a_paragraph_with_its_own_start_at_restarts_the_count_there() {
|
|||||||
// A later paragraph with a new start begins a fresh run.
|
// A later paragraph with a new start begins a fresh run.
|
||||||
check(&[1, 1, 7, 7], &[1, 2, 7, 8]);
|
check(&[1, 1, 7, 7], &[1, 2, 7, 8]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// #1632: when insets are wider than the shape, they scale down proportionally so the text
|
||||||
|
/// rectangle stays inside the shape bounds and unwrapped text does not project outside it.
|
||||||
|
#[test]
|
||||||
|
fn insets_wider_than_the_shape_scale_down_within_the_shape_bounds() {
|
||||||
|
let (p, mut sh) = setup("Collaboration Hub", 14.17, 14.17);
|
||||||
|
sh.ph = None;
|
||||||
|
let bp = &mut sh.text.as_mut().unwrap().body;
|
||||||
|
bp.wrap = Some(false);
|
||||||
|
bp.inset_l = Some(127.5);
|
||||||
|
bp.inset_r = Some(11.33);
|
||||||
|
bp.inset_t = Some(11.33);
|
||||||
|
bp.inset_b = Some(11.33);
|
||||||
|
sh.text.as_mut().unwrap().paragraphs[0].props = deckcraft_model::text::ParaProps { align: Some(Align::Center), ..Default::default() };
|
||||||
|
let l = lay(&p, &sh, 14.17, 14.17);
|
||||||
|
assert_eq!(l.lines.len(), 1);
|
||||||
|
let line = &l.lines[0];
|
||||||
|
let mid = (line.caret_x[0] + *line.caret_x.last().unwrap()) / 2.0;
|
||||||
|
// The text center must remain inside the 14.17 pt wide shape, not pushed ~127.5 pt to the right.
|
||||||
|
assert!((0.0..=14.17).contains(&mid), "line center {mid} should be within [0, 14.17]");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// #1632: signed insets (e.g. negative left inset with positive right inset) are preserved when
|
||||||
|
/// their sum fits within the shape bounds.
|
||||||
|
#[test]
|
||||||
|
fn signed_insets_are_preserved_when_they_fit() {
|
||||||
|
let (p, mut sh) = setup("Text", 100.0, 50.0);
|
||||||
|
sh.ph = None;
|
||||||
|
let bp = &mut sh.text.as_mut().unwrap().body;
|
||||||
|
bp.inset_l = Some(-10.0);
|
||||||
|
bp.inset_r = Some(10.0);
|
||||||
|
sh.text.as_mut().unwrap().paragraphs[0].props = deckcraft_model::text::ParaProps { align: Some(Align::Left), ..Default::default() };
|
||||||
|
let l = lay(&p, &sh, 100.0, 50.0);
|
||||||
|
assert_eq!(l.lines.len(), 1);
|
||||||
|
assert_eq!(l.inner.x0, -10.0);
|
||||||
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ pub mod icons;
|
|||||||
pub mod media;
|
pub mod media;
|
||||||
pub mod menus;
|
pub mod menus;
|
||||||
pub mod opening;
|
pub mod opening;
|
||||||
|
pub mod presenter;
|
||||||
pub mod ribbon;
|
pub mod ribbon;
|
||||||
pub mod settings;
|
pub mod settings;
|
||||||
pub mod show;
|
pub mod show;
|
||||||
|
|||||||
@@ -0,0 +1,450 @@
|
|||||||
|
//! Bounded presenter-window messages. The audience owns the show; the remote window
|
||||||
|
//! receives previews and sends input, never a second independently advancing show.
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
pub const MAX_PREVIEW_SIDE: usize = 640;
|
||||||
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct Preview {
|
||||||
|
pub width: usize,
|
||||||
|
pub height: usize,
|
||||||
|
pub rgba: Vec<u8>,
|
||||||
|
}
|
||||||
|
impl Preview {
|
||||||
|
pub fn from_image(image: &egui::ColorImage) -> Option<Self> {
|
||||||
|
let [w, h] = image.size;
|
||||||
|
if w == 0 || h == 0 || w.checked_mul(h)? != image.pixels.len() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let scale = (MAX_PREVIEW_SIDE as f64 / w.max(h) as f64).min(1.0);
|
||||||
|
let width = ((w as f64 * scale) as usize).max(1);
|
||||||
|
let height = ((h as f64 * scale) as usize).max(1);
|
||||||
|
let mut rgba = Vec::with_capacity(width * height * 4);
|
||||||
|
for y in 0..height {
|
||||||
|
for x in 0..width {
|
||||||
|
let pixel = image.pixels.get((y * h / height) * w + x * w / width)?;
|
||||||
|
rgba.extend_from_slice(&pixel.to_srgba_unmultiplied());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(Self { width, height, rgba })
|
||||||
|
}
|
||||||
|
pub fn image(&self) -> Option<egui::ColorImage> {
|
||||||
|
if self.width == 0
|
||||||
|
|| self.height == 0
|
||||||
|
|| self.width > MAX_PREVIEW_SIDE
|
||||||
|
|| self.height > MAX_PREVIEW_SIDE
|
||||||
|
|| self.width.checked_mul(self.height)?.checked_mul(4)? != self.rgba.len()
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(egui::ColorImage::from_rgba_unmultiplied([self.width, self.height], &self.rgba))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub struct Snapshot {
|
||||||
|
pub slide: usize,
|
||||||
|
pub total: usize,
|
||||||
|
pub ended: bool,
|
||||||
|
pub notes: String,
|
||||||
|
pub elapsed: String,
|
||||||
|
pub paused: bool,
|
||||||
|
pub notes_px: f32,
|
||||||
|
pub blank: Option<String>,
|
||||||
|
pub grid: bool,
|
||||||
|
pub zoom_armed: bool,
|
||||||
|
pub current: Option<Preview>,
|
||||||
|
pub next: Option<Preview>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Images acknowledged by a successful transport send, never merely attempted images.
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct DeltaCache {
|
||||||
|
current: Option<Preview>,
|
||||||
|
next: Option<Preview>,
|
||||||
|
token: String,
|
||||||
|
}
|
||||||
|
impl DeltaCache {
|
||||||
|
pub fn send<E>(
|
||||||
|
&mut self,
|
||||||
|
mut snapshot: Snapshot,
|
||||||
|
token: &str,
|
||||||
|
refresh: bool,
|
||||||
|
send: impl FnOnce(Snapshot, bool, bool) -> Result<(), E>,
|
||||||
|
) -> Result<(), E> {
|
||||||
|
let fresh = refresh || self.token != token;
|
||||||
|
let current_changed = fresh || self.current != snapshot.current;
|
||||||
|
let next_changed = fresh || self.next != snapshot.next;
|
||||||
|
let current = snapshot.current.clone();
|
||||||
|
let next = snapshot.next.clone();
|
||||||
|
if !current_changed {
|
||||||
|
snapshot.current = None;
|
||||||
|
}
|
||||||
|
if !next_changed {
|
||||||
|
snapshot.next = None;
|
||||||
|
}
|
||||||
|
send(snapshot, current_changed, next_changed)?;
|
||||||
|
self.current = current;
|
||||||
|
self.next = next;
|
||||||
|
self.token = token.to_owned();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub enum Command {
|
||||||
|
Key { name: String, command: bool, shift: bool, alt: bool, ctrl: bool },
|
||||||
|
Zoom { x: f32, y: f32 },
|
||||||
|
End,
|
||||||
|
}
|
||||||
|
/// Implemented by the browser host. Native hosts retain the existing egui viewport.
|
||||||
|
pub trait Host {
|
||||||
|
fn open(&mut self) -> Result<(), String>;
|
||||||
|
fn is_open(&self) -> bool;
|
||||||
|
fn commands(&mut self) -> Vec<Command>;
|
||||||
|
fn update(&mut self, snapshot: Snapshot);
|
||||||
|
fn close(&mut self);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The popup's independent egui renderer. It never advances a local show clock.
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct View {
|
||||||
|
fonts: crate::uifonts::UiFonts,
|
||||||
|
styled: bool,
|
||||||
|
current: Option<egui::TextureHandle>,
|
||||||
|
next: Option<egui::TextureHandle>,
|
||||||
|
previous_current: Option<Preview>,
|
||||||
|
previous_next: Option<Preview>,
|
||||||
|
}
|
||||||
|
impl View {
|
||||||
|
/// Install the same interface and dynamic fallback fonts as SlideApp before layout.
|
||||||
|
/// Font definitions take effect on the next pass; never forward input on a discarded pass.
|
||||||
|
pub fn prepare(&mut self, ctx: &egui::Context, notes: &str) -> bool {
|
||||||
|
if !self.styled {
|
||||||
|
crate::theme::install_fonts_with(ctx, &self.fonts.faces);
|
||||||
|
crate::theme::apply(ctx, &crate::theme::Tokens::dark());
|
||||||
|
self.styled = true;
|
||||||
|
ctx.request_discard("install presenter interface fonts before layout");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if ctx.will_discard() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
self.fonts.note(ctx, notes);
|
||||||
|
if self.fonts.apply(ctx) {
|
||||||
|
ctx.request_discard("install presenter note fallback fonts before layout");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn ui(&mut self, ui: &mut egui::Ui, snapshot: &Snapshot) -> Vec<Command> {
|
||||||
|
if !self.prepare(ui.ctx(), &snapshot.notes) {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
let mut commands = Vec::new();
|
||||||
|
let keys = ui.input(|i| {
|
||||||
|
i.events
|
||||||
|
.iter()
|
||||||
|
.filter_map(|event| if let egui::Event::Key { key, pressed: true, modifiers, .. } = event { Some((*key, *modifiers)) } else { None })
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
});
|
||||||
|
for (key, modifiers) in keys {
|
||||||
|
// Consume once before widgets see Space/Enter, avoiding a second button activation.
|
||||||
|
ui.input_mut(|i| i.consume_key(modifiers, key));
|
||||||
|
commands.push(Command::Key {
|
||||||
|
name: key.name().into(),
|
||||||
|
command: modifiers.command,
|
||||||
|
shift: modifiers.shift,
|
||||||
|
alt: modifiers.alt,
|
||||||
|
ctrl: modifiers.ctrl,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
fn update(
|
||||||
|
ctx: &egui::Context,
|
||||||
|
name: &str,
|
||||||
|
previous: &mut Option<Preview>,
|
||||||
|
texture: &mut Option<egui::TextureHandle>,
|
||||||
|
image: &Option<Preview>,
|
||||||
|
) {
|
||||||
|
if previous == image {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
*previous = image.clone();
|
||||||
|
*texture = image.as_ref().and_then(Preview::image).map(|image| ctx.load_texture(name, image, egui::TextureOptions::LINEAR));
|
||||||
|
}
|
||||||
|
update(ui.ctx(), "remote-current", &mut self.previous_current, &mut self.current, &snapshot.current);
|
||||||
|
update(ui.ctx(), "remote-next", &mut self.previous_next, &mut self.next, &snapshot.next);
|
||||||
|
let key = |name: &str| Command::Key { name: name.into(), command: false, shift: false, alt: false, ctrl: false };
|
||||||
|
ui.horizontal_wrapped(|ui| {
|
||||||
|
ui.heading(&snapshot.elapsed);
|
||||||
|
ui.label(format!("Slide {} of {}", snapshot.slide.saturating_add(1), snapshot.total));
|
||||||
|
for (label, name) in [("◀ Previous", "ArrowLeft"), ("Next ▶", "ArrowRight")] {
|
||||||
|
if ui.button(label).clicked() {
|
||||||
|
commands.push(key(name));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ui.button("End Show").clicked() {
|
||||||
|
commands.push(Command::End);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ui.horizontal_wrapped(|ui| {
|
||||||
|
for (label, name) in [
|
||||||
|
(if snapshot.paused { "Resume" } else { "Pause" }, "T"),
|
||||||
|
("Reset", "R"),
|
||||||
|
("Black", "B"),
|
||||||
|
("White", "W"),
|
||||||
|
("All slides", "G"),
|
||||||
|
("Zoom", "Z"),
|
||||||
|
] {
|
||||||
|
if ui.button(label).clicked() {
|
||||||
|
commands.push(key(name));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ui.separator();
|
||||||
|
ui.columns(2, |columns| {
|
||||||
|
let left = &mut columns[0];
|
||||||
|
if snapshot.ended {
|
||||||
|
left.heading("End of slide show");
|
||||||
|
} else if let Some(texture) = &self.current {
|
||||||
|
let response = left.add(
|
||||||
|
egui::Image::new(texture)
|
||||||
|
.fit_to_exact_size(fit_preview(texture.size_vec2(), left.available_width(), left.available_height() * 0.8))
|
||||||
|
.sense(egui::Sense::click()),
|
||||||
|
);
|
||||||
|
if let Some(blank) = &snapshot.blank {
|
||||||
|
let color = if blank == "white" { egui::Color32::WHITE } else { egui::Color32::BLACK };
|
||||||
|
left.painter().rect_filled(response.rect, 0.0, color);
|
||||||
|
}
|
||||||
|
if snapshot.zoom_armed
|
||||||
|
&& response.clicked()
|
||||||
|
&& let Some(point) = response.interact_pointer_pos()
|
||||||
|
{
|
||||||
|
commands.push(Command::Zoom {
|
||||||
|
x: (point.x - response.rect.left()) / response.rect.width().max(1.0),
|
||||||
|
y: (point.y - response.rect.top()) / response.rect.height().max(1.0),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if snapshot.zoom_armed {
|
||||||
|
left.label("Click the preview to zoom.");
|
||||||
|
}
|
||||||
|
if snapshot.grid {
|
||||||
|
left.label("Choose a slide in the audience window, or press G to return.");
|
||||||
|
}
|
||||||
|
let right = &mut columns[1];
|
||||||
|
right.label("Next");
|
||||||
|
if let Some(texture) = &self.next {
|
||||||
|
right.add(egui::Image::new(texture).fit_to_exact_size(fit_preview(
|
||||||
|
texture.size_vec2(),
|
||||||
|
right.available_width(),
|
||||||
|
right.available_height() * 0.45,
|
||||||
|
)));
|
||||||
|
} else {
|
||||||
|
right.label("End of slide show");
|
||||||
|
}
|
||||||
|
right.horizontal(|ui| {
|
||||||
|
ui.label("Notes");
|
||||||
|
if ui.button("A−").clicked() {
|
||||||
|
commands.push(key(egui::Key::OpenBracket.name()));
|
||||||
|
}
|
||||||
|
if ui.button("A+").clicked() {
|
||||||
|
commands.push(key(egui::Key::CloseBracket.name()));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
egui::ScrollArea::vertical().show(right, |ui| {
|
||||||
|
ui.label(
|
||||||
|
egui::RichText::new(if snapshot.notes.is_empty() { "No notes." } else { &snapshot.notes })
|
||||||
|
.size(snapshot.notes_px.clamp(10.0, 72.0)),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
commands
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn fit_preview(size: egui::Vec2, width: f32, height: f32) -> egui::Vec2 {
|
||||||
|
size * (width.max(1.0) / size.x.max(1.0)).min(height.max(1.0) / size.y.max(1.0))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn snapshot() -> Snapshot {
|
||||||
|
Snapshot {
|
||||||
|
slide: 0,
|
||||||
|
total: 2,
|
||||||
|
ended: false,
|
||||||
|
notes: String::new(),
|
||||||
|
elapsed: "00:00:00".into(),
|
||||||
|
paused: false,
|
||||||
|
notes_px: 18.0,
|
||||||
|
blank: None,
|
||||||
|
grid: false,
|
||||||
|
zoom_armed: false,
|
||||||
|
current: Some(Preview { width: 1, height: 1, rgba: vec![255; 4] }),
|
||||||
|
next: Some(Preview { width: 1, height: 1, rgba: vec![0; 4] }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn failed_transport_retries_images_and_only_success_deduplicates() {
|
||||||
|
let mut cache = DeltaCache::default();
|
||||||
|
let first = snapshot();
|
||||||
|
let failed = cache.send(first.clone(), "session", false, |delta, current, next| {
|
||||||
|
assert!(current && next);
|
||||||
|
assert_eq!(delta.current, first.current);
|
||||||
|
assert_eq!(delta.next, first.next);
|
||||||
|
Err("serialization or postMessage failed")
|
||||||
|
});
|
||||||
|
assert!(failed.is_err());
|
||||||
|
let sent = cache.send(first.clone(), "session", false, |delta, current, next| {
|
||||||
|
assert!(current && next, "a failed initial send must not suppress either image");
|
||||||
|
assert_eq!(delta.current, first.current);
|
||||||
|
assert_eq!(delta.next, first.next);
|
||||||
|
Ok::<_, ()>(())
|
||||||
|
});
|
||||||
|
assert!(sent.is_ok());
|
||||||
|
let sent = cache.send(first.clone(), "session", false, |delta, current, next| {
|
||||||
|
assert!(!current && !next);
|
||||||
|
assert!(delta.current.is_none() && delta.next.is_none());
|
||||||
|
Ok::<_, ()>(())
|
||||||
|
});
|
||||||
|
assert!(sent.is_ok());
|
||||||
|
let mut changed = first.clone();
|
||||||
|
changed.current.as_mut().unwrap().rgba = vec![42; 4];
|
||||||
|
let failed = cache.send(changed.clone(), "session", false, |_, current, next| {
|
||||||
|
assert!(current && !next);
|
||||||
|
Err(())
|
||||||
|
});
|
||||||
|
assert!(failed.is_err());
|
||||||
|
let sent = cache.send(changed.clone(), "session", false, |delta, current, next| {
|
||||||
|
assert!(current && !next, "unchanged retry after a failed changed-image send");
|
||||||
|
assert_eq!(delta.current, changed.current);
|
||||||
|
Ok::<_, ()>(())
|
||||||
|
});
|
||||||
|
assert!(sent.is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn transport_refreshes_new_sessions_reloads_and_clears_previews() {
|
||||||
|
let mut cache = DeltaCache::default();
|
||||||
|
for (token, refresh) in [("first", false), ("second", false), ("second", true)] {
|
||||||
|
let sent = cache.send(snapshot(), token, refresh, |delta, current, next| {
|
||||||
|
assert!(current && next);
|
||||||
|
assert!(delta.current.is_some() && delta.next.is_some());
|
||||||
|
Ok::<_, ()>(())
|
||||||
|
});
|
||||||
|
assert!(sent.is_ok());
|
||||||
|
}
|
||||||
|
let failed = cache.send(snapshot(), "second", true, |_, current, next| {
|
||||||
|
assert!(current && next);
|
||||||
|
Err(())
|
||||||
|
});
|
||||||
|
// BrowserHost retains refresh_images after failure, even when the token is unchanged.
|
||||||
|
let refresh_images = failed.is_err();
|
||||||
|
let sent = cache.send(snapshot(), "second", refresh_images, |_, current, next| {
|
||||||
|
assert!(current && next, "reload refresh must survive a failed send");
|
||||||
|
Ok::<_, ()>(())
|
||||||
|
});
|
||||||
|
assert!(sent.is_ok());
|
||||||
|
let mut cleared = snapshot();
|
||||||
|
cleared.current = None;
|
||||||
|
cleared.next = None;
|
||||||
|
let failed = cache.send(cleared.clone(), "second", false, |_, current, next| {
|
||||||
|
assert!(current && next);
|
||||||
|
Err(())
|
||||||
|
});
|
||||||
|
assert!(failed.is_err());
|
||||||
|
let sent = cache.send(cleared.clone(), "second", false, |delta, current, next| {
|
||||||
|
assert!(current && next, "clearing images is retried too");
|
||||||
|
assert!(delta.current.is_none() && delta.next.is_none());
|
||||||
|
Ok::<_, ()>(())
|
||||||
|
});
|
||||||
|
assert!(sent.is_ok());
|
||||||
|
let sent = cache.send(cleared, "second", false, |_, current, next| {
|
||||||
|
assert!(!current && !next);
|
||||||
|
Ok::<_, ()>(())
|
||||||
|
});
|
||||||
|
assert!(sent.is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn popup_installs_interface_fonts_before_non_latin_note_layout() {
|
||||||
|
let ctx = egui::Context::default();
|
||||||
|
let mut view = View::default();
|
||||||
|
let mut data = snapshot();
|
||||||
|
let arabic = "\u{645}\u{631}\u{62d}\u{628}\u{627}";
|
||||||
|
let japanese = "\u{65e5}\u{672c}\u{8a9e}";
|
||||||
|
let korean = "\u{d55c}\u{ae00}";
|
||||||
|
data.notes = format!("{arabic} {japanese} {korean}");
|
||||||
|
let mut installing = 0;
|
||||||
|
let mut layouts = 0;
|
||||||
|
for _ in 0..4 {
|
||||||
|
let mut output = ctx.run_ui(egui::RawInput::default(), |ui| {
|
||||||
|
if !view.prepare(ui.ctx(), &data.notes) {
|
||||||
|
installing += 1;
|
||||||
|
assert!(view.ui(ui, &data).is_empty(), "no commands during a discarded font-install pass");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
assert!(!ui.ctx().will_discard());
|
||||||
|
layouts += 1;
|
||||||
|
for (family, text) in [("Noto Sans Arabic", arabic), ("BIZ UDPGothic", japanese), ("NanumGothic", korean)] {
|
||||||
|
if deckcraft_fonts::CRAFT_FONTS.iter().any(|face| face.family == family) {
|
||||||
|
ui.ctx().fonts_mut(|fonts| {
|
||||||
|
for c in text.chars() {
|
||||||
|
assert!(fonts.has_glyph(&egui::FontId::proportional(18.0), c), "missing bundled {family} glyph {c:?}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let _ = view.ui(ui, &data);
|
||||||
|
});
|
||||||
|
output.textures_delta.clear();
|
||||||
|
}
|
||||||
|
assert!(installing > 0 && layouts > 0, "font installation must precede an actual notes layout");
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn popup_input_is_forwarded_once_without_advancing_its_own_state() {
|
||||||
|
use egui_kittest::kittest::Queryable;
|
||||||
|
let snapshot = Snapshot {
|
||||||
|
slide: 0,
|
||||||
|
total: 3,
|
||||||
|
ended: false,
|
||||||
|
notes: "Synthetic notes".into(),
|
||||||
|
elapsed: "00:00:00".into(),
|
||||||
|
paused: false,
|
||||||
|
notes_px: 18.0,
|
||||||
|
blank: None,
|
||||||
|
grid: false,
|
||||||
|
zoom_armed: false,
|
||||||
|
current: None,
|
||||||
|
next: None,
|
||||||
|
};
|
||||||
|
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1000.0, 700.0)).build_ui_state(
|
||||||
|
|ui, (view, snapshot, sent): &mut (View, Snapshot, Vec<Command>)| {
|
||||||
|
sent.extend(view.ui(ui, snapshot));
|
||||||
|
},
|
||||||
|
(View::default(), snapshot, Vec::new()),
|
||||||
|
);
|
||||||
|
h.run_steps(2);
|
||||||
|
h.get_by_label("Next ▶").click();
|
||||||
|
h.run_steps(2);
|
||||||
|
assert_eq!(h.state().2.len(), 1);
|
||||||
|
h.key_press(egui::Key::Space);
|
||||||
|
h.run_steps(2);
|
||||||
|
assert_eq!(h.state().2.len(), 2, "Space must not also activate the focused Next button");
|
||||||
|
assert_eq!(h.state().0.previous_current, None);
|
||||||
|
assert_eq!(h.state().1.slide, 0, "only an audience snapshot changes the displayed position");
|
||||||
|
assert!(matches!(h.state().2.last(), Some(Command::Key { name, .. }) if name == egui::Key::Space.name()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn previews_are_bounded_and_bad_dimensions_are_rejected() {
|
||||||
|
let image = egui::ColorImage::filled([1280, 720], egui::Color32::RED);
|
||||||
|
let p = Preview::from_image(&image).unwrap();
|
||||||
|
assert_eq!((p.width, p.height), (640, 360));
|
||||||
|
assert_eq!(p.image().unwrap().pixels[0], egui::Color32::RED);
|
||||||
|
for (width, height, rgba) in [(usize::MAX, 1, vec![]), (0, 1, vec![]), (1, 1, vec![0; 3]), (641, 1, vec![0; 2564])] {
|
||||||
|
assert!(Preview { width, height, rgba }.image().is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,6 +36,8 @@ pub type Inbox = std::sync::Arc<std::sync::Mutex<Vec<InboxEvent>>>;
|
|||||||
/// Platform services injected by the host (desktop or web).
|
/// Platform services injected by the host (desktop or web).
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
pub struct Services {
|
pub struct Services {
|
||||||
|
/// Browser-owned presenter popup; absent on native hosts.
|
||||||
|
pub presenter: Option<Box<dyn crate::presenter::Host>>,
|
||||||
/// Open-file dialog for a purpose (`open`, `picture`, `audio`, `video`) → path.
|
/// Open-file dialog for a purpose (`open`, `picture`, `audio`, `video`) → path.
|
||||||
pub pick_open: Option<PickFn>,
|
pub pick_open: Option<PickFn>,
|
||||||
/// Save dialog with a suggested name → path.
|
/// Save dialog with a suggested name → path.
|
||||||
|
|||||||
@@ -63,6 +63,10 @@ pub struct Show {
|
|||||||
/// Preview renders started on another thread.
|
/// Preview renders started on another thread.
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
preview_spawns: usize,
|
preview_spawns: usize,
|
||||||
|
remote_preview: Option<crate::presenter::Preview>,
|
||||||
|
remote_next: Option<(usize, crate::presenter::Preview)>,
|
||||||
|
remote_sent: f64,
|
||||||
|
remote_opened: bool,
|
||||||
/// The next slide's first frame, rendered in the background while this one is shown so
|
/// The next slide's first frame, rendered in the background while this one is shown so
|
||||||
/// advancing only uploads it (#240).
|
/// advancing only uploads it (#240).
|
||||||
prefetch: Option<Prefetch>,
|
prefetch: Option<Prefetch>,
|
||||||
@@ -231,6 +235,10 @@ impl Show {
|
|||||||
next_job: None,
|
next_job: None,
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
preview_spawns: 0,
|
preview_spawns: 0,
|
||||||
|
remote_preview: None,
|
||||||
|
remote_next: None,
|
||||||
|
remote_sent: f64::NEG_INFINITY,
|
||||||
|
remote_opened: false,
|
||||||
prefetch: None,
|
prefetch: None,
|
||||||
backdrop: None,
|
backdrop: None,
|
||||||
blank: None,
|
blank: None,
|
||||||
|
|||||||
@@ -12,6 +12,22 @@ pub(super) fn keyboard(ctx: &egui::Context, show: &mut Show, doc: &Presentation,
|
|||||||
.filter_map(|e| if let egui::Event::Key { key, pressed: true, modifiers, .. } = e { Some((*key, *modifiers)) } else { None })
|
.filter_map(|e| if let egui::Event::Key { key, pressed: true, modifiers, .. } = e { Some((*key, *modifiers)) } else { None })
|
||||||
.collect()
|
.collect()
|
||||||
});
|
});
|
||||||
|
let (keep, consumed) = handle_keys(show, doc, now, keys, presenter);
|
||||||
|
for (key, modifiers) in consumed {
|
||||||
|
ctx.input_mut(|i| i.consume_key(modifiers, key));
|
||||||
|
ctx.request_repaint_of(egui::ViewportId::ROOT);
|
||||||
|
}
|
||||||
|
keep
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn handle_keys(
|
||||||
|
show: &mut Show,
|
||||||
|
doc: &Presentation,
|
||||||
|
now: f64,
|
||||||
|
keys: Vec<(egui::Key, egui::Modifiers)>,
|
||||||
|
presenter: bool,
|
||||||
|
) -> (bool, Vec<(egui::Key, egui::Modifiers)>) {
|
||||||
|
let mut consumed = vec![];
|
||||||
let mut keep = true;
|
let mut keep = true;
|
||||||
let mut actions = vec![];
|
let mut actions = vec![];
|
||||||
for (k, m) in keys {
|
for (k, m) in keys {
|
||||||
@@ -31,15 +47,14 @@ pub(super) fn keyboard(ctx: &egui::Context, show: &mut Show, doc: &Presentation,
|
|||||||
}
|
}
|
||||||
// The same key must not also activate a focused presenter button or be handled again
|
// The same key must not also activate a focused presenter button or be handled again
|
||||||
// by an embedded presenter viewport.
|
// by an embedded presenter viewport.
|
||||||
ctx.input_mut(|i| i.consume_key(m, k));
|
consumed.push((k, m));
|
||||||
ctx.request_repaint_of(egui::ViewportId::ROOT);
|
|
||||||
}
|
}
|
||||||
for action in actions {
|
for action in actions {
|
||||||
if !show.apply(action, doc, now) {
|
if !show.apply(action, doc, now) {
|
||||||
keep = false;
|
keep = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
keep
|
(keep, consumed)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether the grid that the presenter key G opens is showing in this viewport.
|
/// Whether the grid that the presenter key G opens is showing in this viewport.
|
||||||
|
|||||||
@@ -1123,6 +1123,66 @@ fn input_state_tracks_the_modes_that_move_show_click_targets() {
|
|||||||
assert_ne!(show.input_state(), base, "a blank screen has no slide click targets");
|
assert_ne!(show.input_state(), base, "a blank screen has no slide click targets");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn remote_presenter_uses_the_audience_state_and_closes_with_the_show() {
|
||||||
|
use crate::presenter::{Command, Host, Snapshot};
|
||||||
|
use std::{cell::RefCell, rc::Rc};
|
||||||
|
#[derive(Default)]
|
||||||
|
struct Bridge {
|
||||||
|
opens: usize,
|
||||||
|
closes: usize,
|
||||||
|
commands: Vec<Command>,
|
||||||
|
}
|
||||||
|
struct Mock(Rc<RefCell<Bridge>>);
|
||||||
|
impl Host for Mock {
|
||||||
|
fn open(&mut self) -> Result<(), String> {
|
||||||
|
self.0.borrow_mut().opens += 1;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
fn is_open(&self) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
fn commands(&mut self) -> Vec<Command> {
|
||||||
|
std::mem::take(&mut self.0.borrow_mut().commands)
|
||||||
|
}
|
||||||
|
fn update(&mut self, _: Snapshot) {}
|
||||||
|
fn close(&mut self) {
|
||||||
|
self.0.borrow_mut().closes += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let bridge = Rc::new(RefCell::new(Bridge::default()));
|
||||||
|
let mut session = deckcraft_engine::Session::with_new();
|
||||||
|
session.execute("slide.new", &json!({})).unwrap();
|
||||||
|
session.execute("slide.new", &json!({})).unwrap();
|
||||||
|
let services = crate::Services { presenter: Some(Box::new(Mock(bridge.clone()))), ..Default::default() };
|
||||||
|
let mut app = SlideApp::new(session, services);
|
||||||
|
app.ui.presenter_view = true;
|
||||||
|
let mut h = egui_kittest::Harness::builder().with_size(vec2(1000.0, 700.0)).build_ui_state(
|
||||||
|
|ui, app: &mut SlideApp| {
|
||||||
|
app.logic(ui.ctx());
|
||||||
|
app.ui(ui);
|
||||||
|
},
|
||||||
|
app,
|
||||||
|
);
|
||||||
|
h.run_steps(3);
|
||||||
|
h.state_mut().start_show(0, false);
|
||||||
|
h.run_steps(2);
|
||||||
|
assert_eq!(bridge.borrow().opens, 1);
|
||||||
|
for (key, slide) in [(egui::Key::ArrowRight, 1), (egui::Key::ArrowLeft, 0), (egui::Key::End, 2), (egui::Key::Home, 0)] {
|
||||||
|
bridge.borrow_mut().commands.push(Command::Key { name: key.name().into(), command: false, shift: false, alt: false, ctrl: false });
|
||||||
|
h.run_steps(2);
|
||||||
|
assert_eq!(h.state().show.as_ref().unwrap().state.slide, slide);
|
||||||
|
}
|
||||||
|
bridge.borrow_mut().commands.push(Command::End);
|
||||||
|
h.run_steps(2);
|
||||||
|
assert!(h.state().show.is_none());
|
||||||
|
assert_eq!(bridge.borrow().closes, 1);
|
||||||
|
h.state_mut().start_show(1, false);
|
||||||
|
h.run_steps(2);
|
||||||
|
assert_eq!(bridge.borrow().opens, 2);
|
||||||
|
assert_eq!(h.state().show.as_ref().unwrap().state.slide, 1);
|
||||||
|
}
|
||||||
|
|
||||||
/// #964: the next-slide preview renders on another thread; the presenter frame that asks for
|
/// #964: the next-slide preview renders on another thread; the presenter frame that asks for
|
||||||
/// it doesn't wait, and the preview arrives on a later frame.
|
/// it doesn't wait, and the preview arrives on a later frame.
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
use super::keys::keyboard;
|
use super::keys::{handle_keys, keyboard};
|
||||||
use super::media::{
|
use super::media::{
|
||||||
Pick, Under, gif_hit, media_bar_target, media_hit, on_screen, pointer_on_media_bar, run_media_cues, shapes_under_pointer, show_media,
|
Pick, Under, gif_hit, media_bar_target, media_hit, on_screen, pointer_on_media_bar, run_media_cues, shapes_under_pointer, show_media,
|
||||||
};
|
};
|
||||||
use super::presenter::{PresenterTimer, presenter_window};
|
use super::presenter::{PresenterTimer, format_elapsed, presenter_window};
|
||||||
use super::render::{below_playing_video, fit, part_textures, quad_mesh, render_frame, render_overlay, render_threads, start_prefetch};
|
use super::render::{below_playing_video, fit, part_textures, quad_mesh, render_frame, render_overlay, render_threads, start_prefetch};
|
||||||
use super::zoom::{
|
use super::zoom::{
|
||||||
Zoom, grid_indices, norm_on_slide, paint_grid, see_all_hit, slide_aspect, slide_box, slide_to_screen, toggle_blank, tool_bar_rect, unit_uv,
|
Zoom, grid_indices, norm_on_slide, paint_grid, see_all_hit, slide_aspect, slide_box, slide_to_screen, toggle_blank, tool_bar_rect, unit_uv,
|
||||||
@@ -58,6 +58,9 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) {
|
|||||||
let ctx = ui.ctx().clone();
|
let ctx = ui.ctx().clone();
|
||||||
let Some((doc_uid, doc)) = app.session.active().map(|d| (d.uid, d.doc.clone())) else {
|
let Some((doc_uid, doc)) = app.session.active().map(|d| (d.uid, d.doc.clone())) else {
|
||||||
app.show = None;
|
app.show = None;
|
||||||
|
if let Some(host) = app.services.presenter.as_mut() {
|
||||||
|
host.close();
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let now = ctx.input(|i| i.time);
|
let now = ctx.input(|i| i.time);
|
||||||
@@ -71,8 +74,35 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
show.observe_visible_frame(now, web_time::Instant::now());
|
show.observe_visible_frame(now, web_time::Instant::now());
|
||||||
begin_show(&ctx, &mut show, &doc, now);
|
begin_show(&ctx, &mut show, &doc, now, app.services.presenter.is_some());
|
||||||
let mut out = Out { keep: keyboard(&ctx, &mut show, &doc, now, false), actions: vec![] };
|
let mut out = Out { keep: keyboard(&ctx, &mut show, &doc, now, false), actions: vec![] };
|
||||||
|
if show.presenter
|
||||||
|
&& !show.reading
|
||||||
|
&& let Some(host) = app.services.presenter.as_mut()
|
||||||
|
{
|
||||||
|
if !show.remote_opened {
|
||||||
|
show.remote_opened = true;
|
||||||
|
if let Err(message) = host.open() {
|
||||||
|
app.status = Some((message, crate::now_ms()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for command in host.commands() {
|
||||||
|
match command {
|
||||||
|
crate::presenter::Command::End => out.keep = false,
|
||||||
|
crate::presenter::Command::Zoom { x, y } if x.is_finite() && y.is_finite() && show.zoom_arm => {
|
||||||
|
show.zoom = Some(Zoom::at(x.clamp(0.0, 1.0), y.clamp(0.0, 1.0)));
|
||||||
|
show.zoom_arm = false;
|
||||||
|
}
|
||||||
|
crate::presenter::Command::Key { name, command, shift, alt, ctrl } => {
|
||||||
|
if let Some(key) = egui::Key::ALL.iter().copied().find(|key| key.name() == name) {
|
||||||
|
let modifiers = egui::Modifiers { command, shift, alt, ctrl, mac_cmd: command && !ctrl };
|
||||||
|
out.keep &= handle_keys(&mut show, &doc, now, vec![(key, modifiers)], false).0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
let (clicked, secondary, moved) = ctx.input(|i| (i.pointer.primary_clicked(), i.pointer.secondary_clicked(), i.pointer.delta().length() > 0.0));
|
let (clicked, secondary, moved) = ctx.input(|i| (i.pointer.primary_clicked(), i.pointer.secondary_clicked(), i.pointer.delta().length() > 0.0));
|
||||||
if moved {
|
if moved {
|
||||||
show.last_move = now;
|
show.last_move = now;
|
||||||
@@ -94,6 +124,43 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) {
|
|||||||
draw_pen_and_hints(ui, &mut show, &f, &painter, pointing);
|
draw_pen_and_hints(ui, &mut show, &f, &painter, pointing);
|
||||||
draw_bars(ui, &mut show, &f, &painter, &mut out);
|
draw_bars(ui, &mut show, &f, &painter, &mut out);
|
||||||
draw_extras(&mut show, &f, &painter, &mut out);
|
draw_extras(&mut show, &f, &painter, &mut out);
|
||||||
|
if out.keep
|
||||||
|
&& show.presenter
|
||||||
|
&& !show.reading
|
||||||
|
&& let Some(host) = app.services.presenter.as_mut()
|
||||||
|
{
|
||||||
|
if now - show.remote_sent >= 0.2 {
|
||||||
|
let next = show.state.peek_next(&doc);
|
||||||
|
if let Some(n) = next
|
||||||
|
&& show.remote_next.as_ref().is_none_or(|(old, _)| *old != n)
|
||||||
|
{
|
||||||
|
let size =
|
||||||
|
crate::textures::fit_size((480, (480.0 * doc.slide_size.height / doc.slide_size.width.max(1.0)).clamp(1.0, 1e9) as u32), 640);
|
||||||
|
let image = deckcraft_render::render_slide(
|
||||||
|
&doc,
|
||||||
|
n,
|
||||||
|
&RenderOpts { scale: size.0 as f64 / doc.slide_size.width.max(1.0), size: Some(size), ..Default::default() },
|
||||||
|
);
|
||||||
|
show.remote_next = crate::presenter::Preview::from_image(&crate::textures::to_color_image(&image, false)).map(|image| (n, image));
|
||||||
|
}
|
||||||
|
host.update(crate::presenter::Snapshot {
|
||||||
|
slide: show.state.slide,
|
||||||
|
total: doc.slides.len(),
|
||||||
|
ended: show.ended,
|
||||||
|
notes: doc.slides.get(show.state.slide).map(|s| s.notes_text()).unwrap_or_default(),
|
||||||
|
elapsed: format_elapsed(show.timer.elapsed(now)),
|
||||||
|
paused: show.timer.paused(),
|
||||||
|
notes_px: show.notes_px,
|
||||||
|
blank: show.blank_name().map(str::to_owned),
|
||||||
|
grid: show.grid,
|
||||||
|
zoom_armed: show.zoom_arm,
|
||||||
|
current: show.remote_preview.clone(),
|
||||||
|
next: show.remote_next.as_ref().filter(|(n, _)| Some(*n) == next).map(|(_, image)| image.clone()),
|
||||||
|
});
|
||||||
|
show.remote_sent = now;
|
||||||
|
}
|
||||||
|
ctx.request_repaint_after(std::time::Duration::from_millis(200));
|
||||||
|
}
|
||||||
finish(app, show, &f, out.keep);
|
finish(app, show, &f, out.keep);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,7 +177,7 @@ fn paint_hidden(ui: &Ui, doc: &Presentation, show: &Show) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn begin_show(ctx: &egui::Context, show: &mut Show, doc: &Presentation, now: f64) {
|
fn begin_show(ctx: &egui::Context, show: &mut Show, doc: &Presentation, now: f64, remote: bool) {
|
||||||
if !show.started {
|
if !show.started {
|
||||||
show.started = true;
|
show.started = true;
|
||||||
show.slide_start = now;
|
show.slide_start = now;
|
||||||
@@ -121,7 +188,7 @@ fn begin_show(ctx: &egui::Context, show: &mut Show, doc: &Presentation, now: f64
|
|||||||
show.show_start = now;
|
show.show_start = now;
|
||||||
show.timer = PresenterTimer::start(now);
|
show.timer = PresenterTimer::start(now);
|
||||||
show.last_move = now;
|
show.last_move = now;
|
||||||
if show_fills_screen(show.reading, &doc.show.show_type) {
|
if show_fills_screen(show.reading, &doc.show.show_type) && !(show.presenter && remote) {
|
||||||
ctx.send_viewport_cmd(egui::ViewportCommand::Fullscreen(true));
|
ctx.send_viewport_cmd(egui::ViewportCommand::Fullscreen(true));
|
||||||
}
|
}
|
||||||
show.after_enter(doc, now);
|
show.after_enter(doc, now);
|
||||||
@@ -303,6 +370,9 @@ fn frame_texture(show: &mut Show, f: &Fr, sl: &Sl) -> Option<TextureHandle> {
|
|||||||
render_frame(doc, idx, tl, step, sl.t, size, sl.hidden.clone(), &sl.times, render_threads(), &sl.plays, &sl.stamps, back.as_deref())
|
render_frame(doc, idx, tl, step, sl.t, size, sl.hidden.clone(), &sl.times, render_threads(), &sl.plays, &sl.stamps, back.as_deref())
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
if show.remote_opened && show.presenter {
|
||||||
|
show.remote_preview = crate::presenter::Preview::from_image(&ci);
|
||||||
|
}
|
||||||
let tex = match show.tex.take() {
|
let tex = match show.tex.take() {
|
||||||
Some((_, mut h)) => {
|
Some((_, mut h)) => {
|
||||||
crate::textures::replace(ctx, &mut h, ci);
|
crate::textures::replace(ctx, &mut h, ci);
|
||||||
@@ -730,7 +800,7 @@ fn draw_extras(show: &mut Show, f: &Fr, painter: &egui::Painter, out: &mut Out)
|
|||||||
ctx.request_repaint_after(std::time::Duration::from_millis(100));
|
ctx.request_repaint_after(std::time::Duration::from_millis(100));
|
||||||
}
|
}
|
||||||
// Presenter view in a second window.
|
// Presenter view in a second window.
|
||||||
if out.keep && show.presenter && !show.reading {
|
if out.keep && show.presenter && !show.reading && !show.remote_opened {
|
||||||
presenter_window(ctx, show, doc, now, &mut out.keep);
|
presenter_window(ctx, show, doc, now, &mut out.keep);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -740,6 +810,9 @@ fn finish(app: &mut SlideApp, mut show: Show, f: &Fr, keep: bool) {
|
|||||||
if keep {
|
if keep {
|
||||||
app.show = Some(show);
|
app.show = Some(show);
|
||||||
} else {
|
} else {
|
||||||
|
if let Some(host) = app.services.presenter.as_mut() {
|
||||||
|
host.close();
|
||||||
|
}
|
||||||
app.media.stop_owned(crate::media::Owner::Show);
|
app.media.stop_owned(crate::media::Owner::Show);
|
||||||
if !show.reading {
|
if !show.reading {
|
||||||
ctx.send_viewport_cmd(egui::ViewportCommand::Fullscreen(false));
|
ctx.send_viewport_cmd(egui::ViewportCommand::Fullscreen(false));
|
||||||
|
|||||||
@@ -999,7 +999,8 @@ fn the_language_dialog_marks_the_selected_text() {
|
|||||||
harness.state_mut().run("app.dialog", json!({"id": "language"})).unwrap();
|
harness.state_mut().run("app.dialog", json!({"id": "language"})).unwrap();
|
||||||
// The anchored window settles over its first frames; click once it stays put.
|
// The anchored window settles over its first frames; click once it stays put.
|
||||||
harness.run_steps(4);
|
harness.run_steps(4);
|
||||||
harness.get_by_label("Français (France)").click();
|
// Installed dictionaries append a marker to the language's accessible label.
|
||||||
|
harness.get_by_label_contains("Français (France)").click();
|
||||||
harness.run_steps(2);
|
harness.run_steps(2);
|
||||||
harness.get_by_label("OK").click();
|
harness.get_by_label("OK").click();
|
||||||
harness.run_steps(2);
|
harness.run_steps(2);
|
||||||
|
|||||||
+2
-1
@@ -1,6 +1,6 @@
|
|||||||
# Where DeckCraft falls short of PowerPoint
|
# Where DeckCraft falls short of PowerPoint
|
||||||
|
|
||||||
> **Last reviewed:** 2026-10-30 · **Last updated:** 2026-10-30 · **Change:** minor (slide-show hit testing follows each shape’s own animation state; scale and rotation covered; previous: trivial, AutoFormat lists run independently of Replace text as you type; previous: minor, slide-show click targets follow animated shapes and text links; previous: minor, Animation Pane ▸ Play From; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: soak tests, PPTX corpus, comment threads (#70), embedded fonts, SmartArt Text Pane, Animation Pane timeline, Hunspell spelling, thesaurus, AutoCorrect, 14 new dialogs, pen pressure and trackpad gestures; duplicated entries and headers folded; previous: minor, 3-D bevel, camera and face lighting rendered) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
> **Last reviewed:** 2026-10-30 · **Last updated:** 2026-11-01 · **Change:** trivial (locked No Fill and no-outline click-through regression coverage added; No Fill hit-testing follows resolved fill instead of the presence of a style (#1621); scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||||
|
|
||||||
Every known shortfall, one entry each, ranked by how much it stops someone who presents with
|
Every known shortfall, one entry each, ranked by how much it stops someone who presents with
|
||||||
PowerPoint from switching. This is the work list: pick from the top. Numbers are summarized in
|
PowerPoint from switching. This is the work list: pick from the top. Numbers are summarized in
|
||||||
@@ -368,6 +368,7 @@ Detail in [localization-parity.md](localization-parity.md).
|
|||||||
|
|
||||||
| Date | Change | Summary |
|
| Date | Change | Summary |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
| 2026-10-31 | trivial | No Fill hit-testing follows resolved fill instead of the presence of a style (#1621); scores unchanged |
|
||||||
| 2026-10-30 | minor | Slide-show click targets use each shape’s own animation state; scale and rotation test added |
|
| 2026-10-30 | minor | Slide-show click targets use each shape’s own animation state; scale and rotation test added |
|
||||||
| 2026-10-28 | trivial | Automatic lists now run independently of Replace text as you type; proofing gap description updated |
|
| 2026-10-28 | trivial | Automatic lists now run independently of Replace text as you type; proofing gap description updated |
|
||||||
| 2026-10-27 | minor | Refreshed against merged PRs: removed the Morph alignment entry (#67 merged 2026-10-14); #77 (animation rewind, PR #83) is fixed, leaving #74 in the round-trip gap; #75 was closed unmerged, so #60 stays open with no fix PR; #53 is closed and the image-paste gap is now Windows #1414; Reuse Slides (#1103) and Compare reject (#850) noted in the smaller rows; the duplicate header line is gone |
|
| 2026-10-27 | minor | Refreshed against merged PRs: removed the Morph alignment entry (#67 merged 2026-10-14); #77 (animation rewind, PR #83) is fixed, leaving #74 in the round-trip gap; #75 was closed unmerged, so #60 stays open with no fix PR; #53 is closed and the image-paste gap is now Windows #1414; Reuse Slides (#1103) and Compare reject (#850) noted in the smaller rows; the duplicate header line is gone |
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
> **Generated:** 2026-10-14 (bullets/numbering remain D after empty-line marker review; summary by `cargo xtask parity`; rows re-scored by the 2026-10-14 community PR wave, duplicate Thesaurus, AutoCorrect, Artistic effects and Corrections rows folded; Preview in editor re-scored 2026-10-13; earlier rows re-scored 2026-10-10 against PowerPoint for Mac 16.113.4; video/animated GIF row re-scored 2026-10-11, #171) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
> **Generated:** 2026-10-14 (bullets/numbering remain D after empty-line marker review; summary by `cargo xtask parity`; rows re-scored by the 2026-10-14 community PR wave, duplicate Thesaurus, AutoCorrect, Artistic effects and Corrections rows folded; Preview in editor re-scored 2026-10-13; earlier rows re-scored 2026-10-10 against PowerPoint for Mac 16.113.4; video/animated GIF row re-scored 2026-10-11, #171) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||||
|
|
||||||
|
> **Last updated:** 2026-11-01 · Locked-shape and no-outline regressions added for #1621; fill row re-assessed: remains D; No Fill hit-testing now follows resolved appearance. Scores unchanged.
|
||||||
|
|
||||||
The summary between `<!-- SUMMARY -->
|
The summary between `<!-- SUMMARY -->
|
||||||
**Weighted breadth parity: 84%** over 191 features.
|
**Weighted breadth parity: 84%** over 191 features.
|
||||||
|
|
||||||
@@ -118,7 +120,7 @@ Open P0 items: Contextual tabs (Shape Format, Picture Format, Table Design/Layou
|
|||||||
| Shapes | Text warp transforms | P2 | P |
|
| Shapes | Text warp transforms | P2 | P |
|
||||||
| Shapes | Shape quick styles | P1 | D |
|
| Shapes | Shape quick styles | P1 | D |
|
||||||
| Shapes | Set as default shape | P2 | D |
|
| Shapes | Set as default shape | P2 | D |
|
||||||
| Format | Solid fill and transparency | P0 | D |
|
| Format | Solid fill and transparency (No Fill interiors pass clicks through, #1621) | P0 | D |
|
||||||
| Format | Gradient fill (linear, radial, rectangular, path) | P0 | D |
|
| Format | Gradient fill (linear, radial, rectangular, path) | P0 | D |
|
||||||
| Format | Picture and texture fill | P1 | P |
|
| Format | Picture and texture fill | P1 | P |
|
||||||
| Format | Pattern fill | P2 | P |
|
| Format | Pattern fill | P2 | P |
|
||||||
@@ -233,3 +235,9 @@ Open P0 items: Contextual tabs (Shape Format, Picture Format, Table Design/Layou
|
|||||||
| Platforms (✱) | macOS, Windows, Linux builds | P0 | P |
|
| Platforms (✱) | macOS, Windows, Linux builds | P0 | P |
|
||||||
| Platforms (✱) | Web (WASM) build | P1 | D |
|
| Platforms (✱) | Web (WASM) build | P1 | D |
|
||||||
| Platforms (✱) | BSD build | P2 | P |
|
| Platforms (✱) | BSD build | P2 | P |
|
||||||
|
|
||||||
|
## Revision history
|
||||||
|
|
||||||
|
| Date | Change | Summary |
|
||||||
|
|---|---|---|
|
||||||
|
| 2026-10-31 | trivial | No Fill interior hit-testing follows resolved fill (#1621); fill row remains D and summary scores are unchanged |
|
||||||
|
|||||||
+3
-2
@@ -1,6 +1,6 @@
|
|||||||
# UI parity with PowerPoint
|
# UI parity with PowerPoint
|
||||||
|
|
||||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-29 · **Change:** trivial (Picture Format: the Picture Styles row leaves room for Crop and Size, #1232; previous: trivial, Edit Points: Shift, Ctrl/Cmd or Alt/Option on a handle drag sets the point type; previous: trivial, scroll bars on the zoomed slide pane, #1157; previous: major, community PR wave 2026-10-14: 14 new dialogs (41 in all), Eyedropper, Export button, New Window, Grid Options, WordArt gallery and handles, Slide Sorter sections, Quick Access Toolbar, Format Background and Format Shape depth, rich-text clipboard; duplicated rows and headers folded; previous: minor, File tab / Backstage, #76) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-11-01 · **Change:** trivial (locked No Fill and no-outline click-through regression coverage added; No Fill hit-testing follows resolved fill instead of the presence of a style (#1621); scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-26 · **Change:** trivial (dragging several selected slides moves them all; previous: trivial, Edit Points: Shift, Ctrl/Cmd or Alt/Option on a handle drag sets the point type; previous: trivial, scroll bars on the zoomed slide pane, #1157; previous: major, community PR wave 2026-10-14: 14 new dialogs (41 in all), Eyedropper, Export button, New Window, Grid Options, WordArt gallery and handles, Slide Sorter sections, Quick Access Toolbar, Format Background and Format Shape depth, rich-text clipboard; duplicated rows and headers folded; previous: minor, File tab / Backstage, #76) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-26 · **Change:** trivial (dragging several selected slides moves them all; previous: trivial, Edit Points: Shift, Ctrl/Cmd or Alt/Option on a handle drag sets the point type; previous: trivial, scroll bars on the zoomed slide pane, #1157; previous: major, community PR wave 2026-10-14: 14 new dialogs (41 in all), Eyedropper, Export button, New Window, Grid Options, WordArt gallery and handles, Slide Sorter sections, Quick Access Toolbar, Format Background and Format Shape depth, rich-text clipboard; duplicated rows and headers folded; previous: minor, File tab / Backstage, #76) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-22 · **Change:** trivial (group fill and outline edits reach members, #1079; scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-22 · **Change:** trivial (group fill and outline edits reach members, #1079; scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-24 · **Change:** trivial (Edit Points: Shift, Ctrl/Cmd or Alt/Option on a handle drag sets the point type; previous: trivial, scroll bars on the zoomed slide pane, #1157; previous: major, community PR wave 2026-10-14: 14 new dialogs (41 in all), Eyedropper, Export button, New Window, Grid Options, WordArt gallery and handles, Slide Sorter sections, Quick Access Toolbar, Format Background and Format Shape depth, rich-text clipboard; duplicated rows and headers folded; previous: minor, File tab / Backstage, #76) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-24 · **Change:** trivial (Edit Points: Shift, Ctrl/Cmd or Alt/Option on a handle drag sets the point type; previous: trivial, scroll bars on the zoomed slide pane, #1157; previous: major, community PR wave 2026-10-14: 14 new dialogs (41 in all), Eyedropper, Export button, New Window, Grid Options, WordArt gallery and handles, Slide Sorter sections, Quick Access Toolbar, Format Background and Format Shape depth, rich-text clipboard; duplicated rows and headers folded; previous: minor, File tab / Backstage, #76) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||||
@@ -81,7 +81,7 @@ menu colours taken from the theme tokens (menu `chrome`, page `card`).
|
|||||||
|
|
||||||
| Behaviour | PowerPoint | DeckCraft |
|
| Behaviour | PowerPoint | DeckCraft |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Selection handles, rotation handle, yellow adjustment handles | yes | yes |
|
| Selection handles, rotation handle, yellow adjustment handles | yes | yes; an empty No Fill shape passes interior clicks to underlying objects while its outline and handles remain selectable. Explicit, style and theme fills resolve as rendered; tests also cover locked outline targets and empty interiors without an outline (#1621) |
|
||||||
| Marquee, Shift/Cmd multi-select, Tab to cycle | yes | yes |
|
| Marquee, Shift/Cmd multi-select, Tab to cycle | yes | yes |
|
||||||
| Objects off the slide: drawn on the area around it in Normal view (full strength, master graphics too), selectable and movable there; clipped in the show, exports and thumbnails | yes | yes (#270) |
|
| Objects off the slide: drawn on the area around it in Normal view (full strength, master graphics too), selectable and movable there; clipped in the show, exports and thumbnails | yes | yes (#270) |
|
||||||
| Scroll bars on the slide pane when the zoomed slide is larger than the window | yes | yes, on each axis that overflows: drag the thumb or click the track; the wheel and trackpad still pan. The vertical bar does not turn the page (#1157) |
|
| Scroll bars on the slide pane when the zoomed slide is larger than the window | yes | yes, on each axis that overflows: drag the thumb or click the track; the wheel and trackpad still pan. The vertical bar does not turn the page (#1157) |
|
||||||
@@ -171,6 +171,7 @@ consistency is open (#73, #56).
|
|||||||
|
|
||||||
| Date | Change | Summary |
|
| Date | Change | Summary |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
| 2026-10-31 | trivial | No Fill hit-testing follows resolved fill instead of the presence of a style (#1621); scores unchanged |
|
||||||
| 2026-10-29 | trivial | Picture Format: the Picture Styles row shows as many styles as fit beside the groups after it (at least two) and a More arrow with all 24, as the Transitions and Animations galleries do; Crop and Size are in view in the default window (#1232) |
|
| 2026-10-29 | trivial | Picture Format: the Picture Styles row shows as many styles as fit beside the groups after it (at least two) and a More arrow with all 24, as the Transitions and Animations galleries do; Crop and Size are in view in the default window (#1232) |
|
||||||
| 2026-10-26 | trivial | Thumbnail pane and Slide Sorter: dragging one of several selected slides moves the whole selection |
|
| 2026-10-26 | trivial | Thumbnail pane and Slide Sorter: dragging one of several selected slides moves the whole selection |
|
||||||
| 2026-10-24 | trivial | Table cells: dragging across cells selects a range of cells |
|
| 2026-10-24 | trivial | Table cells: dragging across cells selects a range of cells |
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
# WASM presenter-view popout (#729)
|
||||||
|
|
||||||
|
Tracks https://github.com/storytold/deckcraft/issues/729 (the reporter moved duplicate #726 here).
|
||||||
|
|
||||||
|
## Implementation
|
||||||
|
|
||||||
|
The browser presenter opens the same DeckCraft web build in a separate, same-origin window.
|
||||||
|
That window starts its own eframe runner and renders only presenter controls, current/next
|
||||||
|
previews and notes. The audience window remains the sole owner of slideshow state, animations,
|
||||||
|
media and navigation. Presenter keyboard input uses the same navigation handler as the native
|
||||||
|
presenter, so an input is applied once rather than advancing two independent shows.
|
||||||
|
|
||||||
|
Messages check the exact origin, source window and a per-opening session identifier. Input
|
||||||
|
queues and preview dimensions are bounded. Previews use the audience's rendered current frame;
|
||||||
|
unchanged preview pixels are not retransmitted. Presenter updates are limited to five per second.
|
||||||
|
|
||||||
|
If the browser blocks the initial popup, an explicit Open presenter window button calls the
|
||||||
|
browser API directly from its click handler. Browser policy can still require the user to allow
|
||||||
|
popups, or choose to open a tab instead of a window. Closing the presenter leaves the audience
|
||||||
|
running and offers the same button to reopen it. Ending the show closes the presenter. The
|
||||||
|
presenter reports a closed or unresponsive audience rather than continuing an independent show.
|
||||||
|
Native presenter view continues to use its existing egui viewport.
|
||||||
|
|
||||||
|
No native second-display placement, Linux display-counting policy, laser/rehearsal changes or
|
||||||
|
other contributors' unmerged presenter work is included.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
Candidate implementation; not yet qualified for release. The current-main reconciliation and
|
||||||
|
transport/font corrections have had source inspection and static diff checks only. No local Rust
|
||||||
|
builds, tests or formatter were run for this candidate; hosted compilation and regression tests
|
||||||
|
remain pending. Earlier-head validation recorded in the PR does not qualify this changed tree.
|
||||||
|
The original bug was reproduced on the deployed 0.7.0 browser build on 2026-10-14; that
|
||||||
|
deployment's exact commit is unknown.
|
||||||
|
|
||||||
|
Regression coverage submitted for hosted execution checks successful image deduplication,
|
||||||
|
failed-send retries, session/reload refresh and clearing previews. The independent popup uses
|
||||||
|
the existing interface font definitions and UiFonts fallback discovery before laying out notes,
|
||||||
|
discarding font-install passes before input forwarding. Its font regression checks bundled
|
||||||
|
Arabic/Japanese/Hangul glyphs when those optional font inputs are present; missing optional
|
||||||
|
inputs do not establish script coverage.
|
||||||
|
|
||||||
|
Source inspection verifies these lifecycle paths: a blocked popup offers an explicit retry
|
||||||
|
whose click handler calls the browser API; closing the popup offers reopening; ending the show
|
||||||
|
closes the popup; incoming messages check origin, source window and opening-session token.
|
||||||
|
This establishes the intended control flow, not empirical compatibility with each browser.
|
||||||
|
|
||||||
|
Runtime checks that would add empirical coverage with a synthetic deck:
|
||||||
|
|
||||||
|
- Popup allowed and blocked, explicit retry, and independent window positioning.
|
||||||
|
- Initial popup behavior in Safari and Firefox, including whether explicit retry is needed on
|
||||||
|
each show start. These browser families have not been exercised for this candidate; missing
|
||||||
|
manual coverage alone is not evidence of a defect or a mandatory merge blocker.
|
||||||
|
- Next/Previous, Home/End, numeric navigation, and no double advance on Space/Enter.
|
||||||
|
- Current/next previews, notes, pause/reset, black/white screens, All Slides and zoom.
|
||||||
|
- Navigation from both windows, including animation steps and the end screen.
|
||||||
|
- Close/reopen the presenter, reload it, end/restart the show, and close/reload the audience.
|
||||||
|
- Rejection of messages from the wrong origin, source window or prior session.
|
||||||
|
- Native presenter regressions, WASM compilation and the full repository quality gates.
|
||||||
|
|
||||||
|
All runtime items above, including actual browser popup/reload/end-show execution, remain
|
||||||
|
unexecuted for this candidate. Shared native tests and source inspection do not constitute
|
||||||
|
browser-executed validation. Font subsets can legitimately omit individual script families;
|
||||||
|
the regression always checks installation/layout/discard handling, and checks a script's glyphs
|
||||||
|
only when its corresponding bundled face is present.
|
||||||
Reference in New Issue
Block a user