Merge upstream/main into docs/drag-modifiers-measured

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Trevor Brown
2026-11-12 02:10:29 -05:00
co-authored by Claude Opus 5.5
7 changed files with 160 additions and 8 deletions
+7 -2
View File
@@ -360,8 +360,13 @@ impl Session {
pub(super) fn eyedrop(&mut self, ev: &PointerEvent) -> Result<()> {
let ds = self.ds()?;
let doc = ds.doc.clone();
let view = figcraft_render::View { origin: (ev.x, ev.y), scale: ev.zoom.max(0.01), width: 1, height: 1 };
let Some(pix) = figcraft_render::render_view(&doc, ds.page, &view, &Default::default()) else { return Ok(()) };
// One screen pixel centred on the pointer (its top left half a pixel up and to the left).
let scale = ev.zoom.max(0.01);
let view = figcraft_render::View { origin: (ev.x - 0.5 / scale, ev.y - 0.5 / scale), scale, width: 1, height: 1 };
// As the canvas draws it: an image magnified past 2x shows its pixels, so the colour picked
// is the pixel under the pointer, not a blend with its neighbours (#2189).
let opts = figcraft_render::Options { magnified_image_pixels: true, ..Default::default() };
let Some(pix) = figcraft_render::render_view(&doc, ds.page, &view, &opts) else { return Ok(()) };
let rgba = figcraft_render::to_rgba8(&pix);
let c = figcraft_color::Color::from_rgba8(
rgba.first().copied().unwrap_or(0),
@@ -0,0 +1,42 @@
//! #2189: the eyedropper picks the colour the canvas shows under the pointer. A magnified image
//! draws its pixels on the canvas (`magnified_image_pixels`), so the picked colour is that pixel's,
//! not a blend with its neighbours.
use base64::Engine as _;
use serde_json::json;
use crate::Session;
use crate::tools::{PointerEvent, PointerKind};
/// A 9 x 9 image of `back` with a 1 px vertical line of `line` in column 4 (a thin border in a
/// screenshot, as in the report).
fn line_png(back: [u8; 3], line: [u8; 3]) -> Vec<u8> {
let mut p = figcraft_render::Pixmap::new(9, 9).unwrap();
for (i, px) in p.data_mut().chunks_exact_mut(4).enumerate() {
let c = if i % 9 == 4 { line } else { back };
px.copy_from_slice(&[c[0], c[1], c[2], 255]);
}
figcraft_render::encode_png(&p).unwrap()
}
/// The colour the eyedropper picks at page point (x, y) at `zoom`.
fn picked(png: &[u8], x: f64, y: f64, zoom: f64) -> String {
let mut s = Session::new();
let data = base64::engine::general_purpose::STANDARD.encode(png);
s.execute("image.place", &json!({"data": data, "x": 0, "y": 0})).unwrap();
s.execute("edit.pickColor", &json!({"apply": false})).unwrap();
s.pointer(&PointerEvent::at(PointerKind::Down, x, y, zoom)).unwrap();
s.tool.picked.map(|c| c.to_hex()).unwrap_or_default()
}
#[test]
fn the_eyedropper_picks_a_magnified_images_pixel_not_a_blend_with_its_neighbours() {
// The reporter's colours: a #3D444D border, 1 px wide, beside a darker background.
let png = line_png([0x15, 0x1B, 0x23], [0x3D, 0x44, 0x4D]);
for zoom in [2.0, 8.0, 20.0] {
assert_eq!(picked(&png, 4.5, 4.5, zoom), "3D444D", "on the line at {zoom}x");
assert_eq!(picked(&png, 2.5, 4.5, zoom), "151B23", "beside it at {zoom}x");
}
// At 1:1 the canvas pixel is the image's pixel too.
assert_eq!(picked(&png, 4.5, 4.5, 1.0), "3D444D", "on the line at 1x");
}
+2
View File
@@ -327,6 +327,8 @@ mod extended_chain_tests;
#[cfg(test)]
mod eyedropper_create_tests;
#[cfg(test)]
mod eyedropper_image_tests;
#[cfg(test)]
mod facilitate_tests;
#[cfg(test)]
mod fig_export_assets_tests;
+2
View File
@@ -254,6 +254,8 @@ mod save_as_tests;
#[cfg(test)]
mod select_all_tests;
#[cfg(test)]
mod selection_colors_tests;
#[cfg(test)]
mod shortcut_keys_tests;
#[cfg(test)]
mod shortcuts_tests;
@@ -65,7 +65,7 @@ pub(crate) fn toggle(app: &mut FigApp, targets: Vec<PaintTarget>, response: Opti
}
}
/// Draw the selection-colour groups (at least two): swatch opens the picker, hex and opacity
/// Draw the selection-colour groups (two, or one when a selected layer has children): swatch opens the picker, hex and opacity
/// edits, style/variable detach/apply/bind, a style from this file or an enabled library, Create
/// style, and “select layers with this color”; disabled during an interaction.
pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Arc<Document>, ids: &[NodeId]) {
@@ -73,7 +73,10 @@ pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Arc<Document>, ids: &[NodeId
let interacting = ds.interaction.is_some();
let uid = ds.uid;
let colors = colors(ui, uid, doc, ids, interacting);
if colors.len() < 2 {
// Measured in Figma: two distinct colours show the section; one colour does too when a selected
// layer has children (frame, group, component, instance).
let has_children = ids.iter().any(|id| doc.node(*id).is_some_and(|n| !n.children.is_empty()));
if colors.len() < 2 && !(colors.len() == 1 && has_children) {
ui.data_mut(|d| d.remove::<CreateForm>(create_form_id()));
return;
}
@@ -0,0 +1,96 @@
//! When the Design panel shows Selection colors (#2188): measured in Figma's web app, a layer with
//! children shows the section from one distinct colour, while layers without children need two.
//! Driven through egui headless; each row has a "Select layers with this color" button.
use egui::accesskit::Role;
use figcraft_doc::NodeId;
use figcraft_engine::Session;
use serde_json::{Value, json};
use crate::layers_drag_tests::Ui;
const ROW_BUTTON: &str = "Select layers with this color";
fn id(v: Value) -> NodeId {
NodeId(v["id"].as_u64().unwrap())
}
fn rect(s: &mut Session, x: i32, color: &str, parent: Option<NodeId>) -> NodeId {
let mut p = json!({"x": x, "y": 0, "width": 40, "height": 40});
if let Some(parent) = parent {
p["parent"] = json!(parent);
}
let r = id(s.execute("shape.rectangle", &p).unwrap());
s.execute("fill.set", &json!({"ids": [r], "color": color})).unwrap();
r
}
fn frame(s: &mut Session, fill: Option<&str>) -> NodeId {
let f = id(s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 200, "height": 200})).unwrap());
match fill {
Some(c) => s.execute("fill.set", &json!({"ids": [f], "color": c})).unwrap(),
None => s.execute("fill.remove", &json!({"ids": [f], "index": 0})).unwrap(),
};
f
}
/// How many Selection colors rows the Design panel shows with `ids` selected.
fn rows(mut s: Session, ids: &[NodeId]) -> usize {
s.execute("select.set", &json!({"ids": ids})).unwrap();
let mut u = Ui::new(s);
u.ctx.enable_accesskit();
u.frame(vec![]);
let out = u.frame_output(vec![]);
let nodes = out.platform_output.accesskit_update.expect("an AccessKit update").nodes;
nodes.iter().filter(|(_, n)| n.role() == Role::Button && n.label() == Some(ROW_BUTTON)).count()
}
#[test]
fn a_frame_holding_one_red_layer_shows_selection_colors_with_one_row() {
let mut s = Session::new();
let f = frame(&mut s, None);
rect(&mut s, 10, "#FF0000", Some(f));
assert_eq!(rows(s, &[f]), 1);
}
#[test]
fn a_group_of_two_red_layers_shows_one_row() {
let mut s = Session::new();
let a = rect(&mut s, 0, "#FF0000", None);
let b = rect(&mut s, 60, "#FF0000", None);
s.execute("select.set", &json!({"ids": [a, b]})).unwrap();
let g = id(s.execute("object.group", &json!({"ids": [a, b]})).unwrap());
assert_eq!(rows(s, &[g]), 1);
}
#[test]
fn an_instance_whose_child_has_one_fill_shows_selection_colors() {
let mut s = Session::new();
let f = frame(&mut s, None);
rect(&mut s, 10, "#FF0000", Some(f));
let c = id(s.execute("component.create", &json!({"ids": [f]})).unwrap());
let i = id(s.execute("instance.create", &json!({"component": c, "x": 400, "y": 0})).unwrap());
assert_eq!(rows(s, &[i]), 1);
}
#[test]
fn one_layer_or_two_of_the_same_colour_without_children_hide_it() {
let mut s = Session::new();
let a = rect(&mut s, 0, "#FF0000", None);
assert_eq!(rows(s, &[a]), 0, "one red rectangle");
let mut s = Session::new();
let a = rect(&mut s, 0, "#FF0000", None);
let b = rect(&mut s, 60, "#FF0000", None);
assert_eq!(rows(s, &[a, b]), 0, "two red rectangles");
let mut s = Session::new();
let g = frame(&mut s, Some("#00FF00"));
assert_eq!(rows(s, &[g]), 0, "a childless green frame");
}
#[test]
fn two_loose_layers_of_different_colours_show_two_rows() {
let mut s = Session::new();
let a = rect(&mut s, 0, "#FF0000", None);
let b = rect(&mut s, 60, "#0000FF", None);
assert_eq!(rows(s, &[a, b]), 2);
}
+6 -4
View File
File diff suppressed because one or more lines are too long