diff --git a/crates/ui-egui/src/panels/stroke.rs b/crates/ui-egui/src/panels/stroke.rs index 7e70a5e78..b73a799e6 100644 --- a/crates/ui-egui/src/panels/stroke.rs +++ b/crates/ui-egui/src/panels/stroke.rs @@ -66,18 +66,24 @@ pub fn dash_fields(pattern: &[f64]) -> [Option; 6] { out } -/// The dash/gap fields and alignment the Dashed Line section shows: the stroke's dashes, or with +/// The full pattern and alignment the Dashed Line section shows: the stroke's dashes, or with /// dashes off the last ones turned off (`last`), at first 12 pt dashes fitted to corners. -fn dash_state(dash: Option<&Dash>, last: Option<([Option; 6], bool)>) -> ([Option; 6], bool) { +fn dash_state(dash: Option<&Dash>, last: Option<(Vec, bool)>) -> (Vec, bool) { match dash { - Some(d) => (dash_fields(&d.pattern), d.align_corners), - None => last.unwrap_or(([Some(12.0), None, None, None, None, None], true)), + Some(d) => (d.pattern.clone(), d.align_corners), + None => last.unwrap_or((vec![12.0], true)), } } /// Six dash/gap fields → a dash pattern: stops at the first empty dash; a dash without a gap /// repeats its length as the gap (Illustrator's behaviour). pub fn dash_pattern(fields: &[Option; 6]) -> Vec { + edited_dash_pattern(fields, &[]) +} + +/// Edit the visible fields without dropping entries the panel cannot show. Clearing a dash +/// still ends the pattern there; a mixed selection supplies no shared pattern to preserve. +pub(crate) fn edited_dash_pattern(fields: &[Option; 6], previous: &[f64]) -> Vec { let mut out = vec![]; for pair in 0..3 { let Some(d) = fields[pair * 2] else { break }; @@ -85,6 +91,9 @@ pub fn dash_pattern(fields: &[Option; 6]) -> Vec { out.push(d.max(0.0)); out.push(g.max(0.0)); } + if out.len() == fields.len() { + out.extend(previous.iter().skip(fields.len()).copied()); + } if out.iter().all(|v| *v == 0.0) { out.clear(); } @@ -105,6 +114,13 @@ fn set(app: &mut VectorcraftApp, p: Value) { } } +/// A mixed selection starts a new shared pattern instead of copying one object's hidden tail. +fn set_dash_fields(app: &mut VectorcraftApp, fields: &[Option; 6], previous: &[f64], mixed: bool) { + let previous = if mixed { &[][..] } else { previous }; + // The command keeps each stroke's dash offset and alignment. + set(app, json!({"dash": edited_dash_pattern(fields, previous)})); +} + /// The weight the Stroke panel, Control bar and Properties panel show: blank where the selected /// objects differ, the default for new art without a stroke. pub(crate) fn shown_weight(app: &VectorcraftApp, st: Option<&StrokeLayer>, mixed: &StrokeMixed) -> Option { @@ -203,15 +219,16 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { widgets::divider(ui); // Dashed line. let dash = st.as_ref().and_then(|s| s.dash.as_ref()); - let (fields, align_corners) = dash_state(dash, pstate(ui.ctx(), "stroke-dash-last")); + let (pattern, align_corners) = dash_state(dash, pstate(ui.ctx(), "stroke-dash-last")); + let fields = dash_fields(&pattern); let shown = if mixed.dash { [None; 6] } else { fields }; ui.horizontal(|ui| { if widgets::check(ui, tl!("Dashed Line"), dash.is_some(), st.is_some()) { if dash.is_some() { - set_pstate(ui.ctx(), "stroke-dash-last", Some((fields, align_corners))); + set_pstate(ui.ctx(), "stroke-dash-last", Some((pattern.clone(), align_corners))); set(app, json!({"dash": null})); } else { - set(app, json!({"dash": dash_pattern(&fields), "alignDashes": align_corners})); + set(app, json!({"dash": edited_dash_pattern(&fields, &pattern), "alignDashes": align_corners})); } } ui.add_space((ui.available_width() - 56.0).max(0.0)); @@ -236,8 +253,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { } } if changed && dash.is_some() { - // The command keeps the dash offset and alignment. - set(app, json!({"dash": dash_pattern(&nf)})); + set_dash_fields(app, &nf, &pattern, mixed.dash); } }); ui.horizontal(|ui| { @@ -495,6 +511,101 @@ mod tests { assert!(dash_pattern(&[Some(0.0), Some(0.0), None, None, None, None]).is_empty()); } + #[test] + fn dash_edits_keep_unshown_entries_and_the_existing_empty_field_rules() { + for pattern in [ + vec![12.0, 6.0, 2.0, 3.0, 4.0, 5.0], + vec![12.0, 6.0, 2.0, 3.0, 4.0, 5.0, 20.0], + vec![12.0, 6.0, 2.0, 3.0, 4.0, 5.0, 20.0, 10.0], + vec![0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 9.0], + ] { + let mut fields = dash_fields(&pattern); + assert_eq!(edited_dash_pattern(&fields, &pattern), pattern); + fields[0] = Some(14.0); + let mut expected = pattern.clone(); + expected[0] = 14.0; + assert_eq!(edited_dash_pattern(&fields, &pattern), expected); + // An empty gap repeats its dash; an empty dash deliberately shortens the pattern. + fields[1] = None; + expected[1] = 14.0; + assert_eq!(edited_dash_pattern(&fields, &pattern), expected); + fields[2] = None; + assert_eq!(edited_dash_pattern(&fields, &pattern), vec![14.0, 14.0]); + } + assert!(edited_dash_pattern(&[None; 6], &[12.0; 8]).is_empty()); + assert!(edited_dash_pattern(&[Some(0.0); 6], &[0.0; 8]).is_empty()); + let invalid = [12.0, 6.0, 2.0, 3.0, 4.0, 5.0, -1.0, 10.0]; + let pattern = edited_dash_pattern(&dash_fields(&invalid), &invalid); + assert_eq!(pattern, invalid); + assert!(!Dash { pattern, ..Default::default() }.is_dashed()); + } + + #[test] + fn turning_dashes_off_keeps_the_full_pattern_for_turning_them_back_on() { + let dash = Dash { pattern: vec![0.0, 6.0, 0.0, 7.0, 0.0, 8.0, 0.0, 9.0], align_corners: true, ..Default::default() }; + let saved = dash_state(Some(&dash), None); + let (pattern, align) = dash_state(None, Some(saved)); + assert_eq!(edited_dash_pattern(&dash_fields(&pattern), &pattern), dash.pattern); + assert!(align); + let (pattern, align) = dash_state(None, None); + assert_eq!(dash_pattern(&dash_fields(&pattern)), vec![12.0, 12.0]); + assert!(align); + } + + #[test] + fn editing_an_extended_dash_keeps_other_stroke_options_and_is_undoable() { + use vectorcraft_engine::Session; + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + app.session.execute("shape.line", &json!({"x1": 10, "y1": 50, "x2": 90, "y2": 50})).unwrap(); + set(&mut app, json!({"dash": [0, 6, 0, 7, 0, 8, 0, 9], "dashOffset": 3, "alignDashes": true, "cap": "round"})); + let before = current_stroke(&app).unwrap(); + frame(&mut app); + assert_eq!(current_stroke(&app).unwrap(), before, "viewing the panel changes nothing"); + let dash = before.dash.as_ref().unwrap(); + let mut fields = dash_fields(&dash.pattern); + fields[5] = Some(10.0); + set_dash_fields(&mut app, &fields, &dash.pattern, false); + let after = current_stroke(&app).unwrap(); + let mut expected = before.clone(); + expected.dash.as_mut().unwrap().pattern[5] = 10.0; + assert_eq!(after, expected, "the tail, offset, alignment, cap and other options stay"); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(current_stroke(&app).unwrap(), before); + app.run("edit.redo", json!({})).unwrap(); + assert_eq!(current_stroke(&app).unwrap(), after); + } + + #[test] + fn mixed_dashes_stay_mixed_until_an_edit_replaces_the_shared_fields() { + use vectorcraft_doc::NodeId; + use vectorcraft_engine::Session; + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + let mut ids = vec![]; + let mut before = vec![]; + for tail in [10.0, 20.0] { + let r = app.session.execute("shape.line", &json!({"x1": 10, "y1": tail, "x2": 90, "y2": tail})).unwrap(); + ids.push(NodeId(r["id"].as_u64().unwrap())); + set(&mut app, json!({"dash": [12, 6, 2, 3, 4, 5, tail, 9], "dashOffset": tail, "alignDashes": true})); + before.push(current_stroke(&app).unwrap()); + } + app.session.execute("select.set", &json!({"ids": ids.iter().map(|id| id.0).collect::>()})).unwrap(); + assert!(app.session.active().unwrap().stroke_mixed().dash); + frame(&mut app); + for (id, expected) in ids.iter().zip(&before) { + assert_eq!(app.session.active().unwrap().doc.node(*id).unwrap().appearance.stroke(), Some(expected)); + } + let fields = [Some(4.0), None, None, None, None, None]; + set_dash_fields(&mut app, &fields, &before[0].dash.as_ref().unwrap().pattern, true); + for (id, original) in ids.iter().zip(&before) { + let st = app.session.active().unwrap().doc.node(*id).unwrap().appearance.stroke().unwrap(); + let mut expected = original.clone(); + expected.dash.as_mut().unwrap().pattern = vec![4.0, 4.0]; + assert_eq!(st, &expected, "no hidden tail is copied to the mixed selection"); + } + } + /// #909: a profile many times the stroke's weight (made with the Width tool) draws inside its /// box, scaled by its widest point, and the plain bar keeps its look. #[test] diff --git a/crates/ui-egui/src/tests_numfields.rs b/crates/ui-egui/src/tests_numfields.rs index 4cd728879..81409786b 100644 --- a/crates/ui-egui/src/tests_numfields.rs +++ b/crates/ui-egui/src/tests_numfields.rs @@ -174,6 +174,56 @@ fn a_gap_field_passed_through_with_tab_commits_nothing() { } } +/// #3497: committing a visible dash field must not replace an extended pattern with six values. +/// Passing through the next field without typing must not commit anything else. +#[test] +fn dash_tab_and_enter_commits_preserve_the_unshown_pattern_tail() { + use crate::panels::stroke::{dash_fields, edited_dash_pattern}; + for end in [key(Key::Tab, Modifiers::NONE), enter()] { + let original = vec![12.0, 6.0, 2.0, 3.0, 4.0, 5.0, 20.0, 10.0]; + let pattern = RefCell::new(original.clone()); + let edits = RefCell::new([None; 6]); + let draw = |ui: &mut egui::Ui| { + let fields = dash_fields(&pattern.borrow()); + for (i, value) in fields.iter().enumerate() { + edits.borrow_mut()[i] = widgets::opt_field(ui, ("dash", i), *value, Unit::Points, 40.0); + } + None + }; + let step = |f: &mut Field, events: Vec| { + f.frame(events, &draw); + let mut fields = dash_fields(&pattern.borrow()); + let mut changed = false; + for (field, edit) in fields.iter_mut().zip(edits.borrow_mut().iter_mut()) { + if let Some(value) = edit.take() { + *field = value; + changed = true; + } + } + if changed { + let next = edited_dash_pattern(&fields, &pattern.borrow()); + *pattern.borrow_mut() = next; + } + changed + }; + let mut f = Field::new(); + let tab = || key(Key::Tab, Modifiers::NONE); + for events in [vec![], vec![tab()], vec![], vec![tab()], vec![]] { + assert!(!step(&mut f, events)); + } + assert_eq!(*pattern.borrow(), original, "tabbing without editing changes nothing"); + // Shift+Tab back to the first dash, replace it, then commit with Tab or Enter. + for events in [vec![key(Key::Tab, Modifiers::SHIFT)], vec![], vec![Event::Text("14".into())]] { + assert!(!step(&mut f, events)); + } + assert!(step(&mut f, vec![end])); + assert!(!step(&mut f, vec![])); + assert_eq!(*pattern.borrow(), vec![14.0, 6.0, 2.0, 3.0, 4.0, 5.0, 20.0, 10.0]); + assert!(!step(&mut f, vec![tab()])); + assert!(!step(&mut f, vec![])); + } +} + /// Headless frames of one field: its rect and id as last drawn. struct Field { ctx: egui::Context,