Reduce cognitive complexity in UI dialogs, canvas, chrome, shortcut import and IDML export (#1848)

* refactor(ui): split shortcut set import into sort, apply and conflict steps

Resolves SonarCloud rust:S3776 on import (ui-egui/src/shortcut_import.rs).

* refactor(ui): split drop caps and nested styles dialog into row helpers

Resolves SonarCloud rust:S3776 on drop_caps_and_nested_styles (ui-egui/src/dialogs.rs).

* refactor(ui): split draw_guides into bleed, grid, overlay and guide helpers

Resolves SonarCloud rust:S3776 on draw_guides (ui-egui/src/canvas.rs).

* refactor(ui): split control_paragraph into alignment, indent, drop cap and shading helpers

Resolves SonarCloud rust:S3776 on control_paragraph (ui-egui/src/chrome.rs).

* refactor(idml): move graphic layer option tree out of image_el

Resolves SonarCloud rust:S3776 on image_el (idml/src/export.rs).
This commit is contained in:
Dylan M. Taylor
2026-10-25 21:25:57 -04:00
committed by GitHub
parent 42463ce99d
commit 63a4f687fb
5 changed files with 527 additions and 389 deletions
+50 -45
View File
@@ -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<El>> = 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
}
/// `<GraphicLayerOption>` 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<El>> = 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
}
/// `<Index>`: nested topics with their See / See also cross-references.
fn index_el(&mut self) -> El {
let mut topics = self.index_topics.clone();
+133 -84
View File
@@ -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<GuideSel> =
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<GuideSel>,
) {
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)?;
+83 -55
View File
@@ -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<Align> {
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<serde_json::Value>, 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,
+146 -123
View File
@@ -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<Value> = cur(d, "p.nestedStyles", &pv["nestedStyles"]).as_array().cloned().unwrap_or_default();
let mut list: Vec<Value> = 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<Value> = cur(d, "p.nestedLineStyles", &pv["nestedLineStyles"]).as_array().cloned().unwrap_or_default();
let mut lines: Vec<Value> = 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());
+115 -82
View File
@@ -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<Value, String> {
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<Decision> = 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::<Vec<_>>().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::<Vec<&Entry>>();
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<Decision>,
unmatched: Vec<Value>,
unmatched_without_shortcut: usize,
unreadable: Vec<Value>,
}
let before: HashMap<String, Option<String>> = 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<String> = 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<String>, &'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::<Vec<_>>().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::<Vec<&Entry>>();
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<Value>,
/// The commands the set decided, whether or not it changed them.
decided: BTreeSet<String>,
}
/// 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<Value, String> {
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<Value, String> {
}
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<Value> {
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<Value, String> {
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<Value, String> {
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<String, Option<String>> = 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<Value> = 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<Value, String> {
.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,