diff --git a/crates/ui-egui/src/field_tests.rs b/crates/ui-egui/src/field_tests.rs index 49b8a94b7..dacab277a 100644 --- a/crates/ui-egui/src/field_tests.rs +++ b/crates/ui-egui/src/field_tests.rs @@ -187,13 +187,18 @@ fn setup() -> (Ui, NodeId, NodeId) { (u, a, b) } -/// The field named `name` in the Design panel: the middle of its value, and what it shows. +/// The topmost field named `name` in the Design panel: its value centre and displayed text. +/// Layer opacity precedes the equally named opacity fields in paint rows. fn field(u: &mut Ui, name: &str) -> (Pos2, String) { u.frame(vec![]); let out = u.frame_output(vec![]); let nodes = out.platform_output.accesskit_update.expect("an AccessKit update").nodes; - let n = - nodes.iter().map(|(_, n)| n).find(|n| n.role() == Role::SpinButton && n.label() == Some(name)).unwrap_or_else(|| panic!("no field {name:?}")); + let n = nodes + .iter() + .map(|(_, n)| n) + .filter(|n| n.role() == Role::SpinButton && n.label() == Some(name)) + .min_by(|a, b| a.bounds().map_or(f64::INFINITY, |r| r.y0).total_cmp(&b.bounds().map_or(f64::INFINITY, |r| r.y0))) + .unwrap_or_else(|| panic!("no field {name:?}")); let b = n.bounds().expect("bounds"); (pos2(((b.x0 + b.x1) / 2.0) as f32, ((b.y0 + b.y1) / 2.0) as f32), n.value().unwrap_or_default().to_string()) } @@ -203,11 +208,26 @@ fn label(u: &mut Ui, name: &str) -> Pos2 { u.frame(vec![]); let out = u.frame_output(vec![]); let nodes = out.platform_output.accesskit_update.expect("an AccessKit update").nodes; - let n = nodes.iter().map(|(_, n)| n).find(|n| n.role() == Role::SpinButton && n.label() == Some(name)).unwrap(); + let n = nodes + .iter() + .map(|(_, n)| n) + .filter(|n| n.role() == Role::SpinButton && n.label() == Some(name)) + .min_by(|a, b| a.bounds().map_or(f64::INFINITY, |r| r.y0).total_cmp(&b.bounds().map_or(f64::INFINITY, |r| r.y0))) + .unwrap(); let b = n.bounds().unwrap(); pos2(b.x0 as f32 - 12.0, ((b.y0 + b.y1) / 2.0) as f32) } +/// Layer and fill opacity are both named for assistive technology, even without a visible lead. +#[test] +fn paint_opacity_has_an_accessible_name() { + let (mut u, _, _) = setup(); + let output = u.frame_output(vec![]); + let nodes = output.platform_output.accesskit_update.unwrap().nodes; + let opacity_fields = nodes.iter().filter(|(_, n)| n.role() == Role::SpinButton && n.label() == Some("Opacity")).count(); + assert_eq!(opacity_fields, 2, "both layer and fill opacity must have an accessible name"); +} + fn geom(u: &Ui, n: NodeId) -> (f64, f64, f64, f64) { let doc = u.app.session.doc().unwrap(); let b = doc.world_bounds(n).unwrap(); diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index e93e557f0..d10cc019f 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -29,6 +29,8 @@ pub use draw::{paint, paint_outlines}; icons! { Move, + /// Select layers using a colour: selection corners around a colour swatch. + SelectColor, Scale, FitContents, Hand, diff --git a/crates/ui-egui/src/icons/draw.rs b/crates/ui-egui/src/icons/draw.rs index 5a05e44cc..0eaa9a8be 100644 --- a/crates/ui-egui/src/icons/draw.rs +++ b/crates/ui-egui/src/icons/draw.rs @@ -285,6 +285,13 @@ fn draw(p: &P, icon: Icon) { use Icon::*; match icon { Move => p.fill(&[(4.0, 2.0), (4.0, 13.6), (6.9, 10.9), (8.8, 14.8), (10.6, 14.0), (8.7, 10.2), (12.6, 10.2)]), + SelectColor => { + p.poly(&[(2.5, 5.5), (2.5, 2.5), (5.5, 2.5)], false); + p.poly(&[(10.5, 2.5), (13.5, 2.5), (13.5, 5.5)], false); + p.poly(&[(2.5, 10.5), (2.5, 13.5), (5.5, 13.5)], false); + p.poly(&[(10.5, 13.5), (13.5, 13.5), (13.5, 10.5)], false); + p.circle(8.0, 8.0, 2.5); + } Scale => { p.rect(2.5, 7.5, 8.5, 13.5, 0.0); p.line(6.0, 10.0, 13.0, 3.0); @@ -391,11 +398,20 @@ fn draw(p: &P, icon: Icon) { ChevronUp => p.poly(&[(4.5, 10.0), (8.0, 6.5), (11.5, 10.0)], false), ChevronRight => p.poly(&[(6.0, 4.5), (9.5, 8.0), (6.0, 11.5)], false), ChevronLeft => p.poly(&[(10.0, 4.5), (6.5, 8.0), (10.0, 11.5)], false), - Plus => { - p.line(7.5, 2.0, 7.5, 13.0); - p.line(2.0, 7.5, 13.0, 7.5); + Plus | Minus => { + // Snap the shared centre once, then give every arm the same pixel length. + // Snapping each line's ends separately can offset its midpoint at small sizes. + let c = p.sp(8.0, 8.0); + let half = p.stroke.width / 2.0; + let reach = (5.0 * p.s / p.px).round() * p.px + half; + let mut mesh = Mesh::default(); + mesh.add_colored_rect(Rect::from_min_max(c - vec2(reach, half), c + vec2(reach, half)), p.color); + if icon == Plus { + mesh.add_colored_rect(Rect::from_min_max(c - vec2(half, reach), c + vec2(half, -half)), p.color); + mesh.add_colored_rect(Rect::from_min_max(c + vec2(-half, half), c + vec2(half, reach)), p.color); + } + p.painter.add(Shape::mesh(mesh)); } - Minus => p.line(2.0, 7.5, 13.0, 7.5), Eye | EyeOff => { // An almond (two arcs meeting at the corners) with the pupil. let (r, cy, half) = (7.0f32, 7.5f32, 5.5f32); diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index 2454e5f67..934f3bffd 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -89,12 +89,18 @@ impl FigApp { /// Apply a field edit through any command: `params` builds the command's params from the value. pub fn field_run(&mut self, edit: Option, cmd: &str, params: impl Fn(f64) -> Value) { + self.field_run_named(edit, "Edit", cmd, params); + } + + /// Apply a field edit with a specific undo label for scrubs; commit only on release. + /// Typed edits use `run`; scrub errors are discarded as in `field_run`. + pub(crate) fn field_run_named(&mut self, edit: Option, label: &str, cmd: &str, params: impl Fn(f64) -> Value) { match edit { Some(FieldEdit::Set(v)) => { self.run(cmd, params(v)); } Some(FieldEdit::Scrub(v, done)) => { - let _ = self.session.begin_interaction("Edit"); + let _ = self.session.begin_interaction(label); let _ = self.session.execute(cmd, ¶ms(v)); if done { let _ = self.session.commit_interaction(); diff --git a/crates/ui-egui/src/panels/right/paints.rs b/crates/ui-egui/src/panels/right/paints.rs index 58423d252..fb004f3c1 100644 --- a/crates/ui-egui/src/panels/right/paints.rs +++ b/crates/ui-egui/src/panels/right/paints.rs @@ -446,78 +446,62 @@ fn paint_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, p: &Paint, let cmd = if fills { "fill.set" } else { "stroke.set" }; let r = row_rect(ui, 32.0, |ui| { let w = ui.available_width() - 60.0; - let (r, _) = ui.allocate_exact_size(vec2(w, FIELD_H), Sense::hover()); - ui.painter().rect_filled(r, CornerRadius::same(5), t.field); - let sw = Rect::from_min_size(pos2(r.min.x + 4.0, r.center().y - 8.0), vec2(16.0, 16.0)); - widgets::paint_swatch(ui.painter(), sw, p); - let sresp = ui.interact(sw.expand(2.0), Id::unique(("swatch", fills, i)), Sense::click()); - app.picker_control(crate::colorpicker::PickerTarget::Paint { fills, index: i, ids: ids.clone() }, &sresp); - let text_r = Rect::from_min_max(pos2(sw.max.x + 8.0, r.min.y), pos2(r.max.x - 52.0, r.max.y)); - let mut child = ui.new_child(egui::UiBuilder::new().max_rect(text_r)); - // The variable bound to this paint's colour (its key on the layers: `fill` or `fills//color`). - let bound = app.session.doc().ok().and_then(|d| { - let key = ids.first().and_then(|id| d.node(*id)).and_then(|n| figcraft_engine::cmd::lists::paint_binding_key(n, fills, i))?; - Some((key.to_string(), crate::variable_ui::bound_name(d, &ids, key)?)) - }); - match &p.kind { - PaintKind::Solid { .. } if bound.is_some() => { - let (key, name) = bound.clone().unwrap_or_default(); - let chip = Rect::from_min_max(pos2(text_r.min.x - 2.0, r.min.y + 3.0), pos2(text_r.max.x - 20.0, r.max.y - 3.0)); - child.painter().rect_filled(chip, CornerRadius::same(4), t.hover); - child.painter().rect_stroke(chip, CornerRadius::same(4), Stroke::new(1.0, t.border), egui::StrokeKind::Inside); - let short = name.rsplit('/').next().unwrap_or(&name).to_string(); - child.painter().with_clip_rect(chip.shrink2(vec2(5.0, 0.0))).text( - pos2(chip.min.x + 5.0, r.center().y), - Align2::LEFT_CENTER, - short, - regular(11.0), - t.text, - ); - let _ = ui.interact(chip, Id::unique(("bound_paint", fills, i)), Sense::hover()).on_hover_text(&name); - let dr = Rect::from_center_size(pos2(text_r.max.x - 9.0, r.center().y), vec2(18.0, 18.0)); - let dresp = ui.interact(dr, Id::unique(("detach_paint", fills, i)), Sense::click()).tip(tr("Detach variable")); - icons::paint(ui.painter(), dr.shrink(3.0), Icon::Detach, t.icon_secondary); - if dresp.clicked() { - app.run("variable.bind", json!({"ids": ids, "field": key, "variable": null})); + let (sresp, e) = widgets::paint_field(ui, Id::unique(("swatch", fills, i)), Id::unique(("pop", fills, i)), p, w, |child, text_r| { + let r = child.max_rect(); + // The variable bound to this paint's colour (its key on the layers: `fill` or `fills//color`). + let bound = app.session.doc().ok().and_then(|d| { + let key = ids.first().and_then(|id| d.node(*id)).and_then(|n| figcraft_engine::cmd::lists::paint_binding_key(n, fills, i))?; + Some((key.to_string(), crate::variable_ui::bound_name(d, &ids, key)?)) + }); + match &p.kind { + PaintKind::Solid { .. } if bound.is_some() => { + let (key, name) = bound.clone().unwrap_or_default(); + let chip = Rect::from_min_max(pos2(text_r.min.x - 2.0, r.min.y + 3.0), pos2(text_r.max.x - 20.0, r.max.y - 3.0)); + child.painter().rect_filled(chip, CornerRadius::same(4), t.hover); + child.painter().rect_stroke(chip, CornerRadius::same(4), Stroke::new(1.0, t.border), egui::StrokeKind::Inside); + let short = name.rsplit('/').next().unwrap_or(&name).to_string(); + child.painter().with_clip_rect(chip.shrink2(vec2(5.0, 0.0))).text( + pos2(chip.min.x + 5.0, r.center().y), + Align2::LEFT_CENTER, + short, + regular(11.0), + t.text, + ); + let _ = child.interact(chip, Id::unique(("bound_paint", fills, i)), Sense::hover()).on_hover_text(&name); + let dr = Rect::from_center_size(pos2(text_r.max.x - 9.0, r.center().y), vec2(18.0, 18.0)); + let dresp = child.interact(dr, Id::unique(("detach_paint", fills, i)), Sense::click()).tip(tr("Detach variable")); + icons::paint(child.painter(), dr.shrink(3.0), Icon::Detach, t.icon_secondary); + if dresp.clicked() { + app.run("variable.bind", json!({"ids": ids, "field": key, "variable": null})); + } } - } - PaintKind::Solid { color } => { - if let Some(h) = widgets::text_field_inline( - &mut child, - Id::unique(("hex", fills, i)), - &color.to_hex(), - text_r.shrink2(vec2(0.0, 4.0)), - regular(11.0), - ) && let Some(c) = figcraft_color::Color::from_hex_or_name(&h) - { - app.run(cmd, json!({"ids": ids, "index": i, "color": c.to_css_hex()})); + PaintKind::Solid { color } => { + if let Some(h) = widgets::text_field_inline( + child, + Id::unique(("hex", fills, i)), + &color.to_hex(), + text_r.shrink2(vec2(0.0, 4.0)), + regular(11.0), + ) && let Some(c) = figcraft_color::Color::from_hex_or_name(&h) + { + app.run(cmd, json!({"ids": ids, "index": i, "color": c.to_css_hex()})); + } } - } - _ => { - child.painter().text(pos2(text_r.min.x, r.center().y), Align2::LEFT_CENTER, paint_label(p), regular(11.0), t.text); - // One layer's image fill: crop it on the canvas. - if fills && ids.len() == 1 && matches!(p.kind, PaintKind::Image(_)) { - let cr = Rect::from_center_size(pos2(text_r.max.x - 9.0, r.center().y), vec2(18.0, 18.0)); - let cresp = ui.interact(cr, Id::unique(("crop_image", i)), Sense::click()).tip(tr("Crop image")); - icons::paint(ui.painter(), cr.shrink(3.0), Icon::Crop, if cresp.hovered() { t.icon } else { t.icon_secondary }); - if cresp.clicked() { - app.run("image.crop", json!({"ids": ids, "index": i})); + _ => { + child.painter().text(pos2(text_r.min.x, r.center().y), Align2::LEFT_CENTER, paint_label(p), regular(11.0), t.text); + // One layer's image fill: crop it on the canvas. + if fills && ids.len() == 1 && matches!(p.kind, PaintKind::Image(_)) { + let cr = Rect::from_center_size(pos2(text_r.max.x - 9.0, r.center().y), vec2(18.0, 18.0)); + let cresp = child.interact(cr, Id::unique(("crop_image", i)), Sense::click()).tip(tr("Crop image")); + icons::paint(child.painter(), cr.shrink(3.0), Icon::Crop, if cresp.hovered() { t.icon } else { t.icon_secondary }); + if cresp.clicked() { + app.run("image.crop", json!({"ids": ids, "index": i})); + } } } } - } - let op_r = Rect::from_min_max(pos2(r.max.x - 48.0, r.min.y), r.max); - ui.painter().line_segment([pos2(op_r.min.x, r.min.y + 4.0), pos2(op_r.min.x, r.max.y - 4.0)], Stroke::new(1.0, t.panel)); - let mut child = ui.new_child(egui::UiBuilder::new().max_rect(op_r)); - let e = widgets::number_field( - &mut child, - Id::unique(("pop", fills, i)), - Lead::None, - Some((f64::from(p.opacity) * 100.0).round()), - "%", - 48.0, - 1.0, - ); + }); + app.picker_control(crate::colorpicker::PickerTarget::Paint { fills, index: i, ids: ids.clone() }, &sresp); match e { Some(FieldEdit::Set(v)) => { app.run(cmd, json!({"ids": ids, "index": i, "opacity": (v / 100.0).clamp(0.0, 1.0)})); diff --git a/crates/ui-egui/src/panels/selection_colors.rs b/crates/ui-egui/src/panels/selection_colors.rs index 97333367f..92fcd941e 100644 --- a/crates/ui-egui/src/panels/selection_colors.rs +++ b/crates/ui-egui/src/panels/selection_colors.rs @@ -24,6 +24,12 @@ struct Cache { colors: Arc>, } +#[derive(Clone)] +struct OpacityScrub { + targets: Vec, + opacity: f32, +} + /// Return the cached selection colours when the document and selection match. During an /// interaction reuse an existing matching cache, or return an empty list without discovering /// colours; otherwise discover and cache them. @@ -47,6 +53,7 @@ fn colors(ui: &Ui, document: u64, doc: &Arc, ids: &[NodeId], interacti colors } +/// Open or close the picker for frozen paint targets, anchored to the invoking control. pub(crate) fn toggle(app: &mut FigApp, targets: Vec, response: Option<&egui::Response>) { let Ok(ds) = app.session.ds() else { return }; let target = PickerTarget::SelectionColor { targets, selection: ds.selection.clone(), document: ds.uid }; @@ -70,97 +77,139 @@ pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Arc, ids: &[NodeId let t = Tokens::get(ui.ctx()); widgets::section_header(ui, tr("Selection colors"), &[], false); // Bound the panel's height; a document with many distinct colors stays scrollable. - ui.add_enabled_ui(!interacting, |ui| { + let scrub_key = Id::unique("selection_color_scrub"); + if !interacting { + ui.data_mut(|d| d.remove::(scrub_key)); + } + let scrubbing = ui.data(|d| d.get_temp::(scrub_key)); + ui.add_enabled_ui(!interacting || scrubbing.is_some(), |ui| { egui::ScrollArea::vertical().id_salt("selection_colors_rows").max_height(240.0).show_rows(ui, 32.0, colors.len(), |ui, range| { for group in &colors[range] { ui.push_id((&group.targets, "selection_color"), |ui| { crate::right::row(ui, 32.0, |ui| { - let swatch = widgets::swatch(ui, &group.paint, 24.0).tip(tr("Edit colour")); - toggle(app, group.targets.clone(), Some(&swatch)); - let width = (ui.available_width() - 116.0).max(40.0); let linked_name = group .style .as_ref() .and_then(|id| doc.style(id).map(|s| s.name.as_str())) .or_else(|| group.variable.as_ref().and_then(|id| doc.variables.get_by_id(id).map(|v| v.name.as_str()))); - if let Some(name) = linked_name { - let response = widgets::dropdown(ui, name, width, None); - toggle(app, group.targets.clone(), Some(&response)); - } else if let Some(color) = group.paint.solid_color() { - if let Some(hex) = widgets::text_field_named(ui, ui.scope_id().with("hex"), &color.to_hex(), width, tr("Color")) { - if let Some(color) = Color::from_hex(&hex) { - let mut paint = group.paint.clone(); - paint.kind = figcraft_color::PaintKind::Solid { color: color.with_alpha(1.0) }; - if color.a < 1.0 { - paint.opacity = color.a; - } - app.run("selection.setPaint", json!({"targets": group.targets, "paint": paint})); - } else { - app.toast(tr("Invalid color")); - } - } - } else { - let response = widgets::dropdown(ui, &widgets::paint_name(&group.paint), width, None); - toggle(app, group.targets.clone(), Some(&response)); - } - let alpha = format!("{}%", figcraft_doc::trim_num(f64::from(group.paint.opacity) * 100.0)); - if let Some(value) = widgets::text_field_named(ui, ui.scope_id().with("opacity"), &alpha, 44.0, tr("Opacity")) - && let Ok(value) = value.trim().trim_end_matches('%').parse::() - && value.is_finite() + let width = ui.available_width() - 60.0; + let mut paint = group.paint.clone(); + if let Some(scrub) = &scrubbing + && scrub.targets == group.targets { - let mut paint = group.paint.clone(); - paint.opacity = (value / 100.0).clamp(0.0, 1.0); - app.run("selection.setPaint", json!({"targets": group.targets, "paint": paint})); + paint.opacity = scrub.opacity; } - if linked_name.is_some() { - let detach = if group.style.is_some() { tr("Detach style") } else { tr("Detach variable") }; - if icons::button(ui, Icon::Detach, 24.0, false, detach).clicked() { - app.run("selection.detachColor", json!({"targets": group.targets})); - } - } else { - let styles = icons::button(ui, Icon::Styles, 24.0, false, tr("Color styles")); - crate::right::menu_popup(&styles).show(|ui| { - let mut any = false; - for style in &doc.styles { - if !matches!(style.kind, StyleKind::Paint { .. }) { - continue; - } - any = true; - if crate::right::menu_item(ui, &style.name, "") { - app.run("selection.applyColorStyle", json!({"targets": group.targets, "style": style.id})); - app.ui.picker = None; - ui.close(); + let mut name_response = None; + let (swatch, opacity) = widgets::paint_field( + ui, + ui.scope_id().with("paint_field"), + ui.scope_id().with("opacity"), + &paint, + width, + |child, rect| { + if linked_name.is_some() || group.paint.solid_color().is_none() { + let name = linked_name.map(str::to_owned).unwrap_or_else(|| widgets::paint_name(&group.paint)); + child.painter().with_clip_rect(rect).text( + rect.left_center(), + egui::Align2::LEFT_CENTER, + &name, + crate::theme::regular(11.0), + t.text, + ); + name_response = Some(child.interact(rect, child.scope_id().with("paint_name"), egui::Sense::click()).tip(name)); + } else if let Some(color) = group.paint.solid_color() + && let Some(hex) = widgets::text_field_inline( + child, + child.scope_id().with("hex"), + &color.to_hex(), + rect.shrink2(egui::vec2(0.0, 4.0)), + crate::theme::regular(11.0), + ) + { + if let Some(color) = Color::from_hex_or_name(&hex) { + let mut paint = group.paint.clone(); + paint.kind = figcraft_color::PaintKind::Solid { color: color.with_alpha(1.0) }; + if color.a < 1.0 { + paint.opacity = color.a; + } + app.run("selection.setPaint", json!({"targets": group.targets, "paint": paint})); + } else { + app.toast(tr("Invalid color")); } } - if !any { - widgets::label(ui, tr("No color styles"), t.text_secondary); - } - let variables: Vec<_> = doc - .variables - .variables - .iter() - .filter(|v| { - v.kind == VariableType::Color - && bind_refusal(doc, &group.targets, &v.id).is_none() - && in_scope(doc, &group.targets, &v.id) - }) - .collect(); - if !variables.is_empty() { - ui.separator(); - widgets::label(ui, tr("Color variables"), t.text_secondary); - } - for v in variables { - if crate::right::menu_item(ui, &v.name, "") { - app.run("selection.bindColorVariable", json!({"targets": group.targets, "variable": v.id})); - app.ui.picker = None; - ui.close(); - } + }, + ); + toggle(app, group.targets.clone(), Some(&swatch)); + if let Some(response) = name_response { + toggle(app, group.targets.clone(), Some(&response)); + } + if let Some(edit) = opacity { + let (value, scrub, done) = match edit { + widgets::FieldEdit::Set(value) => (value, false, false), + widgets::FieldEdit::Scrub(value, done) => (value, true, done), + }; + let mut paint = group.paint.clone(); + paint.opacity = (value / 100.0).clamp(0.0, 1.0) as f32; + ui.data_mut(|d| { + if scrub && !done { + d.insert_temp(scrub_key, OpacityScrub { targets: group.targets.clone(), opacity: paint.opacity }); + } else { + d.remove::(scrub_key); } }); + app.field_run_named(Some(edit), "Opacity", "selection.setPaint", |_| json!({"targets": group.targets, "paint": paint})); } - if icons::button(ui, Icon::Move, 24.0, false, tr("Select layers with this color")).clicked() { - app.run("selection.selectColor", json!({"targets": group.targets})); - } + widgets::tight_end(ui, |ui| { + if linked_name.is_some() { + let detach = if group.style.is_some() { tr("Detach style") } else { tr("Detach variable") }; + if icons::button(ui, Icon::Detach, 24.0, false, detach).clicked() { + app.run("selection.detachColor", json!({"targets": group.targets})); + } + } else { + let styles = icons::button(ui, Icon::Styles, 24.0, false, tr("Color styles")); + crate::right::menu_popup(&styles).show(|ui| { + let mut any = false; + for style in &doc.styles { + if !matches!(style.kind, StyleKind::Paint { .. }) { + continue; + } + any = true; + if crate::right::menu_item(ui, &style.name, "") { + app.run("selection.applyColorStyle", json!({"targets": group.targets, "style": style.id})); + app.ui.picker = None; + ui.close(); + } + } + if !any { + widgets::label(ui, tr("No color styles"), t.text_secondary); + } + let variables: Vec<_> = doc + .variables + .variables + .iter() + .filter(|v| { + v.kind == VariableType::Color + && bind_refusal(doc, &group.targets, &v.id).is_none() + && in_scope(doc, &group.targets, &v.id) + }) + .collect(); + if !variables.is_empty() { + ui.separator(); + widgets::label(ui, tr("Color variables"), t.text_secondary); + } + for v in variables { + if crate::right::menu_item(ui, &v.name, "") { + app.run("selection.bindColorVariable", json!({"targets": group.targets, "variable": v.id})); + app.ui.picker = None; + ui.close(); + } + } + }); + } + if icons::button(ui, Icon::SelectColor, 24.0, false, tr("Select layers with this color")).clicked() { + app.run("selection.selectColor", json!({"targets": group.targets})); + } + }); }); }); } @@ -181,6 +230,7 @@ mod tests { ctx: egui::Context, app: FigApp, time: f64, + modifiers: egui::Modifiers, } impl Panel { @@ -192,7 +242,7 @@ mod tests { ids.push(NodeId(value["id"].as_u64().unwrap())); } s.execute("select.set", &json!({"ids":ids})).unwrap(); - let mut p = Self { ctx: egui::Context::default(), app: FigApp::new(s, Box::new(NoServices)), time: 0.0 }; + let mut p = Self { ctx: egui::Context::default(), app: FigApp::new(s, Box::new(NoServices)), time: 0.0, modifiers: Default::default() }; crate::theme::install_fonts(&p.ctx, false, 0); crate::theme::apply(&p.ctx, crate::theme::Tokens::light()); for _ in 0..3 { @@ -200,7 +250,9 @@ mod tests { } (p, ids[0], ids[1]) } - fn frame(&mut self, events: Vec) -> Vec<(String, Pos2)> { + /// Run one panel frame, retaining its shapes and accessibility output for assertions. + fn output(&mut self, mut events: Vec) -> egui::FullOutput { + events.insert(0, Event::ModifiersChanged(self.modifiers)); self.time += 1.0 / 60.0; let input = RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(280.0, 600.0))), @@ -215,6 +267,10 @@ mod tests { section(&mut self.app, ui, &doc, &ids); }); output.textures_delta.clear(); + output + } + fn frame(&mut self, events: Vec) -> Vec<(String, Pos2)> { + let output = self.output(events); fn texts(shape: &Shape, out: &mut Vec<(String, Pos2)>) { match shape { Shape::Text(t) => out.push((t.galley.text().to_string(), t.pos + t.galley.size() / 2.0)), @@ -300,6 +356,127 @@ mod tests { assert!(p.app.ui.last_error.is_none()); } + /// Stored variable ids must never resolve to another variable with that name. + #[test] + fn linked_variable_name_uses_its_stored_id_when_another_name_matches() { + let (mut p, a, _) = Panel::new(); + p.app.run("variable.createCollection", json!({"name":"Theme"})); + let shadow = p.app.run("variable.create", json!({"collection":"Theme","name":"Shadow","type":"color","value":"#ff0000"})).unwrap(); + let accent = p.app.run("variable.create", json!({"collection":"Theme","name":"Accent","type":"color","value":"#00ff00"})).unwrap(); + p.app.run("variable.bind", json!({"id":a,"field":"fill","variable":accent["id"]})); + p.app.run("variable.update", json!({"variable":shadow["id"],"name":accent["id"]})); + assert!(p.app.ui.last_error.is_none()); + let texts = p.frame(vec![]); + assert!(texts.iter().any(|(text, _)| text == "Accent")); + assert!(!texts.iter().any(|(text, _)| text == accent["id"].as_str().unwrap())); + } + + /// Shared swatches stay distinguishable by their paint values to assistive technology. + #[test] + fn shared_swatch_announces_its_colour_and_keeps_the_edit_label() { + let (mut p, _, _) = Panel::new(); + p.ctx.enable_accesskit(); + let output = p.output(vec![]); + let nodes = output.platform_output.accesskit_update.unwrap().nodes; + let mut values: Vec<_> = nodes + .iter() + .filter_map(|(_, node)| { + if node.role() == egui::accesskit::Role::ColorWell { + assert_eq!(node.label(), Some("Edit colour")); + Some(node.value().unwrap().to_string()) + } else { + None + } + }) + .collect(); + values.sort(); + assert_eq!(values, ["0000FF", "FF0000"]); + } + + /// Consecutive fine scrubs retain fractional baselines and each get one undo step. + #[test] + fn fractional_opacity_is_displayed_and_accumulates_across_fine_scrubs() { + let (mut p, a, _) = Panel::new(); + p.app.run("fill.set", json!({"id":a, "opacity":0.999})); + let texts = p.frame(vec![]); + assert!(texts.iter().any(|(text, _)| text == "99.9%")); + let red = texts.into_iter().find(|(text, _)| text == "FF0000").unwrap().1; + let start = Pos2::new(167.0, red.y); + p.modifiers.alt = true; + for expected in [0.989, 0.979] { + p.frame(vec![ + Event::PointerMoved(start), + Event::PointerButton { pos: start, button: egui::PointerButton::Primary, pressed: true, modifiers: p.modifiers }, + ]); + for dx in [-4.0, -8.0, -10.0] { + p.frame(vec![Event::PointerMoved(start + vec2(dx, 0.0))]); + } + p.frame(vec![Event::PointerButton { + pos: start - vec2(10.0, 0.0), + button: egui::PointerButton::Primary, + pressed: false, + modifiers: p.modifiers, + }]); + assert!(p.app.session.ds().unwrap().interaction.is_none()); + let opacity = p.app.session.doc().unwrap().node(a).unwrap().fills[0].opacity; + assert!((opacity - expected).abs() < 1e-6, "fine scrub kept fractional baseline: {opacity}"); + } + assert!(p.frame(vec![]).iter().any(|(text, _)| text == "97.9%")); + for expected in [0.989, 0.999] { + p.app.run("edit.undo", json!({})); + let opacity = p.app.session.doc().unwrap().node(a).unwrap().fills[0].opacity; + assert!((opacity - expected).abs() < 1e-6, "each fine scrub is one undo step: {opacity}"); + } + } + + #[test] + fn shared_color_field_edits_hex_and_opacity_and_undoes_each_edit() { + let (mut p, a, _) = Panel::new(); + let red = p.frame(vec![]).into_iter().find(|(s, _)| s == "FF0000").unwrap().1; + p.click(red); + p.frame(vec![Event::Text("00FF00".into())]); + let enter = Event::Key { key: egui::Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }; + p.frame(vec![enter.clone()]); + assert_eq!(p.app.session.doc().unwrap().node(a).unwrap().fills[0].solid_color().unwrap().to_hex(), "00FF00"); + p.frame(vec![]); + p.click(Pos2::new(176.0, red.y)); + p.frame(vec![Event::Text("45".into())]); + p.frame(vec![enter]); + assert_eq!(p.app.session.doc().unwrap().node(a).unwrap().fills[0].opacity, 0.45); + p.app.run("edit.undo", json!({})); + assert_eq!(p.app.session.doc().unwrap().node(a).unwrap().fills[0].opacity, 1.0); + p.app.run("edit.undo", json!({})); + assert_eq!(p.app.session.doc().unwrap().node(a).unwrap().fills[0].solid_color().unwrap().to_hex(), "FF0000"); + } + + #[test] + fn shared_opacity_field_keeps_dragging_until_release_as_one_undo_step() { + let (mut p, a, _) = Panel::new(); + let red = p.frame(vec![]).into_iter().find(|(s, _)| s == "FF0000").unwrap().1; + let start = Pos2::new(167.0, red.y); + p.frame(vec![ + Event::PointerMoved(start), + Event::PointerButton { pos: start, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }, + ]); + for dx in [-10.0, -20.0, -30.0] { + p.frame(vec![Event::PointerMoved(start + vec2(dx, 0.0))]); + } + assert!(p.app.session.ds().unwrap().interaction.is_some(), "the scrub commits only on release"); + assert!(p.frame(vec![]).iter().any(|(text, _)| text == "70%"), "the live opacity stays visible while scrubbing"); + p.frame(vec![Event::PointerButton { + pos: start - vec2(30.0, 0.0), + button: egui::PointerButton::Primary, + pressed: false, + modifiers: Default::default(), + }]); + assert!(p.app.session.ds().unwrap().interaction.is_none()); + assert_eq!(p.app.session.doc().unwrap().node(a).unwrap().fills[0].opacity, 0.7); + assert_eq!(p.app.session.undo_labels(1), ["Opacity"], "the shared helper preserves the scrub's undo label"); + p.app.run("edit.undo", json!({})); + assert_eq!(p.app.session.doc().unwrap().node(a).unwrap().fills[0].opacity, 1.0); + assert_eq!(p.app.session.selection().len(), 2); + } + #[test] fn panel_displays_the_linked_style_name_and_the_other_color() { let (mut p, a, b) = Panel::new(); diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index c7e20bb1d..80c610351 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -1041,6 +1041,35 @@ pub fn checker(painter: &egui::Painter, rect: Rect, cell: f32) { } } +/// Shared Fill/Stroke/Selection colors control: one background, a 16 pt swatch, +/// caller-provided colour content, and a separated, scrubbable opacity field. +/// The caller owns picker routing and commands; this widget only draws and reports edits. +pub fn paint_field( + ui: &mut Ui, + id: Id, + opacity_id: Id, + paint: &figcraft_color::Paint, + width: f32, + content: impl FnOnce(&mut Ui, Rect), +) -> (Response, Option) { + let t = Tokens::get(ui.ctx()); + let (r, _) = ui.allocate_exact_size(vec2(width, FIELD_H), Sense::hover()); + ui.painter().rect_filled(r, CornerRadius::same(5), t.field); + let sw = Rect::from_min_size(pos2(r.min.x + 4.0, r.center().y - 8.0), vec2(16.0, 16.0)); + paint_swatch(ui.painter(), sw, paint); + let swatch = ui.interact(sw.expand(2.0), id, Sense::click()).tip(tr("Edit colour")); + let value = if a11y::active(&swatch.ctx) { paint_name(paint) } else { String::new() }; + a11y::color(&swatch, tr("Edit colour"), &value); + let text_r = Rect::from_min_max(pos2(sw.max.x + 8.0, r.min.y), pos2(r.max.x - 52.0, r.max.y)); + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(text_r)); + content(&mut child, text_r); + let op_r = Rect::from_min_max(pos2(r.max.x - 48.0, r.min.y), r.max); + ui.painter().line_segment([pos2(op_r.min.x, r.min.y + 4.0), pos2(op_r.min.x, r.max.y - 4.0)], Stroke::new(1.0, t.panel)); + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(op_r)); + let opacity = number_field(&mut child, opacity_id, Lead::Named(tr("Opacity")), Some(f64::from(paint.opacity) * 100.0), "%", 48.0, 1.0); + (swatch, opacity) +} + /// A paint swatch (with checkerboard under transparent colours). pub fn swatch(ui: &mut Ui, paint: &figcraft_color::Paint, size: f32) -> Response { swatch_named(ui, paint, size, tr("Color"))