mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-28 04:27:06 -06:00
Merge pull request #1233 from marcoarnulfo/fix/set-transparent-color-pick
This commit is contained in:
@@ -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<CommandSpec> {
|
||||
"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<Value> {
|
||||
}
|
||||
|
||||
fn transparent_color(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<Value> {
|
||||
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<Vec<usize>> {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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<deckcraft_color::Rgba> {
|
||||
fn find(shapes: &[deckcraft_model::Shape], id: &serde_json::Value) -> Option<deckcraft_model::Shape> {
|
||||
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();
|
||||
|
||||
@@ -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<Affine> {
|
||||
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();
|
||||
|
||||
@@ -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::<Option<(Id, String)>>(key(RESULT))).flatten().is_some_and(|(b, _)| b == button)
|
||||
@@ -67,8 +85,13 @@ pub fn take_result(ctx: &Context) -> Option<String> {
|
||||
/// 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::<bool>(key(REQUEST))).unwrap_or(false) {
|
||||
begin(app, ctx);
|
||||
if ctx.data_mut(|d| d.remove_temp::<bool>(key(REQUEST))).unwrap_or(false)
|
||||
&& let Some(menu) = open_menu(ctx)
|
||||
{
|
||||
begin(app, Pick::Menu(menu));
|
||||
}
|
||||
if ctx.data_mut(|d| d.remove_temp::<bool>(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();
|
||||
}
|
||||
|
||||
@@ -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))));
|
||||
}
|
||||
|
||||
@@ -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| {
|
||||
|
||||
Reference in New Issue
Block a user