mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-11 04:51:51 +00:00
Merge pull request #2011 from Carbocode/fix/selection-color-icon
M8: unify Selection colors input and align sidebar icons
This commit is contained in:
7 files changed
+387
-153
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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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, ¶ms(v));
|
||||
if done {
|
||||
let _ = self.session.commit_interaction();
|
||||
|
||||
@@ -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,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();
|
||||
|
||||
@@ -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"))
|
||||
|
||||
Reference in new issue
Block a user