mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-27 11:37:59 -06:00
M6: Several fields at once in Prepare a form: Shift/⌘-click to select, then right-click to Align (left, right, top, bottom, centres), Distribute evenly, or Set Fields to Same Size, anchored on the right-clicked field; one undo step each. Test fix: the area-highlight test waits for the text layer before dragging over text
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
aac0db461d
commit
4df7a73a1a
@@ -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();
|
||||
|
||||
@@ -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<usize>,
|
||||
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<Edit> {
|
||||
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<Edit> = 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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -249,3 +249,38 @@ fn duplicating_a_field_onto_every_page() {
|
||||
assert_eq!(pages, (0..n).collect::<Vec<_>>());
|
||||
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");
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user