diff --git a/crates/engine/src/cmd/recolor.rs b/crates/engine/src/cmd/recolor.rs index 3ee049eb..734d166d 100644 --- a/crates/engine/src/cmd/recolor.rs +++ b/crates/engine/src/cmd/recolor.rs @@ -1,8 +1,9 @@ //! Picture Format ▸ Color: Recolor presets and Set Transparent Color. use deckcraft_color::{Rgba, SchemeSlot}; -use deckcraft_model::style::PictureAdjust; -use deckcraft_model::{ColorRef, ShapeKind}; +use deckcraft_geom::{Affine, Point, Rect, Xfrm}; +use deckcraft_model::style::{PictureAdjust, PictureMode}; +use deckcraft_model::{ColorRef, Shape, ShapeId, ShapeKind}; use serde_json::{Value, json}; use super::*; @@ -27,7 +28,7 @@ pub fn specs() -> Vec { "Set Transparent Color", ["Picture Format", "Adjust", "Color"], None, - "{color: #RRGGBB | none: true, ids?} (pixels of that colour become transparent)", + "{color: #RRGGBB | at: [x, y] | none: true, ids?} (pixels of that colour become transparent; `at` takes the colour of the picture's own pixel under that slide point, as a click does in PowerPoint)", has_selection, transparent_color ), @@ -121,6 +122,15 @@ fn recolor(s: &mut Session, p: &Value) -> Result { } fn transparent_color(s: &mut Session, p: &Value) -> Result { + if let Some(at) = p.get("at") { + let (id, c) = picture_pixel_at(s, p, at)?; + return edit_shapes(s, &json!({"ids": [id]}), "picture.transparentColor", |sh| { + if let ShapeKind::Picture { fill } = &mut sh.kind { + fill.adjust.clear_color = Some(c); + } + Ok(()) + }); + } let c = if bool_or(p, "none", false) { None } else { @@ -134,3 +144,97 @@ fn transparent_color(s: &mut Session, p: &Value) -> Result { Ok(()) }) } + +/// The topmost target picture under slide point `at` and the colour of its own pixel there: the +/// picture as stored, before corrections and recolouring, so the colour matches the pixels that +/// `clear_color` is compared with. +fn picture_pixel_at(s: &Session, p: &Value, at: &Value) -> Result<(ShapeId, Rgba)> { + const CMD: &str = "picture.transparentColor"; + let xy = |i: usize| at.get(i).and_then(Value::as_f64).filter(|v| v.is_finite()); + let (Some(x), Some(y)) = (xy(0), xy(1)) else { + return Err(bad(CMD, "`at` is [x, y] in slide units")); + }; + let mut ids = targets(s, p)?; + let st = s.doc()?; + let shapes = current_shapes(&st.doc, &st.selection).ok_or_else(|| bad(CMD, "no slide"))?; + // Same order as the hit-test: later in the shape list is in front, and a group's children sit + // where the group is. The selection order says nothing about what is on top. + ids.sort_by_cached_key(|id| std::cmp::Reverse(stack_path(shapes, *id))); + for id in ids { + let Some((sh, xf, to_slide)) = placed(&st.doc, &st.selection, shapes, id, Affine::IDENTITY, 0) else { continue }; + let ShapeKind::Picture { fill } = &sh.kind else { continue }; + // The camera turns the picture where it is drawn, so the click is mapped back through it too. + let turn = text::ctx_for(&st.doc, &st.selection).and_then(|c| deckcraft_render::camera_turn(&c, sh, xf.w, xf.h)).unwrap_or(Affine::IDENTITY); + let local = (to_slide * turn).inverse() * Point::new(x, y); + if !(local.is_finite() && (0.0..=xf.w).contains(&local.x) && (0.0..=xf.h).contains(&local.y)) { + continue; + } + let PictureMode::Stretch { fill_rect: [l, t, r, b] } = fill.mode else { + return Err(bad(CMD, "a tiled picture has no single pixel under a point; give `color`")); + }; + let inner = Rect::new(l * xf.w, t * xf.h, xf.w - r * xf.w, xf.h - b * xf.h); + let dest = deckcraft_render::crop_dest(inner, fill.crop); + let img = + st.doc.media(fill.media).and_then(|m| deckcraft_render::decode_image(&m.data)).ok_or_else(|| bad(CMD, "the picture can't be read"))?; + let (u, v) = ((local.x - dest.x0) / dest.width(), (local.y - dest.y0) / dest.height()); + if !((0.0..=1.0).contains(&u) && (0.0..=1.0).contains(&v)) { + continue; + } + let (w, h) = (img.width(), img.height()); + let px = ((u * f64::from(w)) as u32).min(w.saturating_sub(1)); + let py = ((v * f64::from(h)) as u32).min(h.saturating_sub(1)); + let Some(c) = img.get_pixel_checked(px, py) else { continue }; + // A transparent pixel keeps a colour nobody sees (often black): clearing it would clear + // that colour where it is drawn. + if c[3] == 0 { + return Err(bad(CMD, "that spot of the picture is already transparent")); + } + return Ok((id, Rgba::rgb(c[0], c[1], c[2]))); + } + Err(bad(CMD, "the point is not on a selected picture")) +} + +/// Where `id` sits in the paint order: its index in each list from the slide down to it. A larger +/// path is drawn later, so in front. +fn stack_path(shapes: &[Shape], id: ShapeId) -> Option> { + for (i, sh) in shapes.iter().enumerate() { + if sh.id == id { + return Some(vec![i]); + } + if let ShapeKind::Group { children, .. } = &sh.kind + && let Some(mut rest) = stack_path(children, id) + { + rest.insert(0, i); + return Some(rest); + } + } + None +} + +/// Shape `id` among `shapes` (searched into groups), its box, and the map from that box to slide +/// space. +fn placed<'a>( + doc: &deckcraft_model::Presentation, + sel: &crate::Selection, + shapes: &'a [Shape], + id: ShapeId, + to_slide: Affine, + depth: usize, +) -> Option<(&'a Shape, Xfrm, Affine)> { + if depth > 64 { + return None; + } + for sh in shapes { + let x = xfrm_of(doc, sel, sh); + if sh.id == id { + return Some((sh, x, to_slide * x.affine())); + } + if let ShapeKind::Group { children, child } = &sh.kind { + let ch = Rect::new(child.x, child.y, child.x + child.w, child.y + child.h); + if let Some(found) = placed(doc, sel, children, id, to_slide * deckcraft_geom::group_child_affine(&x, ch), depth + 1) { + return Some(found); + } + } + } + None +} diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index cf7c5070..3997e974 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2395,6 +2395,136 @@ fn picture_recolor_and_transparent_color() { assert_eq!(adj(&s), deckcraft_model::style::PictureAdjust::default()); } +#[test] +fn transparent_color_at_a_point_takes_the_pictures_own_pixel_there() { + // Red on the left half, blue on the right. + let img = image::RgbaImage::from_fn(4, 2, |x, _| if x < 2 { image::Rgba([255, 0, 0, 255]) } else { image::Rgba([0, 0, 255, 255]) }); + let mut png = vec![]; + image::DynamicImage::ImageRgba8(img).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + let mut s = session(); + let id = + s.execute("insert.picture", &json!({"data": cmd::base64_encode(&png), "name": "a.png", "rect": [100, 100, 400, 200]})).unwrap()["id"].clone(); + let clear = |s: &Session| match &s.doc().unwrap().shapes().iter().find(|sh| json!(sh.id) == id).unwrap().kind { + deckcraft_model::ShapeKind::Picture { fill } => fill.adjust.clear_color, + _ => panic!("not a picture"), + }; + let red = deckcraft_color::Rgba::rgb(255, 0, 0); + let blue = deckcraft_color::Rgba::rgb(0, 0, 255); + s.execute("picture.transparentColor", &json!({"ids": [id], "at": [450, 200]})).unwrap(); + assert_eq!(clear(&s), Some(blue)); + // The picture's own colour, not the one drawn after a correction brightened it. + s.execute("picture.adjust", &json!({"ids": [id], "brightness": 0.4})).unwrap(); + s.execute("picture.transparentColor", &json!({"ids": [id], "at": [150, 200]})).unwrap(); + assert_eq!(clear(&s), Some(red)); + // Flipped, the blue half is drawn on the left. + s.execute("shape.flip", &json!({"ids": [id], "axis": "horizontal"})).unwrap(); + s.execute("picture.transparentColor", &json!({"ids": [id], "at": [150, 200]})).unwrap(); + assert_eq!(clear(&s), Some(blue)); + // Beside the picture there is no colour to take, and nothing changes. + assert!(s.execute("picture.transparentColor", &json!({"ids": [id], "at": [50, 50]})).is_err()); + assert_eq!(clear(&s), Some(blue)); + // A spot that is already transparent hides a colour (black here, as in most PNGs): taking it + // would clear the black drawn elsewhere in the picture. + let img = image::RgbaImage::from_fn(4, 2, |x, _| if x < 2 { image::Rgba([0, 0, 0, 0]) } else { image::Rgba([0, 0, 0, 255]) }); + let mut png = vec![]; + image::DynamicImage::ImageRgba8(img).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + let logo = + s.execute("insert.picture", &json!({"data": cmd::base64_encode(&png), "name": "b.png", "rect": [100, 400, 400, 200]})).unwrap()["id"].clone(); + assert!(s.execute("picture.transparentColor", &json!({"ids": [logo], "at": [150, 500]})).is_err()); + s.execute("picture.transparentColor", &json!({"ids": [logo], "at": [450, 500]})).unwrap(); +} + +/// A picture of `n` one-pixel columns whose red channel is 20 * column, inserted at `rect`. +fn ramp_picture(s: &mut Session, n: u32, rect: [u32; 4]) -> serde_json::Value { + let img = image::RgbaImage::from_fn(n, 1, |x, _| image::Rgba([(20 * x) as u8, 0, 0, 255])); + let mut png = vec![]; + image::DynamicImage::ImageRgba8(img).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + s.execute("insert.picture", &json!({"data": cmd::base64_encode(&png), "name": "r.png", "rect": rect})).unwrap()["id"].clone() +} + +fn clear_color_of(s: &Session, id: &serde_json::Value) -> Option { + fn find(shapes: &[deckcraft_model::Shape], id: &serde_json::Value) -> Option { + shapes.iter().find_map(|sh| { + if json!(sh.id) == *id { + return Some(sh.clone()); + } + match &sh.kind { + deckcraft_model::ShapeKind::Group { children, .. } => find(children, id), + _ => None, + } + }) + } + match find(s.doc().unwrap().shapes(), id).unwrap().kind { + deckcraft_model::ShapeKind::Picture { fill } => fill.adjust.clear_color, + _ => panic!("not a picture"), + } +} + +#[test] +fn transparent_color_at_a_point_follows_the_pictures_3d_camera() { + let mut s = session(); + let id = ramp_picture(&mut s, 10, [100, 100, 200, 100]); + // Turned 60 degrees about the vertical axis the picture is drawn about half as wide, centred + // where it was: 149..251 instead of 100..300. + s.execute("shape.effects", &json!({"ids": [id], "threeD": {"camera": "orthographicFront", "rotX": 60}})).unwrap(); + // 175 is a quarter of the way across the drawn picture: column 2, not column 3 (the 75/200 + // of the unturned box). + s.execute("picture.transparentColor", &json!({"ids": [id], "at": [175, 150]})).unwrap(); + assert_eq!(clear_color_of(&s, &id), Some(deckcraft_color::Rgba::rgb(40, 0, 0))); + // Inside the unturned box but outside the drawn picture: nothing is under the pointer. + assert!(s.execute("picture.transparentColor", &json!({"ids": [id], "at": [120, 150]})).is_err()); +} + +#[test] +fn transparent_color_at_a_point_finds_a_grouped_picture_without_its_own_box() { + let mut s = session(); + let pic = ramp_picture(&mut s, 10, [100, 100, 200, 100]); + let other = s.execute("shape.insert", &json!({"preset": "rect", "rect": [400, 300, 50, 50]})).unwrap()["id"].clone(); + s.execute("edit.select", &json!({"ids": [pic, other]})).unwrap(); + s.execute("arrange.group", &json!({})).unwrap(); + // A grouped picture that keeps no box of its own is drawn where the default box puts it. + let sz = s.doc().unwrap().doc.slide_size; + s.edit(|doc, sel| { + fn clear(shapes: &mut [deckcraft_model::Shape], id: &serde_json::Value) { + for sh in shapes { + if json!(sh.id) == *id { + sh.xfrm = None; + } + if let deckcraft_model::ShapeKind::Group { children, .. } = &mut sh.kind { + clear(children, id); + } + } + } + clear(crate::shapes_mut(doc, sel).unwrap(), &pic); + Ok(()) + }) + .unwrap(); + // Left tenth of the default box (10%, 10%, 80% x 20% of the slide): column 0. + let at = [sz.width * 0.1 + 1.0, sz.height * 0.2]; + s.execute("picture.transparentColor", &json!({"ids": [pic], "at": at})).unwrap(); + assert_eq!(clear_color_of(&s, &pic), Some(deckcraft_color::Rgba::rgb(0, 0, 0))); + let at = [sz.width * 0.9 - 1.0, sz.height * 0.2]; + s.execute("picture.transparentColor", &json!({"ids": [pic], "at": at})).unwrap(); + assert_eq!(clear_color_of(&s, &pic), Some(deckcraft_color::Rgba::rgb(180, 0, 0))); +} + +#[test] +fn transparent_color_at_a_point_takes_the_topmost_selected_picture() { + let mut s = session(); + // `under` is inserted first (drawn below), `over` after it (drawn on top); they overlap in x 150..300. + let under = ramp_picture(&mut s, 10, [100, 100, 200, 100]); + let over = ramp_picture(&mut s, 10, [150, 100, 200, 100]); + // Selected bottom first: selection order is the opposite of the stacking order. + s.execute("edit.select", &json!({"ids": [under, over]})).unwrap(); + // x = 250 is column 7 of `under` (140) and column 5 of `over` (100): the click is on `over`. + s.execute("picture.transparentColor", &json!({"at": [250, 150]})).unwrap(); + assert_eq!(clear_color_of(&s, &over), Some(deckcraft_color::Rgba::rgb(100, 0, 0))); + assert_eq!(clear_color_of(&s, &under), None); + // Where `over` is not, the picture below is still found. + s.execute("picture.transparentColor", &json!({"at": [120, 150]})).unwrap(); + assert_eq!(clear_color_of(&s, &under), Some(deckcraft_color::Rgba::rgb(20, 0, 0))); +} + #[test] fn select_all_slides_scope() { let mut s = session(); diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 28df6567..795da053 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -600,6 +600,16 @@ pub fn parse_path(d: &str) -> BezPath { bp } +/// The transform a 3-D camera applies to shape `s` of size `w` x `h` (approximated by the affine +/// map that fits it best), as drawn; `None` for no camera, and for groups, tables and charts. +pub fn camera_turn(rctx: &Ctx, s: &Shape, w: f64, h: f64) -> Option { + if matches!(s.kind, ShapeKind::Group { .. } | ShapeKind::Table(_) | ShapeKind::Chart(_)) { + return None; + } + let (e, _) = resolve::effects(rctx, s); + e.as_ref().and_then(|e| e.raw3d.as_deref()).and_then(camera::from_raw).and_then(|c| camera::affine(&c, w, h)) +} + /// The rectangle a picture's image occupies given its crop (fractions; negative = padding). pub fn crop_dest(bounds: Rect, crop: [f64; 4]) -> Rect { let [l, t, r, b] = crop.map(|v| if v.is_finite() { v.clamp(-10.0, 0.99) } else { 0.0 }); @@ -873,15 +883,7 @@ impl Renderer { * Affine::translate(-c.to_vec2()); m = parent * Affine::translate(st.offset) * x.affine() * around; } - // A 3-D camera turns the shape (approximated by the affine transform that fits it best). - if !matches!(s.kind, ShapeKind::Group { .. } | ShapeKind::Table(_) | ShapeKind::Chart(_)) - && let Some(a) = resolve::effects(rctx, s) - .0 - .as_ref() - .and_then(|e| e.raw3d.as_deref()) - .and_then(camera::from_raw) - .and_then(|c| camera::affine(&c, x.w, x.h)) - { + if let Some(a) = camera_turn(rctx, s, x.w, x.h) { m *= a; } let layered = st.opacity < 0.999 || st.clip.is_some(); diff --git a/crates/ui-egui/src/eyedropper.rs b/crates/ui-egui/src/eyedropper.rs index f6483898..5d00f3e5 100644 --- a/crates/ui-egui/src/eyedropper.rs +++ b/crates/ui-egui/src/eyedropper.rs @@ -5,6 +5,9 @@ //! therefore hands its colour back through the same menu: the menu closes while the pointer picks, //! then opens again for one frame and `color_grid` returns the colour as if its swatch had been //! clicked, so every caller applies it its own way. +//! +//! Picture Format ▸ Color ▸ Set Transparent Color uses the same pointer, as PowerPoint does: the +//! click names the colour of the selected picture under it, which turns transparent. use egui::{Color32, Context, CursorIcon, Id, Pos2, Rect, Stroke, StrokeKind, Ui, pos2, vec2}; @@ -13,15 +16,25 @@ use crate::canvas::Xf; use crate::theme::{self, Tokens}; const REQUEST: &str = "deckcraft.eyedropper.request"; +const TRANSPARENT: &str = "deckcraft.eyedropper.transparent"; const MENU: &str = "deckcraft.eyedropper.menu"; const RESULT: &str = "deckcraft.eyedropper.result"; /// Pixels per slide unit of the picture the Eyedropper looks at. const SCALE: f64 = 2.0; -/// An Eyedropper waiting for a click: the slide as it is drawn and the menu button it came from. +/// What a click with the pointer does. +#[derive(Clone, Copy)] +enum Pick { + /// Hands the colour to the colour menu on this button. + Menu(Id), + /// Makes the colour of the selected picture under the pointer transparent. + TransparentColor, +} + +/// An Eyedropper waiting for a click: the slide as it is drawn and what the click is for. pub struct State { - menu: Id, + pick: Pick, slide: usize, image: deckcraft_render::Image, } @@ -50,6 +63,11 @@ pub fn request(ctx: &Context) { ctx.data_mut(|d| d.insert_temp(key(REQUEST), true)); } +/// Picture Format ▸ Color ▸ "Set Transparent Color" was chosen. +pub fn request_transparent_color(ctx: &Context) { + ctx.data_mut(|d| d.insert_temp(key(TRANSPARENT), true)); +} + /// Has the Eyedropper taken a colour for the menu on `button`? pub fn result_waits_for(ctx: &Context, button: Id) -> bool { ctx.data(|d| d.get_temp::>(key(RESULT))).flatten().is_some_and(|(b, _)| b == button) @@ -67,8 +85,13 @@ pub fn take_result(ctx: &Context) -> Option { /// Once a frame: start the Eyedropper when a menu asked for it, stop it when something else is /// clicked. pub fn tick(app: &mut SlideApp, ctx: &Context) { - if ctx.data_mut(|d| d.remove_temp::(key(REQUEST))).unwrap_or(false) { - begin(app, ctx); + if ctx.data_mut(|d| d.remove_temp::(key(REQUEST))).unwrap_or(false) + && let Some(menu) = open_menu(ctx) + { + begin(app, Pick::Menu(menu)); + } + if ctx.data_mut(|d| d.remove_temp::(key(TRANSPARENT))).unwrap_or(false) { + begin(app, Pick::TransparentColor); } if app.eyedropper.is_some() { let outside = @@ -79,8 +102,7 @@ pub fn tick(app: &mut SlideApp, ctx: &Context) { } } -fn begin(app: &mut SlideApp, ctx: &Context) { - let Some(menu) = open_menu(ctx) else { return }; +fn begin(app: &mut SlideApp, pick: Pick) { let Some(d) = app.session.active() else { return }; if d.selection.target != deckcraft_engine::Target::Slides { app.set_status("The Eyedropper takes colours from a slide"); @@ -88,8 +110,11 @@ fn begin(app: &mut SlideApp, ctx: &Context) { } let slide = d.selection.slide; let image = deckcraft_render::render_slide(&d.doc, slide, &deckcraft_render::RenderOpts { scale: SCALE, ..Default::default() }); - app.eyedropper = Some(State { menu, slide, image }); - app.set_status("Eyedropper: click the slide (Esc cancels)"); + app.eyedropper = Some(State { pick, slide, image }); + app.set_status(match pick { + Pick::Menu(_) => "Eyedropper: click the slide (Esc cancels)", + Pick::TransparentColor => "Set Transparent Color: click the colour in the picture (Esc cancels)", + }); } fn hex(c: [u8; 4]) -> String { @@ -121,15 +146,24 @@ pub fn canvas(app: &mut SlideApp, ui: &Ui, resp: &egui::Response, xf: Xf) -> boo loupe(ui, pos, c); } if resp.clicked() { - let (menu, slide) = (state.menu, state.slide); + let (pick, slide) = (state.pick, state.slide); app.eyedropper = None; - match app.session.execute("slide.colorAt", &serde_json::json!({"slide": slide, "x": p.x, "y": p.y, "scale": SCALE})) { - Ok(v) => { - if let Some(color) = v["color"].as_str() { - store_result(ui.ctx(), menu, color); + match pick { + Pick::Menu(menu) => { + match app.session.execute("slide.colorAt", &serde_json::json!({"slide": slide, "x": p.x, "y": p.y, "scale": SCALE})) { + Ok(v) => { + if let Some(color) = v["color"].as_str() { + store_result(ui.ctx(), menu, color); + } + } + Err(e) => app.set_status(e.to_string()), + } + } + Pick::TransparentColor => { + if let Err(e) = app.session.execute("picture.transparentColor", &serde_json::json!({"at": [p.x, p.y]})) { + app.set_status(e.to_string()); } } - Err(e) => app.set_status(e.to_string()), } ui.ctx().request_repaint(); } diff --git a/crates/ui-egui/src/eyedropper_tests.rs b/crates/ui-egui/src/eyedropper_tests.rs index 245c4be2..cc4687b3 100644 --- a/crates/ui-egui/src/eyedropper_tests.rs +++ b/crates/ui-egui/src/eyedropper_tests.rs @@ -4,6 +4,7 @@ use deckcraft_engine::Session; use egui::{Event, Id, Key, Modifiers, PointerButton, Pos2}; use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; use serde_json::json; use crate::eyedropper::{note_open_menu, request, result_waits_for, take_result}; @@ -111,3 +112,41 @@ fn escape_and_a_click_off_the_slide_take_nothing() { other.run_steps(3); assert!(other.state().eyedropper.is_none()); } + +#[test] +fn set_transparent_color_takes_the_colour_clicked_in_the_picture() { + // Red on the left half, blue on the right. + let pixels = (0..8).flat_map(|i| if i % 4 < 2 { [255, 0, 0, 255] } else { [0, 0, 255, 255] }).collect(); + let png = deckcraft_render::Image { width: 4, height: 2, pixels }.to_png(); + let mut session = Session::with_new(); + session.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + let data = deckcraft_engine::cmd::base64_encode(&png); + session.execute("insert.picture", &json!({"data": data, "name": "a.png", "rect": [100, 100, 400, 200]})).unwrap(); + let mut app = SlideApp::new(session, Services::default()); + app.ui.tab = "Picture Format".into(); + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1200.0)).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ); + h.run_steps(4); + // A colour menu opened earlier (Shape Fill, say) must not receive the colour. + note_open_menu(&h.ctx, menu()); + h.get_by_label("Color").click(); + h.run_steps(3); + h.get_by_label("Set Transparent Color").click(); + h.run_steps(3); + assert!(h.state().eyedropper.is_some(), "the command arms the pointer"); + let blue_half = at(&h, 450.0, 200.0); + click(&mut h, blue_half); + assert!(h.state().eyedropper.is_none(), "one click ends it"); + assert!(!result_waits_for(&h.ctx, menu()), "the colour went to the picture, not to another menu"); + let doc = h.state().session.doc().unwrap(); + let clear = doc.shapes().iter().find_map(|sh| match &sh.kind { + deckcraft_model::ShapeKind::Picture { fill } => Some(fill.adjust.clear_color), + _ => None, + }); + assert_eq!(clear, Some(Some(deckcraft_color::Rgba::rgb(0, 0, 255)))); +} diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 4a8139f8..c963b663 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -2652,18 +2652,12 @@ fn picture_format(app: &mut SlideApp, ui: &mut Ui) { run(app, "picture.recolor", json!({"preset": id})); } ui.separator(); - ui.menu_button("Set Transparent Color", |ui| { - ui.label("Pixels of this colour become transparent:"); - if let Some(c) = widgets::color_grid(ui, &sc, Some("None")) { - match c { - Some(c) => { - let rgb = c.resolve(&sc, None); - run(app, "picture.transparentColor", json!({"color": format!("#{}", rgb.hex())})); - } - None => run(app, "picture.transparentColor", json!({"none": true})), - } - } - }); + // As in PowerPoint, the pointer picks the colour in the picture itself: a colour + // chosen from a grid almost never matches a photo's pixels. + if ui.button("Set Transparent Color").on_hover_text("Click a colour in the picture to make it transparent").clicked() { + crate::eyedropper::request_transparent_color(ui.ctx()); + ui.close(); + } }); let r = big_button(ui, Icon::ArtisticEffects, "Artistic\nEffects", enabled(app, "picture.artisticEffect")); egui::Popup::menu(&r).show(|ui| {