diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index f0513520b..293de1dca 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -296,6 +296,8 @@ impl FigApp { self.ui.narrow = narrow; // A narrow window shows the right panel when its button asks for it, a board's too. let right_open = (self.right_panel_shown(hidden) && !narrow && !right_minimized) || mobile == Some(MobilePanel::Right); + // A highlighted fill, stroke or effect row goes with the panel it's in, before it draws. + crate::property_rows::reconcile(self, &ctx, right_open); // A right-to-left UI language mirrors the window: the "left" rail and panel go on the // right, the "right" (Design) panel on the left. Their ids and widths stay as they are. let mirrored = self.ui.mirrored; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 60c68f526..9e6f77f0f 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -226,6 +226,9 @@ mod present_tests; mod present_view_tests; #[cfg(test)] mod property_menu_tests; +mod property_rows; +#[cfg(test)] +mod property_rows_tests; #[cfg(test)] mod proto_editor_tests; #[cfg(test)] @@ -589,6 +592,8 @@ pub struct UiState { /// copy changes the engine clipboard without writing the OS clipboard; the generation then /// no longer matches, so the stale mirror is not mistaken for the user's own copy. pub last_copy_generation: u64, + /// The fill, stroke or effect row highlighted in the Design panel. + pub property_row: Option, /// A save shortcut pressed while a panel field had the keyboard: the field lets go (which /// commits what was typed) and the command runs next frame for that document/window. pub after_field: Option, @@ -732,6 +737,7 @@ impl Default for UiState { last_copy: None, last_copy_generation: 0, + property_row: None, after_field: None, held: egui::Modifiers::NONE, next_autosave: 0.0, diff --git a/crates/ui-egui/src/panels/list_drag.rs b/crates/ui-egui/src/panels/list_drag.rs index 08a391aeb..4fa614d4e 100644 --- a/crates/ui-egui/src/panels/list_drag.rs +++ b/crates/ui-egui/src/panels/list_drag.rs @@ -75,8 +75,19 @@ impl ListDrag { /// Item `index` (model order) was laid out with its main row at `header`, covering `extent` /// (the header plus anything open under it). Its grip starts a drag carrying `label`. pub fn row(&mut self, ui: &mut Ui, index: usize, header: Rect, extent: Rect, label: impl FnOnce() -> String) { + self.grip_row(ui, index, header, extent, Sense::drag(), label); + } + + /// [`Self::row`] whose grip also takes a click, the way a click beside the row's fields + /// highlights a fill, stroke or effect row: `true` when it was clicked. The grip senses the + /// click without being focusable, so Tab doesn't stop on it. + pub fn row_clicked(&mut self, ui: &mut Ui, index: usize, header: Rect, extent: Rect, label: impl FnOnce() -> String) -> bool { + self.grip_row(ui, index, header, extent, Sense::DRAG | Sense::CLICK, label) + } + + fn grip_row(&mut self, ui: &mut Ui, index: usize, header: Rect, extent: Rect, sense: Sense, label: impl FnOnce() -> String) -> bool { let grip = Rect::from_min_max(header.min, pos2(header.min.x + PAD, header.max.y)); - let resp = ui.interact(grip, grip_of(self.list, index), Sense::drag()); + let resp = ui.interact(grip, grip_of(self.list, index), sense); // The handle shows while the pointer is over the row (or the row is being dragged). let shown = match self.dragging { Some(d) => d == index, @@ -93,6 +104,7 @@ impl ListDrag { ui.ctx().set_cursor_icon(egui::CursorIcon::Grab); } self.row_by(&resp, index, extent, label); + resp.clicked() } /// Item `index` laid out over `extent`, dragged by the whole row (`resp` senses drags; the diff --git a/crates/ui-egui/src/panels/right/effects.rs b/crates/ui-egui/src/panels/right/effects.rs index b25990db7..642892d18 100644 --- a/crates/ui-egui/src/panels/right/effects.rs +++ b/crates/ui-egui/src/panels/right/effects.rs @@ -1,9 +1,10 @@ -use super::menu::{context_popup, menu_check, menu_item, menu_popup, row, row_context_menu, row_rect}; +use super::menu::{context_popup, menu_check, menu_item, menu_popup, row, row_context_menu, row_rect, row_target}; use super::{ Blur, BlurType, Document, Effect, EffectKind, FieldEdit, FigApp, Glass, GridAlign, GridKind, Icon, Id, LayoutGrid, Lead, ListDrag, MAX_BLUR_RADIUS, NodeId, Noise, NoiseMode, Paint, Sense, Shadow, StyleSlot, Texture, Ui, VariableType, common, icons, json, move_flags, moved_index, remove_flags, removed_index, style_picker, tr, trf, trn, vec2, widgets, }; +use crate::property_rows::PropertyKind; /// Draw the effect list with add/style/type/toggle/remove/reorder, open settings, and keep an /// open picker and settings flags aligned when rows move or are removed. @@ -29,9 +30,12 @@ pub(super) fn effects(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[Node let Some(list) = list else { return }; let mut drag = ListDrag::new(ui, "effects"); let mut removed = None; + let selected_row = crate::theme::Tokens::get(ui.ctx()).selected_row; for (i, e) in list.iter().enumerate().rev() { let mut open = effect_open(ui.ctx(), i); - let header = row_rect(ui, 32.0, |ui| { + // A click on the row's background highlights it (`property_rows`). + let highlight = crate::property_rows::fill(app, PropertyKind::Effect, i, selected_row); + let header = row_target(ui, 32.0, highlight, |ui| { if icons::button(ui, Icon::Effects, 24.0, open, tr("Effect settings")).clicked() { open = !open; ui.data_mut(|d| d.insert_temp(Id::unique((FX_OPEN, i)), open)); @@ -63,6 +67,8 @@ pub(super) fn effects(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[Node removed = Some(i); } }); + crate::property_rows::row(app, &header, PropertyKind::Effect, i, &ids); + let header = header.rect; let can_paste = app.session.clipboard.effect.is_some(); row_context_menu(ui, header, Id::unique(("effect_menu", i))).show(|ui| { if menu_item(ui, tr("Copy effect"), "") { @@ -77,10 +83,13 @@ pub(super) fn effects(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[Node if open { effect_settings(app, ui, &ids, i, e); } - drag.row(ui, i, header, header.with_max_y(ui.cursor().min.y.max(header.max.y)), || tr(e.label()).to_string()); + if drag.row_clicked(ui, i, header, header.with_max_y(ui.cursor().min.y.max(header.max.y)), || tr(e.label()).to_string()) { + crate::property_rows::click(app, PropertyKind::Effect, i, &ids, header); + } } if let Some((from, to)) = drag.finish(ui) { app.run("effect.move", json!({"ids": ids, "from": from, "to": to})); + crate::property_rows::follow(app, PropertyKind::Effect, from, Some(to)); move_flags(ui, FX_OPEN, list.len(), from, to); if let Some(crate::colorpicker::PickerTarget::Effect { index, .. }) = &mut app.ui.picker { *index = moved_index(*index, from, to); @@ -88,6 +97,7 @@ pub(super) fn effects(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[Node } // Open settings and an open colour picker stay with their effects, or go with theirs. if let Some(i) = removed { + crate::property_rows::follow(app, PropertyKind::Effect, i, None); remove_flags(ui, FX_OPEN, list.len(), i); if let Some(crate::colorpicker::PickerTarget::Effect { index, .. }) = &mut app.ui.picker { match removed_index(*index, i) { diff --git a/crates/ui-egui/src/panels/right/menu.rs b/crates/ui-egui/src/panels/right/menu.rs index 9b80ab8d8..40215a8a5 100644 --- a/crates/ui-egui/src/panels/right/menu.rs +++ b/crates/ui-egui/src/panels/right/menu.rs @@ -155,12 +155,31 @@ pub(crate) fn row_rect(ui: &mut Ui, h: f32, add: impl FnOnce(&mut Ui)) -> Rect { pub(crate) fn block(ui: &mut Ui, h: f32, inset_y: f32, add: impl FnOnce(&mut Ui)) -> Rect { let w = ui.available_width(); let (r, _) = ui.allocate_exact_size(vec2(w, h), Sense::hover()); + lay_out(ui, r, inset_y, add); + r +} + +/// A [`row_rect`] that is a pointer target itself: a click on its background (beside and between +/// its controls, which are on top of it) comes back in the response. It senses clicks without +/// being focusable, so Tab and F6 still go from control to control. `fill` is drawn under the +/// controls (a highlighted fill, stroke or effect row). +pub(crate) fn row_target(ui: &mut Ui, h: f32, fill: Option, add: impl FnOnce(&mut Ui)) -> egui::Response { + let w = ui.available_width(); + let (r, resp) = ui.allocate_exact_size(vec2(w, h), Sense::CLICK); + if let Some(fill) = fill { + ui.painter().rect_filled(r, CornerRadius::ZERO, fill); + } + lay_out(ui, r, (h - FIELD_H) / 2.0, add); + resp +} + +/// Add a row's controls in `r`, on the section's grid, inset `inset_y` from its top and bottom. +fn lay_out(ui: &mut Ui, r: Rect, inset_y: f32, add: impl FnOnce(&mut Ui)) { // The section's grid: 16 in from the left, 8 from the right (not 16 on both sides). let inner = Rect::from_min_max(pos2(r.min.x + PAD, r.min.y + inset_y), pos2(r.max.x - crate::widgets::PAD_RIGHT, r.max.y - inset_y)); let mut child = ui.new_child(egui::UiBuilder::new().max_rect(inner).layout(egui::Layout::left_to_right(egui::Align::Center))); child.spacing_mut().item_spacing = vec2(crate::widgets::GUTTER, 0.0); add(&mut child); - r } /// Width of the check column of [`menu_icon_item`] (the mark, or nothing, so labels line up). diff --git a/crates/ui-egui/src/panels/right/paints.rs b/crates/ui-egui/src/panels/right/paints.rs index 967ae266b..dfe82cc8b 100644 --- a/crates/ui-egui/src/panels/right/paints.rs +++ b/crates/ui-egui/src/panels/right/paints.rs @@ -1,10 +1,11 @@ -use super::menu::{blend_menu, context_popup, menu_check, menu_item, menu_popup, preview_blend, row, row_context_menu, row_rect}; +use super::menu::{blend_menu, context_popup, menu_check, menu_item, menu_popup, preview_blend, row, row_context_menu, row_target}; use super::stroke::stroke_settings; use super::{ Align2, BlendMode, CornerRadius, Document, FIELD_H, FieldEdit, FigApp, Icon, Id, Lead, ListDrag, MaskType, Node, NodeId, NodeKind, Paint, PaintKind, Rect, Sense, Stroke, StrokeAlign, StyleSlot, Tip, Tokens, Ui, Value, VariableType, common, icons, json, moved_index, pos2, regular, removed_index, style_picker, tr, trf, vec2, widgets, }; +use crate::property_rows::PropertyKind; /// Draw visibility/blend/mask, opacity, uniform or per-corner radius, corner smoothing and /// the blend-mode row. @@ -341,10 +342,13 @@ pub(super) fn paints(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeI let mut drag = ListDrag::new(ui, if fills { "fills" } else { "strokes" }); for (i, p) in l.iter().enumerate().rev() { let r = paint_row(app, ui, &ids, i, p, fills, region); - drag.row(ui, i, r, r, || paint_label(p)); + if drag.row_clicked(ui, i, r, r, || paint_label(p)) { + crate::property_rows::click(app, if fills { PropertyKind::Fill } else { PropertyKind::Stroke }, i, &ids, r); + } } if let Some((from, to)) = drag.finish(ui) { app.run(if fills { "fill.move" } else { "stroke.move" }, json!({"ids": ids, "from": from, "to": to})); + crate::property_rows::follow(app, if fills { PropertyKind::Fill } else { PropertyKind::Stroke }, from, Some(to)); // An open colour picker keeps editing the paint it was on. if let Some(crate::colorpicker::PickerTarget::Paint { fills: f, index, .. }) = &mut app.ui.picker && *f == fills @@ -451,12 +455,15 @@ pub(super) fn paints(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeI /// Paint `i`'s row (swatch, colour or kind, opacity, visibility, remove); returns its area. /// One paint's row; `region`: a row of the regions selected in vector edit mode (#1875), which -/// leaves out what belongs to the layer (its variable binding, the image crop). +/// leaves out what belongs to the layer (its variable binding, the image crop). A click on its +/// background highlights it ([`crate::property_rows`]). fn paint_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, p: &Paint, fills: bool, region: bool) -> Rect { let t = Tokens::get(ui.ctx()); let ids = ids.to_vec(); let cmd = if fills { "fill.set" } else { "stroke.set" }; - let r = row_rect(ui, 32.0, |ui| { + let kind = if fills { PropertyKind::Fill } else { PropertyKind::Stroke }; + let highlight = crate::property_rows::fill(app, kind, i, t.selected_row); + let resp = row_target(ui, 32.0, highlight, |ui| { let w = ui.available_width() - 60.0; 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(); @@ -537,18 +544,22 @@ fn paint_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, p: &Paint, if toggle { app.run(cmd, json!({"ids": ids, "index": i, "visible": !p.visible})); } - if remove - && app.run(if fills { "fill.remove" } else { "stroke.remove" }, json!({"ids": ids, "index": i})).is_some() - && let Some(crate::colorpicker::PickerTarget::Paint { fills: f, index, .. }) = &mut app.ui.picker - && *f == fills - { + if remove && app.run(if fills { "fill.remove" } else { "stroke.remove" }, json!({"ids": ids, "index": i})).is_some() { + // A highlighted row stays on its paint, or goes with it. + crate::property_rows::follow(app, kind, i, None); // An open colour picker keeps editing the paint it was on, or closes with it. - match removed_index(*index, i) { - Some(k) => *index = k, - None => app.ui.picker = None, + if let Some(crate::colorpicker::PickerTarget::Paint { fills: f, index, .. }) = &mut app.ui.picker + && *f == fills + { + match removed_index(*index, i) { + Some(k) => *index = k, + None => app.ui.picker = None, + } } } }); + crate::property_rows::row(app, &resp, kind, i, &ids); + let r = resp.rect; let copy = if fills { tr("Copy fill") } else { tr("Copy stroke") }; let paste = if fills { tr("Paste fill") } else { tr("Paste stroke") }; let can_paste = app.session.clipboard.paint.as_ref().is_some_and(|(f, _)| *f == fills); diff --git a/crates/ui-egui/src/property_rows.rs b/crates/ui-egui/src/property_rows.rs new file mode 100644 index 000000000..1e06debfa --- /dev/null +++ b/crates/ui-egui/src/property_rows.rs @@ -0,0 +1,117 @@ +//! Fill, stroke and effect rows of the Design panel that highlight, the first step of the help +//! center's "Copy and paste properties between layers": click to highlight the property in the +//! right sidebar. +//! +//! A click on a row's background (beside and between its controls, or on its reorder grip) +//! highlights it, one row at a time. The highlight belongs to the selection it was made on, and +//! goes with Escape or a press on the canvas or elsewhere in the Design panel. + +use egui::{Color32, Context, Rect, Response}; +use figcraft_doc::NodeId; + +use crate::{FigApp, RightTab}; + +/// Which list of the Design panel a row belongs to. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PropertyKind { + Fill, + Stroke, + Effect, +} + +/// The row highlighted in the Design panel. It belongs to the layers selected when it was clicked +/// and goes when they are no longer the selection. +#[derive(Clone, Debug, PartialEq)] +pub struct Highlight { + pub kind: PropertyKind, + /// The paint's or effect's index in its list (model order: the top one is the last). + pub index: usize, + /// The selection the row was highlighted on. + pub ids: Vec, + /// The file it was highlighted in. + pub doc: u64, + /// Where the row was drawn this frame (`None` until it draws): a press elsewhere lets it go, + /// and so does a frame it isn't drawn in. + pub rect: Option, +} + +/// The highlighted row, when it still belongs to the selection in the Design panel. +pub(crate) fn current(app: &FigApp) -> Option<&Highlight> { + let h = app.ui.property_row.as_ref()?; + let ds = app.session.ds().ok()?; + (app.ui.right_tab == RightTab::Design && ds.uid == h.doc && ds.selection == h.ids).then_some(h) +} + +/// Let the highlight go once it no longer belongs to what the Design panel shows (another file or +/// tab, another selection, the panel not `shown`), or when the pointer is pressed on the canvas or +/// elsewhere in the Design panel. A press on its own row keeps it; so does one on a menu, a popup +/// or the toolbar, so a menu command can still act on it. Called each frame before +/// the right panel draws its rows. +pub(crate) fn reconcile(app: &mut FigApp, ctx: &Context, shown: bool) { + if app.ui.property_row.is_none() { + return; + } + let pressed = ctx.input(|i| if i.pointer.any_pressed() { i.pointer.press_origin() } else { None }); + // A menu or popup is a foreground area over whatever is under it. + let lets_go = pressed.filter(|p| ctx.layer_id_at(*p).is_none_or(|l| l.order != egui::Order::Foreground)).is_some_and(|p| { + let on_row = app.ui.property_row.as_ref().and_then(|h| h.rect).is_some_and(|r| r.contains(p)); + let in_panel = app.ui.right_panel_rect.is_some_and(|r| r.contains(p)); + !on_row && (in_panel || app.canvas.rect.contains(p)) + }); + // The row draws itself each frame (`row`): one that wasn't drawn last frame is gone (an undo + // took its paint away, say), and its index may now name another row or none. + let drawn = app.ui.property_row.as_ref().is_some_and(|h| h.rect.is_some()); + if lets_go || !shown || !drawn || current(app).is_none() { + app.ui.property_row = None; + } else if let Some(h) = app.ui.property_row.as_mut() { + h.rect = None; + } +} + +/// Escape with a highlighted row: the highlight goes and the selection stays. `false` when no row +/// is highlighted (Escape does what it does otherwise). +pub(crate) fn escape(app: &mut FigApp) -> bool { + let highlighted = current(app).is_some(); + app.ui.property_row = None; + highlighted +} + +/// Row `index` of the `kind` list, drawn as `resp` (a row target, see [`fill`]) for the selection +/// `ids`: a click on its background highlights it; the highlighted row keeps where it was drawn. +pub(crate) fn row(app: &mut FigApp, resp: &Response, kind: PropertyKind, index: usize, ids: &[NodeId]) { + if resp.clicked() { + click(app, kind, index, ids, resp.rect); + } else if let Some(h) = app.ui.property_row.as_mut() + && h.kind == kind + && h.index == index + { + h.rect = Some(resp.rect); + } +} + +/// A click on row `index` of the `kind` list (drawn at `rect`) for the selection `ids`, on its +/// background or on its reorder grip, the strip left of its fields: it is the highlighted row now. +pub(crate) fn click(app: &mut FigApp, kind: PropertyKind, index: usize, ids: &[NodeId], rect: Rect) { + let Some(doc) = app.session.ds().ok().map(|d| d.uid) else { return }; + app.ui.property_row = Some(Highlight { kind, index, ids: ids.to_vec(), doc, rect: Some(rect) }); +} + +/// The background of row `index` of the `kind` list: the selected-row colour when it is the +/// highlighted one. +pub(crate) fn fill(app: &FigApp, kind: PropertyKind, index: usize, selected: Color32) -> Option { + current(app).is_some_and(|h| h.kind == kind && h.index == index).then_some(selected) +} + +/// The highlight follows its paint or effect when the rows of `kind` are reordered (`from` moved +/// to `to`) or one is removed (`to` is `None`); it goes with a removed one. +pub(crate) fn follow(app: &mut FigApp, kind: PropertyKind, from: usize, to: Option) { + let Some(h) = app.ui.property_row.as_mut().filter(|h| h.kind == kind) else { return }; + let moved = match to { + Some(to) => Some(figcraft_engine::cmd::lists::moved_index(h.index, from, to)), + None => figcraft_engine::cmd::lists::removed_index(h.index, from), + }; + match moved { + Some(i) => h.index = i, + None => app.ui.property_row = None, + } +} diff --git a/crates/ui-egui/src/property_rows_tests.rs b/crates/ui-egui/src/property_rows_tests.rs new file mode 100644 index 000000000..47b61a99c --- /dev/null +++ b/crates/ui-egui/src/property_rows_tests.rs @@ -0,0 +1,191 @@ +//! A fill, stroke or effect row of the Design panel highlights on a click on its background or its +//! grip ("Copy and paste properties between layers": click to highlight the property in the right +//! sidebar). + +use std::cell::RefCell; +use std::rc::Rc; + +use egui::{Event, Modifiers, PointerButton, Pos2, pos2}; +use figcraft_doc::NodeId; +use figcraft_engine::Session; +use serde_json::json; + +use crate::Services; +use crate::layers_drag_tests::Ui; +use crate::live_tests::{flat, text_center}; +use crate::property_rows::{Highlight, PropertyKind}; + +/// The test machine's system clipboard: the text last written, which another app may replace. +struct Clip(Rc>>); + +impl Services for Clip { + fn clipboard_set_text(&mut self, s: &str) { + *self.0.borrow_mut() = Some(s.to_string()); + } + fn clipboard_get_text(&mut self) -> Option { + self.0.borrow().clone() + } +} + +/// A 40 x 40 rectangle filled with `color`; its id. +fn rect(s: &mut Session, x: f64, color: &str) -> NodeId { + let id = NodeId(s.execute("shape.rectangle", &json!({"x": x, "y": 0, "width": 40, "height": 40})).unwrap()["id"].as_u64().unwrap()); + s.execute("fill.set", &json!({"ids": [id], "color": color})).unwrap(); + id +} + +/// Rectangles A (red, a 3 pt blue stroke and a layer blur) and B (green, a drop shadow), A +/// selected, in a window whose system clipboard the test reads and writes. +fn setup() -> (Ui, NodeId, NodeId, Rc>>) { + let mut s = Session::new(); + let a = rect(&mut s, 0.0, "#FF0000"); + let b = rect(&mut s, 100.0, "#00FF00"); + s.execute("stroke.add", &json!({"ids": [a], "paint": {"type": "solid", "color": "#0000FF"}})).unwrap(); + s.execute("node.set", &json!({"ids": [a], "props": {"strokeWeight": 3.0}})).unwrap(); + s.execute("effect.add", &json!({"ids": [a], "type": "layerBlur"})).unwrap(); + s.execute("effect.add", &json!({"ids": [b], "type": "dropShadow"})).unwrap(); + s.execute("select.set", &json!({"ids": [a]})).unwrap(); + let mut u = Ui::new(s); + let clip = Rc::new(RefCell::new(None)); + u.app.services = Box::new(Clip(clip.clone())); + u.frame(vec![]); + (u, a, b, clip) +} + +/// A primary click at `p`, and a few frames after it. +fn click(u: &mut Ui, p: Pos2) { + u.frame(vec![Event::PointerMoved(p)]); + for pressed in [true, false] { + u.frame(vec![Event::PointerButton { pos: p, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }]); + } + u.frame(vec![]); +} + +/// Click the background of the Design panel row showing `label`: its left margin, where no +/// control is (the reorder grip shows there on hover). +fn click_row(u: &mut Ui, label: &str) { + let shapes = flat(&u.frame_output(vec![]).shapes); + let y = text_center(&shapes, label).y; + let panel = u.app.ui.right_panel_rect.expect("the Design panel is open"); + click(u, pos2(panel.min.x + 6.0, y)); +} + +/// Select `id` only, and draw the panel for it. +fn select(u: &mut Ui, id: NodeId) { + u.app.run("select.set", json!({"ids": [id]})); + u.frame(vec![]); +} + +#[test] +fn a_press_elsewhere_lets_the_highlight_go() { + let (mut u, a, _, _) = setup(); + click_row(&mut u, "FF0000"); + assert!(u.app.ui.property_row.is_some()); + // A press on the row's own background keeps it. + click_row(&mut u, "FF0000"); + assert!(u.app.ui.property_row.is_some()); + // One anywhere else in the panel lets it go, the selection staying as it is. + let shapes = flat(&u.frame_output(vec![]).shapes); + click(&mut u, text_center(&shapes, "Effects")); + assert_eq!(u.app.session.selection(), [a]); + assert!(u.app.ui.property_row.is_none()); +} + +#[test] +fn the_highlight_follows_its_row_when_rows_move_or_go() { + let (mut u, a, _, _) = setup(); + let doc = u.app.session.ds().unwrap().uid; + let at = |u: &Ui| u.app.ui.property_row.as_ref().map(|h| h.index); + u.app.ui.property_row = Some(Highlight { kind: PropertyKind::Fill, index: 1, ids: vec![a], doc, rect: None }); + crate::property_rows::follow(&mut u.app, PropertyKind::Fill, 1, Some(3)); + assert_eq!(at(&u), Some(3), "moved with its paint"); + crate::property_rows::follow(&mut u.app, PropertyKind::Stroke, 3, None); + assert_eq!(at(&u), Some(3), "another list's change leaves it"); + crate::property_rows::follow(&mut u.app, PropertyKind::Fill, 0, None); + assert_eq!(at(&u), Some(2), "a paint under it went"); + crate::property_rows::follow(&mut u.app, PropertyKind::Fill, 2, None); + assert_eq!(at(&u), None, "its paint went"); +} + +/// A key press with no modifiers, as the desktop shell delivers it. +fn key(u: &mut Ui, key: egui::Key) { + u.frame(vec![Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }]); + u.frame(vec![Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE }]); +} + +#[test] +fn escape_lets_the_highlight_go_and_keeps_the_selection() { + let (mut u, a, _, _) = setup(); + click_row(&mut u, "FF0000"); + assert!(u.app.ui.property_row.is_some()); + key(&mut u, egui::Key::Escape); + assert!(u.app.ui.property_row.is_none(), "Escape let the highlight go"); + assert_eq!(u.app.session.selection(), [a], "and only that: A is still selected"); + // The next Escape deselects, as it does with no row highlighted. + key(&mut u, egui::Key::Escape); + assert!(u.app.session.selection().is_empty()); +} + +#[test] +fn a_click_on_the_canvas_lets_the_highlight_go() { + let (mut u, a, _, _) = setup(); + u.app.run("view.zoomFit", json!({})); + u.frame(vec![]); + click_row(&mut u, "FF0000"); + assert!(u.app.ui.property_row.is_some()); + // A click on A itself: the selection stays as it is, the highlight goes. + let on_a = u.app.to_screen(20.0, 20.0); + assert!(u.app.canvas.rect.contains(on_a), "A is in view"); + click(&mut u, on_a); + assert_eq!(u.app.session.selection(), [a]); + assert!(u.app.ui.property_row.is_none()); +} + +#[test] +fn a_highlighted_row_whose_paint_an_undo_took_away_goes() { + let (mut u, a, _, _) = setup(); + u.app.run("fill.add", json!({"ids": [a], "paint": {"type": "solid", "color": "#123456"}})); + u.frame(vec![]); + click_row(&mut u, "123456"); + assert_eq!(u.app.ui.property_row.as_ref().map(|h| h.index), Some(1)); + u.app.run("edit.undo", json!({})); + u.frame(vec![]); + u.frame(vec![]); + assert!(u.app.ui.property_row.is_none(), "its row isn't drawn any more"); + assert_eq!(u.app.session.selection(), [a]); +} + +#[test] +fn a_click_beside_the_rows_controls_highlights_it_too() { + let (mut u, _, _, _) = setup(); + // The row's grip, left of the swatch, highlights it (`click_row`); so does the gap between its + // colour field and its visibility button (the grid's 8 pt gutter, 60 pt from the row's right). + click_row(&mut u, "FF0000"); + let row = u.app.ui.property_row.as_ref().and_then(|h| h.rect).expect("the highlighted row was drawn"); + key(&mut u, egui::Key::Escape); + assert!(u.app.ui.property_row.is_none()); + click(&mut u, pos2(row.max.x - 64.0, row.center().y)); + assert_eq!(u.app.ui.property_row.as_ref().map(|h| (h.kind, h.index)), Some((PropertyKind::Fill, 0))); +} + +#[test] +fn a_click_on_a_rows_grip_highlights_that_row_for_the_selection() { + let (mut u, a, b, _) = setup(); + for (label, kind) in [("FF0000", PropertyKind::Fill), ("0000FF", PropertyKind::Stroke), ("Layer blur", PropertyKind::Effect)] { + click_row(&mut u, label); + assert_eq!( + u.app.ui.property_row.as_ref().map(|h| (h.kind, h.index, h.ids.clone())), + Some((kind, 0, vec![a])), + "{label}: one row highlighted, for A" + ); + } + // The row is drawn in the selected-row colour. + let t = crate::theme::Tokens::get(&u.ctx); + assert_eq!(crate::property_rows::fill(&u.app, PropertyKind::Effect, 0, t.selected_row), Some(t.selected_row)); + assert_eq!(crate::property_rows::fill(&u.app, PropertyKind::Fill, 0, t.selected_row), None); + // It belonged to A: another selection lets it go, and A selected again doesn't bring it back. + select(&mut u, b); + assert!(u.app.ui.property_row.is_none()); + select(&mut u, a); + assert!(u.app.ui.property_row.is_none()); +} diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 26bdb60d2..145d9d35a 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -428,6 +428,11 @@ fn key_event(app: &mut FigApp, key: Key, physical: Option, m: Modifiers, re if key == Key::Escape && !egui::Popup::is_any_open(ctx) && crate::colorpicker::close_if_open(app) { return true; } + // A fill, stroke or effect row highlighted in the Design panel: Escape lets it go, before the + // binding deselects the layers. + if key == Key::Escape && m.is_none() && !egui::Popup::is_any_open(ctx) && crate::property_rows::escape(app) { + return true; + } if key_pick(app, key, m) || key_box(app, key, m) { return true; }