mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-12 17:01:09 +00:00
M8: share paint input with Selection colors
This commit is contained in:
1 parent
84d9391ba4
commit
967917dbbd
5 files changed
+283
-148
No files matched your search
@@ -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();
|
||||
|
||||
@@ -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)}));
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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`. |
|
||||
|
||||
Reference in new issue
Block a user