mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-25 02:07:09 -06:00
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:
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user