mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-24 10:37:03 -06:00
Merge PR #436 from Silvertree2010: Colour menus: an Eyedropper that takes a colour from the slide (#367)
This commit is contained in:
@@ -128,6 +128,7 @@ app's JSON control channel (`deckcraft --control PORT`).
|
||||
|
||||
| Date | What landed |
|
||||
|---|---|
|
||||
| 2026-10-13 | Eyedropper: Shape Fill, Shape Outline, Font Color and every other colour menu end with an Eyedropper that takes the colour drawn at a point of the slide (hover shows it, a click applies it, Esc cancels); `slide.colorAt` gives the same from scripts and MCP (#367) |
|
||||
| 2026-10-13 | Slide transitions like PowerPoint: every transition and option measured frame by frame against PowerPoint's video export and retuned (curves, directions, soft edges, 3-D perspective, contents moving over backgrounds); PowerPoint's own XML elements and defaults read and written (diagonal Wipe, Zoom, Shape In/Out, Glitter Hexagons, Shred In/Out, Page Curl Single); `deckcraft-cli render-transition` renders frames headless |
|
||||
| 2026-10-13 | Wild text cases measured against PowerPoint (102 synthetic slides): bidi by run language, East Asian vertical text upright, mongolianVert/wordArtVert/wordArtVertRtl, underline styles and strikethrough at the font's metrics, centre/right/decimal tabs, East Asian, Thai, Hindi, Arabic and Hebrew numbering, small caps, all-caps "ß", transparent text, soft hyphens, kinsoku, rtlCol and just/dist anchoring, emoji presentation, fallback faces that no longer move the baseline |
|
||||
| 2026-10-13 | Render matches PowerPoint more closely on wild shape, picture and table cases: even-odd custom paths, dashes and caps, arrowheads, radial and path gradients, tile rects, rotWithShape, gradient lines, shadows cast by what is drawn (group and perspective too), group fill, picture borders, tiles, reflection, recolour (biLevel, alphaInv, brightness/contrast), table borders and grid default, 20+ preset shapes by their ECMA formulas, default miter joins, and approximate 3-D bevels, cameras and face lighting |
|
||||
@@ -150,6 +151,7 @@ app's JSON control channel (`deckcraft --control PORT`).
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-13 | trivial | Progress log: Eyedropper in the colour menus, slide.colorAt (#367) |
|
||||
| 2026-10-13 | trivial | Progress log: wild text cases |
|
||||
| 2026-10-12 | trivial | Progress log: File tab (Backstage, #76) |
|
||||
| 2026-10-12 | trivial | Progress log: lines in scaled groups (#233) |
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
//! `slide.colorAt`, the colour the slide is drawn with at a point: what the Eyedropper takes (#367).
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::*;
|
||||
|
||||
fn deck() -> Session {
|
||||
let mut s = Session::with_new();
|
||||
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
|
||||
s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 200, 100]})).unwrap();
|
||||
s.execute("shape.fill", &json!({"color": "#ff0000"})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn color_at(s: &mut Session, x: f64, y: f64) -> Value {
|
||||
s.execute("slide.colorAt", &json!({"x": x, "y": y})).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_point_in_a_shape_gives_its_fill_and_one_beside_it_the_background() {
|
||||
let mut s = deck();
|
||||
let inside = color_at(&mut s, 150.0, 150.0);
|
||||
assert_eq!(inside["color"], "FF0000");
|
||||
assert_eq!(inside["alpha"], 255);
|
||||
let beside = color_at(&mut s, 700.0, 450.0);
|
||||
assert_ne!(beside["color"], "FF0000");
|
||||
// Just inside and just outside the shape's corner.
|
||||
assert_eq!(color_at(&mut s, 101.0, 101.0)["color"], "FF0000");
|
||||
assert_eq!(color_at(&mut s, 99.0, 99.0)["color"], beside["color"]);
|
||||
// A finer or coarser picture gives the same colour.
|
||||
for scale in [0.5, 1.0, 4.0] {
|
||||
let v = s.execute("slide.colorAt", &json!({"x": 150, "y": 150, "scale": scale})).unwrap();
|
||||
assert_eq!(v["color"], "FF0000", "scale {scale}");
|
||||
}
|
||||
// It is a query: the deck is not changed and nothing goes on the undo stack.
|
||||
let revision = s.doc().unwrap().revision;
|
||||
color_at(&mut s, 150.0, 150.0);
|
||||
assert_eq!(s.doc().unwrap().revision, revision);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_see_through_fill_gives_the_colour_it_is_drawn_with() {
|
||||
let mut s = deck();
|
||||
let background = color_at(&mut s, 700.0, 450.0)["color"].as_str().unwrap().to_string();
|
||||
s.execute("shape.fill", &json!({"transparency": 0.5})).unwrap();
|
||||
let v = color_at(&mut s, 150.0, 150.0);
|
||||
let c = v["color"].as_str().unwrap();
|
||||
assert_ne!(c, "FF0000", "half of the background shows through");
|
||||
assert_ne!(c, background);
|
||||
assert_eq!(v["alpha"], 255, "the slide is opaque");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn points_outside_the_slide_and_bad_parameters_are_errors() {
|
||||
let mut s = deck();
|
||||
for p in [
|
||||
json!({"x": -1, "y": 10}),
|
||||
json!({"x": 10, "y": -0.5}),
|
||||
json!({"x": 1e9, "y": 10}),
|
||||
json!({"x": 10, "y": 1e9}),
|
||||
json!({"x": 10}),
|
||||
json!({"y": 10}),
|
||||
json!({"x": "10", "y": 10}),
|
||||
json!({"x": 10, "y": 10, "slide": 99}),
|
||||
] {
|
||||
assert!(s.execute("slide.colorAt", &p).is_err(), "{p}");
|
||||
}
|
||||
// Odd scales are clamped, not refused or crashed on.
|
||||
for scale in [0.0, -3.0, 1e9] {
|
||||
let v = s.execute("slide.colorAt", &json!({"x": 150, "y": 150, "scale": scale}));
|
||||
assert!(v.is_ok(), "{scale}: {v:?}");
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
info
|
||||
),
|
||||
cmd!(query "file.render", "Render Slide", [], None, "{slide?: index, scale?, edit?: bool} → {png: base64, width, height}", has_doc, render),
|
||||
cmd!(
|
||||
query "slide.colorAt",
|
||||
"Color At",
|
||||
[],
|
||||
None,
|
||||
"{slide?: index, x, y (slide units), scale?: px per unit (2)} → {color: \"RRGGBB\", alpha: 0..255, x, y}: the colour drawn at a point of the slide (the Eyedropper)",
|
||||
has_doc,
|
||||
color_at
|
||||
),
|
||||
cmd!(noundo "file.close", "Close", ["File"], Some("Cmd+W"), "{}", has_doc, close),
|
||||
cmd!(
|
||||
"file.properties",
|
||||
@@ -269,6 +278,29 @@ fn render(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"png": base64_encode(&png), "width": w, "height": h}))
|
||||
}
|
||||
|
||||
/// The colour the slide is drawn with at one point: what the Eyedropper takes.
|
||||
fn color_at(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let i = usize_param(p, "slide").unwrap_or(st.selection.slide);
|
||||
if i >= st.doc.slides.len() {
|
||||
return Err(bad("slide.colorAt", format!("no slide {i}")));
|
||||
}
|
||||
let coord = |key: &str| {
|
||||
p.get(key)
|
||||
.and_then(Value::as_f64)
|
||||
.filter(|v| v.is_finite())
|
||||
.ok_or_else(|| bad("slide.colorAt", format!("`{key}` (a number, in slide units) is required")))
|
||||
};
|
||||
let (x, y) = (coord("x")?, coord("y")?);
|
||||
let scale = f64_or(p, "scale", 2.0).clamp(0.01, 16.0);
|
||||
let img = deckcraft_render::render_slide(&st.doc, i, &deckcraft_render::RenderOpts { scale, ..Default::default() });
|
||||
let (px, py) = (x * scale, y * scale);
|
||||
let inside = px >= 0.0 && py >= 0.0 && px < f64::from(img.width) && py < f64::from(img.height);
|
||||
let [r, g, b, a] =
|
||||
img.straight_at(px as u32, py as u32).filter(|_| inside).ok_or_else(|| bad("slide.colorAt", "the point is outside the slide"))?;
|
||||
Ok(json!({"color": format!("{r:02X}{g:02X}{b:02X}"), "alpha": a, "x": x, "y": y}))
|
||||
}
|
||||
|
||||
fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let path = str_param(p, "path").ok_or_else(|| bad("file.export", "missing `path`"))?.to_string();
|
||||
let format = str_param(p, "format").unwrap_or(format_for_path(&path)).to_string();
|
||||
@@ -430,3 +462,7 @@ fn recovery_discard(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "color_at_tests.rs"]
|
||||
mod color_at_tests;
|
||||
|
||||
@@ -78,6 +78,19 @@ impl Image {
|
||||
}
|
||||
out
|
||||
}
|
||||
/// One pixel as straight (unpremultiplied) RGBA; `None` outside the image.
|
||||
pub fn straight_at(&self, x: u32, y: u32) -> Option<[u8; 4]> {
|
||||
if x >= self.width || y >= self.height {
|
||||
return None;
|
||||
}
|
||||
let i = (y as usize).checked_mul(self.width as usize)?.checked_add(x as usize)?.checked_mul(4)?;
|
||||
let [r, g, b, a] = <[u8; 4]>::try_from(self.pixels.get(i..i.checked_add(4)?)?).ok()?;
|
||||
if a == 0 || a == 255 {
|
||||
return Some([r, g, b, a]);
|
||||
}
|
||||
let unpremultiply = |c: u8| ((c as u32 * 255 + a as u32 / 2) / a as u32).min(255) as u8;
|
||||
Some([unpremultiply(r), unpremultiply(g), unpremultiply(b), a])
|
||||
}
|
||||
pub fn to_png(&self) -> Vec<u8> {
|
||||
let mut px = self.to_straight();
|
||||
px.resize(self.width as usize * self.height as usize * 4, 0);
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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<Id> {
|
||||
ctx.data(|d| d.get_temp::<Id>(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::<Option<(Id, String)>>(key(RESULT))).flatten().is_some_and(|(b, _)| b == button)
|
||||
}
|
||||
|
||||
/// The colour the Eyedropper took (`RRGGBB`), once.
|
||||
pub fn take_result(ctx: &Context) -> Option<String> {
|
||||
let r = ctx.data(|d| d.get_temp::<Option<(Id, String)>>(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::<bool>(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);
|
||||
}
|
||||
@@ -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<u32> {
|
||||
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());
|
||||
}
|
||||
@@ -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<egui::Event>,
|
||||
/// Canvas screen rect and points-per-slide-point scale (for control & tests).
|
||||
pub canvas_rect: Option<egui::Rect>,
|
||||
/// The Eyedropper is waiting for a click on the slide.
|
||||
pub eyedropper: Option<eyedropper::State>,
|
||||
pub canvas_scale: f32,
|
||||
pub slide_rect: Option<egui::Rect>,
|
||||
/// 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;
|
||||
|
||||
|
||||
@@ -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<Option<ColorRef>> {
|
||||
// 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::<bool>(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");
|
||||
}
|
||||
}
|
||||
|
||||
+3
-1
@@ -1,6 +1,6 @@
|
||||
# UI parity with PowerPoint
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (File tab / Backstage, #76; previous: off-slide objects in Normal view, #270) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** trivial (Eyedropper in the colour menus (#367); previously minor: File tab / Backstage, #76; previous: off-slide objects in Normal view, #270) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
|
||||
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (text editing keys observed in PowerPoint for Windows, #268) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
|
||||
@@ -17,6 +17,7 @@ AGENTS.md §2). Dimension score: **~60% ready** (estimated), **30–50 h** to pa
|
||||
| Native macOS menu bar (File, Edit, View, Insert, Format, Arrange, Tools, Slide Show, Window, Help) | yes | **no** (`menus::menu_tree()` is defined but not installed) | 3–5 h |
|
||||
| Menu-bar items with a DeckCraft equivalent | 217 app-specific items | 128 matched by name (59%, heuristic lower bound) | See [target-app-parity.md](target-app-parity.md) |
|
||||
| Ribbon tabs: Home, Insert, Draw, Design, Transitions, Animations, Slide Show, Record, Review, View | 10 | 10 | Record tab is a placeholder |
|
||||
| Colour menus: theme and standard colours, hex field, Eyedropper | yes (plus a More Colors dialog) | yes: theme and standard colours, hex field and an Eyedropper that takes the colour drawn under the pointer on the slide (#367) | More Colors dialog; Eyedropper only takes colours from the slide, not the rest of the screen |
|
||||
| Contextual tabs: Shape Format, Picture Format, Table Design, Table Layout, Chart Design, Chart Format, Video/Audio Format and Playback | yes | most; checklist P0 row partial | Chart Format, media tabs thinner |
|
||||
| File tab (Backstage): Home, New, Open, Info, Save, Save As, Print, Share, Export, Close, Account, Options | yes | yes (#76), see [File tab](#file-tab-backstage); no Share (cloud) | Video/GIF/CD exports, templates beyond the built-in themes |
|
||||
| Start screen: new, themes, templates, recent, open | yes, the same page as File › Home | yes: File › Home with Home, New, Open, Account and Options (#76) | |
|
||||
@@ -148,6 +149,7 @@ imitate Microsoft's. No pixel comparison against PowerPoint screenshots has been
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-13 | trivial | Eyedropper in the colour menus (#367) |
|
||||
| 2026-10-12 | minor | File tab (Backstage) observed in PowerPoint for Windows and matched (#76); the start screen is File › Home |
|
||||
| 2026-10-12 | minor | Off-slide objects drawn and editable around the slide in Normal view (#270) |
|
||||
| 2026-10-12 | minor | Selection Pane matched to PowerPoint (#133): drag and drop, arrows, groups, eye, Lock |
|
||||
|
||||
Reference in New Issue
Block a user