Browse / github.com/storytold/figcraft / patch
fix(engine): make Set as thumbnail affect saved files (#1428)
"Set as thumbnail" (object.setThumbnail) has no effect on the file's thumbnail What happens object.setThumbnail (Object menu ▸ Set as thumbnail) only stores plugin_data["figcraft.thumbnail"] = "true" on the chosen frame (and clears it from any other node). Nothing reads that flag when the thumbnail is made: thumbnail() in crates/engine/src/cmd/file/save.rs (it was cmd/file.rs before the file split) always renders the first page's content bounds. The same function feeds the .fig export's thumbnail. The only other uses of the key are the setter itself (cmd/structure.rs) and a cleanup in crates/engine/src/assets/library.rs. Checked in the source of main at 43af145 (2026-10-
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- Zaxaerith (imported from pull request #1432, open)
- Base
43af145a12eacd1e87f5f5e6984c49b7da040288- Size
- +187 −11 in 1 file
- Review
- pending
- Shared
- 2026-10-28
- Id
0049b942a2575c6623239efa
How it went
No other operator has reported using it yet.
Diff
raw · crates/engine/src/cmd/file/save.rs
@@ -3,19 +3,38 @@ use super::load::{ext, file_stem, write}; use super::open::remember; use super::*; -/// Thumbnail PNG of the first page's content (for file browsers / the home screen).+/// Thumbnail PNG of the chosen frame, or the first page's content (file browsers / home screen). pub fn thumbnail(doc: &Document) -> Option<Vec<u8>> {- let page = doc.first_page()?;- let b = doc.page_content_bounds(page)?;- let scale = (400.0 / b.width().max(b.height()).max(1.0)).min(2.0);- let view = figcraft_render::View {- origin: (b.x0, b.y0),- scale,- width: (b.width() * scale).ceil().max(1.0) as u32,- height: (b.height() * scale).ceil().max(1.0) as u32,+ let render = |page, b: figcraft_geom::Rect, solo| {+ let scale = (400.0 / b.width().max(b.height()).max(1.0)).min(2.0);+ let view = figcraft_render::View {+ origin: (b.x0, b.y0),+ scale,+ width: (b.width() * scale).ceil().max(1.0) as u32,+ height: (b.height() * scale).ceil().max(1.0) as u32,+ };+ let opts = figcraft_render::Options { solo, ..Default::default() };+ let pix = figcraft_render::render_view(doc, page, &view, &opts)?;+ figcraft_render::encode_png(&pix).ok() };- let pix = figcraft_render::render_view(doc, page, &view, &Default::default())?;- figcraft_render::encode_png(&pix).ok()+ doc.ids()+ .find_map(|id| {+ let n = doc.node(id)?;+ if !matches!(n.kind, NodeKind::Frame) || n.plugin_data.get("figcraft.thumbnail").map(String::as_str) != Some("true") {+ return None;+ }+ // Hidden library-store pages are not document pages.+ let page = doc.page_of(id).filter(|p| doc.pages.contains(p))?;+ let b = figcraft_render::export_bounds(doc, id)?;+ if ![b.x0, b.y0, b.x1, b.y1, b.width(), b.height()].into_iter().all(f64::is_finite) || b.width() <= 0.0 || b.height() <= 0.0 {+ return None;+ }+ render(page, b, Some(id))+ })+ .or_else(|| {+ let page = doc.first_page()?;+ render(page, doc.page_content_bounds(page)?, None)+ }) } /// The `.figcraft` file for a document: content, thumbnail and version history.@@ -223,3 +242,160 @@ pub(super) fn save_as(s: &mut Session, p: &Value) -> Result<Value> { } } }++#[cfg(test)]+mod thumbnail_tests {+ use super::*;+ use figcraft_color::{Color, Paint};+ use figcraft_geom::Affine;++ const RED: Color = Color::from_rgb8_const(255, 0, 0);+ const GREEN: Color = Color::from_rgb8_const(0, 255, 0);+ const BLUE: Color = Color::from_rgb8_const(0, 0, 255);++ fn scene() -> (Session, NodeId, NodeId) {+ let mut doc = Document::new("Thumbnail");+ let page = doc.first_page().unwrap();+ let cover = doc.create(page, NodeKind::Frame, "Cover", 0.0, 600.0, 100.0, 100.0).unwrap();+ let wide = doc.create(page, NodeKind::Frame, "Wide", 700.0, 600.0, 600.0, 100.0).unwrap();+ doc.get_mut(cover).unwrap().fills = vec![Paint::solid(RED)];+ doc.get_mut(wide).unwrap().fills = vec![Paint::solid(BLUE)];+ (Session::with_document(doc), cover, wide)+ }++ fn choose(s: &mut Session, id: NodeId) {+ s.execute("object.setThumbnail", &json!({"id": id})).unwrap();+ }++ fn picture(doc: &Document) -> image::RgbaImage {+ image::load_from_memory(&thumbnail(doc).unwrap()).unwrap().to_rgba8()+ }++ #[test]+ fn chosen_frame_draws_its_children_without_siblings_or_document_changes() {+ let (mut s, cover, _) = scene();+ let doc = s.doc_mut().unwrap();+ let page = doc.first_page().unwrap();+ let child = doc.create(cover, NodeKind::Rectangle, "Child", 20.0, 20.0, 20.0, 20.0).unwrap();+ doc.get_mut(child).unwrap().fills = vec![Paint::solid(GREEN)];+ // Cropping the whole page to Cover's bounds would still draw this overlapping sibling.+ let sibling = doc.create(page, NodeKind::Rectangle, "Overlap", 0.0, 600.0, 100.0, 100.0).unwrap();+ doc.get_mut(sibling).unwrap().fills = vec![Paint::solid(BLUE)];+ choose(&mut s, cover);+ let before = s.doc().unwrap().clone();+ let png = picture(s.doc().unwrap());+ assert_eq!(png.dimensions(), (200, 200));+ assert_eq!(png.get_pixel(10, 10).0, [255, 0, 0, 255]);+ assert_eq!(png.get_pixel(60, 60).0, [0, 255, 0, 255]);+ assert!(!png.pixels().any(|p| p.0 == [0, 0, 255, 255]), "neither blue sibling is drawn");+ assert_eq!(s.doc().unwrap(), &before);+ s.doc_mut().unwrap().set_world_transform(cover, Affine::translate((4200.0, -3600.0))).unwrap();+ assert_eq!(picture(s.doc().unwrap()), png, "moving the frame changes neither size nor contents");+ }++ #[test]+ fn no_thumbnail_choice_keeps_the_first_page_content_bounds() {+ let (s, _, _) = scene();+ let png = picture(s.doc().unwrap());+ assert_eq!(png.dimensions(), (400, 31));+ assert_eq!(png.get_pixel(10, 15).0, [255, 0, 0, 255]);+ assert_eq!(png.get_pixel(310, 15).0, [0, 0, 255, 255]);+ }++ #[test]+ fn invalid_flags_and_non_frames_keep_the_default_thumbnail() {+ let (mut s, cover, wide) = scene();+ let default = thumbnail(s.doc().unwrap()).unwrap();+ for value in ["false", "TRUE", ""] {+ s.doc_mut().unwrap().get_mut(cover).unwrap().plugin_data.insert("figcraft.thumbnail".into(), value.into());+ assert_eq!(thumbnail(s.doc().unwrap()).unwrap(), default, "flag {value:?}");+ }+ choose(&mut s, wide);+ s.doc_mut().unwrap().get_mut(wide).unwrap().kind = NodeKind::Rectangle;+ assert_eq!(thumbnail(s.doc().unwrap()).unwrap(), default, "only frames can be thumbnails");+ }++ #[test]+ fn deleting_the_chosen_frame_falls_back_to_the_remaining_page_content() {+ let (mut s, cover, _) = scene();+ choose(&mut s, cover);+ s.doc_mut().unwrap().remove(cover).unwrap();+ let png = picture(s.doc().unwrap());+ assert_eq!(png.dimensions(), (400, 67));+ assert_eq!(png.get_pixel(200, 30).0, [0, 0, 255, 255]);+ }++ #[test]+ fn invalid_thumbnail_ranges_and_pages_fall_back() {+ let (mut s, _, _) = scene();+ let default = thumbnail(s.doc().unwrap()).unwrap();+ let doc = s.doc_mut().unwrap();+ let page = doc.add_page("Invalid cover");+ let cover = doc.create(page, NodeKind::Frame, "Cover", 0.0, 0.0, 100.0, 100.0).unwrap();+ choose(&mut s, cover);+ for (width, height) in [(0.0, 100.0), (100.0, 0.0), (f64::INFINITY, 100.0), (f64::NAN, 100.0)] {+ let n = s.doc_mut().unwrap().get_mut(cover).unwrap();+ n.width = width;+ n.height = height;+ assert_eq!(thumbnail(s.doc().unwrap()).unwrap(), default, "invalid range {width} x {height}");+ }+ let doc = s.doc_mut().unwrap();+ let n = doc.get_mut(cover).unwrap();+ n.width = 100.0;+ n.height = 100.0;+ n.parent = Some(NodeId(u64::MAX));+ assert_eq!(thumbnail(doc).unwrap(), default, "missing parent page");+ doc.get_mut(cover).unwrap().parent = Some(page);+ doc.pages.retain(|p| *p != page);+ assert_eq!(thumbnail(doc).unwrap(), default, "page outside the document's page list");+ }++ #[test]+ fn a_nested_frame_on_another_page_uses_its_page_background() {+ let (mut s, _, _) = scene();+ let doc = s.doc_mut().unwrap();+ let page = doc.add_page("Cover page");+ if let NodeKind::Page { background, .. } = &mut doc.get_mut(page).unwrap().kind {+ *background = GREEN;+ }+ let section = doc.create(page, NodeKind::Section { contents_hidden: false }, "Section", 700.0, 900.0, 300.0, 200.0).unwrap();+ let cover = doc.create(section, NodeKind::Frame, "Cover", -50.0, 80.0, 80.0, 40.0).unwrap();+ doc.get_mut(cover).unwrap().fills.clear();+ let child = doc.create(cover, NodeKind::Rectangle, "Child", 10.0, 10.0, 20.0, 10.0).unwrap();+ doc.get_mut(child).unwrap().fills = vec![Paint::solid(RED)];+ choose(&mut s, cover);+ let png = picture(s.doc().unwrap());+ assert_eq!(png.dimensions(), (160, 80));+ assert_eq!(png.get_pixel(10, 10).0, [0, 255, 0, 255]);+ assert_eq!(png.get_pixel(30, 30).0, [255, 0, 0, 255]);+ }++ #[test]+ fn custom_thumbnails_keep_the_size_limit_and_aspect_ratio() {+ let (mut s, cover, _) = scene();+ choose(&mut s, cover);+ for (width, height, want) in [(100.0, 100.0, (200, 200)), (800.0, 200.0, (400, 100)), (200.0, 800.0, (100, 400))] {+ let n = s.doc_mut().unwrap().get_mut(cover).unwrap();+ n.width = width;+ n.height = height;+ assert_eq!(picture(s.doc().unwrap()).dimensions(), want);+ }+ }++ #[test]+ fn native_save_and_fig_export_embed_the_same_chosen_thumbnail() {+ let (mut s, cover, _) = scene();+ choose(&mut s, cover);+ let before = s.doc().unwrap().clone();+ let expected = picture(s.doc().unwrap());+ let native = file_bytes(s.ds().unwrap()).unwrap();+ let (fig, _) = super::super::export::fig_bytes(s.doc().unwrap()).unwrap();+ for bytes in [native, fig] {+ let png = figcraft_format::thumbnail::thumbnail_png(std::io::Cursor::new(bytes)).unwrap();+ let actual = image::load_from_memory(&png).unwrap().to_rgba8();+ assert_eq!(actual.dimensions(), (200, 200));+ assert_eq!(actual, expected);+ }+ assert_eq!(s.doc().unwrap(), &before);+ }+}