M8: share paint input with Selection colors

This commit is contained in:
Carbocode committed 2026-10-30 11:29:28 +01:00
1 parent 84d9391ba4
commit 967917dbbd
5 files changed
+283 -148

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();
+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)}));
+178 -75
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,147 @@ 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(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);
}
});
if scrub {
let _ = app.session.begin_interaction("Opacity");
let _ = app.session.execute("selection.setPaint", &json!({"targets": group.targets, "paint": paint}));
if done {
let _ = app.session.commit_interaction();
}
} else {
app.run("selection.setPaint", json!({"targets": group.targets, "paint": paint}));
}
}
if icons::button(ui, Icon::SelectColor, 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}));
}
});
});
});
}
@@ -300,6 +357,52 @@ mod tests {
assert!(p.app.ui.last_error.is_none());
}
#[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.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);
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();
+27
View File
@@ -1016,6 +1016,33 @@ 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 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).round()), "%", 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"))
+3 -2
View File
@@ -1,6 +1,6 @@
# UI and interaction parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (Selection colors uses an original outlined selection icon for selecting layers using a colour; the Plus icon’s bars share one snapped centre and equal arms at every size. with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); colour picker review fixes for text undo, fitted sidebars, moving controls and gradient variables; Corner smoothing: an original icon and the shared number-field drag gesture, preserving fractional baselines; guides snap to layers when dragged; Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); the Design panel's sections on one grid — 16 / 8 margins, 88 + 8 + 88 + 8 + 24 columns, 24 pt rows, 4 pt between a row's buttons (#858); the toolbar on one 32 pt grid, without its Resources button (#1210); colour picker LCH and OKLCH models; an XML (Android Vector Drawable) export format; Context menus: the Menu key reaches egui 0.37 but is unbound (row done → partial); the Text on a path tool; board tool keys pinned; community fixes: the text box by dragging, the font picker keys, the pen path on leaving Design, a still canvas in vector edit mode, a scrolling pages list, colour emoji, readable new text in frames, system theme on first run; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths; a rename selects the whole name, so typing replaces it; Escape or Enter after a pen path stays on the same vector (#760); IME compLine truncated
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (Selection colors shares Fill and Stroke’s swatch/colour/opacity control, including live opacity scrubbing with one undo step. Selection colors uses an original outlined selection icon for selecting layers using a colour; the Plus icon’s bars share one snapped centre and equal arms at every size; with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); colour picker review fixes for text undo, fitted sidebars, moving controls and gradient variables; Corner smoothing: an original icon and the shared number-field drag gesture, preserving fractional baselines; guides snap to layers when dragged; Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); the Design panel's sections on one grid — 16 / 8 margins, 88 + 8 + 88 + 8 + 24 columns, 24 pt rows, 4 pt between a row's buttons (#858); the toolbar on one 32 pt grid, without its Resources button (#1210); colour picker LCH and OKLCH models; an XML (Android Vector Drawable) export format; Context menus: the Menu key reaches egui 0.37 but is unbound (row done → partial); the Text on a path tool; board tool keys pinned; community fixes: the text box by dragging, the font picker keys, the pen path on leaving Design, a still canvas in vector edit mode, a scrolling pages list, colour emoji, readable new text in frames, system theme on first run; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths; a Line truncated
Fine canvas control is what Figma users feel first: handles, snapping, nudging, modifier keys,
drag behaviour, numeric precision. This checklist covers the tools, gestures and panels. Status:
@@ -73,7 +73,7 @@ interaction feel not yet compared.
| Transform box at small sizes | done | a press inside a layer smaller than its handles moves it, the nearest handle wins, handles answer just outside and are hidden under 16 px on screen; cursors follow each handle and the box's rotation |
| Locked layers | done | selectable from the layers panel; no handles, not dragged with the rest of a selection |
| Smart selection spacing | partial | with layers in a row, a column or an aligned grid selected, the Layout section shows horizontal and vertical spacing ("Mixed" until set; typed, scrubbed, fractional and negative) and pink handles in the gaps on the canvas drag them, one undo step, Escape cancels (`arrange.spacing`; sizes, parents and offsets within each track kept; at most 2,000 layers; flow children, locked layers and layers inside instances left out). No reorder rings for swapping layers; checked by its author on macOS and in the app on Windows (#402) |
| Selection colors | partial | An outlined selection icon selects layers using the colour. for a mixed selection the Design panel lists their solid and gradient paints (children and text ranges included; hidden paints, masks, images, patterns and boolean operands left out; styles and variables listed apart): editing a colour changes every matching fill and stroke in one undo step, a row selects the layers that use it or applies a color style or color variable of the document, and a row linked to a style or variable detaches it, keeping the color (`selection.colors`, `selection.setPaint`, `selection.applyColorStyle`, `selection.bindColorVariable`, `selection.detachColor`, `selection.selectColor`). No library styles or variables from there, and no new style or variable from a row (#403) |
| Selection colors | partial | for a mixed selection the Design panel lists their solid and gradient paints (children and text ranges included; hidden paints, masks, images, patterns and boolean operands left out; styles and variables listed apart); each row shares Fill and Stroke’s colour control, and an outlined selection icon selects the layers that use its colour. Editing a colour changes every matching fill and stroke in one undo step, a row selects the layers that use it or applies a colour style or colour variable of the document, and a row linked to a style or variable detaches it, keeping the colour (`selection.colors`, `selection.setPaint`, `selection.applyColorStyle`, `selection.bindColorVariable`, `selection.detachColor`, `selection.selectColor`). No library styles or variables from there, and no new style or variable from a row (#403) |
| Keyboard box selection | done | Alt+Space on macOS, Ctrl+Space on Windows and Linux, or F8 (`select.area`): a box around the selection or in the middle of the view; arrows move it, Cmd/Ctrl+arrows or Shift+arrows size it (Shift with Cmd/Ctrl: farther), Enter selects like a marquee (Shift+Enter toggles, Cmd/Ctrl+Enter nested), Escape closes |
## Tools
@@ -175,6 +175,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look
| Date | Change | Summary |
|---|---|---|
| 2026-10-30 | minor | M8: Selection colors shares Fill and Stroke’s swatch/colour/opacity control, including live opacity scrubbing with one undo step. Verified in light and dark themes; colour edits, opacity scrubbing and undo covered by panel tests. Readiness estimates unchanged. |
| 2026-10-30 | minor | M8: Selection colors uses an original outlined selection icon for selecting layers using a colour; the Plus icon’s bars share one snapped centre and equal arms at every size. Readiness estimates unchanged. |
| 2026-10-30 | minor | Pen into vector edit mode (#1430): Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on; Escape keeps the pen. Numbers unchanged. |
| 2026-10-30 | minor | M8, #1574: the Actions palette (Ctrl+K) offered engine commands that need parameters it can't give, such as `image.place` ("Place image", needing the image's `data` or `path`), which failed with "Invalid parameters" and sat above the menu's Place image… row with the same label. A search now leaves out a command no menu lists when its params doc names a parameter without `?` (`figcraft_engine::cmd::needs_params`), and a row no menu item gives whose label reads like a menu item's (any case, without the ellipsis). Place image runs File ▸ Place image…, which opens the file dialog. Tests: `palette_tests.rs` (no row needs parameters unless a menu item runs it, no duplicate label across several searches), `unknown_params_tests.rs`. |