From 7887a13868f1add6b449a7c74c816540b7c5582b Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Tue, 13 Oct 2026 16:41:57 +0200 Subject: [PATCH] Colour menus: an Eyedropper that takes a colour from the slide (#367) Every colour menu ends with Eyedropper. Choosing it closes the menu; the pointer is a crosshair over the slide with the colour under it and its hex beside it, a click takes the colour (slide.colorAt) and Esc, a right click or a click off the slide cancels. The colour comes back through the menu that asked, which opens for a frame and returns it as if its swatch had been clicked, so Shape Fill, Shape Outline, Font Color, the Format pane and the other callers all apply it their own way without changes. --- crates/ui-egui/src/canvas.rs | 3 + crates/ui-egui/src/eyedropper.rs | 150 +++++++++++++++++++++++++ crates/ui-egui/src/eyedropper_tests.rs | 113 +++++++++++++++++++ crates/ui-egui/src/lib.rs | 7 ++ crates/ui-egui/src/widgets.rs | 51 ++++++++- 5 files changed, 323 insertions(+), 1 deletion(-) create mode 100644 crates/ui-egui/src/eyedropper.rs create mode 100644 crates/ui-egui/src/eyedropper_tests.rs 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"); + } }