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:
BFlat
2026-10-17 15:46:01 +07:00
committed by GitHub
8 changed files with 352 additions and 4 deletions
+5 -3
View File
@@ -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()
}
+1
View File
@@ -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"),
+112
View File
@@ -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")]);
}
+4
View File
@@ -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;
+227
View File
@@ -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:?}");
}
+1
View File
@@ -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
View File
@@ -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 |