Merge pull request #2011 from Carbocode/fix/selection-color-icon

M8: unify Selection colors input and align sidebar icons
This commit is contained in:
BFlat authored and GitHub committed 2026-11-07 09:31:02 +08:00
commit 798eedf9f3
7 files changed
+387 -153

No files matched your search

+24 -4
View File
@@ -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();
+2
View File
@@ -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,
+20 -4
View File
@@ -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);
+7 -1
View File
@@ -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<FieldEdit>, 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<FieldEdit>, 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, &params(v));
if done {
let _ = self.session.commit_interaction();
+51 -67
View File
@@ -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/<i>/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/<i>/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)}));
+254 -77
View File
@@ -24,6 +24,12 @@ struct Cache {
colors: Arc<Vec<SelectionColor>>,
}
#[derive(Clone)]
struct OpacityScrub {
targets: Vec<PaintTarget>,
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<Document>, 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<PaintTarget>, 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<Document>, 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::<OpacityScrub>(scrub_key));
}
let scrubbing = ui.data(|d| d.get_temp::<OpacityScrub>(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::<f32>()
&& 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::<OpacityScrub>(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<Event>) -> Vec<(String, Pos2)> {
/// Run one panel frame, retaining its shapes and accessibility output for assertions.
fn output(&mut self, mut events: Vec<Event>) -> 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<Event>) -> 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();
+29
View File
@@ -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<FieldEdit>) {
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"))