diff --git a/crates/engine/src/cmd/recolor.rs b/crates/engine/src/cmd/recolor.rs index 3ee049eb..7f2c5afb 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,70 @@ fn transparent_color(s: &mut Session, p: &Value) -> Result { Ok(()) }) } + +/// The first 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 ids = targets(s, p)?; + let st = s.doc()?; + let shapes = current_shapes(&st.doc, &st.selection).ok_or_else(|| bad(CMD, "no slide"))?; + 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 }; + let local = to_slide.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 }; + return Ok((id, Rgba::rgb(c[0], c[1], c[2]))); + } + Err(bad(CMD, "the point is not on a selected picture")) +} + +/// 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 Some(x) = (if depth == 0 { Some(xfrm_of(doc, sel, sh)) } else { sh.xfrm }) else { continue }; + 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 dd64b08c..525fdf73 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2352,6 +2352,36 @@ 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)); +} + #[test] fn select_all_slides_scope() { let mut s = session(); 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 3a679dd1..a64aa5e8 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -2636,18 +2636,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| {