diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 253765bf..7f4c27bf 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -367,6 +367,9 @@ fn ruler(p: &egui::Painter, avail: Rect, slide: Rect, scale: f32, t: &Tokens) { } fn pointer(app: &mut SlideApp, ui: &Ui, resp: &egui::Response, xf: Xf) { + if crate::eyedropper::canvas(app, ui, resp, xf) { + return; + } let tol = (4.0 / xf.scale) as f64; let m = mods(ui); let Some(pos) = resp.interact_pointer_pos().or(resp.hover_pos()) else { return }; diff --git a/crates/ui-egui/src/eyedropper.rs b/crates/ui-egui/src/eyedropper.rs new file mode 100644 index 00000000..f6483898 --- /dev/null +++ b/crates/ui-egui/src/eyedropper.rs @@ -0,0 +1,150 @@ +//! The Eyedropper in the colour menus (Shape Fill, Shape Outline, Font Color…): choose it, then +//! click the slide to take the colour drawn under the pointer; Esc or a click elsewhere cancels. +//! +//! The menus are drawn by many callers that each apply the colour a swatch gives. The Eyedropper +//! 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. + +use egui::{Color32, Context, CursorIcon, Id, Pos2, Rect, Stroke, StrokeKind, Ui, pos2, vec2}; + +use crate::SlideApp; +use crate::canvas::Xf; +use crate::theme::{self, Tokens}; + +const REQUEST: &str = "deckcraft.eyedropper.request"; +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. +pub struct State { + menu: Id, + slide: usize, + image: deckcraft_render::Image, +} + +fn key(name: &str) -> Id { + Id::new(name) +} + +/// The colour menu button whose menu is open (noted by `color_popup` every frame it is). +pub fn note_open_menu(ctx: &Context, button: Id) { + ctx.data_mut(|d| d.insert_temp(key(MENU), button)); +} + +/// The menu button that is open now, if a menu noted itself. +pub fn open_menu(ctx: &Context) -> Option { + ctx.data(|d| d.get_temp::(key(MENU))) +} + +/// Keeps `hex` (`RRGGBB`) for the menu on `button`, which opens to hand it to its caller. +pub fn store_result(ctx: &Context, button: Id, hex: &str) { + ctx.data_mut(|d| d.insert_temp(key(RESULT), Some((button, hex.to_string())))); +} + +/// "Eyedropper" was chosen in the open menu. +pub fn request(ctx: &Context) { + ctx.data_mut(|d| d.insert_temp(key(REQUEST), 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) +} + +/// The colour the Eyedropper took (`RRGGBB`), once. +pub fn take_result(ctx: &Context) -> Option { + let r = ctx.data(|d| d.get_temp::>(key(RESULT))).flatten(); + if r.is_some() { + ctx.data_mut(|d| d.insert_temp(key(RESULT), None::<(Id, String)>)); + } + r.map(|(_, hex)| hex) +} + +/// 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 app.eyedropper.is_some() { + let outside = + ctx.input(|i| i.pointer.primary_pressed() && i.pointer.hover_pos().is_some_and(|p| app.canvas_rect.is_none_or(|c| !c.contains(p)))); + if outside { + app.eyedropper = None; + } + } +} + +fn begin(app: &mut SlideApp, ctx: &Context) { + let Some(menu) = open_menu(ctx) else { return }; + 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"); + return; + } + 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)"); +} + +fn hex(c: [u8; 4]) -> String { + format!("{:02X}{:02X}{:02X}", c[0], c[1], c[2]) +} + +/// The slide canvas while the Eyedropper waits: the pointer is a crosshair with the colour under it +/// beside it, a click takes it. Returns true when it used the pointer (the tools get none). +pub fn canvas(app: &mut SlideApp, ui: &Ui, resp: &egui::Response, xf: Xf) -> bool { + let Some(state) = app.eyedropper.as_ref() else { return false }; + let escape = ui.input(|i| i.key_pressed(egui::Key::Escape)); + if escape || resp.secondary_clicked() { + app.eyedropper = None; + return true; + } + let Some(pos) = resp.hover_pos() else { return true }; + let on_slide = app.slide_rect.is_some_and(|r| r.contains(pos)); + if !on_slide { + ui.ctx().set_cursor_icon(CursorIcon::NotAllowed); + if resp.clicked() { + app.eyedropper = None; + } + return true; + } + ui.ctx().set_cursor_icon(CursorIcon::Crosshair); + let p = xf.to_slide(pos); + let under = state.image.straight_at((p.x * SCALE).max(0.0) as u32, (p.y * SCALE).max(0.0) as u32); + if let Some(c) = under { + loupe(ui, pos, c); + } + if resp.clicked() { + let (menu, slide) = (state.menu, 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); + } + } + Err(e) => app.set_status(e.to_string()), + } + ui.ctx().request_repaint(); + } + true +} + +/// The colour under the pointer, drawn beside it with its hex. +fn loupe(ui: &Ui, pos: Pos2, c: [u8; 4]) { + let t = Tokens::get(ui.ctx()); + let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Tooltip, key("deckcraft.eyedropper.loupe"))); + let at = pos + vec2(18.0, 18.0); + let swatch = Rect::from_min_size(at, vec2(64.0, 26.0)); + painter.rect(swatch, 3, Color32::from_rgb(c[0], c[1], c[2]), Stroke::new(2.0, Color32::WHITE), StrokeKind::Outside); + painter.rect_stroke(swatch.expand(2.0), 3, Stroke::new(1.0, t.border), StrokeKind::Outside); + let label = Rect::from_min_size(pos2(at.x, swatch.max.y + 5.0), vec2(64.0, 16.0)); + painter.rect_filled(label, 3, Color32::WHITE); + painter.text(label.center(), egui::Align2::CENTER_CENTER, format!("#{}", hex(c)), theme::bold(11.0), Color32::BLACK); +} diff --git a/crates/ui-egui/src/eyedropper_tests.rs b/crates/ui-egui/src/eyedropper_tests.rs new file mode 100644 index 00000000..245c4be2 --- /dev/null +++ b/crates/ui-egui/src/eyedropper_tests.rs @@ -0,0 +1,113 @@ +//! The Eyedropper on the slide canvas through real egui input. +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use deckcraft_engine::Session; +use egui::{Event, Id, Key, Modifiers, PointerButton, Pos2}; +use egui_kittest::Harness; +use serde_json::json; + +use crate::eyedropper::{note_open_menu, request, result_waits_for, take_result}; +use crate::{Services, SlideApp}; + +fn harness() -> Harness<'static, SlideApp> { + let mut session = Session::with_new(); + session.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + session.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 200, 100]})).unwrap(); + session.execute("shape.fill", &json!({"color": "#ff0000"})).unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SlideApp::new(session, Services::default()), + ); + h.run_steps(4); + h +} + +fn menu() -> Id { + Id::new("test-menu") +} + +/// A colour menu asked for the Eyedropper. +fn ask(h: &mut Harness<'static, SlideApp>) { + note_open_menu(&h.ctx, menu()); + request(&h.ctx); + h.run_steps(3); +} + +/// Where a point of the slide is on screen. +fn at(h: &Harness<'static, SlideApp>, x: f32, y: f32) -> Pos2 { + let app = h.state(); + let r = app.slide_rect.expect("the slide is drawn"); + r.min + egui::vec2(x, y) * app.canvas_scale +} + +fn click(h: &mut Harness<'static, SlideApp>, pos: Pos2) { + h.input_mut().events.push(Event::PointerMoved(pos)); + h.run_steps(2); + for pressed in [true, false] { + h.input_mut().events.push(Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); + h.run_steps(2); + } + h.run_steps(2); +} + +fn selection(h: &Harness<'static, SlideApp>) -> Vec { + h.state().session.doc().unwrap().selection.shapes.iter().map(|s| s.0).collect() +} + +#[test] +fn a_click_on_the_slide_takes_the_colour_under_it_and_nothing_else_happens() { + let mut h = harness(); + let before = selection(&h); + let revision = h.state().session.doc().unwrap().revision; + ask(&mut h); + assert!(h.state().eyedropper.is_some(), "asking starts the Eyedropper"); + // A hover shows the colour; nothing is chosen yet. + let on_shape = at(&h, 150.0, 150.0); + h.input_mut().events.push(Event::PointerMoved(on_shape)); + h.run_steps(3); + assert!(h.state().eyedropper.is_some() && !result_waits_for(&h.ctx, menu())); + click(&mut h, on_shape); + assert!(h.state().eyedropper.is_none(), "one click ends it"); + assert!(result_waits_for(&h.ctx, menu()), "the colour waits for the menu that asked"); + assert_eq!(take_result(&h.ctx).as_deref(), Some("FF0000"), "the red of the shape under the pointer"); + assert_eq!(take_result(&h.ctx), None, "taken once"); + assert_eq!(selection(&h), before, "the click did not select or move anything"); + // The slide itself is untouched, and the next click is an ordinary click again. + assert_eq!(h.state().session.doc().unwrap().revision, revision, "the deck is untouched"); + // Beside the shape the same click takes the slide's background instead. + ask(&mut h); + let empty = at(&h, 700.0, 450.0); + click(&mut h, empty); + let bg = take_result(&h.ctx).expect("a colour"); + assert_ne!(bg, "FF0000"); +} + +#[test] +fn escape_and_a_click_off_the_slide_take_nothing() { + let mut h = harness(); + ask(&mut h); + let on_shape = at(&h, 150.0, 150.0); + h.input_mut().events.push(Event::PointerMoved(on_shape)); + h.run_steps(2); + for pressed in [true, false] { + h.input_mut().events.push(Event::Key { key: Key::Escape, physical_key: None, pressed, repeat: false, modifiers: Modifiers::NONE }); + h.run_steps(2); + } + assert!(h.state().eyedropper.is_none(), "Esc cancels"); + assert_eq!(take_result(&h.ctx), None); + + ask(&mut h); + let off = h.state().slide_rect.unwrap().min - egui::vec2(10.0, 10.0); + click(&mut h, off); + assert!(h.state().eyedropper.is_none(), "a click beside the slide cancels"); + assert_eq!(take_result(&h.ctx), None); + + // Without an open colour menu there is nothing to hand a colour to. + let mut other = harness(); + request(&other.ctx); + other.run_steps(3); + assert!(other.state().eyedropper.is_none()); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index ec97e6e6..0937ccb9 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -11,6 +11,7 @@ pub mod canvas; pub mod control; pub mod credits; pub mod dialogs; +pub mod eyedropper; pub mod fillui; pub mod icons; pub mod media; @@ -197,6 +198,8 @@ pub struct SlideApp { pub synthetic: Vec, /// Canvas screen rect and points-per-slide-point scale (for control & tests). pub canvas_rect: Option, + /// The Eyedropper is waiting for a click on the slide. + pub eyedropper: Option, pub canvas_scale: f32, pub slide_rect: Option, /// Where the canvas texture (the slide and the pasteboard drawn around it) is on screen. @@ -247,6 +250,7 @@ impl SlideApp { perf: Perf { frame_ms: 0.0, render_ms: 0.0, fps: 0.0 }, synthetic: vec![], canvas_rect: None, + eyedropper: None, canvas_scale: 1.0, slide_rect: None, canvas_region: None, @@ -935,6 +939,7 @@ impl SlideApp { let t0 = now_ms(); let t = theme::Tokens::get(&ctx); self.tick_media(&ctx); + eyedropper::tick(self, &ctx); if self.show.is_some() { show::ui(self, ui); self.perf.frame_ms = now_ms() - t0; @@ -1127,6 +1132,8 @@ fn js_now() -> f64 { js_sys::Date::now() } +#[cfg(test)] +mod eyedropper_tests; #[cfg(test)] mod ime_tests; diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 5827b396..fadefe8b 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -243,12 +243,25 @@ pub fn dropdown(ui: &mut Ui, id: &str, current: &str, width: f32, enabled: bool, /// (egui menus close on any click inside). Picking a colour closes it (`color_grid` calls /// `ui.close()`); other buttons in it must close it themselves. pub fn color_popup(button: &Response) -> egui::Popup<'_> { - egui::Popup::menu(button).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside) + let mut popup = egui::Popup::menu(button).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside); + // A colour the Eyedropper took for this menu: open it so `color_grid` hands the colour back. + if crate::eyedropper::result_waits_for(&button.ctx, button.id) { + popup = popup.open_memory(egui::SetOpenCommand::Bool(true)); + } + if popup.is_open() { + crate::eyedropper::note_open_menu(&button.ctx, button.id); + } + popup } /// The colour grid popup body: theme colours with tints/shades, standard colours, extra entries. /// Returns the picked colour (`Some(None)` = "No Fill"/"Automatic" when `none_label` is given). pub fn color_grid(ui: &mut Ui, scheme: &ColorScheme, none_label: Option<&str>) -> Option> { + // The Eyedropper's colour, handed back as if its swatch had been clicked. + if let Some(c) = crate::eyedropper::take_result(ui.ctx()).and_then(|hex| Rgba::from_hex(&hex)) { + ui.close(); + return Some(Some(ColorRef::rgb(c))); + } let t = Tokens::get(ui.ctx()); let mut out = None; ui.set_min_width(220.0); @@ -343,6 +356,10 @@ pub fn color_grid(ui: &mut Ui, scheme: &ColorScheme, none_label: Option<&str>) - } }); ui.data_mut(|d| d.insert_temp(id, hex)); + if ui.button("Eyedropper").on_hover_text("Click the slide to take the colour under the pointer").clicked() { + crate::eyedropper::request(ui.ctx()); + ui.close(); + } if out.is_some() { ui.close(); } @@ -485,4 +502,36 @@ mod tests { assert_eq!(*picked.borrow(), Some(Some(ColorRef::rgb(Rgba::from_hex("00FF00").unwrap())))); assert!(harness.query_by_role(Role::TextInput).is_none(), "picking a colour closes the menu"); } + + /// Choosing Eyedropper closes the menu and asks for it; the colour it takes comes back through + /// the same menu, as if its swatch had been clicked. + #[test] + fn the_eyedroppers_colour_comes_back_through_the_menu_that_asked() { + let picked = RefCell::new(None); + let scheme = deckcraft_model::Theme::default().colors; + let mut harness = egui_kittest::Harness::builder().with_size(vec2(400.0, 500.0)).build_ui(|ui| { + theme::install_fonts(ui.ctx()); + let r = ui.button("Pick"); + color_popup(&r).show(|ui| { + if let Some(c) = color_grid(ui, &scheme, None) { + *picked.borrow_mut() = Some(c); + } + }); + }); + harness.get_by_label("Pick").click(); + harness.run(); + harness.get_by_label("Eyedropper").click(); + harness.run(); + assert!(harness.query_by_label("Eyedropper").is_none(), "choosing it closes the menu"); + assert!(picked.borrow().is_none(), "nothing is picked yet"); + // The app starts the Eyedropper from this request; here the colour is taken at once. + assert!(harness.ctx.data_mut(|d| d.remove_temp::(egui::Id::new("deckcraft.eyedropper.request"))).unwrap_or(false)); + let menu = crate::eyedropper::open_menu(&harness.ctx).expect("the open menu noted its button"); + crate::eyedropper::store_result(&harness.ctx, menu, "00FF00"); + harness.run(); + assert_eq!(*picked.borrow(), Some(Some(ColorRef::rgb(Rgba::from_hex("00FF00").unwrap())))); + assert!(harness.query_by_label("Eyedropper").is_none(), "the menu closes again"); + harness.run(); + assert_eq!(picked.borrow().iter().count(), 1, "the colour is handed back once"); + } }