Merge pull request #3513 from Zaxaerith/fix/3497-dash-pattern-preservation

fix(stroke): preserve extended dash patterns when editing (#3497)
This commit is contained in:
BFlat
2026-10-24 04:11:17 +07:00
committed by GitHub
2 changed files with 170 additions and 9 deletions
+120 -9
View File
@@ -66,18 +66,24 @@ pub fn dash_fields(pattern: &[f64]) -> [Option<f64>; 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<f64>; 6], bool)>) -> ([Option<f64>; 6], bool) {
fn dash_state(dash: Option<&Dash>, last: Option<(Vec<f64>, bool)>) -> (Vec<f64>, 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<f64>; 6]) -> Vec<f64> {
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<f64>; 6], previous: &[f64]) -> Vec<f64> {
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<f64>; 6]) -> Vec<f64> {
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<f64>; 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<f64> {
@@ -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::<Vec<_>>()})).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]
+50
View File
@@ -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<Event>| {
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,