mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 09:46:59 -06:00
Blend: Window › Blend opens a Blend panel with the blend's spacing, orientation and commands
The reference app has a Blend panel, opened with Window > Blend, that makes and edits blends in one place. VectorCraft had the Blend tool, Object › Blend and the Blend Options dialog, but no Blend panel. Window › Blend opens a panel with Make, the selected blend's Spacing and Orientation, Reverse Front to Back, Reverse Spine, Replace Spine, Expand and Release. Each control runs its Object › Blend command, so a change of the selected blend's spacing or orientation shows on the canvas at once and is one undo step. With two or more objects selected and no blend, Spacing and Orientation set the options new blends start with, as Blend Options does. 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. The Blend Options dialog's orientation toggles now have names for screen readers. Docs: docs/target-app-parity.md, docs/ui-parity.md.
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