mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-24 18:46:57 -06:00
Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b47195feec | ||
|
|
a2435383b1 | ||
|
|
d01254cf2d | ||
|
|
3f399dd450 | ||
|
|
8f04a999b9 | ||
|
|
f011313a3a | ||
|
|
792829ec41 | ||
|
|
4a1d27a227 | ||
|
|
0b8a4cc23e | ||
|
|
128cb0b883 | ||
|
|
2741d3236b | ||
|
|
e37267f548 | ||
|
|
65564cfc1b | ||
|
|
e34169ff75 | ||
|
|
46dec4a660 | ||
|
|
79088b7060 |
Generated
+28
-28
@@ -892,7 +892,7 @@ checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "deckcraft"
|
name = "deckcraft"
|
||||||
version = "0.9.0"
|
version = "0.10.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"arboard",
|
"arboard",
|
||||||
"cpal",
|
"cpal",
|
||||||
@@ -912,7 +912,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "deckcraft-anim"
|
name = "deckcraft-anim"
|
||||||
version = "0.9.0"
|
version = "0.10.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"deckcraft-geom",
|
"deckcraft-geom",
|
||||||
"deckcraft-model",
|
"deckcraft-model",
|
||||||
@@ -921,7 +921,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "deckcraft-av1"
|
name = "deckcraft-av1"
|
||||||
version = "0.9.0"
|
version = "0.10.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"deckcraft-bitstream",
|
"deckcraft-bitstream",
|
||||||
"rayon",
|
"rayon",
|
||||||
@@ -930,14 +930,14 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "deckcraft-bitstream"
|
name = "deckcraft-bitstream"
|
||||||
version = "0.9.0"
|
version = "0.10.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proptest",
|
"proptest",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "deckcraft-cli"
|
name = "deckcraft-cli"
|
||||||
version = "0.9.0"
|
version = "0.10.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"deckcraft-engine",
|
"deckcraft-engine",
|
||||||
"deckcraft-mcp",
|
"deckcraft-mcp",
|
||||||
@@ -947,14 +947,14 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "deckcraft-color"
|
name = "deckcraft-color"
|
||||||
version = "0.9.0"
|
version = "0.10.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "deckcraft-engine"
|
name = "deckcraft-engine"
|
||||||
version = "0.9.0"
|
version = "0.10.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"deckcraft-anim",
|
"deckcraft-anim",
|
||||||
"deckcraft-color",
|
"deckcraft-color",
|
||||||
@@ -979,7 +979,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "deckcraft-fonts"
|
name = "deckcraft-fonts"
|
||||||
version = "0.9.0"
|
version = "0.10.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"epaint_default_fonts",
|
"epaint_default_fonts",
|
||||||
"harfrust 0.13.3",
|
"harfrust 0.13.3",
|
||||||
@@ -991,7 +991,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "deckcraft-format"
|
name = "deckcraft-format"
|
||||||
version = "0.9.0"
|
version = "0.10.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"deckcraft-model",
|
"deckcraft-model",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -1002,7 +1002,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "deckcraft-geom"
|
name = "deckcraft-geom"
|
||||||
version = "0.9.0"
|
version = "0.10.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"kurbo",
|
"kurbo",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -1010,7 +1010,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "deckcraft-h264"
|
name = "deckcraft-h264"
|
||||||
version = "0.9.0"
|
version = "0.10.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"deckcraft-bitstream",
|
"deckcraft-bitstream",
|
||||||
"rayon",
|
"rayon",
|
||||||
@@ -1019,7 +1019,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "deckcraft-hevc"
|
name = "deckcraft-hevc"
|
||||||
version = "0.9.0"
|
version = "0.10.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"deckcraft-bitstream",
|
"deckcraft-bitstream",
|
||||||
"rayon",
|
"rayon",
|
||||||
@@ -1028,7 +1028,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "deckcraft-isobmff"
|
name = "deckcraft-isobmff"
|
||||||
version = "0.9.0"
|
version = "0.10.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"deckcraft-bitstream",
|
"deckcraft-bitstream",
|
||||||
"thiserror 2.0.21",
|
"thiserror 2.0.21",
|
||||||
@@ -1036,11 +1036,11 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "deckcraft-matroska"
|
name = "deckcraft-matroska"
|
||||||
version = "0.9.0"
|
version = "0.10.0"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "deckcraft-mcp"
|
name = "deckcraft-mcp"
|
||||||
version = "0.9.0"
|
version = "0.10.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"deckcraft-engine",
|
"deckcraft-engine",
|
||||||
"deckcraft-render",
|
"deckcraft-render",
|
||||||
@@ -1050,7 +1050,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "deckcraft-media"
|
name = "deckcraft-media"
|
||||||
version = "0.9.0"
|
version = "0.10.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"deckcraft-av1",
|
"deckcraft-av1",
|
||||||
"deckcraft-h264",
|
"deckcraft-h264",
|
||||||
@@ -1068,7 +1068,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "deckcraft-metafile"
|
name = "deckcraft-metafile"
|
||||||
version = "0.9.0"
|
version = "0.10.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"image",
|
"image",
|
||||||
"kurbo",
|
"kurbo",
|
||||||
@@ -1077,7 +1077,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "deckcraft-model"
|
name = "deckcraft-model"
|
||||||
version = "0.9.0"
|
version = "0.10.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"deckcraft-color",
|
"deckcraft-color",
|
||||||
"deckcraft-geom",
|
"deckcraft-geom",
|
||||||
@@ -1089,15 +1089,15 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "deckcraft-ogg"
|
name = "deckcraft-ogg"
|
||||||
version = "0.9.0"
|
version = "0.10.0"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "deckcraft-opus"
|
name = "deckcraft-opus"
|
||||||
version = "0.9.0"
|
version = "0.10.0"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "deckcraft-pdf"
|
name = "deckcraft-pdf"
|
||||||
version = "0.9.0"
|
version = "0.10.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"craft-pdf",
|
"craft-pdf",
|
||||||
"deckcraft-fonts",
|
"deckcraft-fonts",
|
||||||
@@ -1115,7 +1115,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "deckcraft-pptx"
|
name = "deckcraft-pptx"
|
||||||
version = "0.9.0"
|
version = "0.10.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"deckcraft-color",
|
"deckcraft-color",
|
||||||
"deckcraft-engine",
|
"deckcraft-engine",
|
||||||
@@ -1131,7 +1131,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "deckcraft-render"
|
name = "deckcraft-render"
|
||||||
version = "0.9.0"
|
version = "0.10.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"deckcraft-anim",
|
"deckcraft-anim",
|
||||||
"deckcraft-color",
|
"deckcraft-color",
|
||||||
@@ -1150,7 +1150,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "deckcraft-text"
|
name = "deckcraft-text"
|
||||||
version = "0.9.0"
|
version = "0.10.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"deckcraft-color",
|
"deckcraft-color",
|
||||||
@@ -1164,7 +1164,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "deckcraft-ui-egui"
|
name = "deckcraft-ui-egui"
|
||||||
version = "0.9.0"
|
version = "0.10.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"deckcraft-anim",
|
"deckcraft-anim",
|
||||||
"deckcraft-color",
|
"deckcraft-color",
|
||||||
@@ -1186,7 +1186,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "deckcraft-vp9"
|
name = "deckcraft-vp9"
|
||||||
version = "0.9.0"
|
version = "0.10.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"deckcraft-bitstream",
|
"deckcraft-bitstream",
|
||||||
"rayon",
|
"rayon",
|
||||||
@@ -1195,7 +1195,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "deckcraft-web"
|
name = "deckcraft-web"
|
||||||
version = "0.9.0"
|
version = "0.10.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"deckcraft-engine",
|
"deckcraft-engine",
|
||||||
"deckcraft-ui-egui",
|
"deckcraft-ui-egui",
|
||||||
@@ -5733,7 +5733,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "xtask"
|
name = "xtask"
|
||||||
version = "0.9.0"
|
version = "0.10.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"serde_json",
|
"serde_json",
|
||||||
]
|
]
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
|||||||
default-members = ["crates/*", "apps/deckcraft", "apps/deckcraft-cli"]
|
default-members = ["crates/*", "apps/deckcraft", "apps/deckcraft-cli"]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.9.0"
|
version = "0.10.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
license = "MIT OR Apache-2.0"
|
license = "MIT OR Apache-2.0"
|
||||||
rust-version = "1.95"
|
rust-version = "1.95"
|
||||||
|
|||||||
@@ -7,6 +7,10 @@
|
|||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="assets/app-icon/hicolor/256x256/apps/ai.storyteller.deckcraft.png" width="128" height="128" alt="DeckCraft icon: a presentation screen with a bar chart on an orange tile">
|
||||||
|
</p>
|
||||||
|
|
||||||
<h1 align="center">DeckCraft</h1>
|
<h1 align="center">DeckCraft</h1>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
|
|||||||
@@ -132,6 +132,7 @@ app's JSON control channel (`deckcraft --control PORT`).
|
|||||||
| Date | What landed |
|
| Date | What landed |
|
||||||
|---|---|
|
|---|---|
|
||||||
| 2026-10-18 | Native chart sidecar bytes move from JSON arrays into referenced ZIP entries, with shared media byte/item limits and bounded reads; older inline sidecars still load. Synthetic format regressions cover large workbooks and malformed members |
|
| 2026-10-18 | Native chart sidecar bytes move from JSON arrays into referenced ZIP entries, with shared media byte/item limits and bounded reads; older inline sidecars still load. Synthetic format regressions cover large workbooks and malformed members |
|
||||||
|
| 2026-10-18 | Slide thumbnails and the Slide Sorter render only the slides in view: on a deck of more than 600 slides they used to evict their own thumbnails, so the pane flashed white and never finished (#969) |
|
||||||
| 2026-10-17 | Opening a file caps absurd effect sizes (glow, soft edges, shadows, reflections, on shapes, text and table text) at what Format Shape allows: a glow of 10^18 pt in a .pptx made rendering ask for 34 GB and abort |
|
| 2026-10-17 | Opening a file caps absurd effect sizes (glow, soft edges, shadows, reflections, on shapes, text and table text) at what Format Shape allows: a glow of 10^18 pt in a .pptx made rendering ask for 34 GB and abort |
|
||||||
| 2026-10-17 | Opening a .deckcraft file whose shapes repeat an id gives the repeats fresh ids (groups, layouts and masters too), so the deck validates and edits by id find one shape |
|
| 2026-10-17 | Opening a .deckcraft file whose shapes repeat an id gives the repeats fresh ids (groups, layouts and masters too), so the deck validates and edits by id find one shape |
|
||||||
| 2026-10-17 | Edit Points: turning a shape into a freeform is one undo step, so Undo brings the original shape back (it used to stay a freeform) |
|
| 2026-10-17 | Edit Points: turning a shape into a freeform is one undo step, so Undo brings the original shape back (it used to stay a freeform) |
|
||||||
@@ -242,6 +243,7 @@ app's JSON control channel (`deckcraft --control PORT`).
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 2026-10-18 | minor | Native chart payloads use referenced ZIP members and share media byte/item limits; old inline sidecars remain readable. Synthetic tests cover multi-megabyte layout, all shape owners, shared budgets, missing/corrupt members and invalid references. Scores and milestone scope unchanged |
|
| 2026-10-18 | minor | Native chart payloads use referenced ZIP members and share media byte/item limits; old inline sidecars remain readable. Synthetic tests cover multi-megabyte layout, all shape owners, shared budgets, missing/corrupt members and invalid references. Scores and milestone scope unchanged |
|
||||||
| 2026-10-15 | trivial | Progress log: chart package parts survive a native save. Scores and milestone names unchanged |
|
| 2026-10-15 | trivial | Progress log: chart package parts survive a native save. Scores and milestone names unchanged |
|
||||||
|
| 2026-10-18 | trivial | Progress log: thumbnails of huge decks render only the slides in view (#969) |
|
||||||
| 2026-10-15 | trivial | Progress log: PPTX trigger click-step round trip. Feature scores and milestone names unchanged |
|
| 2026-10-15 | trivial | Progress log: PPTX trigger click-step round trip. Feature scores and milestone names unchanged |
|
||||||
| 2026-10-16 | trivial | Progress log: PNG export with a transparent background (#784) |
|
| 2026-10-16 | trivial | Progress log: PNG export with a transparent background (#784) |
|
||||||
| 2026-10-16 | trivial | Progress log: a `.ppsx` from the system starts the slide show (#862) |
|
| 2026-10-16 | trivial | Progress log: a `.ppsx` from the system starts the slide show (#862) |
|
||||||
|
|||||||
@@ -525,4 +525,46 @@ mod tests {
|
|||||||
s.execute("animation.painterApply", &json!({"ids": [id3]})).unwrap();
|
s.execute("animation.painterApply", &json!({"ids": [id3]})).unwrap();
|
||||||
assert!(s.anim_painter.is_some());
|
assert!(s.anim_painter.is_some());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn setup_triggers() -> (Session, [u64; 4]) {
|
||||||
|
let mut s = Session::with_new();
|
||||||
|
let mut ids = [0u64; 4];
|
||||||
|
for (slot, x) in ids.iter_mut().zip([10.0, 150.0, 300.0, 450.0]) {
|
||||||
|
let r = s.execute("shape.insert", &json!({"preset": "rect", "rect": [x, 10.0, 100.0, 100.0]})).unwrap();
|
||||||
|
*slot = r.get("id").unwrap().as_u64().unwrap();
|
||||||
|
}
|
||||||
|
(s, ids)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn triggers(s: &Session) -> Vec<Option<deckcraft_model::ShapeId>> {
|
||||||
|
s.doc().unwrap().current_slide().unwrap().animations.iter().map(|x| x.trigger).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deleting_a_trigger_shape_clears_the_trigger() {
|
||||||
|
let (mut s, [a, b, c, d]) = setup_triggers();
|
||||||
|
let id = |v: u64| Some(deckcraft_model::ShapeId(v as u32));
|
||||||
|
s.execute("animation.add", &json!({"effect": "fade", "ids": [a]})).unwrap();
|
||||||
|
s.execute("animation.add", &json!({"effect": "fade", "ids": [d]})).unwrap();
|
||||||
|
s.execute("animation.timing", &json!({"index": 0, "trigger": b})).unwrap();
|
||||||
|
s.execute("animation.timing", &json!({"index": 1, "trigger": c})).unwrap();
|
||||||
|
assert_eq!(triggers(&s), vec![id(b), id(c)]);
|
||||||
|
|
||||||
|
s.execute("edit.delete", &json!({"ids": [b]})).unwrap();
|
||||||
|
assert_eq!(triggers(&s), vec![None, id(c)], "only the deleted trigger is cleared");
|
||||||
|
assert_eq!(s.doc().unwrap().current_slide().unwrap().animations.len(), 2, "the animated shapes keep their effects");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deleting_a_group_clears_triggers_of_its_children() {
|
||||||
|
let (mut s, [a, b, c, _]) = setup_triggers();
|
||||||
|
let g = s.execute("arrange.group", &json!({"ids": [b, c]})).unwrap().get("id").unwrap().as_u64().unwrap();
|
||||||
|
s.execute("animation.add", &json!({"effect": "fade", "ids": [a]})).unwrap();
|
||||||
|
s.execute("animation.timing", &json!({"index": 0, "trigger": b})).unwrap();
|
||||||
|
assert_eq!(triggers(&s), vec![Some(deckcraft_model::ShapeId(b as u32))]);
|
||||||
|
|
||||||
|
s.execute("edit.delete", &json!({"ids": [g]})).unwrap();
|
||||||
|
assert_eq!(triggers(&s), vec![None], "the trigger was inside the deleted group");
|
||||||
|
assert_eq!(s.doc().unwrap().current_slide().unwrap().animations.len(), 1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -572,12 +572,24 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
|||||||
}
|
}
|
||||||
s.edit(|doc, sel| {
|
s.edit(|doc, sel| {
|
||||||
let shapes = crate::shapes_mut(doc, sel).ok_or_else(|| bad("edit.delete", "no slide"))?;
|
let shapes = crate::shapes_mut(doc, sel).ok_or_else(|| bad("edit.delete", "no slide"))?;
|
||||||
|
let mut before = Vec::new();
|
||||||
|
deckcraft_model::walk(shapes, &mut |s, _| before.push(s.id));
|
||||||
let n = remove_shapes(shapes, &ids);
|
let n = remove_shapes(shapes, &ids);
|
||||||
|
// Everything that is gone: the given ids plus group descendants and emptied groups.
|
||||||
|
let mut left = Vec::new();
|
||||||
|
deckcraft_model::walk(shapes, &mut |s, _| left.push(s.id));
|
||||||
|
let mut gone = ids.clone();
|
||||||
|
gone.extend(before.into_iter().filter(|i| !left.contains(i)));
|
||||||
// Animations of deleted shapes go too.
|
// Animations of deleted shapes go too.
|
||||||
if sel.target == crate::Target::Slides
|
if sel.target == crate::Target::Slides
|
||||||
&& let Some(sl) = doc.slides.get_mut(sel.slide)
|
&& let Some(sl) = doc.slides.get_mut(sel.slide)
|
||||||
{
|
{
|
||||||
Arc::make_mut(sl).animations.retain(|a| !ids.contains(&a.shape));
|
let sl = Arc::make_mut(sl);
|
||||||
|
sl.animations.retain(|a| !gone.contains(&a.shape));
|
||||||
|
// Effects triggered by a deleted shape fall back to the main sequence.
|
||||||
|
for a in sl.animations.iter_mut().filter(|a| a.trigger.is_some_and(|t| gone.contains(&t))) {
|
||||||
|
a.trigger = None;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
sel.shapes.retain(|i| !ids.contains(i));
|
sel.shapes.retain(|i| !ids.contains(i));
|
||||||
sel.text = None;
|
sel.text = None;
|
||||||
|
|||||||
@@ -318,6 +318,14 @@ pub(crate) fn str_param<'a>(p: &'a Value, key: &str) -> Option<&'a str> {
|
|||||||
pub(crate) fn usize_param(p: &Value, key: &str) -> Option<usize> {
|
pub(crate) fn usize_param(p: &Value, key: &str) -> Option<usize> {
|
||||||
p.get(key).and_then(Value::as_u64).and_then(|v| usize::try_from(v).ok())
|
p.get(key).and_then(Value::as_u64).and_then(|v| usize::try_from(v).ok())
|
||||||
}
|
}
|
||||||
|
/// An index param: `Ok(None)` when absent (callers may fall back to the selection), `Err` when
|
||||||
|
/// present but not a valid index (negative, non-integer), so a bad index never acts on the selection.
|
||||||
|
pub(crate) fn opt_usize_param(p: &Value, key: &str, cmd: &str) -> Result<Option<usize>> {
|
||||||
|
match p.get(key) {
|
||||||
|
None | Some(Value::Null) => Ok(None),
|
||||||
|
Some(v) => v.as_u64().and_then(|v| usize::try_from(v).ok()).map(Some).ok_or_else(|| bad(cmd, format!("`{key}` is not a valid index: {v}"))),
|
||||||
|
}
|
||||||
|
}
|
||||||
/// A shape id param: `Ok(None)` when absent (callers may fall back to the selection), `Err` when
|
/// A shape id param: `Ok(None)` when absent (callers may fall back to the selection), `Err` when
|
||||||
/// present but not a valid id (negative, non-integer, beyond `u32`), so a bad id never edits the selection.
|
/// present but not a valid id (negative, non-integer, beyond `u32`), so a bad id never edits the selection.
|
||||||
pub(crate) fn id_param(p: &Value, key: &str, cmd: &str) -> Result<Option<ShapeId>> {
|
pub(crate) fn id_param(p: &Value, key: &str, cmd: &str) -> Result<Option<ShapeId>> {
|
||||||
|
|||||||
@@ -179,7 +179,7 @@ fn body_placeholder(shapes: &mut [deckcraft_model::Shape]) -> Option<&mut deckcr
|
|||||||
|
|
||||||
fn index_of(s: &Session, p: &Value) -> Result<usize> {
|
fn index_of(s: &Session, p: &Value) -> Result<usize> {
|
||||||
let st = s.doc()?;
|
let st = s.doc()?;
|
||||||
let i = usize_param(p, "index").unwrap_or(st.selection.slide);
|
let i = opt_usize_param(p, "index", "slide")?.unwrap_or(st.selection.slide);
|
||||||
if i >= st.doc.slides.len() {
|
if i >= st.doc.slides.len() {
|
||||||
return Err(bad("slide", format!("no slide {i}")));
|
return Err(bad("slide", format!("no slide {i}")));
|
||||||
}
|
}
|
||||||
@@ -187,7 +187,7 @@ fn index_of(s: &Session, p: &Value) -> Result<usize> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||||
let indices: Vec<usize> = match usize_param(p, "index") {
|
let indices: Vec<usize> = match opt_usize_param(p, "index", "slide.duplicate")? {
|
||||||
Some(i) => vec![i],
|
Some(i) => vec![i],
|
||||||
None => {
|
None => {
|
||||||
let st = s.doc()?;
|
let st = s.doc()?;
|
||||||
@@ -240,7 +240,7 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
|||||||
let st = s.doc()?;
|
let st = s.doc()?;
|
||||||
if let Some(a) = p.get("ids").and_then(Value::as_array) {
|
if let Some(a) = p.get("ids").and_then(Value::as_array) {
|
||||||
a.iter().filter_map(Value::as_u64).filter_map(|v| u32::try_from(v).ok()).map(SlideId).collect()
|
a.iter().filter_map(Value::as_u64).filter_map(|v| u32::try_from(v).ok()).map(SlideId).collect()
|
||||||
} else if let Some(i) = usize_param(p, "index") {
|
} else if let Some(i) = opt_usize_param(p, "index", "slide.delete")? {
|
||||||
st.doc.slides.get(i).map(|x| vec![x.id]).unwrap_or_default()
|
st.doc.slides.get(i).map(|x| vec![x.id]).unwrap_or_default()
|
||||||
} else if !st.selection.slides.is_empty() {
|
} else if !st.selection.slides.is_empty() {
|
||||||
st.selection.slides.clone()
|
st.selection.slides.clone()
|
||||||
@@ -266,7 +266,7 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn move_slide(s: &mut Session, p: &Value) -> Result<Value> {
|
fn move_slide(s: &mut Session, p: &Value) -> Result<Value> {
|
||||||
let from = usize_param(p, "from").unwrap_or(s.doc()?.selection.slide);
|
let from = opt_usize_param(p, "from", "slide.move")?.unwrap_or(s.doc()?.selection.slide);
|
||||||
let to = usize_param(p, "to").ok_or_else(|| bad("slide.move", "missing `to`"))?;
|
let to = usize_param(p, "to").ok_or_else(|| bad("slide.move", "missing `to`"))?;
|
||||||
s.edit(|doc, sel| {
|
s.edit(|doc, sel| {
|
||||||
if from >= doc.slides.len() {
|
if from >= doc.slides.len() {
|
||||||
@@ -531,7 +531,7 @@ fn rename(s: &mut Session, p: &Value) -> Result<Value> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn section_add(s: &mut Session, p: &Value) -> Result<Value> {
|
fn section_add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||||
let at = usize_param(p, "at").unwrap_or(s.doc()?.selection.slide);
|
let at = opt_usize_param(p, "at", "section.add")?.unwrap_or(s.doc()?.selection.slide);
|
||||||
let name = str_param(p, "name").unwrap_or("Untitled Section").to_string();
|
let name = str_param(p, "name").unwrap_or("Untitled Section").to_string();
|
||||||
s.edit(|doc, _| {
|
s.edit(|doc, _| {
|
||||||
let at = at.min(doc.slides.len().saturating_sub(1));
|
let at = at.min(doc.slides.len().saturating_sub(1));
|
||||||
@@ -648,6 +648,25 @@ mod tests {
|
|||||||
|
|
||||||
use crate::Session;
|
use crate::Session;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn invalid_index_params_are_rejected_not_defaulted_to_selection() {
|
||||||
|
let mut s = Session::with_new();
|
||||||
|
while s.doc().expect("doc").doc.slides.len() < 3 {
|
||||||
|
s.execute("slide.new", &json!({})).expect("new slide");
|
||||||
|
}
|
||||||
|
for bad in [json!(-1), json!(1.5)] {
|
||||||
|
for (cmd, p) in [
|
||||||
|
("slide.delete", json!({"index": bad})),
|
||||||
|
("slide.duplicate", json!({"index": bad})),
|
||||||
|
("slide.move", json!({"from": bad, "to": 0})),
|
||||||
|
("section.add", json!({"at": bad})),
|
||||||
|
] {
|
||||||
|
assert!(s.execute(cmd, &p).is_err(), "{cmd} must reject {p}");
|
||||||
|
assert_eq!(s.doc().expect("doc").doc.slides.len(), 3, "{cmd} {p}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn duplicate_keeps_section_order_in_step_with_slides() {
|
fn duplicate_keeps_section_order_in_step_with_slides() {
|
||||||
let mut s = Session::with_new();
|
let mut s = Session::with_new();
|
||||||
|
|||||||
@@ -44,6 +44,11 @@ pub fn instance_id() -> u64 {
|
|||||||
*ID.get_or_init(|| {
|
*ID.get_or_init(|| {
|
||||||
use std::hash::{BuildHasher, Hasher};
|
use std::hash::{BuildHasher, Hasher};
|
||||||
let mut h = std::collections::hash_map::RandomState::new().build_hasher();
|
let mut h = std::collections::hash_map::RandomState::new().build_hasher();
|
||||||
|
// No PIDs and no std::time on wasm (`std::process::id()` and
|
||||||
|
// `SystemTime::now()` panic there): the browser clock stands in.
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
h.write_u64(js_sys::Date::now() as u64);
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
h.write_u32(std::process::id());
|
h.write_u32(std::process::id());
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
if let Ok(d) = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) {
|
if let Ok(d) = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) {
|
||||||
|
|||||||
@@ -281,8 +281,8 @@ fn percent_decode(s: &str) -> String {
|
|||||||
|
|
||||||
/// Resolve a relative target against a base directory into a part name.
|
/// Resolve a relative target against a base directory into a part name.
|
||||||
pub fn resolve(base: &str, target: &str) -> String {
|
pub fn resolve(base: &str, target: &str) -> String {
|
||||||
let target = percent_decode(target);
|
let target = percent_decode(target.split('#').next().unwrap_or(""));
|
||||||
let target = target.split('#').next().unwrap_or("");
|
let target = target.as_str();
|
||||||
let mut segs: Vec<&str> = if target.starts_with('/') { vec![] } else { base.split('/').filter(|s| !s.is_empty()).collect() };
|
let mut segs: Vec<&str> = if target.starts_with('/') { vec![] } else { base.split('/').filter(|s| !s.is_empty()).collect() };
|
||||||
for s in target.split('/') {
|
for s in target.split('/') {
|
||||||
match s {
|
match s {
|
||||||
@@ -441,6 +441,12 @@ impl PackageOut {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_keeps_encoded_hash_and_strips_fragment() {
|
||||||
|
assert_eq!(resolve("ppt/slides", "../media/a%23b.png"), "ppt/media/a#b.png");
|
||||||
|
assert_eq!(resolve("ppt/slides", "../media/a.png#frag"), "ppt/media/a.png");
|
||||||
|
}
|
||||||
|
|
||||||
fn zip_of(entries: &[(&str, usize)]) -> Vec<u8> {
|
fn zip_of(entries: &[(&str, usize)]) -> Vec<u8> {
|
||||||
let mut zw = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
let mut zw = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||||
let o = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
|
let o = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
|
||||||
|
|||||||
+23
-4
@@ -413,6 +413,9 @@ struct ParaShaped {
|
|||||||
/// Size of the paragraph's first run (spacing percentages, empty lines).
|
/// Size of the paragraph's first run (spacing percentages, empty lines).
|
||||||
lead_size: f64,
|
lead_size: f64,
|
||||||
lead_face: Arc<FontFace>,
|
lead_face: Arc<FontFace>,
|
||||||
|
/// Size of the paragraph mark (`a:endParaRPr`): what an empty last line is sized by.
|
||||||
|
end_size: f64,
|
||||||
|
end_face: Arc<FontFace>,
|
||||||
bullet: Option<(String, Style)>,
|
bullet: Option<(String, Style)>,
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
level: u8,
|
level: u8,
|
||||||
@@ -709,7 +712,9 @@ fn shape_para(ctx: &Ctx, shape: &Shape, body: &TextBody, pi: usize, scale: f64,
|
|||||||
faces: vec![],
|
faces: vec![],
|
||||||
props: ParaProps::default(),
|
props: ParaProps::default(),
|
||||||
lead_size: 18.0,
|
lead_size: 18.0,
|
||||||
lead_face: face,
|
lead_face: face.clone(),
|
||||||
|
end_size: 18.0,
|
||||||
|
end_face: face,
|
||||||
bullet: None,
|
bullet: None,
|
||||||
level: 0,
|
level: 0,
|
||||||
rtl: false,
|
rtl: false,
|
||||||
@@ -741,6 +746,9 @@ fn shape_para(ctx: &Ctx, shape: &Shape, body: &TextBody, pi: usize, scale: f64,
|
|||||||
let lead_style = make_style(ctx, &lead, scale, opts.prompt_color);
|
let lead_style = make_style(ctx, &lead, scale, opts.prompt_color);
|
||||||
let lead_size = lead_style.size;
|
let lead_size = lead_style.size;
|
||||||
let lead_face = lead_style.face.clone();
|
let lead_face = lead_style.face.clone();
|
||||||
|
// The paragraph mark, which ends the last line when no break does.
|
||||||
|
let end_style = make_style(ctx, &resolve::run(ctx, shape, para, ¶.end_props), scale, opts.prompt_color);
|
||||||
|
let (end_size, end_face) = (end_style.size, end_style.face.clone());
|
||||||
// Bullet.
|
// Bullet.
|
||||||
let bullet = match &props.bullet {
|
let bullet = match &props.bullet {
|
||||||
Some(Bullet::Char { char }) => {
|
Some(Bullet::Char { char }) => {
|
||||||
@@ -789,7 +797,7 @@ fn shape_para(ctx: &Ctx, shape: &Shape, body: &TextBody, pi: usize, scale: f64,
|
|||||||
}
|
}
|
||||||
_ => None,
|
_ => None,
|
||||||
};
|
};
|
||||||
ParaShaped { cells, styles, faces, props, lead_size, lead_face, bullet, level: para.level, rtl }
|
ParaShaped { cells, styles, faces, props, lead_size, lead_face, end_size, end_face, bullet, level: para.level, rtl }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A face that draws the bullet `label` when the bullet's own can't (circled numbers ①, arrows
|
/// A face that draws the bullet `label` when the bullet's own can't (circled numbers ①, arrows
|
||||||
@@ -1712,10 +1720,21 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if size == 0.0 {
|
if size == 0.0 {
|
||||||
let (a, d) = line_metrics(&p.lead_face, p.lead_size);
|
// PowerPoint sizes an empty line by the mark that ends it: the `a:rPr` of the
|
||||||
|
// `a:br` that closes it, or `a:endParaRPr` for the paragraph's last line -- not
|
||||||
|
// by the paragraph's first run (#780). An empty *paragraph* already lands on
|
||||||
|
// `end_props` through `lead`, and keeps the same height here.
|
||||||
|
let brk = p.cells.get(rl.end).filter(|c| c.ch == '\u{b}').and_then(|c| p.styles.get(c.style));
|
||||||
|
let (face, sz) = match brk {
|
||||||
|
Some(st) => (&st.face, st.size),
|
||||||
|
None if rl.end >= p.cells.len() => (&p.end_face, p.end_size),
|
||||||
|
// Anything else empty (a line of tabs alone) keeps the old measure.
|
||||||
|
None => (&p.lead_face, p.lead_size),
|
||||||
|
};
|
||||||
|
let (a, d) = line_metrics(face, sz);
|
||||||
asc = a;
|
asc = a;
|
||||||
desc = d;
|
desc = d;
|
||||||
size = p.lead_size;
|
size = sz;
|
||||||
}
|
}
|
||||||
let natural = (asc + desc).max(size * 1.0);
|
let natural = (asc + desc).max(size * 1.0);
|
||||||
let lh = match props.line_spacing {
|
let lh = match props.line_spacing {
|
||||||
|
|||||||
@@ -1467,3 +1467,99 @@ fn empty_plain_paragraphs_and_soft_breaks_have_no_bullet_preview() {
|
|||||||
assert_eq!(l.lines.len(), 2);
|
assert_eq!(l.lines.len(), 2);
|
||||||
assert!(l.empty_bullets.is_empty(), "a soft line break is not a new list item");
|
assert!(l.empty_bullets.is_empty(), "a soft line break is not a new list item");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A run of `text` at `size` points.
|
||||||
|
fn sized_run(text: &str, size: f64) -> Run {
|
||||||
|
let mut r = Run::new(text);
|
||||||
|
r.props.size = Some(size);
|
||||||
|
r
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A line break whose own run properties are `size` points.
|
||||||
|
fn sized_break(size: f64) -> Run {
|
||||||
|
let mut r = Run { text: String::new(), props: Default::default(), kind: deckcraft_model::text::RunKind::Break };
|
||||||
|
r.props.size = Some(size);
|
||||||
|
r
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The height one line of `size`-point text takes in this shape: the yardstick the empty-line
|
||||||
|
/// tests measure against, so they do not depend on the test font's ascent and descent.
|
||||||
|
fn one_line_height(p: &Presentation, sh: &mut Shape, size: f64) -> f64 {
|
||||||
|
sh.text.as_mut().unwrap().paragraphs = vec![Paragraph { runs: vec![sized_run("X", size)], ..Default::default() }];
|
||||||
|
let l = lay(p, sh, 400.0, 400.0);
|
||||||
|
l.lines[0].bottom - l.lines[0].top
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_empty_line_is_sized_by_the_break_that_ends_it() {
|
||||||
|
// PowerPoint sizes an empty line by the mark that closes it, not by the paragraph's first
|
||||||
|
// run (#780). Case A of the report: 40pt text, a 40pt break, a 10pt break, 40pt text, so
|
||||||
|
// the empty second line is a 10pt line and not a 40pt one.
|
||||||
|
let (p, mut sh) = setup("x", 400.0, 400.0);
|
||||||
|
let (h10, h40, h60, h20) = (
|
||||||
|
one_line_height(&p, &mut sh, 10.0),
|
||||||
|
one_line_height(&p, &mut sh, 40.0),
|
||||||
|
one_line_height(&p, &mut sh, 60.0),
|
||||||
|
one_line_height(&p, &mut sh, 20.0),
|
||||||
|
);
|
||||||
|
let put = |sh: &mut Shape, runs: Vec<Run>| {
|
||||||
|
sh.text.as_mut().unwrap().paragraphs = vec![Paragraph { runs, ..Default::default() }];
|
||||||
|
};
|
||||||
|
|
||||||
|
put(&mut sh, vec![sized_run("A1", 40.0), sized_break(40.0), sized_break(10.0), sized_run("A2", 40.0)]);
|
||||||
|
let a = lay(&p, &sh, 400.0, 400.0);
|
||||||
|
assert_eq!(a.lines.len(), 3, "{:?}", a.lines.iter().map(|l| l.top).collect::<Vec<_>>());
|
||||||
|
let empty_a = a.lines[2].top - a.lines[1].top;
|
||||||
|
assert!((empty_a - h10).abs() < 0.5, "a 10pt break should give a 10pt line ({h10}), got {empty_a}");
|
||||||
|
assert!((empty_a - h40).abs() > 1.0, "and not the 40pt first run's line ({h40})");
|
||||||
|
|
||||||
|
// Case C: a 60pt break closing the empty line of a 20pt paragraph pushes the third line
|
||||||
|
// down, the opposite direction from the first run's size -- so this cannot pass by the old
|
||||||
|
// behaviour under a different font.
|
||||||
|
put(&mut sh, vec![sized_run("C1", 20.0), sized_break(20.0), sized_break(60.0), sized_run("C2", 20.0)]);
|
||||||
|
let c = lay(&p, &sh, 400.0, 400.0);
|
||||||
|
let empty_c = c.lines[2].top - c.lines[1].top;
|
||||||
|
assert!((empty_c - h60).abs() < 0.5, "a 60pt break should give a 60pt line ({h60}), got {empty_c}");
|
||||||
|
assert!((empty_c - h20).abs() > 1.0, "and not the 20pt first run's line ({h20})");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_empty_last_line_is_sized_by_the_paragraph_mark() {
|
||||||
|
// Case B: 40pt text and a trailing 40pt break, with the paragraph mark at 10pt. No break
|
||||||
|
// closes the empty last line, so it takes the mark's size.
|
||||||
|
let (p, mut sh) = setup("x", 400.0, 400.0);
|
||||||
|
let (h10, h40) = (one_line_height(&p, &mut sh, 10.0), one_line_height(&p, &mut sh, 40.0));
|
||||||
|
{
|
||||||
|
let t = sh.text.as_mut().unwrap();
|
||||||
|
let mut para = Paragraph { runs: vec![sized_run("B1", 40.0), sized_break(40.0)], ..Default::default() };
|
||||||
|
para.end_props.size = Some(10.0);
|
||||||
|
t.paragraphs = vec![para];
|
||||||
|
}
|
||||||
|
let l = lay(&p, &sh, 400.0, 400.0);
|
||||||
|
assert_eq!(l.lines.len(), 2, "the trailing break leaves an empty last line");
|
||||||
|
let h = l.lines[1].bottom - l.lines[1].top;
|
||||||
|
assert!((h - h10).abs() < 0.5, "a 10pt paragraph mark should give {h10}, got {h}");
|
||||||
|
assert!((h - h40).abs() > 1.0, "and not the 40pt first run's line ({h40})");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_empty_paragraph_keeps_its_paragraph_mark_height() {
|
||||||
|
// Case D, the control: an empty *paragraph* already took `end_props` before this change,
|
||||||
|
// through `lead`, and must be unaffected by it.
|
||||||
|
let (p, mut sh) = setup("x", 400.0, 400.0);
|
||||||
|
let h10 = one_line_height(&p, &mut sh, 10.0);
|
||||||
|
{
|
||||||
|
let t = sh.text.as_mut().unwrap();
|
||||||
|
let mut empty = Paragraph::default();
|
||||||
|
empty.end_props.size = Some(10.0);
|
||||||
|
t.paragraphs = vec![
|
||||||
|
Paragraph { runs: vec![sized_run("D1", 40.0)], ..Default::default() },
|
||||||
|
empty,
|
||||||
|
Paragraph { runs: vec![sized_run("D2", 40.0)], ..Default::default() },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
let l = lay(&p, &sh, 400.0, 400.0);
|
||||||
|
assert_eq!(l.lines.len(), 3);
|
||||||
|
let h = l.lines[1].bottom - l.lines[1].top;
|
||||||
|
assert!((h - h10).abs() < 0.5, "a 10pt empty paragraph should give {h10}, got {h}");
|
||||||
|
}
|
||||||
|
|||||||
@@ -37,3 +37,40 @@ fn the_outline_of_a_huge_deck_lays_out_only_what_is_in_view() {
|
|||||||
assert!(h.query_all_by_role(Role::TextInput).count() < 200);
|
assert!(h.query_all_by_role(Role::TextInput).count() < 200);
|
||||||
assert_eq!(h.state().session.doc().unwrap().selection.slide, 1500);
|
assert_eq!(h.state().session.doc().unwrap().selection.slide, 1500);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The thumbnail pane of an 800-slide deck renders only the thumbnails in view. When every row
|
||||||
|
/// asked for one, a deck bigger than the 600-entry thumbnail cache evicted its own thumbnails before
|
||||||
|
/// the pane was done, so the pane kept rendering and flashing white forever (#969).
|
||||||
|
#[test]
|
||||||
|
fn the_thumbnail_pane_of_a_huge_deck_renders_only_the_slides_in_view() {
|
||||||
|
let mut h = harness(800);
|
||||||
|
h.run_steps(60);
|
||||||
|
let settled = h.state().textures.thumb_count();
|
||||||
|
assert!(settled > 3 && settled < 60, "{settled} thumbnails rendered");
|
||||||
|
// The visible thumbnails are done: more frames render nothing new.
|
||||||
|
h.run_steps(10);
|
||||||
|
assert_eq!(h.state().textures.thumb_count(), settled);
|
||||||
|
// Scrolled far down, the slides now in view get their thumbnails, and only those.
|
||||||
|
h.hover_at(egui::pos2(100.0, 450.0));
|
||||||
|
h.event(egui::Event::MouseWheel {
|
||||||
|
unit: egui::MouseWheelUnit::Point,
|
||||||
|
delta: egui::vec2(0.0, -40_000.0),
|
||||||
|
modifiers: egui::Modifiers::NONE,
|
||||||
|
phase: egui::TouchPhase::Move,
|
||||||
|
});
|
||||||
|
h.run_steps(60);
|
||||||
|
let scrolled = h.state().textures.thumb_count();
|
||||||
|
assert!(scrolled > settled && scrolled < 2 * settled + 10, "{scrolled} thumbnails after scrolling (was {settled})");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The same in the Slide Sorter: only the cells in view render a thumbnail (#969).
|
||||||
|
#[test]
|
||||||
|
fn the_sorter_of_a_huge_deck_renders_only_the_slides_in_view() {
|
||||||
|
let mut h = harness(800);
|
||||||
|
h.state_mut().ui.view = crate::ViewMode::Sorter;
|
||||||
|
h.run_steps(60);
|
||||||
|
let settled = h.state().textures.thumb_count();
|
||||||
|
assert!(settled > 3 && settled < 80, "{settled} thumbnails rendered");
|
||||||
|
h.run_steps(10);
|
||||||
|
assert_eq!(h.state().textures.thumb_count(), settled);
|
||||||
|
}
|
||||||
|
|||||||
@@ -203,13 +203,17 @@ fn place_slides(app: &mut SlideApp, ui: &mut Ui, g: &Grid<'_>, budget: &mut u32,
|
|||||||
let img = crate::textures::device_pixel_rect(Rect::from_min_size(cell.min + vec2(18.0, 8.0), vec2(g.tw, g.th)), ppp);
|
let img = crate::textures::device_pixel_rect(Rect::from_min_size(cell.min + vec2(18.0, 8.0), vec2(g.tw, g.th)), ppp);
|
||||||
rects.push((i, cell));
|
rects.push((i, cell));
|
||||||
let p = ui.painter();
|
let p = ui.painter();
|
||||||
match app.textures.thumb(ui.ctx(), g.doc, i, (img.width() * ppp).round() as u32, budget) {
|
// Only cells in view (plus a little margin) ask for a thumbnail, so a deck bigger
|
||||||
|
// than the thumbnail cache does not evict its own thumbnails forever (#969).
|
||||||
|
let near = ui.clip_rect().expand2(vec2(0.0, 2.0 * cell.height())).intersects(cell);
|
||||||
|
let tex = if near { app.textures.thumb(ui.ctx(), g.doc, i, (img.width() * ppp).round() as u32, budget) } else { None };
|
||||||
|
match tex {
|
||||||
Some(tex) => {
|
Some(tex) => {
|
||||||
p.image(tex.id(), img, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
|
p.image(tex.id(), img, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
p.rect_filled(img, CornerRadius::ZERO, Color32::WHITE);
|
p.rect_filled(img, CornerRadius::ZERO, Color32::WHITE);
|
||||||
need_more = true;
|
need_more |= near;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let id = g.doc.slides.get(i).map(|s| s.id);
|
let id = g.doc.slides.get(i).map(|s| s.id);
|
||||||
|
|||||||
@@ -115,7 +115,11 @@ fn list(app: &mut SlideApp, ui: &mut Ui) {
|
|||||||
p.text(pos2(row.min.x + 14.0, img.min.y + 20.0), Align2::CENTER_TOP, "★", theme::font(10.0), t.text_faint);
|
p.text(pos2(row.min.x + 14.0, img.min.y + 20.0), Align2::CENTER_TOP, "★", theme::font(10.0), t.text_faint);
|
||||||
}
|
}
|
||||||
let wpx = (img.width() * ppp).round() as u32;
|
let wpx = (img.width() * ppp).round() as u32;
|
||||||
match app.textures.thumb(ui.ctx(), &doc, i, wpx, &mut budget) {
|
// Only rows in view (plus a little margin) ask for a thumbnail: with every row asking, a
|
||||||
|
// deck bigger than the thumbnail cache evicts its own thumbnails and never settles (#969).
|
||||||
|
let near = ui.clip_rect().expand2(vec2(0.0, 2.0 * row.height())).intersects(row);
|
||||||
|
let tex = if near { app.textures.thumb(ui.ctx(), &doc, i, wpx, &mut budget) } else { None };
|
||||||
|
match tex {
|
||||||
Some(tex) => {
|
Some(tex) => {
|
||||||
p.image(
|
p.image(
|
||||||
tex.id(),
|
tex.id(),
|
||||||
@@ -126,7 +130,7 @@ fn list(app: &mut SlideApp, ui: &mut Ui) {
|
|||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
p.rect_filled(img, CornerRadius::ZERO, Color32::WHITE);
|
p.rect_filled(img, CornerRadius::ZERO, Color32::WHITE);
|
||||||
need_more = true;
|
need_more |= near;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if selected {
|
if selected {
|
||||||
|
|||||||
Reference in New Issue
Block a user