diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 0c49b2ec6..3346095cb 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1319,7 +1319,37 @@ pub fn document_area(app: &mut PrintCraftApp, index: usize, ui: &mut egui::Ui) { } resp.context_menu(|ui| { // Preparing a form: the selected field's menu. - if preparing && let Some((name, _)) = view.prepare.selected.clone() { + if preparing && let Some((name, wi)) = view.prepare.selected.clone() { + // Several fields: Align, Center, Distribute, Set Fields to Same Size. + if !view.prepare.also.is_empty() { + use crate::prepare::Arrange as A; + let others = view.prepare.also.clone(); + let anchor = (name.clone(), wi); + let mut pick = |ui: &mut egui::Ui, op: A, label: &str| { + if ui.add_enabled(can_modify, egui::Button::new(label)).clicked() { + view.pending_edit = crate::prepare::arrange(&form, &anchor, &others, op); + ui.close(); + } + }; + ui.menu_button("Align", |ui| { + pick(ui, A::AlignLeft, "Left"); + pick(ui, A::AlignRight, "Right"); + pick(ui, A::AlignTop, "Top"); + pick(ui, A::AlignBottom, "Bottom"); + pick(ui, A::AlignCenterV, "Vertically"); + pick(ui, A::AlignCenterH, "Horizontally"); + }); + ui.menu_button("Distribute", |ui| { + pick(ui, A::DistributeH, "Horizontally"); + pick(ui, A::DistributeV, "Vertically"); + }); + ui.menu_button("Set Fields to Same Size", |ui| { + pick(ui, A::SameHeight, "Height"); + pick(ui, A::SameWidth, "Width"); + pick(ui, A::SameSize, "Both"); + }); + ui.separator(); + } if ui.button("Properties…").clicked() { field_menu = Some(FieldMenu::Properties); ui.close(); diff --git a/crates/ui-egui/src/prepare.rs b/crates/ui-egui/src/prepare.rs index 1ed3c5179..d7a420c2f 100644 --- a/crates/ui-egui/src/prepare.rs +++ b/crates/ui-egui/src/prepare.rs @@ -119,8 +119,10 @@ enum Grab { #[derive(Clone, Debug, Default, PartialEq)] pub struct PrepareView { - /// The selected field and which of its widgets. + /// The selected field and which of its widgets (the anchor of Align and Match Size). pub selected: Option<(String, usize)>, + /// More fields selected with Shift- or ⌘-click. + pub also: Vec<(String, usize)>, /// Select the field an Add field edit creates (the form's length before it). pub select_added: Option, grab: Option<(usize, Grab, Pos2)>, @@ -266,7 +268,17 @@ pub(crate) fn page_input( f.widgets.iter().enumerate().find(|(_, w)| w.page == Some(page) && screen_rect(xf, info, page, w.rect).contains(p)).map(|(i, _)| (f, i)) }) { - prep.selected = Some((f.name.clone(), wi)); + let key = (f.name.clone(), wi); + if let Some(i) = prep.also.iter().position(|k| *k == key) { + // Part of the selection: it becomes the anchor the others line up with. + prep.also.remove(i); + if let Some(old) = prep.selected.replace(key) { + prep.also.push(old); + } + } else if prep.selected.as_ref() != Some(&key) { + prep.selected = Some(key); + prep.also.clear(); + } } // The Select tool: corner handles of the selection, then fields. let selected_rect = prep @@ -307,12 +319,29 @@ pub(crate) fn page_input( return out; } if resp.clicked() { + let extend = ui.input(|i| i.modifiers.shift || i.modifiers.command); match hit { - Some((f, wi)) => { - prep.selected = Some((f.name.clone(), wi)); + // Shift/⌘-click adds or removes a field from the selection. + Some((f, wi)) if extend && prep.selected.is_some() => { + let key = (f.name.clone(), wi); + if prep.selected.as_ref() != Some(&key) { + if let Some(i) = prep.also.iter().position(|k| *k == key) { + prep.also.remove(i); + } else { + prep.also.push(key); + } + } out.consumed = true; } - None if corner.is_none() => prep.selected = None, + Some((f, wi)) => { + prep.selected = Some((f.name.clone(), wi)); + prep.also.clear(); + out.consumed = true; + } + None if corner.is_none() => { + prep.selected = None; + prep.also.clear(); + } None => out.consumed = true, } } @@ -320,6 +349,101 @@ pub(crate) fn page_input( out } +/// Align, Center, Distribute and Set Fields to Same Size (Prepare a form, several fields +/// selected). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Arrange { + AlignLeft, + AlignRight, + AlignTop, + AlignBottom, + /// Centres on one vertical line (the anchor's). + AlignCenterH, + /// Centres on one horizontal line. + AlignCenterV, + DistributeH, + DistributeV, + SameWidth, + SameHeight, + SameSize, +} + +impl Arrange { + pub fn label(self) -> &'static str { + match self { + Arrange::AlignLeft | Arrange::AlignRight | Arrange::AlignTop | Arrange::AlignBottom | Arrange::AlignCenterH | Arrange::AlignCenterV => { + "Align fields" + } + Arrange::DistributeH | Arrange::DistributeV => "Distribute fields", + Arrange::SameWidth | Arrange::SameHeight | Arrange::SameSize => "Match field sizes", + } + } +} + +/// The edits that arrange the selected widgets (user space; the anchor stays put). +pub fn arrange(form: &[FormField], anchor: &(String, usize), others: &[(String, usize)], op: Arrange) -> Option { + let rect_of = |k: &(String, usize)| form.iter().find(|f| f.name == k.0).and_then(|f| f.widgets.get(k.1)).map(|w| w.rect); + let a = rect_of(anchor)?; + let mut items: Vec<((String, usize), [f64; 4])> = others.iter().filter_map(|k| rect_of(k).map(|r| (k.clone(), r))).collect(); + if items.is_empty() { + return None; + } + let (aw, ah) = (a[2] - a[0], a[3] - a[1]); + let mut moved: Vec<((String, usize), [f64; 4])> = Vec::new(); + match op { + Arrange::DistributeH | Arrange::DistributeV => { + // Even gaps between all selected fields (the outermost stay). + items.push((anchor.clone(), a)); + if items.len() < 3 { + return None; + } + let horiz = op == Arrange::DistributeH; + items.sort_by(|x, y| if horiz { x.1[0].total_cmp(&y.1[0]) } else { y.1[3].total_cmp(&x.1[3]) }); + let size = |r: &[f64; 4]| if horiz { r[2] - r[0] } else { r[3] - r[1] }; + let (first, last) = (items[0].1, items[items.len() - 1].1); + let span = if horiz { last[2] - first[0] } else { first[3] - last[1] }; + let total: f64 = items.iter().map(|(_, r)| size(r)).sum(); + let gap = (span - total) / (items.len() - 1) as f64; + let mut at = if horiz { first[0] } else { first[3] }; + for (k, r) in &items { + let s = size(r); + let nr = if horiz { [at, r[1], at + s, r[3]] } else { [r[0], at - s, r[2], at] }; + at = if horiz { at + s + gap } else { at - s - gap }; + moved.push((k.clone(), nr)); + } + } + _ => { + for (k, r) in items { + let (w, h) = (r[2] - r[0], r[3] - r[1]); + let nr = match op { + Arrange::AlignLeft => [a[0], r[1], a[0] + w, r[3]], + Arrange::AlignRight => [a[2] - w, r[1], a[2], r[3]], + Arrange::AlignTop => [r[0], a[3] - h, r[2], a[3]], + Arrange::AlignBottom => [r[0], a[1], r[2], a[1] + h], + Arrange::AlignCenterH => { + let cx = (a[0] + a[2]) / 2.0; + [cx - w / 2.0, r[1], cx + w / 2.0, r[3]] + } + Arrange::AlignCenterV => { + let cy = (a[1] + a[3]) / 2.0; + [r[0], cy - h / 2.0, r[2], cy + h / 2.0] + } + Arrange::SameWidth => [r[0], r[1], r[0] + aw, r[3]], + Arrange::SameHeight => [r[0], r[3] - ah, r[2], r[3]], + _ => [r[0], r[3] - ah, r[0] + aw, r[3]], + }; + moved.push((k, nr)); + } + } + } + let edits: Vec = moved + .into_iter() + .filter(|(k, nr)| rect_of(k).is_some_and(|r| r.iter().zip(nr).any(|(x, y)| (x - y).abs() > 1e-6))) + .map(|((name, wi), rect)| Edit::SetFieldProps { name, props: Box::new(FieldProps { rect: Some((wi, rect)), ..Default::default() }) }) + .collect(); + (!edits.is_empty()).then(|| Edit::Batch { label: op.label().into(), edits }) +} + /// Fields with their light-blue fill and name tags, the selection with its handles, and the /// rectangle being drawn or dragged. pub(crate) fn paint_page(ui: &egui::Ui, painter: &egui::Painter, xf: &PageXform, page: usize, info: &DocInfo, form: &[FormField], view: &DocView) { @@ -331,6 +455,7 @@ pub(crate) fn paint_page(ui: &egui::Ui, painter: &egui::Painter, xf: &PageXform, for (wi, w) in f.widgets.iter().enumerate().filter(|(_, w)| w.page == Some(page)) { let mut r = screen_rect(xf, info, page, w.rect); let selected = prep.selected.as_ref().is_some_and(|(n, i)| n == &f.name && *i == wi); + let also = prep.also.iter().any(|(n, i)| n == &f.name && *i == wi); if selected && let (Some((_, g, start)), Some(p)) = (grab, pointer) { r = dragged(r, g, p - start); } @@ -338,7 +463,7 @@ pub(crate) fn paint_page(ui: &egui::Ui, painter: &egui::Painter, xf: &PageXform, painter.rect_stroke( r, CornerRadius::ZERO, - Stroke::new(1.0, if selected { SELECT_BLUE } else { Color32::from_gray(90) }), + Stroke::new(if also { 2.0 } else { 1.0 }, if selected || also { SELECT_BLUE } else { Color32::from_gray(90) }), egui::StrokeKind::Inside, ); // The name tag: white text on black, clipped to the field. diff --git a/crates/ui-egui/tests/comments.rs b/crates/ui-egui/tests/comments.rs index c4a224ec0..5ce5520e7 100644 --- a/crates/ui-egui/tests/comments.rs +++ b/crates/ui-egui/tests/comments.rs @@ -465,8 +465,19 @@ fn highlighting_an_area_off_the_text() { assert_eq!((c[0].subtype.as_str(), c[0].quads.len()), ("Highlight", 1)); let q = c[0].quads[0]; assert!((q[0] - 40.0).abs() < 3.0 && (q[1] - 100.0).abs() < 3.0, "{q:?}"); - // Dragging over text still highlights the text. - drag_pt(&mut h, (20.0, 155.0), (120.0, 155.0)); + // Dragging over text still highlights the text (once the text layer is in). + let mut glyphs = None; + for _ in 0..200 { + h.run_steps(1); + let v = &h.state().views[0]; + if let (Some(a), Some(b)) = (v.glyph_screen_pos(0, 4), v.glyph_screen_pos(0, 14)) { + glyphs = Some((a, b)); + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + let (a, b) = glyphs.expect("text layer"); + drag(&mut h, a, b); assert_eq!(comments(&h).len(), 2); } diff --git a/crates/ui-egui/tests/prepare.rs b/crates/ui-egui/tests/prepare.rs index b1f19575b..6127acd9b 100644 --- a/crates/ui-egui/tests/prepare.rs +++ b/crates/ui-egui/tests/prepare.rs @@ -249,3 +249,38 @@ fn duplicating_a_field_onto_every_page() { assert_eq!(pages, (0..n).collect::>()); assert_eq!(doc.can_undo(), Some("Duplicate field")); } + +#[test] +fn aligning_distributing_and_sizing_several_fields() { + use printcraft_ui_egui::prepare::{Arrange, arrange}; + let mut h = harness(); + for (name, x, y, w) in [("a", 20.0, 300.0, 60.0), ("b", 120.0, 280.0, 80.0), ("c", 260.0, 260.0, 30.0)] { + h.state_mut().apply_edit(printcraft_engine::Edit::AddField { + page: 0, + rect: [x, y, x + w, y + 20.0], + kind: printcraft_engine::NewField::Text { multiline: false }, + name: Some(name.into()), + }); + } + h.run_steps(2); + let form = |h: &Harness<'static, PrintCraftApp>| h.state().session.get(h.state().views[0].id).unwrap().form.as_ref().clone(); + let rect = |h: &Harness<'static, PrintCraftApp>, n: &str| form(h).iter().find(|f| f.name == n).unwrap().widgets[0].rect; + let a = ("a".to_string(), 0); + let others = vec![("b".to_string(), 0), ("c".to_string(), 0)]; + // Align tops with a. + let e = arrange(&form(&h), &a, &others, Arrange::AlignTop).unwrap(); + h.state_mut().apply_edit(e); + assert!(["b", "c"].iter().all(|n| rect(&h, n)[3] == 320.0)); + assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().can_undo(), Some("Align fields")); + // Distribute horizontally: equal gaps between a, b and c. + let e = arrange(&form(&h), &a, &others, Arrange::DistributeH).unwrap(); + h.state_mut().apply_edit(e); + let (ra, rb, rc) = (rect(&h, "a"), rect(&h, "b"), rect(&h, "c")); + assert!(((rb[0] - ra[2]) - (rc[0] - rb[2])).abs() < 1e-6, "{ra:?} {rb:?} {rc:?}"); + // Same width as a. + let e = arrange(&form(&h), &a, &others, Arrange::SameWidth).unwrap(); + h.state_mut().apply_edit(e); + assert!(["b", "c"].iter().all(|n| (rect(&h, n)[2] - rect(&h, n)[0] - 60.0).abs() < 1e-6)); + assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().can_undo(), Some("Match field sizes")); + assert!(arrange(&form(&h), &a, &[("b".into(), 0)], Arrange::DistributeV).is_none(), "distributing needs three"); +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 5c1222b59..85692ea09 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -4875,11 +4875,11 @@ title = "Align, centre, distribute and match field sizes" area = "F" tier = "P1" milestone = "M6" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["form.prepare"] +tools = ["form_set_props"] +evidence = ["crates/ui-egui/tests/prepare.rs::aligning_distributing_and_sizing_several_fields"] +notes = "Shift/⌘-click to select several; right-click: Align (left, right, top, bottom, centres), Distribute, Set Fields to Same Size; the right-clicked field is the anchor" [[feature]] id = "form.duplicate-fields"