diff --git a/crates/idml/src/export.rs b/crates/idml/src/export.rs index fe154d306..811a7a163 100644 --- a/crates/idml/src/export.rs +++ b/crates/idml/src/export.rs @@ -2086,51 +2086,7 @@ impl<'a> Ex<'a> { el.push(clipping_settings(c)); } if let Some(options) = &g.layer_options { - let mut option = El::new("GraphicLayerOption"); - for (key, value) in &options.attributes { - if graphic_layer_attribute_name(key) { - option.set(key, value); - } - } - // Only an ancestor whose subtree is still open can parent the next layer. - // Reopening a closed subtree would reorder native-edited lists on roundtrip. - let mut open = Vec::new(); - let mut parents = Vec::with_capacity(options.layers.len()); - for i in 0..options.layers.len() { - let parent = options.parents.get(i).copied().flatten(); - if let Some(at) = parent.and_then(|p| open.iter().position(|id| *id == p)).filter(|at| *at < 239) { - open.truncate(at + 1); - parents.push(parent); - } else { - open.clear(); - parents.push(None); - } - open.push(i); - } - let mut children: Vec> = vec![Vec::new(); options.layers.len()]; - let mut roots = Vec::new(); - for (i, attributes) in options.layers.iter().enumerate().rev() { - let mut layer = El::new("GraphicLayer").attr("Self", self.fresh()); - for (key, value) in attributes { - if graphic_layer_attribute_name(key) { - layer.set(key, value); - } - } - if let Some(nested) = children.get_mut(i) { - for child in std::mem::take(nested).into_iter().rev() { - layer.push(child); - } - } - if let Some(nested) = parents.get(i).copied().flatten().and_then(|parent| children.get_mut(parent)) { - nested.push(layer); - } else { - roots.push(layer); - } - } - for layer in roots.into_iter().rev() { - option.push(layer); - } - el.push(option); + el.push(self.layer_option_el(options)); } let uri = match &asset.link { Some(l) => path_to_uri(l), @@ -2169,6 +2125,55 @@ impl<'a> Ex<'a> { el } + /// `` of a placed graphic: its layer attributes and the nested layer tree. + fn layer_option_el(&mut self, options: &designcraft_doc::GraphicLayerOptions) -> El { + let mut option = El::new("GraphicLayerOption"); + for (key, value) in &options.attributes { + if graphic_layer_attribute_name(key) { + option.set(key, value); + } + } + // Only an ancestor whose subtree is still open can parent the next layer. + // Reopening a closed subtree would reorder native-edited lists on roundtrip. + let mut open = Vec::new(); + let mut parents = Vec::with_capacity(options.layers.len()); + for i in 0..options.layers.len() { + let parent = options.parents.get(i).copied().flatten(); + if let Some(at) = parent.and_then(|p| open.iter().position(|id| *id == p)).filter(|at| *at < 239) { + open.truncate(at + 1); + parents.push(parent); + } else { + open.clear(); + parents.push(None); + } + open.push(i); + } + let mut children: Vec> = vec![Vec::new(); options.layers.len()]; + let mut roots = Vec::new(); + for (i, attributes) in options.layers.iter().enumerate().rev() { + let mut layer = El::new("GraphicLayer").attr("Self", self.fresh()); + for (key, value) in attributes { + if graphic_layer_attribute_name(key) { + layer.set(key, value); + } + } + if let Some(nested) = children.get_mut(i) { + for child in std::mem::take(nested).into_iter().rev() { + layer.push(child); + } + } + if let Some(nested) = parents.get(i).copied().flatten().and_then(|parent| children.get_mut(parent)) { + nested.push(layer); + } else { + roots.push(layer); + } + } + for layer in roots.into_iter().rev() { + option.push(layer); + } + option + } + /// ``: nested topics with their See / See also cross-references. fn index_el(&mut self) -> El { let mut topics = self.index_topics.clone(); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index e2b066fb1..66d3ed373 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1107,106 +1107,155 @@ fn shown_area(app: &DesignApp, doc: &Document, xf: &Xf, slot: &SpreadSlot) -> Re /// `moving`: guides being dragged, drawn where they would be dropped instead of where they are. fn draw_guides(app: &DesignApp, painter: &egui::Painter, xf: &Xf, doc: &Document, layout: &CanvasLayout, t: &Tokens, moving: Option<&MovingGuides>) { - let s = &doc.settings; let selection: std::collections::HashSet = app.session.active().map(|st| st.selection.guides.iter().copied().collect()).unwrap_or_default(); for slot in &layout.slots { let Some(sp) = doc.spread(slot.spread) else { continue }; - // Everything below is in spread coordinates, placed through the slot (which may be turned). - let m = slot.xf; - let pt = |p: Point| xf.to_screen(m * p); - let rect = |r: DRect| xf.rect(m.transform_rect_bbox(r)); - // Bleed, then slug: one rectangle each around the spread (the inside offset only applies - // at the spread's outer edges). if app.ui.guides { - for (r, color) in [(sp.bleed_rect(s.bleed), s.bleed_color), (sp.slug_rect(s.slug), s.slug_color)] { - if let Some(r) = r { - painter.rect_stroke(rect(r), 0.0, Stroke::new(hair(painter), c32(color)), StrokeKind::Middle); - } - } + draw_bleed_and_slug(painter, xf, doc, slot, sp); } - let pasteboard = m.inverse().transform_rect_bbox(layout.pasteboard); + let pasteboard = slot.xf.inverse().transform_rect_bbox(layout.pasteboard); for (pi, p) in sp.pages.iter().enumerate() { let pr = p.bounds(); - // Page tool › Show Parent Page Overlay: the parent page's size over the page. - if p.show_parent_overlay - && let SpreadRef::Doc(si) = slot.spread - && let Some((ppi, ppg)) = doc.parent_page_for(doc.first_page_of_spread(si) + pi) - && let Some(pp) = doc.parents.get(ppi).and_then(|ps| ps.pages.get(ppg)) - { - let r = rect(DRect::new(pr.x0, pr.y0, pr.x0 + pp.width, pr.y0 + pp.height)); - painter.rect_filled(r, 0.0, Color32::from_rgba_unmultiplied(110, 110, 230, 28)); - painter.rect_stroke(r, 0.0, Stroke::new(1.0, Color32::from_rgb(110, 110, 230)), StrokeKind::Middle); - } + draw_parent_overlay(painter, xf, doc, slot, pi, p, pr); if !app.ui.guides { continue; } - // Baseline grid. - if app.ui.baseline_grid && xf.zoom >= s.baseline_grid.view_threshold { - let g = &s.baseline_grid; - for y in baseline_grid_lines(g.origin(p), g.increment, p.height) { - painter.line_segment( - [pt(Point::new(pr.x0, pr.y0 + y)), pt(Point::new(pr.x1, pr.y0 + y))], - Stroke::new(hair(painter), c32(g.color).gamma_multiply(0.8)), - ); - } - } - // Margins (magenta) and columns (violet). - painter.rect_stroke(rect(p.margin_rect()), 0.0, Stroke::new(hair(painter), c32(s.margin_color)), StrokeKind::Middle); - let cols = p.column_rects(); - if cols.len() > 1 { - for (i, c) in cols.iter().enumerate() { - let col = Stroke::new(hair(painter), c32(s.column_color)); - if i > 0 { - painter.line_segment([pt(Point::new(c.x0, c.y0)), pt(Point::new(c.x0, c.y1))], col); - } - if i + 1 < cols.len() { - painter.line_segment([pt(Point::new(c.x1, c.y0)), pt(Point::new(c.x1, c.y1))], col); - } - } - } - // The page's own guides, then those it shows from its parent page. Selected guides are - // drawn thicker, in their layer's color. - let own = p.guides.len(); - for (gi, mut g) in p.guides.iter().cloned().chain(doc.parent_guides(p)).enumerate().filter(|(_, g)| g.visible_in(doc)) { - let key = GuideSel { spread: slot.spread, page: pi, index: gi }; - let selected = gi < own && selection.contains(&key); - if gi < own - && let Some(m) = moving - && m.guides.contains(&key) - { - g.position += match g.orientation { - designcraft_doc::Orientation::Vertical => m.dx, - designcraft_doc::Orientation::Horizontal => m.dy, - }; - } - // Spread guides cross the pasteboard; page guides their page. - let span = if g.spread { pasteboard } else { pr }; - let (a, bb) = match g.orientation { - designcraft_doc::Orientation::Horizontal => (Point::new(span.x0, g.position), Point::new(span.x1, g.position)), - designcraft_doc::Orientation::Vertical => { - let span = if g.spread { sp.bounds().inflate(0.0, 36.0) } else { pr }; - (Point::new(g.position, span.y0), Point::new(g.position, span.y1)) - } - }; - let stroke = if selected { - let color = g.layer.and_then(|l| doc.layer(l)).map_or(Color32::from_rgb(79, 153, 255), |l| c32(l.color)); - Stroke::new(2.0, color) - } else { - Stroke::new(hair(painter), Color32::from_rgb(74, 227, 255)) - }; - if g.liquid { - // Liquid guides are dashed. - painter.extend(egui::Shape::dashed_line(&[pt(a), pt(bb)], stroke, 6.0, 3.0)); - } else { - painter.line_segment([pt(a), pt(bb)], stroke); - } - } + draw_page_grids(painter, xf, doc, slot, p, pr, app.ui.baseline_grid); + draw_page_guides(painter, xf, doc, slot, sp, pi, p, pr, pasteboard, moving, &selection); } } let _ = t; } +/// The bleed and slug outlines of a spread, in their own colours. +fn draw_bleed_and_slug(painter: &egui::Painter, xf: &Xf, doc: &Document, slot: &SpreadSlot, sp: &designcraft_doc::Spread) { + let s = &doc.settings; + let rect = |r: DRect| xf.rect(slot.xf.transform_rect_bbox(r)); + // Bleed, then slug: one rectangle each around the spread (the inside offset only applies + // at the spread's outer edges). + for (r, color) in [(sp.bleed_rect(s.bleed), s.bleed_color), (sp.slug_rect(s.slug), s.slug_color)] { + if let Some(r) = r { + painter.rect_stroke(rect(r), 0.0, Stroke::new(hair(painter), c32(color)), StrokeKind::Middle); + } + } +} + +/// Page tool › Show Parent Page Overlay: the parent page's size over the page. +fn draw_parent_overlay(painter: &egui::Painter, xf: &Xf, doc: &Document, slot: &SpreadSlot, pi: usize, p: &designcraft_doc::Page, pr: DRect) { + let rect = |r: DRect| xf.rect(slot.xf.transform_rect_bbox(r)); + if p.show_parent_overlay + && let SpreadRef::Doc(si) = slot.spread + && let Some((ppi, ppg)) = doc.parent_page_for(doc.first_page_of_spread(si) + pi) + && let Some(pp) = doc.parents.get(ppi).and_then(|ps| ps.pages.get(ppg)) + { + let r = rect(DRect::new(pr.x0, pr.y0, pr.x0 + pp.width, pr.y0 + pp.height)); + painter.rect_filled(r, 0.0, Color32::from_rgba_unmultiplied(110, 110, 230, 28)); + painter.rect_stroke(r, 0.0, Stroke::new(1.0, Color32::from_rgb(110, 110, 230)), StrokeKind::Middle); + } +} + +/// A page's baseline grid (when `baseline` is on and the view is zoomed in far enough), margins +/// and columns. +fn draw_page_grids(painter: &egui::Painter, xf: &Xf, doc: &Document, slot: &SpreadSlot, p: &designcraft_doc::Page, pr: DRect, baseline: bool) { + let s = &doc.settings; + let pt = |q: Point| xf.to_screen(slot.xf * q); + let rect = |r: DRect| xf.rect(slot.xf.transform_rect_bbox(r)); + // Baseline grid. + if baseline && xf.zoom >= s.baseline_grid.view_threshold { + let g = &s.baseline_grid; + for y in baseline_grid_lines(g.origin(p), g.increment, p.height) { + painter.line_segment( + [pt(Point::new(pr.x0, pr.y0 + y)), pt(Point::new(pr.x1, pr.y0 + y))], + Stroke::new(hair(painter), c32(g.color).gamma_multiply(0.8)), + ); + } + } + // Margins (magenta) and columns (violet). + painter.rect_stroke(rect(p.margin_rect()), 0.0, Stroke::new(hair(painter), c32(s.margin_color)), StrokeKind::Middle); + let cols = p.column_rects(); + if cols.len() > 1 { + for (i, c) in cols.iter().enumerate() { + let col = Stroke::new(hair(painter), c32(s.column_color)); + if i > 0 { + painter.line_segment([pt(Point::new(c.x0, c.y0)), pt(Point::new(c.x0, c.y1))], col); + } + if i + 1 < cols.len() { + painter.line_segment([pt(Point::new(c.x1, c.y0)), pt(Point::new(c.x1, c.y1))], col); + } + } + } +} + +/// The page's own guides, then those it shows from its parent page. `moving` draws dragged +/// guides where they would be dropped. +fn draw_page_guides( + painter: &egui::Painter, + xf: &Xf, + doc: &Document, + slot: &SpreadSlot, + sp: &designcraft_doc::Spread, + pi: usize, + p: &designcraft_doc::Page, + pr: DRect, + pasteboard: DRect, + moving: Option<&MovingGuides>, + selection: &std::collections::HashSet, +) { + let own = p.guides.len(); + for (gi, mut g) in p.guides.iter().cloned().chain(doc.parent_guides(p)).enumerate().filter(|(_, g)| g.visible_in(doc)) { + let key = GuideSel { spread: slot.spread, page: pi, index: gi }; + // Selected guides are drawn thicker, in their layer's color. + let selected = gi < own && selection.contains(&key); + if gi < own + && let Some(m) = moving + && m.guides.contains(&key) + { + g.position += match g.orientation { + designcraft_doc::Orientation::Vertical => m.dx, + designcraft_doc::Orientation::Horizontal => m.dy, + }; + } + draw_guide(painter, xf, doc, slot, sp, pr, pasteboard, &g, selected); + } +} + +/// One guide as a line: across the pasteboard for a spread guide, across its page otherwise. +fn draw_guide( + painter: &egui::Painter, + xf: &Xf, + doc: &Document, + slot: &SpreadSlot, + sp: &designcraft_doc::Spread, + pr: DRect, + pasteboard: DRect, + g: &designcraft_doc::Guide, + selected: bool, +) { + let pt = |q: Point| xf.to_screen(slot.xf * q); + // Spread guides cross the pasteboard; page guides their page. + let span = if g.spread { pasteboard } else { pr }; + let (a, bb) = match g.orientation { + designcraft_doc::Orientation::Horizontal => (Point::new(span.x0, g.position), Point::new(span.x1, g.position)), + designcraft_doc::Orientation::Vertical => { + let span = if g.spread { sp.bounds().inflate(0.0, 36.0) } else { pr }; + (Point::new(g.position, span.y0), Point::new(g.position, span.y1)) + } + }; + let stroke = if selected { + let color = g.layer.and_then(|l| doc.layer(l)).map_or(Color32::from_rgb(79, 153, 255), |l| c32(l.color)); + Stroke::new(2.0, color) + } else { + Stroke::new(hair(painter), Color32::from_rgb(74, 227, 255)) + }; + if g.liquid { + // Liquid guides are dashed. + painter.extend(egui::Shape::dashed_line(&[pt(a), pt(bb)], stroke, 6.0, 3.0)); + } else { + painter.line_segment([pt(a), pt(bb)], stroke); + } +} + /// Spread-space → canvas transform for an item at `loc`. fn item_canvas_xf(doc: &Document, layout: &CanvasLayout, id: designcraft_doc::ItemId) -> Option<(Affine, &'static str)> { let loc = doc.find(id)?; diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index dc8a69b46..ed7887b94 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -840,6 +840,64 @@ fn control_paragraph( tr: &dyn Fn(&str) -> String, ) { let cur: Align = serde_json::from_value(para["align"].clone()).unwrap_or_default(); + if let Some(a) = align_buttons(ui, cur, tr) { + let _ = app.run("type.para", json!({"attrs": {"align": a}})); + } + vsep(ui); + let units = app.session.active().map(|d| d.doc.settings.horizontal_units).unwrap_or(Unit::Picas); + if let Some((k, v)) = indent_space_fields(ui, para, units, tr) { + let _ = app.run("type.para", json!({"attrs": {k: v}})); + } + if let Some((k, v)) = drop_cap_fields(ui, para, tr) { + let _ = app.run("type.para", json!({"attrs": {k: v.max(0.0) as u64}})); + } + vsep(ui); + // Bulleted List over Numbered List, as InDesign's (a list with the default hanging indent, or, + // pressed, none). Pressed when every selected paragraph has it, as Type › Apply Bullets shows. + use designcraft_doc::ListType; + let pressed = [ListType::Bullets, ListType::Numbers].map(|k| app.session.selection_has_list(k)); + let mut list = None; + rows(ui, |ui, row| { + let (icon, cmd, tip) = if row == 0 { ("bullets", "list.bullets", "Bulleted List") } else { ("numbering", "list.numbers", "Numbered List") }; + let on = pressed.get(row).copied().unwrap_or(false); + if crate::widgets::icon_toggle(ui, icon, on, &tr(tip)).clicked() { + list = Some((cmd, !on)); + } + }); + if let Some((cmd, on)) = list { + let _ = app.run(cmd, json!({"on": on})); + } + vsep(ui); + let (shade, dialog) = shading_buttons(app, ui, para, tr); + if let Some(attrs) = shade { + let _ = app.run("type.para", json!({ "attrs": attrs })); + } + if let Some(tab) = dialog { + let _ = app.run("app.paragraphBordersDialog", json!({ "tab": tab })); + } + vsep(ui); + ui.vertical(|ui| { + ui.spacing_mut().item_spacing.y = 3.0; + let ps = attrs.as_ref().and_then(|a| a["paragraphStyle"].as_str()).unwrap_or("").to_string(); + let ov = attrs.as_ref().and_then(|a| a["paraOverrides"].as_u64()).unwrap_or(0) + + attrs.as_ref().and_then(|a| a["charOverrides"].as_u64()).unwrap_or(0); + panels::para_style_picker(app, ui, &ps, ov > 0, 170.0); + ui.horizontal(|ui| { + let mut h = para["hyphenate"].as_bool().unwrap_or(true); + if crate::widgets::checkbox(ui, &mut h, &tr("Hyphenate")).changed() { + let _ = app.run("type.para", json!({"attrs": {"hyphenate": h}})); + } + // First Line Only is aligned too: the button shows it pressed, and a click turns alignment off. + let grid = matches!(para["gridAlign"].as_str(), Some("allLines" | "firstLineOnly")); + if crate::widgets::icon_toggle(ui, "grid-baseline", grid, &tr("Align to Baseline Grid")).clicked() { + let _ = app.run("type.para", json!({"attrs": {"gridAlign": if grid { "none" } else { "allLines" }}})); + } + }); + }); +} + +/// The paragraph alignment buttons, in two rows: the alignment picked this frame, if any. +fn align_buttons(ui: &mut egui::Ui, cur: Align, tr: &dyn Fn(&str) -> String) -> Option { let mut align = None; rows(ui, |ui, row| { if row == 0 { @@ -867,11 +925,12 @@ fn control_paragraph( } } }); - if let Some(a) = align { - let _ = app.run("type.para", json!({"attrs": {"align": a}})); - } - vsep(ui); - let units = app.session.active().map(|d| d.doc.settings.horizontal_units).unwrap_or(Unit::Picas); + align +} + +/// The indents and space before and after, two per column. Returns the one field changed this +/// frame, with its paragraph attribute and value. +fn indent_space_fields(ui: &mut egui::Ui, para: &serde_json::Value, units: Unit, tr: &dyn Fn(&str) -> String) -> Option<(&'static str, f64)> { let mut set = None; for ((ia, ta, ka), (ib, tb, kb)) in [ (("indent-left", "Left Indent", "leftIndent"), ("indent-first", "First Line Left Indent", "firstLineIndent")), @@ -892,9 +951,12 @@ fn control_paragraph( }); vsep(ui); } - if let Some((k, v)) = set { - let _ = app.run("type.para", json!({"attrs": {k: v}})); - } + set +} + +/// The drop cap's line and character counts. Returns the one changed this frame, with its +/// paragraph attribute and value. +fn drop_cap_fields(ui: &mut egui::Ui, para: &serde_json::Value, tr: &dyn Fn(&str) -> String) -> Option<(&'static str, f64)> { let mut drop = None; rows(ui, |ui, row| { if row == 0 { @@ -919,28 +981,18 @@ fn control_paragraph( } } }); - if let Some((k, v)) = drop { - let _ = app.run("type.para", json!({"attrs": {k: v.max(0.0) as u64}})); - } - vsep(ui); - // Bulleted List over Numbered List, as InDesign's (a list with the default hanging indent, or, - // pressed, none). Pressed when every selected paragraph has it, as Type › Apply Bullets shows. - use designcraft_doc::ListType; - let pressed = [ListType::Bullets, ListType::Numbers].map(|k| app.session.selection_has_list(k)); - let mut list = None; - rows(ui, |ui, row| { - let (icon, cmd, tip) = if row == 0 { ("bullets", "list.bullets", "Bulleted List") } else { ("numbering", "list.numbers", "Numbered List") }; - let on = pressed.get(row).copied().unwrap_or(false); - if crate::widgets::icon_toggle(ui, icon, on, &tr(tip)).clicked() { - list = Some((cmd, !on)); - } - }); - if let Some((cmd, on)) = list { - let _ = app.run(cmd, json!({"on": on})); - } - vsep(ui); - // Shading over Border: on or off, the Paragraph Borders and Shading dialog on its tab, and the - // colour, as InDesign's. + drop +} + +/// Shading over Border: the on/off checkboxes, the colour swatches and the Paragraph Borders and +/// Shading buttons. Returns the attributes to set and the dialog tab to open, if any. +fn shading_buttons( + app: &DesignApp, + ui: &mut egui::Ui, + para: &serde_json::Value, + tr: &dyn Fn(&str) -> String, +) -> (Option, Option<&'static str>) { + // On or off, the Paragraph Borders and Shading dialog on its tab, and the colour, as InDesign's. let doc = app.session.active().map(|d| d.doc.clone()); let mut shade = None; let mut dialog = None; @@ -973,31 +1025,7 @@ fn control_paragraph( } } }); - if let Some(attrs) = shade { - let _ = app.run("type.para", json!({ "attrs": attrs })); - } - if let Some(tab) = dialog { - let _ = app.run("app.paragraphBordersDialog", json!({ "tab": tab })); - } - vsep(ui); - ui.vertical(|ui| { - ui.spacing_mut().item_spacing.y = 3.0; - let ps = attrs.as_ref().and_then(|a| a["paragraphStyle"].as_str()).unwrap_or("").to_string(); - let ov = attrs.as_ref().and_then(|a| a["paraOverrides"].as_u64()).unwrap_or(0) - + attrs.as_ref().and_then(|a| a["charOverrides"].as_u64()).unwrap_or(0); - panels::para_style_picker(app, ui, &ps, ov > 0, 170.0); - ui.horizontal(|ui| { - let mut h = para["hyphenate"].as_bool().unwrap_or(true); - if crate::widgets::checkbox(ui, &mut h, &tr("Hyphenate")).changed() { - let _ = app.run("type.para", json!({"attrs": {"hyphenate": h}})); - } - // First Line Only is aligned too: the button shows it pressed, and a click turns alignment off. - let grid = matches!(para["gridAlign"].as_str(), Some("allLines" | "firstLineOnly")); - if crate::widgets::icon_toggle(ui, "grid-baseline", grid, &tr("Align to Baseline Grid")).clicked() { - let _ = app.run("type.para", json!({"attrs": {"gridAlign": if grid { "none" } else { "allLines" }}})); - } - }); - }); + (shade, dialog) } /// A swatch field of the Control panel (Shading and Border colours): the swatch's chip and name, diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index e93e64679..3c82c90d2 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -4786,9 +4786,22 @@ fn border_shading(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, pv: &Value, doc }); } +/// The value of dialog field `k`, or `base` when the dialog has not set it. +fn field_or(d: &Dialog, k: &str, base: &Value) -> Value { + d.fields.get(k).cloned().unwrap_or_else(|| base.clone()) +} + /// Drop Caps and Nested Styles: the drop cap, nested styles and nested line styles (`p.` fields). fn drop_caps_and_nested_styles(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, pv: &Value, cnames: &[String]) { - let cur = |d: &Dialog, k: &str, base: &Value| d.fields.get(k).cloned().unwrap_or_else(|| base.clone()); + drop_cap_fields(lang, ui, d, pv, cnames); + ui.add_space(8.0); + nested_style_rows(lang, ui, d, pv, cnames); + ui.add_space(8.0); + nested_line_style_rows(lang, ui, d, pv, cnames); +} + +/// The drop cap's lines, characters, character style and alignment options (`p.` fields). +fn drop_cap_fields(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, pv: &Value, cnames: &[String]) { crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(lang, "Drop Caps")).font(semibold(12.0))); egui::Grid::new("psdc").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { for (label, key, max) in [ @@ -4796,14 +4809,14 @@ fn drop_caps_and_nested_styles(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, pv ("Characters:", "dropCapChars", designcraft_compose::MAX_DROP_CAP_CHARS), ] { crate::rtl::label(ui, crate::i18n::tr(lang, label)); - let v = cur(d, &format!("p.{key}"), &pv[key]).as_f64(); + let v = field_or(d, &format!("p.{key}"), &pv[key]).as_f64(); if let Some(n) = crate::widgets::number(ui, &format!("ps{key}"), v, "", 60.0, 0) { d.fields.insert(format!("p.{key}"), json!((n.max(0.0) as u64).min(max as u64))); } ui.end_row(); } crate::rtl::label(ui, crate::i18n::tr(lang, "Character Style:")); - let style = cur(d, "p.dropCapStyle", &pv["dropCapStyle"]).as_str().unwrap_or("").to_string(); + let style = field_or(d, "p.dropCapStyle", &pv["dropCapStyle"]).as_str().unwrap_or("").to_string(); let style = if style.is_empty() { designcraft_doc::NO_CHAR_STYLE.to_string() } else { style }; egui::ComboBox::from_id_salt("ps_dropcap_style") .selected_text(crate::rtl::widget(ui, crate::i18n::style_name(lang, &style))) @@ -4818,93 +4831,25 @@ fn drop_caps_and_nested_styles(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, pv ui.end_row(); }); for (label, key) in [("Align Left Edge", "dropCapAlignLeft"), ("Scale for Descenders", "dropCapScaleDescenders")] { - let mut on = cur(d, &format!("p.{key}"), &pv[key]).as_bool().unwrap_or(false); + let mut on = field_or(d, &format!("p.{key}"), &pv[key]).as_bool().unwrap_or(false); if ui.checkbox(&mut on, crate::rtl::widget(ui, crate::i18n::tr(lang, label))).changed() { d.fields.insert(format!("p.{key}"), json!(on)); } } - ui.add_space(8.0); +} + +/// The nested styles list (`p.nestedStyles`), with its add button. +fn nested_style_rows(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, pv: &Value, cnames: &[String]) { crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(lang, "Nested Styles")).font(semibold(12.0))); - let mut list: Vec = cur(d, "p.nestedStyles", &pv["nestedStyles"]).as_array().cloned().unwrap_or_default(); + let mut list: Vec = field_or(d, "p.nestedStyles", &pv["nestedStyles"]).as_array().cloned().unwrap_or_default(); let mut changed = false; let mut remove = None; for (i, ns) in list.iter_mut().enumerate() { - ui.horizontal(|ui| { - let style = ns["style"].as_str().unwrap_or("[None]").to_string(); - egui::ComboBox::from_id_salt(("ns_style", i)) - .selected_text(crate::rtl::widget(ui, crate::i18n::style_name(lang, &style))) - .width(110.0) - .show_ui(ui, |ui| { - for c in cnames { - if ui.selectable_label(*c == style, crate::rtl::widget(ui, crate::i18n::style_name(lang, c))).clicked() { - ns["style"] = json!(c); - changed = true; - } - } - }); - let through = ns["through"].as_bool().unwrap_or(true); - egui::ComboBox::from_id_salt(("ns_thr", i)) - .selected_text(crate::rtl::widget(ui, crate::i18n::tr(lang, if through { "through" } else { "up to" }))) - .width(70.0) - .show_ui(ui, |ui| { - for (v, l) in [(true, "through"), (false, "up to")] { - if ui.selectable_label(v == through, crate::rtl::widget(ui, crate::i18n::tr(lang, l))).clicked() { - ns["through"] = json!(v); - changed = true; - } - } - }); - let mut n = ns["count"].as_u64().unwrap_or(1) as u32; - let mut count_value = |ui: &mut egui::Ui, ns: &mut Value, changed: &mut bool| { - if ui.add(egui::DragValue::new(&mut n).range(1..=999)).changed() { - ns["count"] = json!(n); - *changed = true; - } - }; - if !crate::i18n::category_first(lang) { - count_value(ui, ns, &mut changed); - } - let kind = ns["until"]["kind"].as_str().unwrap_or("words").to_string(); - egui::ComboBox::from_id_salt(("ns_until", i)) - .selected_text(crate::rtl::widget( - ui, - if crate::i18n::category_first(lang) { - format!("{}:", crate::i18n::tr(lang, &kind)) - } else { - crate::i18n::tr(lang, &kind).to_owned() - }, - )) - .width(100.0) - .show_ui(ui, |ui| { - for k in - ["sentences", "words", "characters", "letters", "digits", "tab", "forcedLineBreak", "emSpace", "enSpace", "dropcap", "chars"] - { - if ui.selectable_label(k == kind, crate::rtl::widget(ui, crate::i18n::tr(lang, k))).clicked() { - ns["until"] = if k == "chars" { json!({"kind": "chars", "chars": ":"}) } else { json!({"kind": k}) }; - changed = true; - } - } - }); - if crate::i18n::category_first(lang) { - count_value(ui, ns, &mut changed); - } - if kind == "chars" { - let mut c = ns["until"]["chars"].as_str().unwrap_or("").to_string(); - if ui.add(egui::TextEdit::singleline(&mut c).desired_width(40.0)).changed() { - ns["until"]["chars"] = json!(c); - changed = true; - } - } - if ui - .small_button("×") - .on_hover_ui(|ui| { - crate::rtl::label(ui, crate::i18n::tr(lang, "Delete")); - }) - .clicked() - { - remove = Some(i); - } - }); + let (row_changed, delete) = ui.horizontal(|ui| nested_style_row(lang, ui, ns, i, cnames)).inner; + changed |= row_changed; + if delete { + remove = Some(i); + } } if let Some(i) = remove { list.remove(i); @@ -4917,51 +4862,92 @@ fn drop_caps_and_nested_styles(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, pv if changed { d.fields.insert("p.nestedStyles".into(), Value::Array(list)); } - ui.add_space(8.0); +} + +/// One row of the nested styles list. Returns whether the row changed and whether its delete +/// button was pressed. +fn nested_style_row(lang: &str, ui: &mut egui::Ui, ns: &mut Value, i: usize, cnames: &[String]) -> (bool, bool) { + let mut changed = false; + let style = ns["style"].as_str().unwrap_or("[None]").to_string(); + egui::ComboBox::from_id_salt(("ns_style", i)).selected_text(crate::rtl::widget(ui, crate::i18n::style_name(lang, &style))).width(110.0).show_ui( + ui, + |ui| { + for c in cnames { + if ui.selectable_label(*c == style, crate::rtl::widget(ui, crate::i18n::style_name(lang, c))).clicked() { + ns["style"] = json!(c); + changed = true; + } + } + }, + ); + let through = ns["through"].as_bool().unwrap_or(true); + egui::ComboBox::from_id_salt(("ns_thr", i)) + .selected_text(crate::rtl::widget(ui, crate::i18n::tr(lang, if through { "through" } else { "up to" }))) + .width(70.0) + .show_ui(ui, |ui| { + for (v, l) in [(true, "through"), (false, "up to")] { + if ui.selectable_label(v == through, crate::rtl::widget(ui, crate::i18n::tr(lang, l))).clicked() { + ns["through"] = json!(v); + changed = true; + } + } + }); + let mut n = ns["count"].as_u64().unwrap_or(1) as u32; + let mut count_value = |ui: &mut egui::Ui, ns: &mut Value, changed: &mut bool| { + if ui.add(egui::DragValue::new(&mut n).range(1..=999)).changed() { + ns["count"] = json!(n); + *changed = true; + } + }; + if !crate::i18n::category_first(lang) { + count_value(ui, ns, &mut changed); + } + let kind = ns["until"]["kind"].as_str().unwrap_or("words").to_string(); + egui::ComboBox::from_id_salt(("ns_until", i)) + .selected_text(crate::rtl::widget( + ui, + if crate::i18n::category_first(lang) { format!("{}:", crate::i18n::tr(lang, &kind)) } else { crate::i18n::tr(lang, &kind).to_owned() }, + )) + .width(100.0) + .show_ui(ui, |ui| { + for k in ["sentences", "words", "characters", "letters", "digits", "tab", "forcedLineBreak", "emSpace", "enSpace", "dropcap", "chars"] { + if ui.selectable_label(k == kind, crate::rtl::widget(ui, crate::i18n::tr(lang, k))).clicked() { + ns["until"] = if k == "chars" { json!({"kind": "chars", "chars": ":"}) } else { json!({"kind": k}) }; + changed = true; + } + } + }); + if crate::i18n::category_first(lang) { + count_value(ui, ns, &mut changed); + } + if kind == "chars" { + let mut c = ns["until"]["chars"].as_str().unwrap_or("").to_string(); + if ui.add(egui::TextEdit::singleline(&mut c).desired_width(40.0)).changed() { + ns["until"]["chars"] = json!(c); + changed = true; + } + } + let delete = ui + .small_button("×") + .on_hover_ui(|ui| { + crate::rtl::label(ui, crate::i18n::tr(lang, "Delete")); + }) + .clicked(); + (changed, delete) +} + +/// The nested line styles list (`p.nestedLineStyles`), with its add button. +fn nested_line_style_rows(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, pv: &Value, cnames: &[String]) { crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(lang, "Nested Line Styles")).font(semibold(12.0))); - let mut lines: Vec = cur(d, "p.nestedLineStyles", &pv["nestedLineStyles"]).as_array().cloned().unwrap_or_default(); + let mut lines: Vec = field_or(d, "p.nestedLineStyles", &pv["nestedLineStyles"]).as_array().cloned().unwrap_or_default(); let mut changed = false; let mut remove = None; for (i, l) in lines.iter_mut().enumerate() { - ui.horizontal(|ui| { - let style = l["style"].as_str().unwrap_or("").to_string(); - egui::ComboBox::from_id_salt(("nls_style", i)) - .selected_text(crate::rtl::widget(ui, crate::i18n::style_name(lang, &style))) - .width(130.0) - .show_ui(ui, |ui| { - for c in cnames { - if ui.selectable_label(*c == style, crate::rtl::widget(ui, crate::i18n::style_name(lang, c))).clicked() { - l["style"] = json!(c); - changed = true; - } - } - }); - crate::rtl::label( - ui, - if crate::i18n::category_first(lang) { - format!("{}:", crate::i18n::tr(lang, "lines")) - } else { - crate::i18n::tr(lang, "for").to_owned() - }, - ); - let mut n = l["lines"].as_u64().unwrap_or(1) as u32; - if ui.add(egui::DragValue::new(&mut n).range(1..=999)).changed() { - l["lines"] = json!(n); - changed = true; - } - if !crate::i18n::category_first(lang) { - crate::rtl::label(ui, crate::i18n::tr(lang, if n == 1 { "line" } else { "lines" })); - } - if ui - .small_button("×") - .on_hover_ui(|ui| { - crate::rtl::label(ui, crate::i18n::tr(lang, "Delete")); - }) - .clicked() - { - remove = Some(i); - } - }); + let (row_changed, delete) = ui.horizontal(|ui| nested_line_style_row(lang, ui, l, i, cnames)).inner; + changed |= row_changed; + if delete { + remove = Some(i); + } } if let Some(i) = remove { lines.remove(i); @@ -4976,6 +4962,43 @@ fn drop_caps_and_nested_styles(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, pv } } +/// One row of the nested line styles list. Returns whether the row changed and whether its delete +/// button was pressed. +fn nested_line_style_row(lang: &str, ui: &mut egui::Ui, l: &mut Value, i: usize, cnames: &[String]) -> (bool, bool) { + let mut changed = false; + let style = l["style"].as_str().unwrap_or("").to_string(); + egui::ComboBox::from_id_salt(("nls_style", i)).selected_text(crate::rtl::widget(ui, crate::i18n::style_name(lang, &style))).width(130.0).show_ui( + ui, + |ui| { + for c in cnames { + if ui.selectable_label(*c == style, crate::rtl::widget(ui, crate::i18n::style_name(lang, c))).clicked() { + l["style"] = json!(c); + changed = true; + } + } + }, + ); + crate::rtl::label( + ui, + if crate::i18n::category_first(lang) { format!("{}:", crate::i18n::tr(lang, "lines")) } else { crate::i18n::tr(lang, "for").to_owned() }, + ); + let mut n = l["lines"].as_u64().unwrap_or(1) as u32; + if ui.add(egui::DragValue::new(&mut n).range(1..=999)).changed() { + l["lines"] = json!(n); + changed = true; + } + if !crate::i18n::category_first(lang) { + crate::rtl::label(ui, crate::i18n::tr(lang, if n == 1 { "line" } else { "lines" })); + } + let delete = ui + .small_button("×") + .on_hover_ui(|ui| { + crate::rtl::label(ui, crate::i18n::tr(lang, "Delete")); + }) + .clicked(); + (changed, delete) +} + /// GREP Styles: a character style applied to text matching each pattern (`p.` fields). fn grep_styles(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, pv: &Value, cnames: &[String]) { let cur = |d: &Dialog, k: &str, base: &Value| d.fields.get(k).cloned().unwrap_or_else(|| base.clone()); diff --git a/crates/ui-egui/src/shortcut_import.rs b/crates/ui-egui/src/shortcut_import.rs index 9ccbef1b9..161e7aab5 100644 --- a/crates/ui-egui/src/shortcut_import.rs +++ b/crates/ui-egui/src/shortcut_import.rs @@ -354,80 +354,87 @@ fn keys_of(sc: &str) -> Option<(bool, bool, bool, bool, egui::Key)> { menus::parse_shortcut(sc).map(|(m, k)| (m.command, m.alt, m.shift, m.ctrl, k)) } -/// Imports a set: every matched command takes the set's shortcut (or none), another command -/// holding an imported shortcut loses it, and the rest keep theirs. Returns the report. -pub fn import(app: &mut DesignApp, text: &str) -> Result { - if text.len() > MAX_BYTES { - return Err(format!("the set is larger than {} MB", MAX_BYTES >> 20)); - } - let set = parse(text)?; - let mac = is_mac_set(&set); - let ix = Index::build(); - let mut decisions: Vec = Vec::new(); - let mut unmatched = Vec::new(); - let mut unmatched_without_shortcut = 0usize; - let mut unreadable = Vec::new(); - for item in &set.items { - let shown = if item.area.is_empty() { item.name.clone() } else { format!("{} › {}", item.area, item.name) }; - let all_keys = item.entries.iter().map(|e| format!("{}: {}", e.context, e.keys)).collect::>().join(", "); - let Some(id) = ix.find(&item.area, &item.name) else { - if item.entries.is_empty() { - unmatched_without_shortcut += 1; - } else { - let tool = norm(&item.area) == "tools" - && designcraft_tools::TOOL_GROUPS.iter().flat_map(|g| g.iter()).any(|t| norm(t.label) == norm(&item.name)); - let mut row = json!({"item": shown, "keys": all_keys}); - if tool { - row["reason"] = json!("tool keys are fixed"); - } - unmatched.push(row); - } - continue; - }; - let in_context = |ctx: &'static str| item.entries.iter().filter(move |e| e.context.eq_ignore_ascii_case(ctx)).collect::>(); - let text_command = id.starts_with("type.") || id.starts_with("text."); - let mut considered = in_context("Default"); - if considered.is_empty() && text_command { - considered = in_context("Text"); - } - let mut read = None; - let mut why = ""; - for e in &considered { - match read_keys(&e.keys, mac) { - Ok(sc) => { - read = Some(sc); - break; - } - Err(w) => why = w, - } - } - if read.is_none() && !considered.is_empty() { - unreadable.push(json!({"item": shown, "keys": all_keys, "reason": why})); - } - let pos = match decisions.iter().position(|d| d.id == id) { - Some(p) => p, - None => { - decisions.push(Decision { id: id.clone(), item: shown.clone(), shortcut: None, all_none: true }); - decisions.len() - 1 - } - }; - let Some(d) = decisions.get_mut(pos) else { continue }; - d.all_none &= item.entries.is_empty(); - if d.shortcut.is_none() - && let Some(sc) = read - { - d.shortcut = Some(sc); - d.item = shown; - } - } +/// What the first pass makes of a set's items, before any shortcut changes. +#[derive(Default)] +struct Sorted { + decisions: Vec, + unmatched: Vec, + unmatched_without_shortcut: usize, + unreadable: Vec, +} - let before: HashMap> = menus::effective_shortcuts(app).into_iter().map(|(id, sc)| (id, Some(sc))).collect(); - let mut imported = 0usize; - let mut taken: Vec<((bool, bool, bool, bool, egui::Key), String)> = Vec::new(); - let mut skipped = Vec::new(); - let mut decided: BTreeSet = BTreeSet::new(); - // When two items in the set want the same keys, the later one takes them (the rule shared by - // the Craft apps' importers). +/// The first readable shortcut among `entries`, and the reason for the last unreadable one before it. +fn first_readable(entries: &[&Entry], mac: bool) -> (Option, &'static str) { + let mut why = ""; + for e in entries { + match read_keys(&e.keys, mac) { + Ok(sc) => return (Some(sc), why), + Err(w) => why = w, + } + } + (None, why) +} + +/// Sorts one item of a set: matched to a command (its first readable shortcut becomes that +/// command's decision), unmatched, or matched with keys that cannot be read. +fn sort_item(sorted: &mut Sorted, ix: &Index, item: &SetItem, mac: bool) { + let shown = if item.area.is_empty() { item.name.clone() } else { format!("{} › {}", item.area, item.name) }; + let all_keys = item.entries.iter().map(|e| format!("{}: {}", e.context, e.keys)).collect::>().join(", "); + let Some(id) = ix.find(&item.area, &item.name) else { + if item.entries.is_empty() { + sorted.unmatched_without_shortcut += 1; + } else { + let tool = norm(&item.area) == "tools" + && designcraft_tools::TOOL_GROUPS.iter().flat_map(|g| g.iter()).any(|t| norm(t.label) == norm(&item.name)); + let mut row = json!({"item": shown, "keys": all_keys}); + if tool { + row["reason"] = json!("tool keys are fixed"); + } + sorted.unmatched.push(row); + } + return; + }; + let in_context = |ctx: &'static str| item.entries.iter().filter(move |e| e.context.eq_ignore_ascii_case(ctx)).collect::>(); + let text_command = id.starts_with("type.") || id.starts_with("text."); + let mut considered = in_context("Default"); + if considered.is_empty() && text_command { + considered = in_context("Text"); + } + let (read, why) = first_readable(&considered, mac); + if read.is_none() && !considered.is_empty() { + sorted.unreadable.push(json!({"item": shown, "keys": all_keys, "reason": why})); + } + let pos = match sorted.decisions.iter().position(|d| d.id == id) { + Some(p) => p, + None => { + sorted.decisions.push(Decision { id: id.clone(), item: shown.clone(), shortcut: None, all_none: true }); + sorted.decisions.len() - 1 + } + }; + let Some(d) = sorted.decisions.get_mut(pos) else { return }; + d.all_none &= item.entries.is_empty(); + if d.shortcut.is_none() + && let Some(sc) = read + { + d.shortcut = Some(sc); + d.item = shown; + } +} + +/// What applying a set's decisions did. +struct Applied { + imported: usize, + /// The keys each command took, with that command. + taken: Vec<((bool, bool, bool, bool, egui::Key), String)>, + skipped: Vec, + /// The commands the set decided, whether or not it changed them. + decided: BTreeSet, +} + +/// Sets or clears each decided command's shortcut. When two items want the same keys, the later +/// one takes them (the rule shared by the Craft apps' importers). +fn apply_decisions(app: &mut DesignApp, decisions: &[Decision]) -> Applied { + let mut applied = Applied { imported: 0, taken: Vec::new(), skipped: Vec::new(), decided: BTreeSet::new() }; let mut winner: HashMap<(bool, bool, bool, bool, egui::Key), usize> = HashMap::new(); for (i, d) in decisions.iter().enumerate() { if let Some(keys) = d.shortcut.as_deref().and_then(keys_of) { @@ -439,10 +446,10 @@ pub fn import(app: &mut DesignApp, text: &str) -> Result { Some(sc) => { let Some(keys) = keys_of(sc) else { continue }; if let Some(w) = winner.get(&keys).filter(|&&w| w != i).and_then(|&w| decisions.get(w)) { - skipped.push(json!({"id": d.id, "item": d.item, "shortcut": sc, "takenBy": w.id})); + applied.skipped.push(json!({"id": d.id, "item": d.item, "shortcut": sc, "takenBy": w.id})); continue; } - taken.push((keys, d.id.clone())); + applied.taken.push((keys, d.id.clone())); if menus::default_shortcut(&d.id).and_then(keys_of) == Some(keys) { app.ui.shortcuts.remove(&d.id); } else { @@ -458,12 +465,18 @@ pub fn import(app: &mut DesignApp, text: &str) -> Result { } None => continue, } - imported += 1; - decided.insert(d.id.clone()); + applied.imported += 1; + applied.decided.insert(d.id.clone()); } + applied +} + +/// Clears the shortcut of each command outside the set that still holds keys the set took. +/// Returns the report of the commands cleared. +fn clear_conflicts(app: &mut DesignApp, applied: &Applied) -> Vec { let mut conflicts = Vec::new(); for (id, sc) in menus::effective_shortcuts(app) { - if decided.contains(&id) { + if applied.decided.contains(&id) { continue; } // A command sharing its default keys with the one taking them (file.new and the New @@ -473,15 +486,35 @@ pub fn import(app: &mut DesignApp, text: &str) -> Result { default(&id).is_some() && default(&id) == default(by) }; if let Some(keys) = keys_of(&sc) - && taken.iter().any(|(k, by)| *k == keys && !twin(by)) + && applied.taken.iter().any(|(k, by)| *k == keys && !twin(by)) { app.ui.shortcuts.insert(id.clone(), String::new()); conflicts.push(json!({"id": id, "label": command_label(&id), "shortcut": sc})); } } + conflicts +} + +/// Imports a set: every matched command takes the set's shortcut (or none), another command +/// holding an imported shortcut loses it, and the rest keep theirs. Returns the report. +pub fn import(app: &mut DesignApp, text: &str) -> Result { + if text.len() > MAX_BYTES { + return Err(format!("the set is larger than {} MB", MAX_BYTES >> 20)); + } + let set = parse(text)?; + let mac = is_mac_set(&set); + let ix = Index::build(); + let mut sorted = Sorted::default(); + for item in &set.items { + sort_item(&mut sorted, &ix, item, mac); + } + let Sorted { decisions, unmatched, unmatched_without_shortcut, unreadable } = sorted; + let before: HashMap> = menus::effective_shortcuts(app).into_iter().map(|(id, sc)| (id, Some(sc))).collect(); + let applied = apply_decisions(app, &decisions); + let conflicts = clear_conflicts(app, &applied); let changed: Vec = decisions .iter() - .filter(|d| decided.contains(&d.id)) + .filter(|d| applied.decided.contains(&d.id)) .filter_map(|d| { let from = before.get(&d.id).cloned().flatten(); let to = menus::shortcut_of(app, &d.id); @@ -490,10 +523,10 @@ pub fn import(app: &mut DesignApp, text: &str) -> Result { .collect(); Ok(json!({ "set": set.name, - "imported": imported, + "imported": applied.imported, "changed": changed, "conflictsCleared": conflicts, - "skipped": skipped, + "skipped": applied.skipped, "unmatched": unmatched, "unmatchedWithoutShortcut": unmatched_without_shortcut, "unreadable": unreadable,