diff --git a/contributors/people.toml b/contributors/people.toml index e744e31c..a715fbcc 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -38,4 +38,6 @@ real_name = "Justin John" [people.Losminek] [people.RevDonz] + +[people.Nael-Nathanael] sync_github_name = true diff --git a/crates/ui-egui/src/colorpicker.rs b/crates/ui-egui/src/colorpicker.rs index ac89ceb8..778aafc4 100644 --- a/crates/ui-egui/src/colorpicker.rs +++ b/crates/ui-egui/src/colorpicker.rs @@ -212,11 +212,13 @@ fn page_colors(app: &FigApp, ui: &Ui) -> Vec { /// Colour models the value row can show. const MODELS: [&str; 5] = ["Hex", "RGB", "HSL", "HSB", "CSS"]; -/// The model dropdown with its fields and the alpha field. Returns a committed colour. -fn model_fields(ui: &mut Ui, c: Color) -> Option { +/// The model dropdown with its fields and the alpha field. Returns a new colour and whether the +/// edit is done (false while a field is being scrubbed). +fn model_fields(ui: &mut Ui, c: Color) -> Option<(Color, bool)> { let model_id = Id::new("cp_model"); let model: usize = ui.data(|d| d.get_temp(model_id)).unwrap_or(0).min(MODELS.len() - 1); let mut out: Option = None; + let mut done = true; ui.horizontal(|ui| { ui.add_space(16.0); ui.spacing_mut().item_spacing.x = 4.0; @@ -238,7 +240,8 @@ fn model_fields(ui: &mut Ui, c: Color) -> Option { w, 1.0, ) { - Some(widgets::FieldEdit::Set(x)) | Some(widgets::FieldEdit::Scrub(x, _)) => Some(x), + Some(widgets::FieldEdit::Set(x)) => Some((x, true)), + Some(widgets::FieldEdit::Scrub(x, end)) => Some((x, end)), None => None, }; match MODELS.get(model).copied().unwrap_or("Hex") { @@ -246,7 +249,8 @@ fn model_fields(ui: &mut Ui, c: Color) -> Option { let [r, g, b, _] = c.to_rgba8(); let vals = [f64::from(r), f64::from(g), f64::from(b)]; for (k, v) in vals.iter().enumerate() { - if let Some(x) = num(ui, ["r", "g", "b"][k.min(2)], *v, 31.0) { + if let Some((x, end)) = num(ui, ["r", "g", "b"][k.min(2)], *v, 31.0) { + done = end; let mut rgb = vals; if let Some(slot) = rgb.get_mut(k) { *slot = x.clamp(0.0, 255.0); @@ -266,7 +270,8 @@ fn model_fields(ui: &mut Ui, c: Color) -> Option { }; let vals = [f64::from(h), f64::from(s2) * 100.0, f64::from(l2) * 100.0]; for (k, v) in vals.iter().enumerate() { - if let Some(x) = num(ui, ["h", "s", "l"][k.min(2)], *v, 31.0) { + if let Some((x, end)) = num(ui, ["h", "s", "l"][k.min(2)], *v, 31.0) { + done = end; let mut v3 = vals; if let Some(slot) = v3.get_mut(k) { *slot = if k == 0 { x.rem_euclid(360.0) } else { x.clamp(0.0, 100.0) }; @@ -288,14 +293,20 @@ fn model_fields(ui: &mut Ui, c: Color) -> Option { } } } - if MODELS.get(model) != Some(&"CSS") - && let Some(widgets::FieldEdit::Set(v)) = - widgets::number_field(ui, Id::new("cp_a"), widgets::Lead::None, Some((f64::from(out.unwrap_or(c).a) * 100.0).round()), "%", 44.0, 1.0) - { - out = Some(out.unwrap_or(c).with_alpha((v / 100.0).clamp(0.0, 1.0) as f32)); + if MODELS.get(model) != Some(&"CSS") { + let alpha = Some((f64::from(out.unwrap_or(c).a) * 100.0).round()); + let edit = match widgets::number_field(ui, Id::new("cp_a"), widgets::Lead::None, alpha, "%", 44.0, 1.0) { + Some(widgets::FieldEdit::Set(v)) => Some((v, true)), + Some(widgets::FieldEdit::Scrub(v, end)) => Some((v, end)), + None => None, + }; + if let Some((v, end)) = edit { + done = end; + out = Some(out.unwrap_or(c).with_alpha((v / 100.0).clamp(0.0, 1.0) as f32)); + } } }); - out + out.map(|c| (c, done)) } fn hue_color(ctx: &egui::Context, h: f32) -> Color32 { @@ -744,11 +755,11 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { done |= d; } // Colour model fields (Hex, RGB, HSL, HSB or CSS) + alpha. - if let Some(c) = model_fields(ui, nc) { + if let Some((c, d)) = model_fields(ui, nc) { nc = c; hsb = Hsb::from_color(nc); changed = true; - done = true; + done |= d; } ui.data_mut(|d| d.insert_temp(Id::new("cp_hsb"), (hsb.h, hsb.s, hsb.b, nc.to_rgba8()))); // A Display P3 colour sRGB can't show: the canvas (sRGB) shows the nearest one. diff --git a/crates/ui-egui/src/field_tests.rs b/crates/ui-egui/src/field_tests.rs index 4635d862..492fdc01 100644 --- a/crates/ui-egui/src/field_tests.rs +++ b/crates/ui-egui/src/field_tests.rs @@ -2,10 +2,10 @@ //! a scrub and an arrow key move a value, and the Design panel's fields driven through egui //! headless: scrubbing by the label with and without Shift and Alt, arrow keys, arithmetic on //! commit, Tab and Shift+Tab, Escape, fields showing "Mixed", and one undo step per scrub or -//! commit. +//! commit (the colour picker's, the prototype panel's and the Scale panel's fields too). use egui::accesskit::Role; -use egui::{Event, Key, Modifiers, Pos2, pos2}; +use egui::{Event, Id, Key, Modifiers, Pos2, pos2}; use figcraft_doc::NodeId; use figcraft_engine::Session; use serde_json::json; @@ -216,6 +216,11 @@ fn type_into(u: &mut Ui, name: &str, text: &str, end: Key) { /// Scrub field `name` by its label: `dx` points with `modifiers` held, then let go. fn scrub(u: &mut Ui, name: &str, dx: f32, modifiers: Modifiers) { let from = label(u, name); + drag(u, from, dx, modifiers); +} + +/// Press at `from`, move `dx` points sideways in small steps with `modifiers` held, then let go. +fn drag(u: &mut Ui, from: Pos2, dx: f32, modifiers: Modifiers) { let press = |pressed| Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed, modifiers }; u.frame_with(vec![Event::PointerMoved(from), press(true)], modifiers); let steps = 10; @@ -404,3 +409,67 @@ fn mixed_values_take_edits_relative_to_each_layer() { assert_eq!((geom(&u, a).0, geom(&u, b).0), (64.0, 64.0)); assert_eq!(field(&mut u, "X").1, "64"); } + +/// The middle of the label (what scrubs) of the field with id `id`, as last painted. +fn lead(u: &Ui, id: Id) -> Pos2 { + u.ctx.read_response(id.with("lead")).unwrap_or_else(|| panic!("no field {id:?}")).rect.center() +} + +#[test] +fn scrubbing_colour_picker_fields_is_live_and_one_undo_step() { + let (mut u, a, _) = setup(); + let rgba = |u: &Ui| u.app.session.doc().unwrap().node(a).unwrap().fills[0].solid_color().unwrap().to_rgba8(); + let [r0, .., a0] = rgba(&u); + u.ctx.data_mut(|d| d.insert_temp(Id::new("cp_model"), 1usize)); + u.app.ui.picker = Some(crate::colorpicker::PickerTarget::Paint { fills: true, index: 0, ids: vec![a] }); + // The picker window takes a frame to size itself. + for _ in 0..3 { + u.frame(vec![]); + } + let steps = undo_steps(&u); + let from = lead(&u, Id::new(("cp_f", "r"))); + drag(&mut u, from, -20.0, Modifiers::NONE); + assert_eq!((rgba(&u)[0], undo_steps(&u)), (r0 - 20, steps + 1), "R"); + // The alpha field: a scrub moves it too. + let from = lead(&u, Id::new("cp_a")); + drag(&mut u, from, -30.0, Modifiers::NONE); + let [.., alpha] = rgba(&u); + assert!(alpha < a0, "alpha {a0} → {alpha}"); + assert_eq!(undo_steps(&u), steps + 2, "alpha"); +} + +#[test] +fn scrubbing_a_prototype_delay_sets_it_once_when_let_go() { + let mut s = Session::new(); + let f1 = id(s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 200, "height": 300})).unwrap()); + let f2 = id(s.execute("frame.create", &json!({"x": 300, "y": 0, "width": 200, "height": 300})).unwrap()); + let it = json!({"id": f1, "trigger": {"type": "afterDelay", "ms": 500}, "action": {"type": "navigate", "dest": f2}}); + s.execute("proto.interaction.add", &it).unwrap(); + s.execute("select.set", &json!({"ids": [f1]})).unwrap(); + let mut u = Ui::new(s); + u.app.ui.right_tab = crate::RightTab::Prototype; + crate::proto_panel::open_editor(&u.ctx, Some((f1, 0))); + for _ in 0..3 { + u.frame(vec![]); + } + let steps = undo_steps(&u); + let from = lead(&u, Id::new(("proto_delay", f1, 0usize))); + drag(&mut u, from, 30.0, Modifiers::NONE); + let ms = match u.app.session.doc().unwrap().node(f1).unwrap().interactions[0].trigger { + figcraft_doc::proto::Trigger::AfterDelay { ms } => ms, + _ => panic!("still after a delay"), + }; + assert_eq!((ms, undo_steps(&u)), (800, steps + 1)); +} + +#[test] +fn scrubbing_the_scale_panel_width_scales_live_in_one_undo_step() { + let (mut u, a, _) = setup(); + u.app.run("tool.set", json!({"tool": "scale"})); + u.frame(vec![]); + let steps = undo_steps(&u); + let from = lead(&u, Id::new("scale_w")); + drag(&mut u, from, 20.0, Modifiers::NONE); + let (_, _, w, h) = geom(&u, a); + assert_eq!((w, h, undo_steps(&u)), (60.0, 45.0, steps + 1), "40×30 scaled to a width of 60"); +} diff --git a/crates/ui-egui/src/proto_panel.rs b/crates/ui-egui/src/proto_panel.rs index 79ae3eb9..1f67ab44 100644 --- a/crates/ui-egui/src/proto_panel.rs +++ b/crates/ui-egui/src/proto_panel.rs @@ -700,7 +700,7 @@ fn trigger_rows(app: &mut FigApp, ui: &mut Ui, node: NodeId, k: usize, it: &Inte row(ui, 32.0, |ui| { widgets::label(ui, tr("Delay"), t.text_secondary); let id = Id::new(("proto_mouse_delay", node, k)); - if let Some(FieldEdit::Set(v) | FieldEdit::Scrub(v, _)) = + if let Some(FieldEdit::Set(v) | FieldEdit::Scrub(v, true)) = widgets::number_field(ui, id, Lead::None, Some(f64::from(it.delay_ms)), "ms", 96.0, 10.0) && v.is_finite() { @@ -711,13 +711,11 @@ fn trigger_rows(app: &mut FigApp, ui: &mut Ui, node: NodeId, k: usize, it: &Inte Trigger::AfterDelay { ms } => { row(ui, 32.0, |ui| { widgets::label(ui, tr("Delay"), t.text_secondary); - if let Some(e) = widgets::number_field(ui, Id::new(("proto_delay", node, k)), Lead::None, Some(f64::from(*ms)), "ms", 96.0, 10.0) { - let v = match e { - FieldEdit::Set(v) | FieldEdit::Scrub(v, _) => v, - }; - if v.is_finite() { - patch(app, node, k, json!({"trigger": {"type": "afterDelay", "ms": v.clamp(0.0, 600_000.0).round() as u32}})); - } + if let Some(FieldEdit::Set(v) | FieldEdit::Scrub(v, true)) = + widgets::number_field(ui, Id::new(("proto_delay", node, k)), Lead::None, Some(f64::from(*ms)), "ms", 96.0, 10.0) + && v.is_finite() + { + patch(app, node, k, json!({"trigger": {"type": "afterDelay", "ms": v.clamp(0.0, 600_000.0).round() as u32}})); } }); } @@ -851,9 +849,10 @@ pub(crate) fn action_fields(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: row(ui, 32.0, |ui| { let w = ((ui.available_width() - 6.0) / 2.0).max(36.0); for (i, (name, key)) in [("X", "proto_scroll_x"), ("Y", "proto_scroll_y")].into_iter().enumerate() { - let Some(e) = widgets::number_field(ui, slot.id(key), Lead::Text(name), offset.get(i).copied(), "", w, 1.0) else { continue }; - let v = match e { - FieldEdit::Set(v) | FieldEdit::Scrub(v, _) => v, + let Some(FieldEdit::Set(v) | FieldEdit::Scrub(v, true)) = + widgets::number_field(ui, slot.id(key), Lead::Text(name), offset.get(i).copied(), "", w, 1.0) + else { + continue; }; if v.is_finite() { let mut o = offset; @@ -901,7 +900,7 @@ pub(crate) fn action_fields(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: else { continue; }; - let Some(FieldEdit::Set(v) | FieldEdit::Scrub(v, _)) = e.settled() else { continue }; + let Some(FieldEdit::Set(v) | FieldEdit::Scrub(v, true)) = e.settled() else { continue }; if v.is_finite() { let mut o = offset.unwrap_or([0.0, 0.0]); if let Some(slot_v) = o.get_mut(i) { @@ -932,18 +931,12 @@ pub(crate) fn action_fields(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: set_action(app, slot, Action::OpenOverlay { dest, position, close_on_outside, background: Some(c), offset }); } let alpha = (bg.a * 100.0).round(); - if let Some(e) = widgets::number_field(ui, slot.id("proto_ovbg_a"), Lead::None, Some(f64::from(alpha)), "%", 64.0, 1.0) { - let v = match e { - FieldEdit::Set(v) | FieldEdit::Scrub(v, _) => v, - }; - if v.is_finite() { - let a = v.clamp(0.0, 100.0) as f32 / 100.0; - set_action( - app, - slot, - Action::OpenOverlay { dest, position, close_on_outside, background: Some(bg.with_alpha(a)), offset }, - ); - } + if let Some(FieldEdit::Set(v) | FieldEdit::Scrub(v, true)) = + widgets::number_field(ui, slot.id("proto_ovbg_a"), Lead::None, Some(f64::from(alpha)), "%", 64.0, 1.0) + && v.is_finite() + { + let a = v.clamp(0.0, 100.0) as f32 / 100.0; + set_action(app, slot, Action::OpenOverlay { dest, position, close_on_outside, background: Some(bg.with_alpha(a)), offset }); } }); } diff --git a/crates/ui-egui/src/scale_panel.rs b/crates/ui-egui/src/scale_panel.rs index bdc763d7..6edd3497 100644 --- a/crates/ui-egui/src/scale_panel.rs +++ b/crates/ui-egui/src/scale_panel.rs @@ -21,23 +21,29 @@ pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { // Anchor: column and row (0 start, 1 middle, 2 end) of the point that stays put. let anchor = app.session.tool.scale_anchor; let mut factor: Option = None; + // A scrub of W or H scales live, in one undo step: `Some(done)`. + let mut scrub: Option = None; let round = |v: f64| (v * 100.0).round() / 100.0; widgets::sublabels(ui, tr("Dimensions"), None); row(ui, 32.0, |ui| { - if let Some(FieldEdit::Set(w)) = widgets::number_field(ui, Id::new("scale_w"), Lead::Text("W"), Some(round(b.width())), "", col, 1.0) - && b.width() > 1e-9 - { - factor = Some(w / b.width()); - } - if let Some(FieldEdit::Set(h)) = widgets::number_field(ui, Id::new("scale_h"), Lead::Text("H"), Some(round(b.height())), "", col, 1.0) - && b.height() > 1e-9 - { - factor = Some(h / b.height()); + for (key, lead, side) in [("scale_w", "W", b.width()), ("scale_h", "H", b.height())] { + let (v, s) = match widgets::number_field(ui, Id::new(key), Lead::Text(lead), Some(round(side)), "", col, 1.0) { + Some(FieldEdit::Set(v)) => (v, None), + Some(FieldEdit::Scrub(v, done)) => (v, Some(done)), + None => continue, + }; + if side > 1e-9 { + factor = Some(v / side); + scrub = s; + } } }); widgets::sublabels(ui, tr("Scale"), Some(tr("Anchor"))); crate::right::block(ui, 52.0, 2.0, |ui| { - if let Some(FieldEdit::Set(k)) = widgets::number_field(ui, Id::new("scale_k"), Lead::None, Some(1.0), "x", col, 0.1) { + // Relative to the layers as they are: a scrub applies once, when let go. + if let Some(FieldEdit::Set(k) | FieldEdit::Scrub(k, true)) = + widgets::number_field(ui, Id::new("scale_k"), Lead::None, Some(1.0), "x", col, 0.1) + { factor = Some(k); } ui.add_space(8.0); @@ -45,8 +51,24 @@ pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { app.run("tool.scaleAnchor", json!({"anchor": figcraft_engine::tools::anchor_name(a)})); } }); - if let Some((k, cx, cy)) = factor.and_then(|k| scale_about(b, anchor, k)) { - app.run("transform.scale", json!({"ids": sel, "factor": k, "cx": cx, "cy": cy})); + let scale = factor.and_then(|k| scale_about(b, anchor, k)).map(|(k, cx, cy)| json!({"ids": sel, "factor": k, "cx": cx, "cy": cy})); + match scrub { + None => { + if let Some(p) = scale { + app.run("transform.scale", p); + } + } + Some(done) => { + if let Some(p) = scale { + let _ = app.session.begin_interaction("Scale"); + if let Err(e) = app.session.execute("transform.scale", &p) { + app.failed("transform.scale", &e); + } + } + if done { + let _ = app.session.commit_interaction(); + } + } } }