mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-27 03:06:57 -06:00
Merge pull request #2573 from dotCooCoo/feat/blend-panel
Blend: Window › Blend opens a Blend panel with the blend's spacing, orientation and commands
This commit is contained in:
@@ -25,7 +25,7 @@ const CMD: &str = "object.blend.options";
|
||||
const LABEL_W: f32 = 82.0;
|
||||
|
||||
/// Spacing modes: (value, label).
|
||||
const SPACING: [(&str, &str); 3] = [("smooth", "Smooth Color"), ("steps", "Specified Steps"), ("distance", "Specified Distance")];
|
||||
pub(crate) const SPACING: [(&str, &str); 3] = [("smooth", "Smooth Color"), ("steps", "Specified Steps"), ("distance", "Specified Distance")];
|
||||
|
||||
pub(super) const SPEC: DialogSpec =
|
||||
DialogSpec { heading: |_| tl!("Blend Options").into(), body, confirm, preview: true, min_width: 300.0, ..DialogSpec::FORM };
|
||||
@@ -100,8 +100,9 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
}
|
||||
|
||||
/// An orientation toggle drawn in code: three bars on a curve, upright (Align to Page) or square
|
||||
/// to the curve (Align to Path), in a pressed well when `selected`. Returns clicked.
|
||||
fn orientation_button(ui: &mut egui::Ui, along: bool, selected: bool, tip: &str) -> bool {
|
||||
/// to the curve (Align to Path), in a pressed well when `selected`. Screen readers name it `tip`.
|
||||
/// Returns clicked.
|
||||
pub(crate) fn orientation_button(ui: &mut egui::Ui, along: bool, selected: bool, tip: &str) -> bool {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let (rect, resp) = ui.allocate_exact_size(Vec2::splat(28.0), Sense::click());
|
||||
let bg = if selected {
|
||||
@@ -124,6 +125,7 @@ fn orientation_button(ui: &mut egui::Ui, along: bool, selected: bool, tip: &str)
|
||||
let n = if along { vec2(-12.0 * x / 9.0, -1.0).normalized() } else { vec2(0.0, -1.0) };
|
||||
ui.painter().line_segment([p, p + n * 7.0], Stroke::new(2.0, color));
|
||||
}
|
||||
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, ui.is_enabled(), selected, tip));
|
||||
resp.on_hover_text(tip).clicked()
|
||||
}
|
||||
|
||||
|
||||
@@ -2842,6 +2842,7 @@ pub fn menu_tree_named(english_names: bool) -> Vec<(&'static str, Vec<Item>)> {
|
||||
panel("Artboards", "artboards"),
|
||||
panel("Asset Export", crate::panels::asset_export::ID),
|
||||
panel("Attributes", "attributes"),
|
||||
panel("Blend", crate::panels::blend::ID),
|
||||
panel("Brushes", "brushes"),
|
||||
panel("Color", "color"),
|
||||
panel("Color Guide", "colorGuide"),
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
//! Blend panel (Window → Blend): Make, the Spacing and Orientation of the selected blend (with no
|
||||
//! blend selected, the options new blends start with), Reverse Front to Back, Reverse Spine,
|
||||
//! Replace Spine, Expand and Release. Each control runs its Object → Blend command
|
||||
//! (`object.blend.make`, `object.blend.options` and the others), so a change of the selected
|
||||
//! blend's spacing or orientation shows on the canvas at once as one undo step. Make is dimmed
|
||||
//! until two or more objects are selected. Spacing and Orientation are dimmed until a blend or two
|
||||
//! or more objects are selected, and the other buttons are dimmed until a blend is selected.
|
||||
|
||||
use egui::Ui;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::selection_len;
|
||||
use crate::VectorcraftApp;
|
||||
use crate::dialogs::blend_options::{SPACING, orientation_button};
|
||||
use crate::widgets;
|
||||
|
||||
pub const ID: &str = "blend";
|
||||
|
||||
/// Width of the label column, as in Blend Options.
|
||||
const LABEL_W: f32 = 82.0;
|
||||
|
||||
/// Width of the Specified Steps and Specified Distance field.
|
||||
const FIELD_W: f32 = 72.0;
|
||||
|
||||
/// A row of flat buttons sharing the row's width, each (label, command): a click runs the command,
|
||||
/// and a button whose command is unavailable is dimmed.
|
||||
fn buttons(app: &mut VectorcraftApp, ui: &mut Ui, row: &[(&str, &str)]) {
|
||||
let gap = ui.spacing().item_spacing.x;
|
||||
let n = row.len() as f32;
|
||||
let width = ((ui.available_width() - gap * (n - 1.0)) / n).max(48.0);
|
||||
ui.horizontal(|ui| {
|
||||
for &(label, cmd) in row {
|
||||
let on = available(app, cmd);
|
||||
if ui.add_enabled_ui(on, |ui| widgets::flat_button(ui, label, width)).inner.clicked() {
|
||||
app.run(cmd, json!({})).ok();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Can `cmd` run on the selection? Make needs two or more objects, and the other commands need a
|
||||
/// selected blend.
|
||||
fn available(app: &VectorcraftApp, cmd: &str) -> bool {
|
||||
crate::menus::enabled(app, cmd) && (cmd != "object.blend.make" || selection_len(app) >= 2)
|
||||
}
|
||||
|
||||
/// `object.blend.options` parameters for spacing mode `mode`, with the step count and distance
|
||||
/// the panel shows.
|
||||
fn spacing_params(mode: &str, steps: f64, distance: f64) -> Value {
|
||||
match mode {
|
||||
"steps" => json!({"spacing": "steps", "steps": steps}),
|
||||
"distance" => json!({"spacing": "distance", "distance": distance}),
|
||||
_ => json!({"spacing": "smooth"}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Spacing and Orientation, as `object.blend.info` reports them; a change runs
|
||||
/// `object.blend.options`.
|
||||
fn settings(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
let info = app.session.execute("object.blend.info", &json!({})).unwrap_or_default();
|
||||
let unit = app.session.general_unit();
|
||||
let mode = info["spacing"].as_str().unwrap_or("smooth");
|
||||
let steps = info["steps"].as_f64().unwrap_or(5.0);
|
||||
let distance = info["distance"].as_f64().unwrap_or(10.0);
|
||||
let path = info["orientation"] == "path";
|
||||
let mut change = None;
|
||||
let shown = SPACING.iter().find(|(v, _)| *v == mode).map_or(SPACING[0].1, |(_, l)| l);
|
||||
let labels = SPACING.map(|(_, l)| l);
|
||||
widgets::label_row(ui, tl!("Spacing:"), LABEL_W, |ui| {
|
||||
let width = ui.available_width().max(FIELD_W);
|
||||
if let Some((value, _)) = widgets::dropdown(ui, "blend-panel-spacing", shown, &labels, width).and_then(|i| SPACING.get(i)) {
|
||||
change = Some(spacing_params(value, steps, distance));
|
||||
}
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space(LABEL_W);
|
||||
if mode == "distance" {
|
||||
if let Some(d) = widgets::num_field(ui, "blend-panel-distance", Some(distance), unit, FIELD_W) {
|
||||
change = Some(spacing_params("distance", steps, d));
|
||||
}
|
||||
} else {
|
||||
// Smooth Color picks the count itself: the field shows the steps, disabled.
|
||||
ui.add_enabled_ui(mode == "steps", |ui| {
|
||||
if let Some(n) = widgets::range_field(ui, "blend-panel-steps", steps, 1.0..=1000.0, "", 0, FIELD_W) {
|
||||
change = Some(spacing_params("steps", n, distance));
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
widgets::label_row(ui, tl!("Orientation:"), LABEL_W, |ui| {
|
||||
for (value, along, tip) in [("page", false, tl!("Align to Page")), ("path", true, tl!("Align to Path"))] {
|
||||
if orientation_button(ui, along, path == along, tip) {
|
||||
change = Some(json!({ "orientation": value }));
|
||||
}
|
||||
}
|
||||
});
|
||||
if let Some(p) = change {
|
||||
app.run("object.blend.options", p).ok();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
let blend = crate::menus::enabled(app, "object.blend.release");
|
||||
buttons(app, ui, &[(tl!("Make"), "object.blend.make")]);
|
||||
widgets::divider(ui);
|
||||
ui.add_enabled_ui(blend || selection_len(app) >= 2, |ui| settings(app, ui));
|
||||
widgets::label_row(ui, tl!("Direction:"), LABEL_W, |ui| buttons(app, ui, &[(tl!("Reverse Front to Back"), "object.blend.reverseFrontToBack")]));
|
||||
widgets::divider(ui);
|
||||
buttons(app, ui, &[(tl!("Reverse Spine"), "object.blend.reverseSpine"), (tl!("Replace Spine"), "object.blend.replaceSpine")]);
|
||||
widgets::divider(ui);
|
||||
buttons(app, ui, &[(tl!("Expand"), "object.blend.expand"), (tl!("Release"), "object.blend.release")]);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ pub mod appearance;
|
||||
pub mod artboards;
|
||||
pub mod asset_export;
|
||||
pub mod attributes;
|
||||
pub mod blend;
|
||||
pub mod brushes;
|
||||
pub mod character;
|
||||
pub mod color;
|
||||
@@ -146,6 +147,7 @@ pub fn show_icon_panel(app: &mut VectorcraftApp, ui: &mut Ui, id: &str) {
|
||||
"graphicStyles" => graphic_styles::show(app, ui),
|
||||
"align" => align::show(app, ui),
|
||||
"pathfinder" => pathfinder::show(app, ui),
|
||||
blend::ID => blend::show(app, ui),
|
||||
"transform" => transform::show(app, ui),
|
||||
"history" => history::show(app, ui),
|
||||
"actions" => actions::show(app, ui),
|
||||
@@ -541,6 +543,8 @@ mod tests_appearance;
|
||||
#[cfg(test)]
|
||||
mod tests_asset_export;
|
||||
#[cfg(test)]
|
||||
mod tests_blend;
|
||||
#[cfg(test)]
|
||||
mod tests_constrain;
|
||||
#[cfg(test)]
|
||||
mod tests_css_properties;
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
//! The Blend panel drawn headless: Window › Blend opens it, and its controls do nothing until the
|
||||
//! selection allows them. Make needs two or more objects, Spacing and Orientation need a blend or
|
||||
//! two or more objects, and the other buttons need a blend. Make blends the selection, Spacing and
|
||||
//! Orientation edit the selected blend through `object.blend.options`, and each other button runs
|
||||
//! its Object › Blend command.
|
||||
|
||||
use egui::{Event, Pos2, Rect, pos2, vec2};
|
||||
use serde_json::json;
|
||||
use vectorcraft_doc::live::{BlendOrientation, BlendSpacing};
|
||||
use vectorcraft_doc::{BlendSpec, NodeId, NodeKind};
|
||||
use vectorcraft_engine::Session;
|
||||
|
||||
use super::blend::{ID, show};
|
||||
use crate::{VectorcraftApp, theme};
|
||||
|
||||
/// Frames of the panel alone in a 300 × 600 window, with the accessibility tree on.
|
||||
struct Frames {
|
||||
ctx: egui::Context,
|
||||
time: f64,
|
||||
}
|
||||
|
||||
impl Frames {
|
||||
fn new() -> Self {
|
||||
let ctx = egui::Context::default();
|
||||
ctx.enable_accesskit();
|
||||
theme::install_fonts(&ctx);
|
||||
theme::apply(&ctx, Default::default());
|
||||
Self { ctx, time: 0.0 }
|
||||
}
|
||||
|
||||
fn run(&mut self, app: &mut VectorcraftApp, events: Vec<Event>) -> egui::FullOutput {
|
||||
self.time += 0.1;
|
||||
let screen = Rect::from_min_size(Pos2::ZERO, vec2(300.0, 600.0));
|
||||
let input = egui::RawInput { events, time: Some(self.time), screen_rect: Some(screen), ..Default::default() };
|
||||
let mut out = self.ctx.run_ui(input, |ui| show(app, ui));
|
||||
out.textures_delta.clear();
|
||||
out
|
||||
}
|
||||
|
||||
/// The center of the text `label` the panel draws.
|
||||
fn text(&mut self, app: &mut VectorcraftApp, label: &str) -> Pos2 {
|
||||
self.run(app, vec![]);
|
||||
let out = self.run(app, vec![]);
|
||||
let mut texts = vec![];
|
||||
fn collect(s: &egui::Shape, out: &mut Vec<(String, Rect)>) {
|
||||
match s {
|
||||
egui::Shape::Text(t) => out.push((t.galley.text().to_string(), Rect::from_min_size(t.pos, t.galley.size()))),
|
||||
egui::Shape::Vec(v) => v.iter().for_each(|s| collect(s, out)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
out.shapes.iter().for_each(|c| collect(&c.shape, &mut texts));
|
||||
texts.iter().find(|(t, _)| t == label).unwrap_or_else(|| panic!("no {label:?} in {texts:?}")).1.center()
|
||||
}
|
||||
|
||||
/// The center of the button that screen readers name `label`.
|
||||
fn button(&mut self, app: &mut VectorcraftApp, label: &str) -> Pos2 {
|
||||
self.run(app, vec![]);
|
||||
let out = self.run(app, vec![]);
|
||||
let nodes = &out.platform_output.accesskit_update.as_ref().expect("an accessibility tree").nodes;
|
||||
let node = nodes.iter().find(|(_, n)| n.label() == Some(label) && n.role() == egui::accesskit::Role::Button);
|
||||
let b = node.unwrap_or_else(|| panic!("no button named {label:?}")).1.bounds().expect("bounds");
|
||||
pos2((b.x0 + b.x1) as f32 * 0.5, (b.y0 + b.y1) as f32 * 0.5)
|
||||
}
|
||||
|
||||
/// Move to `at` and click there. egui finds what the pointer is over from the frame before, so
|
||||
/// the move gets a frame of its own, drawn with the selection as it is now.
|
||||
fn click(&mut self, app: &mut VectorcraftApp, at: Pos2) {
|
||||
let button = |pressed| Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE };
|
||||
self.run(app, vec![Event::PointerMoved(at)]);
|
||||
self.run(app, vec![button(true)]);
|
||||
self.run(app, vec![button(false)]);
|
||||
self.run(app, vec![]);
|
||||
}
|
||||
|
||||
/// Click the text `label` the panel draws.
|
||||
fn click_text(&mut self, app: &mut VectorcraftApp, label: &str) {
|
||||
let at = self.text(app, label);
|
||||
self.click(app, at);
|
||||
}
|
||||
}
|
||||
|
||||
/// A document with two 20 pt squares 200 pt apart, both selected.
|
||||
fn app() -> VectorcraftApp {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 300, "height": 300})).unwrap();
|
||||
app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 20, "height": 20})).unwrap();
|
||||
app.run("shape.rectangle", json!({"x": 200, "y": 0, "width": 20, "height": 20})).unwrap();
|
||||
app.run("select.all", json!({})).unwrap();
|
||||
app
|
||||
}
|
||||
|
||||
/// [`app`] with the squares blended at Smooth Color; the blend is selected.
|
||||
fn blended() -> (VectorcraftApp, NodeId) {
|
||||
let mut app = app();
|
||||
let id = app.run("object.blend.make", json!({"smooth": true})).unwrap()["id"].as_u64().unwrap();
|
||||
(app, NodeId(id))
|
||||
}
|
||||
|
||||
fn spec(app: &VectorcraftApp, id: NodeId) -> BlendSpec {
|
||||
match &app.session.active().unwrap().doc.node(id).unwrap().kind {
|
||||
NodeKind::Blend { spec, .. } => (**spec).clone(),
|
||||
k => panic!("not a blend: {k:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn undo_depth(app: &VectorcraftApp) -> usize {
|
||||
app.session.active().unwrap().history.undo.len()
|
||||
}
|
||||
|
||||
/// The top-level objects of the first layer.
|
||||
fn top(app: &VectorcraftApp) -> Vec<(NodeId, bool)> {
|
||||
let doc = &app.session.active().unwrap().doc;
|
||||
doc.layers[0].children().unwrap().iter().map(|n| (n.id, matches!(n.kind, NodeKind::Blend { .. }))).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn window_blend_opens_the_panel() {
|
||||
let mut app = app();
|
||||
let entries = crate::menus::menu_entries(&app);
|
||||
let item = entries.iter().find(|e| e.path == ["Window"] && e.label == "Blend").expect("Window › Blend");
|
||||
assert_eq!(item.command.as_deref(), Some("window.panel"));
|
||||
assert_eq!(item.params, json!({"panel": ID, "toggle": true}));
|
||||
crate::menus::invoke(&mut app, "window.panel", item.params.clone());
|
||||
assert_eq!(app.ui.open_panel.as_deref(), Some(ID));
|
||||
let text = crate::tests_labels::painted_text(&mut app, |app, ui| super::show_icon_panel(app, ui, ID));
|
||||
for label in ["Make", "Spacing:", "Orientation:", "Direction:", "Reverse Front to Back", "Reverse Spine", "Replace Spine", "Expand", "Release"] {
|
||||
assert!(text.contains(label), "{label} in {text}");
|
||||
}
|
||||
assert!(!text.contains("on the roadmap"), "{text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_controls_are_dimmed_until_a_blend_or_two_objects_are_selected() {
|
||||
let mut app = app();
|
||||
app.run("select.none", json!({})).unwrap();
|
||||
let depth = undo_depth(&app);
|
||||
let mut f = Frames::new();
|
||||
let page = f.button(&mut app, "Align to Page");
|
||||
let path = f.button(&mut app, "Align to Path");
|
||||
// Nothing selected: no button acts and Orientation does not change the options new blends
|
||||
// start with.
|
||||
for label in ["Make", "Reverse Front to Back", "Reverse Spine", "Replace Spine", "Expand", "Release"] {
|
||||
f.click_text(&mut app, label);
|
||||
}
|
||||
f.click(&mut app, path);
|
||||
assert_eq!(app.session.execute("object.blend.info", &json!({})).unwrap()["orientation"], "page");
|
||||
assert_eq!((top(&app).len(), undo_depth(&app)), (2, depth), "nothing ran");
|
||||
assert_eq!(app.ui.status, "", "no command ran to fail");
|
||||
// One object: Make stays dimmed.
|
||||
let first = top(&app)[0].0;
|
||||
app.run("select.set", json!({"ids": [first.0]})).unwrap();
|
||||
f.click_text(&mut app, "Make");
|
||||
assert_eq!((top(&app).len(), undo_depth(&app), app.ui.status.as_str()), (2, depth, ""), "Make did not run on one object");
|
||||
// Two objects: Orientation sets the options Make then uses.
|
||||
app.run("select.all", json!({})).unwrap();
|
||||
f.click(&mut app, path);
|
||||
assert_eq!(app.session.execute("object.blend.info", &json!({})).unwrap()["orientation"], "path");
|
||||
f.click(&mut app, page);
|
||||
assert_eq!(app.session.execute("object.blend.info", &json!({})).unwrap()["orientation"], "page");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn make_blends_the_selected_objects_in_one_undo_step() {
|
||||
let mut app = app();
|
||||
let depth = undo_depth(&app);
|
||||
let mut f = Frames::new();
|
||||
f.click_text(&mut app, "Make");
|
||||
let objects = top(&app);
|
||||
assert_eq!(objects.len(), 1, "{objects:?}");
|
||||
assert!(objects[0].1, "a blend");
|
||||
assert_eq!(undo_depth(&app), depth + 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spacing_and_orientation_edit_the_selected_blend() {
|
||||
let (mut app, id) = blended();
|
||||
let mut f = Frames::new();
|
||||
// The dropdown shows the blend's Smooth Color; Specified Steps gives it the steps shown.
|
||||
f.click_text(&mut app, "Smooth Color");
|
||||
let depth = undo_depth(&app);
|
||||
f.click_text(&mut app, "Specified Steps");
|
||||
assert_eq!(spec(&app, id).spacing, BlendSpacing::Steps(5));
|
||||
assert_eq!(undo_depth(&app), depth + 1, "one undo step");
|
||||
f.click_text(&mut app, "Specified Steps");
|
||||
f.click_text(&mut app, "Specified Distance");
|
||||
assert_eq!(spec(&app, id).spacing, BlendSpacing::Distance(10.0));
|
||||
let path = f.button(&mut app, "Align to Path");
|
||||
f.click(&mut app, path);
|
||||
assert_eq!(spec(&app, id).orientation, BlendOrientation::AlignToPath);
|
||||
assert_eq!(spec(&app, id).spacing, BlendSpacing::Distance(10.0), "Orientation keeps the spacing");
|
||||
app.run("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(spec(&app, id).orientation, BlendOrientation::AlignToPage);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_button_runs_its_blend_command() {
|
||||
// Reverse Front to Back reverses the keys' stacking order.
|
||||
let (mut app, id) = blended();
|
||||
let keys = |app: &VectorcraftApp| -> Vec<NodeId> {
|
||||
app.session.active().unwrap().doc.node(id).unwrap().children().unwrap().iter().map(|k| k.id).collect()
|
||||
};
|
||||
let before = keys(&app);
|
||||
let mut f = Frames::new();
|
||||
f.click_text(&mut app, "Reverse Front to Back");
|
||||
assert_eq!(keys(&app), before.iter().rev().copied().collect::<Vec<_>>());
|
||||
// Reverse Spine swaps the keys' places along the spine.
|
||||
let x = |app: &VectorcraftApp, k: NodeId| app.session.active().unwrap().doc.node(k).unwrap().geometric_bounds().unwrap().x0;
|
||||
let (a, b) = (keys(&app)[0], keys(&app)[1]);
|
||||
let (xa, xb) = (x(&app, a), x(&app, b));
|
||||
f.click_text(&mut app, "Reverse Spine");
|
||||
assert_eq!((x(&app, a), x(&app, b)), (xb, xa));
|
||||
// Replace Spine puts the blend on the selected path, which it takes in.
|
||||
let line = app.run("shape.line", json!({"x1": 0, "y1": 100, "x2": 200, "y2": 200})).unwrap()["id"].as_u64().unwrap();
|
||||
app.run("select.set", json!({"ids": [id.0, line]})).unwrap();
|
||||
f.click_text(&mut app, "Replace Spine");
|
||||
assert!(spec(&app, id).spine.is_some(), "the line is the spine");
|
||||
assert!(app.session.active().unwrap().doc.node(NodeId(line)).is_none(), "the line was taken in");
|
||||
// Expand turns the blend into a group of its keys and steps.
|
||||
f.click_text(&mut app, "Expand");
|
||||
assert!(matches!(app.session.active().unwrap().doc.node(id).unwrap().kind, NodeKind::Group { .. }));
|
||||
// Release gives back the keys of another blend.
|
||||
let (mut app, _) = blended();
|
||||
f.click_text(&mut app, "Release");
|
||||
let objects = top(&app);
|
||||
assert!(objects.len() >= 2 && objects.iter().all(|(_, blend)| !blend), "the keys are back: {objects:?}");
|
||||
}
|
||||
@@ -134,6 +134,7 @@ pub const ICON_PANELS: &[(&str, &str, &str)] = &[
|
||||
("transform", "Transform", "dc-transform-panel"),
|
||||
("align", "Align", "dc-align"),
|
||||
("pathfinder", "Pathfinder", "dc-pathfinder"),
|
||||
(crate::panels::blend::ID, "Blend", "dc-blend"),
|
||||
("character", "Character", "type"),
|
||||
("paragraph", "Paragraph", "pilcrow"),
|
||||
("glyphs", "Glyphs", "text-cursor-input"),
|
||||
|
||||
File diff suppressed because one or more lines are too long
+1
-1
@@ -214,7 +214,7 @@ panel-menu commands such as Break Link to Symbol), so they can be given shortcut
|
||||
| Pathfinder panel on one group selected alone; a greyed-out button says what it needs | partial | #1450; the group's members are combined; one plain path selected alone stays disabled (the public documentation doesn't say what the operations do to a single path) |
|
||||
| Pathfinder Divide: an open cutting line splits a filled path | done | #1307; straight, crossed, bent and curved cutters, holes and frontmost appearance regression-tested; native automation: independent piece movement, one Undo/Redo, saved-document round trip and window capture; existing bounded-map fallback remains |
|
||||
| Canvas context menu | done | Layers rows, panels and type have none (gap) |
|
||||
| Blend panel (30.7) | missing | Blend Options dialog exists |
|
||||
| Blend panel (30.7) | partial | Window › Blend: Make, Spacing, Orientation, Reverse Front to Back, Reverse Spine, Replace Spine, Expand and Release, running the Object › Blend commands; the easing and color controls are missing |
|
||||
| SVG Interactivity, Retype, Comments, Version History panels | missing | the last two are cloud features (out of scope) |
|
||||
| Workspaces (save, reset, switch) | done | |
|
||||
| DXF export/import preserves intended curve closure | done | #1299; R13–2018 closed SPLINE bit set/read; an open curve ending at its start stays open; knots and control points unchanged; native CIRCLE/ARC export and CNC preset remain pending |
|
||||
|
||||
Reference in New Issue
Block a user