Merge pull request #97 from storytold/pr/m4-slices
M4.88–M4.89: slices and the Slice tools
This commit is contained in:
commit
66e92bbd4c
|
|
@ -21,6 +21,7 @@ pub mod rastersettings;
|
|||
mod reach;
|
||||
pub mod selection;
|
||||
pub mod setup;
|
||||
pub mod slices;
|
||||
pub mod style_libs;
|
||||
pub mod swatches;
|
||||
pub mod text;
|
||||
|
|
@ -60,6 +61,7 @@ pub use profiles::ColorProfiles;
|
|||
pub use rastersettings::{RasterColorModel, RasterEffectsSettings};
|
||||
pub use selection::{AnchorRef, Selection};
|
||||
pub use setup::{Background, DocSetup, ExportText, GridSize, Quotes};
|
||||
pub use slices::{CellAlign, CellVAlign, Slice, SliceArea, SliceKind, SliceOptions, SliceSource};
|
||||
pub use style_libs::StyleLibrary;
|
||||
pub use text::{
|
||||
AreaOptions, CharPosition, CharStyle, FirstBaseline, Justify, ParaStyle, PathEffect, ScriptMetrics, TabAlign, TabStop, TextKind, TextObject,
|
||||
|
|
@ -507,6 +509,13 @@ pub struct Document {
|
|||
/// lose them. Separate from [`Document::unknown`], which holds foreign data by design.
|
||||
#[serde(flatten)]
|
||||
pub extra: serde_json::Map<String, serde_json::Value>,
|
||||
/// User slices (Slice tool, Object → Slice); object slices are [`Node::slice`].
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub slices: Vec<Slice>,
|
||||
/// Object → Slice → Clip to Artboard: slices are clipped to the artboards and auto slices fill
|
||||
/// them (on, the default); off, auto slices cover the art and the slices.
|
||||
#[serde(default = "yes", skip_serializing_if = "skip::is_true")]
|
||||
pub slices_clip_to_artboard: bool,
|
||||
}
|
||||
|
||||
fn ppi72() -> f64 {
|
||||
|
|
@ -569,6 +578,8 @@ impl Document {
|
|||
last_view: None,
|
||||
color_profiles: ColorProfiles::default(),
|
||||
extra: Default::default(),
|
||||
slices: vec![],
|
||||
slices_clip_to_artboard: true,
|
||||
};
|
||||
let id = d.alloc_id();
|
||||
d.layers.push(Arc::new(Node::layer(id, "Layer 1", LayerColor::Preset(0))));
|
||||
|
|
@ -590,6 +601,7 @@ impl Document {
|
|||
for l in &self.layers {
|
||||
l.walk(&mut |n| max = max.max(n.id.0));
|
||||
}
|
||||
max = self.slices.iter().fold(max, |m, s| m.max(s.id.0));
|
||||
self.next_id = self.next_id.max(max + 1);
|
||||
}
|
||||
|
||||
|
|
@ -1018,5 +1030,7 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests_slices;
|
||||
#[cfg(test)]
|
||||
mod tests_units;
|
||||
|
|
|
|||
|
|
@ -331,6 +331,10 @@ pub struct Node {
|
|||
/// Attributes panel: centre point display, image map, URL and note (`None`: all defaults).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub attrs: Option<Box<ObjectAttributes>>,
|
||||
/// Object → Slice → Make: the object is an object slice (its slice follows its bounds) with
|
||||
/// these options.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub slice: Option<Box<crate::SliceOptions>>,
|
||||
}
|
||||
|
||||
/// Opacity mask: the luminance of the mask art sets the object's opacity (white = opaque).
|
||||
|
|
@ -378,6 +382,7 @@ impl Node {
|
|||
kind,
|
||||
graphic_style: None,
|
||||
attrs: None,
|
||||
slice: None,
|
||||
}
|
||||
}
|
||||
pub fn path(id: NodeId, path: PathData, appearance: Appearance) -> Self {
|
||||
|
|
|
|||
|
|
@ -24,6 +24,11 @@ pub struct Selection {
|
|||
/// art selected). Any other selection change clears it.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub target: Option<NodeId>,
|
||||
/// Slices selected with the Slice Selection tool: user slice ids and the ids of objects whose
|
||||
/// object slice is selected (see [`crate::slices`]). Any change to the object selection
|
||||
/// clears them.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub slices: Vec<NodeId>,
|
||||
}
|
||||
|
||||
impl Selection {
|
||||
|
|
@ -41,6 +46,7 @@ impl Selection {
|
|||
self.anchors.clear();
|
||||
self.key = None;
|
||||
self.target = None;
|
||||
self.slices.clear();
|
||||
}
|
||||
pub fn set(&mut self, ids: impl IntoIterator<Item = NodeId>) {
|
||||
self.clear();
|
||||
|
|
@ -50,18 +56,29 @@ impl Selection {
|
|||
}
|
||||
pub fn add(&mut self, id: NodeId) {
|
||||
self.target = None;
|
||||
self.slices.clear();
|
||||
if !self.objects.contains(&id) {
|
||||
self.objects.push(id);
|
||||
}
|
||||
}
|
||||
pub fn remove(&mut self, id: NodeId) {
|
||||
self.target = None;
|
||||
self.slices.clear();
|
||||
self.objects.retain(|x| *x != id);
|
||||
self.anchors.remove(&id);
|
||||
if self.key == Some(id) {
|
||||
self.key = None;
|
||||
}
|
||||
}
|
||||
/// Select slices `ids` (see [`Selection::slices`]) and nothing else.
|
||||
pub fn set_slices(&mut self, ids: impl IntoIterator<Item = NodeId>) {
|
||||
self.clear();
|
||||
for id in ids {
|
||||
if !self.slices.contains(&id) {
|
||||
self.slices.push(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn toggle(&mut self, id: NodeId) {
|
||||
if self.contains(id) { self.remove(id) } else { self.add(id) }
|
||||
}
|
||||
|
|
@ -79,6 +96,7 @@ impl Selection {
|
|||
if self.target.is_some_and(|t| doc.node(t).is_none()) {
|
||||
self.target = None;
|
||||
}
|
||||
self.slices.retain(|id| doc.is_slice(*id));
|
||||
}
|
||||
/// Target `id` (see [`Selection::target`]): a layer gets its visible, unlocked art selected,
|
||||
/// anything else is selected itself.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,315 @@
|
|||
//! Slices (Object → Slice): rectangles that cut the artwork into the pieces web output saves.
|
||||
//!
|
||||
//! User slices are rectangles of their own ([`Document::slices`]); object slices follow the
|
||||
//! bounds of an object with [`Node::slice`] options; auto slices fill the rest of the slice region
|
||||
//! (the artboards with Clip to Artboard on, else the art and the slices) and are never stored.
|
||||
//! User slice ids come from the document's id counter, so a slice id never names an object and
|
||||
//! one id list can hold both kinds (an object slice goes by its object's id).
|
||||
//!
|
||||
//! Slices are numbered left to right, top to bottom, auto slices included.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use vectorcraft_geom::Rect;
|
||||
|
||||
use crate::{Document, Node, NodeId};
|
||||
|
||||
/// Below this a slice edge or size is no edge or size (points).
|
||||
const EPS: f64 = 1e-6;
|
||||
|
||||
/// Enums whose values have a stable key (params, JSON) and a label (dialogs).
|
||||
macro_rules! keyed {
|
||||
($(#[$m:meta])* $name:ident { $($(#[$vm:meta])* $v:ident = ($key:literal, $label:literal)),+ $(,)? }) => {
|
||||
$(#[$m])*
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum $name {
|
||||
$($(#[$vm])* $v,)+
|
||||
}
|
||||
|
||||
impl $name {
|
||||
pub const ALL: &'static [$name] = &[$($name::$v),+];
|
||||
/// The value params and JSON use (`noImage`).
|
||||
pub fn key(self) -> &'static str {
|
||||
match self {
|
||||
$($name::$v => $key,)+
|
||||
}
|
||||
}
|
||||
/// The dialog label (`No Image`).
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
$($name::$v => $label,)+
|
||||
}
|
||||
}
|
||||
/// The value `s` names: its key or label in any case.
|
||||
pub fn parse(s: &str) -> Option<Self> {
|
||||
let s = s.trim();
|
||||
Self::ALL.iter().copied().find(|v| v.key().eq_ignore_ascii_case(s) || v.label().eq_ignore_ascii_case(s))
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
keyed!(
|
||||
/// Slice Options → Slice Type: what web output puts in the slice's cell.
|
||||
SliceKind {
|
||||
/// The artwork as an image (with a link, alt text and a status message).
|
||||
#[default]
|
||||
Image = ("image", "Image"),
|
||||
/// An empty cell showing `text` (HTML allowed) on the background.
|
||||
NoImage = ("noImage", "No Image"),
|
||||
/// The text of the sliced type object as HTML (object slices of type only).
|
||||
HtmlText = ("htmlText", "HTML Text"),
|
||||
}
|
||||
);
|
||||
|
||||
keyed!(
|
||||
/// Horizontal alignment of a No Image / HTML Text cell's text.
|
||||
CellAlign {
|
||||
#[default]
|
||||
Default = ("default", "Default"),
|
||||
Left = ("left", "Left"),
|
||||
Center = ("center", "Center"),
|
||||
Right = ("right", "Right"),
|
||||
}
|
||||
);
|
||||
|
||||
keyed!(
|
||||
/// Vertical alignment of a No Image / HTML Text cell's text.
|
||||
CellVAlign {
|
||||
#[default]
|
||||
Default = ("default", "Default"),
|
||||
Top = ("top", "Top"),
|
||||
Middle = ("middle", "Middle"),
|
||||
Baseline = ("baseline", "Baseline"),
|
||||
Bottom = ("bottom", "Bottom"),
|
||||
}
|
||||
);
|
||||
|
||||
/// Slice Options: how web output writes a slice.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct SliceOptions {
|
||||
#[serde(skip_serializing_if = "crate::skip::is_default")]
|
||||
pub kind: SliceKind,
|
||||
/// The slice's file name in web output; empty: `<document>_<number>` ([`Document::slice_name`]).
|
||||
#[serde(skip_serializing_if = "String::is_empty")]
|
||||
pub name: String,
|
||||
/// Image: the link the slice's image opens.
|
||||
#[serde(skip_serializing_if = "String::is_empty")]
|
||||
pub url: String,
|
||||
/// Image: the frame the link opens in (`_blank`, `_self`, `_parent`, `_top` or a name).
|
||||
#[serde(skip_serializing_if = "String::is_empty")]
|
||||
pub target: String,
|
||||
/// Image: the browser's status message over the slice.
|
||||
#[serde(skip_serializing_if = "String::is_empty")]
|
||||
pub message: String,
|
||||
/// Image: the alternative text.
|
||||
#[serde(skip_serializing_if = "String::is_empty")]
|
||||
pub alt: String,
|
||||
/// No Image: the text the cell shows (HTML allowed).
|
||||
#[serde(skip_serializing_if = "String::is_empty")]
|
||||
pub text: String,
|
||||
/// The cell's background: empty (none), `matte` or `#rrggbb`.
|
||||
#[serde(skip_serializing_if = "String::is_empty")]
|
||||
pub background: String,
|
||||
#[serde(skip_serializing_if = "crate::skip::is_default")]
|
||||
pub h_align: CellAlign,
|
||||
#[serde(skip_serializing_if = "crate::skip::is_default")]
|
||||
pub v_align: CellVAlign,
|
||||
}
|
||||
|
||||
/// A user slice: a rectangle drawn with the Slice tool or made by an Object → Slice command.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Slice {
|
||||
/// From the document's id counter (never an object's id).
|
||||
pub id: NodeId,
|
||||
pub rect: Rect,
|
||||
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
|
||||
pub options: SliceOptions,
|
||||
}
|
||||
|
||||
/// Where a slice comes from.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum SliceSource {
|
||||
/// A [`Slice`] of the document.
|
||||
User,
|
||||
/// The bounds of an object with slice options.
|
||||
Object,
|
||||
/// Generated over what no other slice covers.
|
||||
Auto,
|
||||
}
|
||||
|
||||
impl SliceSource {
|
||||
pub fn key(self) -> &'static str {
|
||||
match self {
|
||||
SliceSource::User => "user",
|
||||
SliceSource::Object => "object",
|
||||
SliceSource::Auto => "auto",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One slice as laid out: its id (none for auto slices), source, rectangle (clipped to the
|
||||
/// artboards with Clip to Artboard on) and number (1-based).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct SliceArea {
|
||||
pub id: Option<NodeId>,
|
||||
pub source: SliceSource,
|
||||
pub rect: Rect,
|
||||
pub number: usize,
|
||||
}
|
||||
|
||||
/// A rectangle with a size and finite coordinates.
|
||||
pub fn valid_rect(r: Rect) -> bool {
|
||||
[r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite()) && r.width() > EPS && r.height() > EPS
|
||||
}
|
||||
|
||||
impl Document {
|
||||
/// The user slice `id`.
|
||||
pub fn slice(&self, id: NodeId) -> Option<&Slice> {
|
||||
self.slices.iter().find(|s| s.id == id)
|
||||
}
|
||||
pub fn slice_mut(&mut self, id: NodeId) -> Option<&mut Slice> {
|
||||
self.slices.iter_mut().find(|s| s.id == id)
|
||||
}
|
||||
/// Is `id` a user slice or an object with slice options?
|
||||
pub fn is_slice(&self, id: NodeId) -> bool {
|
||||
self.slice(id).is_some() || self.node(id).is_some_and(|n| n.slice.is_some())
|
||||
}
|
||||
/// The options of slice `id` (user or object).
|
||||
pub fn slice_options(&self, id: NodeId) -> Option<&SliceOptions> {
|
||||
match self.slice(id) {
|
||||
Some(s) => Some(&s.options),
|
||||
None => self.node(id)?.slice.as_deref(),
|
||||
}
|
||||
}
|
||||
/// The rectangle of slice `id` before clipping: a user slice's own, an object slice's object's
|
||||
/// visual bounds.
|
||||
pub fn slice_bounds(&self, id: NodeId) -> Option<Rect> {
|
||||
match self.slice(id) {
|
||||
Some(s) => Some(s.rect),
|
||||
None => self.node(id).filter(|n| n.slice.is_some())?.visual_bounds(),
|
||||
}
|
||||
}
|
||||
/// The visible objects with slice options and their visual bounds, in paint order.
|
||||
pub fn object_slices(&self) -> Vec<(NodeId, Rect)> {
|
||||
fn walk(nodes: &[Arc<Node>], out: &mut Vec<(NodeId, Rect)>) {
|
||||
for n in nodes.iter().filter(|n| n.visible) {
|
||||
if n.slice.is_some()
|
||||
&& let Some(b) = n.visual_bounds()
|
||||
{
|
||||
out.push((n.id, b));
|
||||
}
|
||||
if let Some(ch) = n.children() {
|
||||
walk(ch, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut out = vec![];
|
||||
walk(&self.layers, &mut out);
|
||||
out
|
||||
}
|
||||
/// Every slice id: the user slices, then the object slices.
|
||||
pub fn slice_ids(&self) -> Vec<NodeId> {
|
||||
self.slices.iter().map(|s| s.id).chain(self.object_slices().into_iter().map(|(id, _)| id)).collect()
|
||||
}
|
||||
/// The box Clip to Artboard clips slices to: the artboards' bounds.
|
||||
pub fn slice_clip(&self) -> Option<Rect> {
|
||||
self.artboards.iter().map(|a| a.rect).reduce(|a, b| a.union(b))
|
||||
}
|
||||
/// The user and object slices with their rectangles as laid out (clipped to [`Self::slice_clip`]
|
||||
/// when Clip to Artboard is on; one wholly outside stays as it is).
|
||||
fn cut_slices(&self) -> Vec<(NodeId, SliceSource, Rect)> {
|
||||
let clip = self.slices_clip_to_artboard.then(|| self.slice_clip()).flatten();
|
||||
let clipped = |r: Rect| clip.map(|c| c.intersect(r)).filter(|c| valid_rect(*c)).unwrap_or(r);
|
||||
let users = self.slices.iter().map(|s| (s.id, SliceSource::User, s.rect));
|
||||
let objects = self.object_slices().into_iter().map(|(id, r)| (id, SliceSource::Object, r));
|
||||
users.chain(objects).map(|(id, src, r)| (id, src, clipped(r))).collect()
|
||||
}
|
||||
/// The slices as laid out, numbered left to right, top to bottom: the user and object slices
|
||||
/// and the auto slices filling the rest of the region (the artboards with Clip to Artboard on,
|
||||
/// else the visible art and the slices). Empty while the document has no user or object slice.
|
||||
pub fn slice_layout(&self) -> Vec<SliceArea> {
|
||||
let cut = self.cut_slices();
|
||||
if cut.is_empty() {
|
||||
return vec![];
|
||||
}
|
||||
let region = if self.slices_clip_to_artboard { self.slice_clip() } else { None }
|
||||
.or_else(|| cut.iter().fold(self.art_bounds(), |acc, (_, _, r)| vectorcraft_geom::union_opt(acc, Some(*r))));
|
||||
let rects: Vec<Rect> = cut.iter().map(|(_, _, r)| *r).collect();
|
||||
let autos = region.map(|g| auto_slices(g, &rects)).unwrap_or_default();
|
||||
let mut out: Vec<SliceArea> = cut
|
||||
.into_iter()
|
||||
.map(|(id, source, rect)| SliceArea { id: Some(id), source, rect, number: 0 })
|
||||
.chain(autos.into_iter().map(|rect| SliceArea { id: None, source: SliceSource::Auto, rect, number: 0 }))
|
||||
.collect();
|
||||
out.sort_by(|a, b| a.rect.y0.total_cmp(&b.rect.y0).then(a.rect.x0.total_cmp(&b.rect.x0)));
|
||||
for (i, a) in out.iter_mut().enumerate() {
|
||||
a.number = i + 1;
|
||||
}
|
||||
out
|
||||
}
|
||||
/// The name web output gives slice `area`: its options' name, else `<document>_<number>`
|
||||
/// (two digits at least, `Untitled-1_03`).
|
||||
pub fn slice_name(&self, area: &SliceArea) -> String {
|
||||
match area.id.and_then(|id| self.slice_options(id)).filter(|o| !o.name.is_empty()) {
|
||||
Some(o) => o.name.clone(),
|
||||
None => {
|
||||
let stem = self.title.rsplit_once('.').map_or(self.title.as_str(), |(s, _)| s);
|
||||
format!("{stem}_{:02}", area.number)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The auto slices of `region`: rectangles covering what none of `cut` covers, without
|
||||
/// overlapping each other or `cut`. The region is cut into rows at every slice's top and bottom;
|
||||
/// each row's uncovered spans become slices, and a span continuing the one above it (same left and
|
||||
/// right) extends it.
|
||||
pub fn auto_slices(region: Rect, cut: &[Rect]) -> Vec<Rect> {
|
||||
if !valid_rect(region) {
|
||||
return vec![];
|
||||
}
|
||||
let inside: Vec<Rect> = cut.iter().map(|r| region.intersect(*r)).filter(|r| valid_rect(*r)).collect();
|
||||
let mut ys: Vec<f64> = inside.iter().flat_map(|r| [r.y0, r.y1]).chain([region.y0, region.y1]).collect();
|
||||
ys.sort_by(f64::total_cmp);
|
||||
ys.dedup_by(|a, b| (*a - *b).abs() <= EPS);
|
||||
let (mut open, mut done): (Vec<Rect>, Vec<Rect>) = (vec![], vec![]);
|
||||
for band in ys.windows(2) {
|
||||
let [y0, y1] = [band[0], band[1]];
|
||||
let mid = (y0 + y1) / 2.0;
|
||||
let mut spans: Vec<(f64, f64)> = inside.iter().filter(|r| r.y0 < mid && mid < r.y1).map(|r| (r.x0, r.x1)).collect();
|
||||
spans.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
// The uncovered spans of this row.
|
||||
let mut free = vec![];
|
||||
let mut x = region.x0;
|
||||
for (a, b) in spans {
|
||||
if a - x > EPS {
|
||||
free.push((x, a));
|
||||
}
|
||||
x = x.max(b);
|
||||
}
|
||||
if region.x1 - x > EPS {
|
||||
free.push((x, region.x1));
|
||||
}
|
||||
// Spans continuing an open slice extend it; the others close.
|
||||
let mut next = Vec::with_capacity(free.len());
|
||||
for (a, b) in free {
|
||||
match open.iter().position(|r: &Rect| (r.x0 - a).abs() <= EPS && (r.x1 - b).abs() <= EPS) {
|
||||
Some(i) => {
|
||||
let mut r = open.swap_remove(i);
|
||||
r.y1 = y1;
|
||||
next.push(r);
|
||||
}
|
||||
None => next.push(Rect::new(a, y0, b, y1)),
|
||||
}
|
||||
}
|
||||
done.append(&mut open);
|
||||
open = next;
|
||||
}
|
||||
done.append(&mut open);
|
||||
done
|
||||
}
|
||||
|
|
@ -0,0 +1,125 @@
|
|||
//! Slice layout: object slices follow their objects, clipping, numbering and auto slices.
|
||||
|
||||
use proptest::prelude::*;
|
||||
use vectorcraft_geom::{Point, Rect, shapes};
|
||||
|
||||
use crate::slices::auto_slices;
|
||||
use crate::{Appearance, Document, Node, Slice, SliceOptions, SliceSource};
|
||||
|
||||
fn rect(d: &mut Document, r: Rect) -> crate::NodeId {
|
||||
let l = d.layers[0].id;
|
||||
let id = d.alloc_id();
|
||||
d.insert(Some(l), usize::MAX, Node::path(id, shapes::rectangle(r), Appearance::basic(crate::color::Paint::None, crate::color::Paint::None, 0.0)))
|
||||
.unwrap();
|
||||
id
|
||||
}
|
||||
|
||||
fn user(d: &mut Document, r: Rect) -> crate::NodeId {
|
||||
let id = d.alloc_id();
|
||||
d.slices.push(Slice { id, rect: r, options: SliceOptions::default() });
|
||||
id
|
||||
}
|
||||
|
||||
/// How many of `rects` hold point `p` (strictly inside).
|
||||
fn holders(rects: &[Rect], p: Point) -> usize {
|
||||
rects.iter().filter(|r| r.x0 < p.x && p.x < r.x1 && r.y0 < p.y && p.y < r.y1).count()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_object_slice_follows_its_object_and_numbers_run_by_rows() {
|
||||
let mut d = Document::new(300.0, 200.0);
|
||||
let a = rect(&mut d, Rect::new(100.0, 50.0, 200.0, 150.0));
|
||||
assert!(d.slice_layout().is_empty(), "no slices, no auto slices");
|
||||
d.node_mut(a).unwrap().slice = Some(Box::default());
|
||||
let lay = d.slice_layout();
|
||||
let own = lay.iter().find(|s| s.id == Some(a)).unwrap();
|
||||
assert_eq!((own.source, own.rect), (SliceSource::Object, Rect::new(100.0, 50.0, 200.0, 150.0)));
|
||||
// Three rows: the band above, the object's row (left, object, right) and the band below.
|
||||
assert_eq!(lay.len(), 5);
|
||||
assert_eq!(lay.iter().map(|s| s.number).collect::<Vec<_>>(), [1, 2, 3, 4, 5]);
|
||||
assert_eq!(own.number, 3);
|
||||
assert_eq!(d.slice_name(own), "Untitled-1_03");
|
||||
// Moving the object moves its slice.
|
||||
d.node_mut(a).unwrap().transform(vectorcraft_geom::Affine::translate((-50.0, 0.0)), crate::Scaling::default());
|
||||
assert_eq!(d.slice_layout().iter().find(|s| s.id == Some(a)).unwrap().rect, Rect::new(50.0, 50.0, 150.0, 150.0));
|
||||
// Hidden objects have no slice.
|
||||
d.node_mut(a).unwrap().visible = false;
|
||||
assert!(d.slice_layout().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clip_to_artboard_clips_slices_and_off_covers_the_art() {
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
let s = user(&mut d, Rect::new(50.0, 50.0, 150.0, 150.0));
|
||||
let own = |d: &Document| d.slice_layout().into_iter().find(|a| a.id == Some(s)).unwrap().rect;
|
||||
assert_eq!(own(&d), Rect::new(50.0, 50.0, 100.0, 100.0));
|
||||
let lay = d.slice_layout();
|
||||
let autos: Vec<Rect> = lay.iter().filter(|a| a.source == SliceSource::Auto).map(|a| a.rect).collect();
|
||||
assert_eq!(autos.iter().map(|r| r.area()).sum::<f64>(), 100.0 * 100.0 - 50.0 * 50.0);
|
||||
d.slices_clip_to_artboard = false;
|
||||
assert_eq!(own(&d), Rect::new(50.0, 50.0, 150.0, 150.0));
|
||||
// The region is now the slice itself (no art): nothing left for auto slices.
|
||||
assert_eq!(d.slice_layout().len(), 1);
|
||||
rect(&mut d, Rect::new(0.0, 0.0, 20.0, 20.0));
|
||||
let total: f64 = d.slice_layout().iter().map(|a| a.rect.area()).sum();
|
||||
assert_eq!(total, 150.0 * 150.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slices_survive_a_native_round_trip_and_old_files_load() {
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
let a = rect(&mut d, Rect::new(10.0, 10.0, 20.0, 20.0));
|
||||
d.node_mut(a).unwrap().slice = Some(Box::new(SliceOptions { kind: crate::SliceKind::NoImage, text: "<b>hi</b>".into(), ..Default::default() }));
|
||||
let s = user(&mut d, Rect::new(30.0, 30.0, 60.0, 60.0));
|
||||
d.slice_mut(s).unwrap().options.url = "https://example.com".into();
|
||||
d.slices_clip_to_artboard = false;
|
||||
let json = serde_json::to_string(&d).unwrap();
|
||||
let back: Document = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(back.slices, d.slices);
|
||||
assert_eq!(back.node(a).unwrap().slice, d.node(a).unwrap().slice);
|
||||
assert!(!back.slices_clip_to_artboard);
|
||||
// A document without slices writes none of the new keys (older readers see what they knew).
|
||||
let plain = serde_json::to_string(&Document::new(10.0, 10.0)).unwrap();
|
||||
assert!(!plain.contains("slice"), "{plain}");
|
||||
let old: Document = serde_json::from_str(&plain).unwrap();
|
||||
assert!(old.slices.is_empty() && old.slices_clip_to_artboard);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_slices_fill_around_a_slice_in_rows() {
|
||||
let autos = auto_slices(Rect::new(0.0, 0.0, 90.0, 90.0), &[Rect::new(30.0, 30.0, 60.0, 60.0)]);
|
||||
let mut got: Vec<(f64, f64, f64, f64)> = autos.iter().map(|r| (r.x0, r.y0, r.x1, r.y1)).collect();
|
||||
got.sort_by(|a, b| a.1.total_cmp(&b.1).then(a.0.total_cmp(&b.0)));
|
||||
assert_eq!(got, [(0.0, 0.0, 90.0, 30.0), (0.0, 30.0, 30.0, 60.0), (60.0, 30.0, 90.0, 60.0), (0.0, 60.0, 90.0, 90.0)]);
|
||||
// Spans that continue down one column merge.
|
||||
let autos = auto_slices(Rect::new(0.0, 0.0, 90.0, 90.0), &[Rect::new(30.0, 0.0, 60.0, 40.0), Rect::new(30.0, 40.0, 60.0, 90.0)]);
|
||||
assert_eq!(autos.len(), 2, "{autos:?}");
|
||||
}
|
||||
|
||||
fn arb_rect() -> impl Strategy<Value = Rect> {
|
||||
(-20.0..120.0f64, -20.0..120.0f64, 1.0..80.0f64, 1.0..80.0f64).prop_map(|(x, y, w, h)| Rect::new(x, y, x + w, y + h))
|
||||
}
|
||||
|
||||
proptest! {
|
||||
/// Auto slices never overlap each other or the slices, and with the slices they cover the
|
||||
/// whole region.
|
||||
#[test]
|
||||
fn auto_slices_cover_without_overlap(cut in prop::collection::vec(arb_rect(), 0..8)) {
|
||||
let region = Rect::new(0.0, 0.0, 100.0, 100.0);
|
||||
let autos = auto_slices(region, &cut);
|
||||
for r in &autos {
|
||||
prop_assert!(r.x0 >= region.x0 && r.y0 >= region.y0 && r.x1 <= region.x1 && r.y1 <= region.y1, "{r:?} leaves the region");
|
||||
}
|
||||
// Sample off every edge (edges are at integers plus random fractions; the samples sit at
|
||||
// odd 1/8ths, which a random float edge practically never hits).
|
||||
for i in 0..50 {
|
||||
for j in 0..50 {
|
||||
let p = Point::new(i as f64 * 2.0 + 0.125 * 7.0, j as f64 * 2.0 + 0.125 * 3.0);
|
||||
let in_cut = holders(&cut, p) > 0;
|
||||
let in_auto = holders(&autos, p);
|
||||
prop_assert!(in_auto <= 1, "{p:?} in {in_auto} auto slices");
|
||||
prop_assert!(in_cut != (in_auto == 1), "{p:?}: in a slice {in_cut}, in an auto slice {in_auto}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -28,6 +28,10 @@ pub struct MenuState {
|
|||
pub new_masks_inverted: bool,
|
||||
/// Magic Wand panel settings.
|
||||
pub wand: super::wand::WandSettings,
|
||||
/// View → Hide Slices.
|
||||
pub slices_hidden: bool,
|
||||
/// View → Lock Slices.
|
||||
pub slices_locked: bool,
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ pub mod prefscmds;
|
|||
pub mod rasterfx;
|
||||
mod recolor;
|
||||
mod select;
|
||||
pub(crate) mod slices;
|
||||
mod stroke;
|
||||
mod style;
|
||||
pub mod stylelib;
|
||||
|
|
@ -225,6 +226,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
|||
v.extend(pdfcmds::specs());
|
||||
v.extend(fileio::dxf::specs());
|
||||
v.extend(package::specs());
|
||||
v.extend(slices::specs());
|
||||
v
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,717 @@
|
|||
//! Object → Slice, View → Hide/Lock Slices and Select → Object → Slices.
|
||||
//!
|
||||
//! The model is in [`vectorcraft_doc::slices`]: user slices live in [`Document::slices`], object
|
||||
//! slices are objects with slice options, auto slices are laid out around them. The Object → Slice
|
||||
//! commands act on the selected slices: those the Slice Selection tool (or `object.slice.select`)
|
||||
//! selected and the selected objects that are object slices. Commands that take slice ids take
|
||||
//! user slice ids and the ids of objects with an object slice.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::Paint;
|
||||
use vectorcraft_doc::slices::valid_rect;
|
||||
use vectorcraft_doc::{Appearance, CellAlign, CellVAlign, Document, Node, NodeKind, Slice, SliceKind, SliceOptions};
|
||||
use vectorcraft_geom::{Rect, shapes};
|
||||
|
||||
use super::*;
|
||||
|
||||
/// The most slices one Divide Slices makes.
|
||||
const MAX_PIECES: usize = 1000;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
"object.slice.make",
|
||||
"Make",
|
||||
["Object", "Slice"],
|
||||
None,
|
||||
"{ids?} make each selected object (or ids) an object slice: its slice follows the object's bounds → {ids: the new object slices}",
|
||||
has_selection,
|
||||
make
|
||||
),
|
||||
cmd!(
|
||||
"object.slice.release",
|
||||
"Release",
|
||||
["Object", "Slice"],
|
||||
None,
|
||||
"{} release the selected slices: an object slice leaves its object as it was, a user slice becomes an unpainted rectangle; the objects end up selected → {ids}",
|
||||
slices_targeted,
|
||||
release
|
||||
),
|
||||
cmd!(
|
||||
"object.slice.fromGuides",
|
||||
"Create from Guides",
|
||||
["Object", "Slice"],
|
||||
None,
|
||||
"{} replace the user slices with the grid the ruler guides cut the artboards into (two vertical and two horizontal guides give 9) → {ids}",
|
||||
has_doc,
|
||||
from_guides
|
||||
),
|
||||
cmd!(
|
||||
"object.slice.fromSelection",
|
||||
"Create from Selection",
|
||||
["Object", "Slice"],
|
||||
None,
|
||||
"{ids?} a user slice over the visual bounds of the selected objects (or ids), selected → {id}",
|
||||
has_selection,
|
||||
from_selection
|
||||
),
|
||||
cmd!(
|
||||
"object.slice.duplicate",
|
||||
"Duplicate Slice",
|
||||
["Object", "Slice"],
|
||||
None,
|
||||
"{dx?: pt (default 10), dy?: pt (default 10)} copy the selected slices as user slices with their options, offset by dx, dy; the copies are selected → {ids}",
|
||||
slices_targeted,
|
||||
duplicate
|
||||
),
|
||||
cmd!(
|
||||
"object.slice.combine",
|
||||
"Combine Slices",
|
||||
["Object", "Slice"],
|
||||
None,
|
||||
"{} replace the two or more selected slices with one user slice over their bounds (the first one's options; object slices are released) → {id}",
|
||||
slices_targeted,
|
||||
combine
|
||||
),
|
||||
cmd!(
|
||||
"object.slice.divide",
|
||||
"Divide Slices…",
|
||||
["Object", "Slice"],
|
||||
None,
|
||||
"{rows?: slices down, rowHeight?: pt per slice, columns?: slices across, columnWidth?: pt per slice} cut each selected slice into a grid of user slices (evenly by count, or by size with a smaller last one; an object slice is released); the pieces are selected → {ids}",
|
||||
slices_targeted,
|
||||
divide
|
||||
),
|
||||
cmd!(
|
||||
"object.slice.deleteAll",
|
||||
"Delete All",
|
||||
["Object", "Slice"],
|
||||
None,
|
||||
"{} delete every user slice and release every object slice → {count}",
|
||||
doc_has_slices,
|
||||
delete_all
|
||||
),
|
||||
cmd!(
|
||||
"object.slice.options",
|
||||
"Slice Options…",
|
||||
["Object", "Slice"],
|
||||
None,
|
||||
"{kind?: \"image\"|\"noImage\"|\"htmlText\" (object slices of type only), name?, url?, target?, message?, alt?: (image), text?: cell text, HTML allowed (noImage), background?: \"\"|\"matte\"|\"#rrggbb\", hAlign?: \"default\"|\"left\"|\"center\"|\"right\", vAlign?: \"default\"|\"top\"|\"middle\"|\"baseline\"|\"bottom\"} set the selected slices' options as one undo step; no options: read them → the first slice's {kind, name, url, target, message, alt, text, background, hAlign, vAlign, htmlText: whether HTML Text applies}",
|
||||
slices_targeted,
|
||||
options
|
||||
),
|
||||
cmd!(
|
||||
"object.slice.clipToArtboard",
|
||||
"Clip to Artboard",
|
||||
["Object", "Slice"],
|
||||
None,
|
||||
"{on?: bool} toggle (or set) Clip to Artboard: on, slices are clipped to the artboards and auto slices fill them; off, auto slices cover the art and the slices → {on}",
|
||||
has_doc,
|
||||
clip_to_artboard
|
||||
),
|
||||
cmd!(query "view.slices.hide", "Hide Slices", ["View"], None, "{hidden?: bool} toggle (or set) whether the canvas hides the slices (session view state) → {hidden}", has_doc, hide),
|
||||
cmd!(
|
||||
query "view.slices.lock",
|
||||
"Lock Slices",
|
||||
["View"],
|
||||
None,
|
||||
"{locked?: bool} toggle (or set) the slice lock: locked slices can't be selected or edited with the Slice Selection tool → {locked}",
|
||||
has_doc,
|
||||
lock
|
||||
),
|
||||
cmd!(
|
||||
query "slice.list",
|
||||
"List Slices",
|
||||
[],
|
||||
None,
|
||||
"{} the slices as laid out, numbered left to right and top to bottom → {slices: [{number, id (null for auto slices), source: \"user\"|\"object\"|\"auto\", name, x, y, width, height, options, selected}], clipToArtboard, hidden, locked}",
|
||||
has_doc,
|
||||
list
|
||||
),
|
||||
cmd!("select.object.slices", "Slices", ["Select", "Object"], None, "{} select every user and object slice → {count}", has_doc, select_all),
|
||||
cmd!(
|
||||
"object.slice.create",
|
||||
"Create Slice",
|
||||
[],
|
||||
None,
|
||||
"{x, y, width, height} a user slice over that rectangle, selected (the Slice tool's drag) → {id}",
|
||||
has_doc,
|
||||
create
|
||||
),
|
||||
cmd!(
|
||||
"object.slice.setRect",
|
||||
"Set Slice Rectangle",
|
||||
[],
|
||||
None,
|
||||
"{id: user slice, x, y, width, height} move or resize a user slice (an object slice follows its object) → {id}",
|
||||
has_doc,
|
||||
set_rect
|
||||
),
|
||||
cmd!(
|
||||
"object.slice.move",
|
||||
"Move Slices",
|
||||
[],
|
||||
None,
|
||||
"{slices?: [id…] (default: the selected slices), dx, dy} move the slices: user slices move, object slices move their objects → {ids}",
|
||||
has_doc,
|
||||
move_slices
|
||||
),
|
||||
cmd!(
|
||||
"object.slice.delete",
|
||||
"Delete Slices",
|
||||
[],
|
||||
None,
|
||||
"{slices?: [id…] (default: the selected slices)} delete the user slices and release the object slices (their objects stay) → {count}",
|
||||
has_doc,
|
||||
delete
|
||||
),
|
||||
cmd!(
|
||||
"object.slice.select",
|
||||
"Select Slices",
|
||||
[],
|
||||
None,
|
||||
"{slices?: [id…] (user slice ids or ids of objects with an object slice; none: deselect the slices), toggle?: bool (Shift: add the unselected ones, drop the selected ones)} select slices as the Slice Selection tool does; the objects are deselected → {selected: [id…]}",
|
||||
has_doc,
|
||||
select
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// View → Hide Slices.
|
||||
pub fn slices_hidden(&self) -> bool {
|
||||
self.menu.slices_hidden
|
||||
}
|
||||
/// View → Lock Slices.
|
||||
pub fn slices_locked(&self) -> bool {
|
||||
self.menu.slices_locked
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- targets and params ----------
|
||||
|
||||
/// The slices a command acts on: `slices` (ids that must all be slices), else the selected ones.
|
||||
fn slice_targets(s: &Session, p: &Value, cmd: &str) -> Result<Vec<NodeId>> {
|
||||
let st = s.doc()?;
|
||||
match ids_param(p, "slices") {
|
||||
Some(ids) => match ids.iter().find(|id| !st.doc.is_slice(**id)) {
|
||||
Some(id) => Err(bad(cmd, format!("{id} is not a slice"))),
|
||||
None => Ok(dedup(ids)),
|
||||
},
|
||||
None => Ok(selected_slices(&st.doc, &st.selection)),
|
||||
}
|
||||
}
|
||||
|
||||
/// The selected slices: those the Slice Selection tool selected and the selected objects that are
|
||||
/// object slices.
|
||||
fn selected(s: &Session) -> Result<Vec<NodeId>> {
|
||||
let st = s.doc()?;
|
||||
Ok(selected_slices(&st.doc, &st.selection))
|
||||
}
|
||||
|
||||
fn selected_slices(d: &Document, sel: &vectorcraft_doc::Selection) -> Vec<NodeId> {
|
||||
let objects = sel.objects.iter().filter(|id| d.node(**id).is_some_and(|n| n.slice.is_some()));
|
||||
dedup(sel.slices.iter().chain(objects).copied().collect())
|
||||
}
|
||||
|
||||
fn dedup(mut ids: Vec<NodeId>) -> Vec<NodeId> {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
ids.retain(|id| seen.insert(*id));
|
||||
ids
|
||||
}
|
||||
|
||||
fn slices_targeted(s: &Session) -> std::result::Result<(), String> {
|
||||
let st = s.active().ok_or("no document open")?;
|
||||
if selected_slices(&st.doc, &st.selection).is_empty() { Err("no slice selected".into()) } else { Ok(()) }
|
||||
}
|
||||
|
||||
fn doc_has_slices(s: &Session) -> std::result::Result<(), String> {
|
||||
let st = s.active().ok_or("no document open")?;
|
||||
if st.doc.slices.is_empty() && object_slice_ids(&st.doc).is_empty() { Err("the document has no slices".into()) } else { Ok(()) }
|
||||
}
|
||||
|
||||
/// Every object with slice options, hidden ones included.
|
||||
fn object_slice_ids(d: &Document) -> Vec<NodeId> {
|
||||
let mut ids = vec![];
|
||||
d.walk(|n| {
|
||||
if n.slice.is_some() {
|
||||
ids.push(n.id);
|
||||
}
|
||||
});
|
||||
ids
|
||||
}
|
||||
|
||||
/// A rectangle a slice can have: a size, and coordinates on the canvas.
|
||||
pub(crate) fn slice_rect_ok(r: Rect) -> bool {
|
||||
valid_rect(r) && [r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.abs() <= crate::MAX_COORD)
|
||||
}
|
||||
|
||||
/// A new user slice over `rect` with `options`.
|
||||
pub(crate) fn add_slice(d: &mut Document, rect: Rect, options: SliceOptions) -> NodeId {
|
||||
let id = d.alloc_id();
|
||||
d.slices.push(Slice { id, rect, options });
|
||||
id
|
||||
}
|
||||
|
||||
/// Remove slice `id`: a user slice goes, an object slice's object loses its slice options.
|
||||
pub(crate) fn remove_slice(d: &mut Document, id: NodeId) {
|
||||
let before = d.slices.len();
|
||||
d.slices.retain(|s| s.id != id);
|
||||
if d.slices.len() == before
|
||||
&& let Some(n) = d.node_mut(id)
|
||||
{
|
||||
n.slice = None;
|
||||
}
|
||||
}
|
||||
|
||||
fn ids_json(ids: &[NodeId]) -> Value {
|
||||
json!(ids.iter().map(|i| i.0).collect::<Vec<_>>())
|
||||
}
|
||||
|
||||
/// Slice options as the commands give them back (every field).
|
||||
pub(crate) fn options_json(o: &SliceOptions) -> Value {
|
||||
json!({
|
||||
"kind": o.kind.key(), "name": o.name, "url": o.url, "target": o.target, "message": o.message, "alt": o.alt,
|
||||
"text": o.text, "background": o.background, "hAlign": o.h_align.key(), "vAlign": o.v_align.key(),
|
||||
})
|
||||
}
|
||||
|
||||
// ---------- Object → Slice ----------
|
||||
|
||||
fn make(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = match ids_param(p, "ids") {
|
||||
Some(ids) => ids,
|
||||
None => super::edit::selected_roots(s)?,
|
||||
};
|
||||
let d = &s.doc()?.doc;
|
||||
let made: Vec<NodeId> = ids.into_iter().filter(|id| d.node(*id).is_some_and(|n| !n.is_layer() && n.slice.is_none())).collect();
|
||||
if made.is_empty() {
|
||||
return Ok(json!({ "ids": [] }));
|
||||
}
|
||||
s.edit("Make Slice", |d, _| {
|
||||
for id in &made {
|
||||
if let Some(n) = d.node_mut(*id) {
|
||||
n.slice = Some(Box::default());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({ "ids": ids_json(&made) }))
|
||||
}
|
||||
|
||||
fn release(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let targets = selected(s)?;
|
||||
let parent = s.doc()?.insertion_parent();
|
||||
let ids = s.edit("Release Slice", |d, sel| {
|
||||
let mut out = vec![];
|
||||
for id in &targets {
|
||||
match d.slice(*id).map(|s| s.rect) {
|
||||
// A user slice becomes an unpainted rectangle.
|
||||
Some(r) => {
|
||||
d.slices.retain(|s| s.id != *id);
|
||||
let parent = parent.ok_or_else(|| EngineError::Other("Release: no layer to put the rectangle in".into()))?;
|
||||
let rid = d.alloc_id();
|
||||
d.insert(Some(parent), usize::MAX, Node::path(rid, shapes::rectangle(r), Appearance::basic(Paint::None, Paint::None, 1.0)))?;
|
||||
out.push(rid);
|
||||
}
|
||||
None => {
|
||||
remove_slice(d, *id);
|
||||
out.push(*id);
|
||||
}
|
||||
}
|
||||
}
|
||||
sel.set(out.iter().copied());
|
||||
Ok(out)
|
||||
})?;
|
||||
Ok(json!({ "ids": ids_json(&ids) }))
|
||||
}
|
||||
|
||||
fn from_guides(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let d = &s.doc()?.doc;
|
||||
let region = d.slice_clip().ok_or_else(|| EngineError::Other("Create from Guides: the document has no artboard".into()))?;
|
||||
let cuts = |vertical: bool, lo: f64, hi: f64| -> Vec<f64> {
|
||||
let mut v: Vec<f64> = d.guides.iter().filter(|g| g.vertical == vertical && g.pos > lo && g.pos < hi).map(|g| g.pos).collect();
|
||||
v.extend([lo, hi]);
|
||||
v.sort_by(f64::total_cmp);
|
||||
v.dedup();
|
||||
v
|
||||
};
|
||||
let (xs, ys) = (cuts(true, region.x0, region.x1), cuts(false, region.y0, region.y1));
|
||||
let rects: Vec<Rect> =
|
||||
ys.windows(2).flat_map(|y| xs.windows(2).map(move |x| Rect::new(x[0], y[0], x[1], y[1]))).filter(|r| valid_rect(*r)).collect();
|
||||
if rects.len() > MAX_PIECES {
|
||||
return Err(EngineError::Other(format!("Create from Guides: the guides make more than {MAX_PIECES} slices")));
|
||||
}
|
||||
let ids = s.edit("Create Slices from Guides", |d, _| {
|
||||
d.slices.clear();
|
||||
Ok(rects.iter().map(|r| add_slice(d, *r, SliceOptions::default())).collect::<Vec<_>>())
|
||||
})?;
|
||||
Ok(json!({ "ids": ids_json(&ids) }))
|
||||
}
|
||||
|
||||
fn from_selection(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = targets(s, p)?;
|
||||
let r = s.doc()?.doc.bounds_of(&ids, true).filter(|r| slice_rect_ok(*r));
|
||||
let r = r.ok_or_else(|| EngineError::Other("Create from Selection: the selection has no area".into()))?;
|
||||
let id = s.edit("Create Slice from Selection", |d, sel| {
|
||||
let id = add_slice(d, r, SliceOptions::default());
|
||||
sel.set_slices([id]);
|
||||
Ok(id)
|
||||
})?;
|
||||
Ok(json!({ "id": id.0 }))
|
||||
}
|
||||
|
||||
fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let targets = selected(s)?;
|
||||
let (dx, dy) = (f64_or(p, "dx", 10.0), f64_or(p, "dy", 10.0));
|
||||
let ids = s.edit("Duplicate Slice", |d, sel| {
|
||||
let mut out = vec![];
|
||||
for id in &targets {
|
||||
let (Some(r), Some(o)) = (d.slice_bounds(*id), d.slice_options(*id).cloned()) else { continue };
|
||||
let r = r + vectorcraft_geom::Vec2::new(dx, dy);
|
||||
if !slice_rect_ok(r) {
|
||||
return Err(bad("object.slice.duplicate", "the copy would leave the canvas"));
|
||||
}
|
||||
out.push(add_slice(d, r, o));
|
||||
}
|
||||
sel.set_slices(out.iter().copied());
|
||||
Ok(out)
|
||||
})?;
|
||||
Ok(json!({ "ids": ids_json(&ids) }))
|
||||
}
|
||||
|
||||
fn combine(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let targets = selected(s)?;
|
||||
if targets.len() < 2 {
|
||||
return Err(bad("object.slice.combine", "select two or more slices to combine"));
|
||||
}
|
||||
let id = s.edit("Combine Slices", |d, sel| {
|
||||
let r = targets.iter().filter_map(|id| d.slice_bounds(*id)).reduce(|a, b| a.union(b));
|
||||
let r = r.ok_or_else(|| EngineError::Other("Combine Slices: the slices have no bounds".into()))?;
|
||||
let o = targets.first().and_then(|id| d.slice_options(*id)).cloned().unwrap_or_default();
|
||||
for id in &targets {
|
||||
remove_slice(d, *id);
|
||||
}
|
||||
let id = add_slice(d, r, o);
|
||||
sel.set_slices([id]);
|
||||
Ok(id)
|
||||
})?;
|
||||
Ok(json!({ "id": id.0 }))
|
||||
}
|
||||
|
||||
/// The cuts that split `lo..hi` into `count` equal parts or parts `size` long (the last one
|
||||
/// shorter), whichever `count`/`size` gives; `None` for neither.
|
||||
fn splits(lo: f64, hi: f64, count: Option<u64>, size: Option<f64>) -> std::result::Result<Vec<f64>, String> {
|
||||
let len = hi - lo;
|
||||
let n = match (count, size) {
|
||||
(Some(n), _) => n,
|
||||
(None, Some(sz)) if sz.is_finite() && sz > 0.0 => (len / sz - 1e-9).ceil().max(1.0).min(MAX_PIECES as f64 + 1.0) as u64,
|
||||
(None, Some(_)) => return Err("a slice size must be a positive number of points".into()),
|
||||
(None, None) => 1,
|
||||
};
|
||||
if n == 0 || n as usize > MAX_PIECES {
|
||||
return Err(format!("divide into 1 to {MAX_PIECES} slices"));
|
||||
}
|
||||
let step = match (count, size) {
|
||||
(None, Some(sz)) => sz,
|
||||
_ => len / n as f64,
|
||||
};
|
||||
Ok((0..=n).map(|i| if i == n { hi } else { lo + step * i as f64 }).collect())
|
||||
}
|
||||
|
||||
fn divide(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "object.slice.divide";
|
||||
let targets = selected(s)?;
|
||||
let count = |k: &str| p.get(k).map(|v| v.as_u64().ok_or_else(|| bad(C, format!("`{k}` must be a whole number")))).transpose();
|
||||
let size = |k: &str| p.get(k).map(|v| v.as_f64().ok_or_else(|| bad(C, format!("`{k}` must be a number of points")))).transpose();
|
||||
let (rows, row_h, cols, col_w) = (count("rows")?, size("rowHeight")?, count("columns")?, size("columnWidth")?);
|
||||
if rows.is_none() && row_h.is_none() && cols.is_none() && col_w.is_none() {
|
||||
return Err(bad(C, "give rows, rowHeight, columns or columnWidth"));
|
||||
}
|
||||
let ids = s.edit("Divide Slices", |d, sel| {
|
||||
let mut out = vec![];
|
||||
for id in &targets {
|
||||
let (Some(r), Some(o)) = (d.slice_bounds(*id), d.slice_options(*id).cloned()) else { continue };
|
||||
let ys = splits(r.y0, r.y1, rows, row_h).map_err(|e| bad(C, e))?;
|
||||
let xs = splits(r.x0, r.x1, cols, col_w).map_err(|e| bad(C, e))?;
|
||||
if (ys.len() - 1) * (xs.len() - 1) + out.len() > MAX_PIECES {
|
||||
return Err(bad(C, format!("that makes more than {MAX_PIECES} slices")));
|
||||
}
|
||||
remove_slice(d, *id);
|
||||
for (i, rect) in ys.windows(2).flat_map(|y| xs.windows(2).map(move |x| Rect::new(x[0], y[0], x[1], y[1]))).enumerate() {
|
||||
// The pieces keep the options; only the first keeps the name.
|
||||
let o = if i == 0 { o.clone() } else { SliceOptions { name: String::new(), ..o.clone() } };
|
||||
out.push(add_slice(d, rect, o));
|
||||
}
|
||||
}
|
||||
sel.set_slices(out.iter().copied());
|
||||
Ok(out)
|
||||
})?;
|
||||
Ok(json!({ "ids": ids_json(&ids) }))
|
||||
}
|
||||
|
||||
fn delete_all(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let n = s.edit("Delete All Slices", |d, _| {
|
||||
let objects = object_slice_ids(d);
|
||||
let n = d.slices.len() + objects.len();
|
||||
d.slices.clear();
|
||||
for id in objects {
|
||||
remove_slice(d, id);
|
||||
}
|
||||
Ok(n)
|
||||
})?;
|
||||
Ok(json!({ "count": n }))
|
||||
}
|
||||
|
||||
/// The Slice Options edits a command's params ask for.
|
||||
#[derive(Default)]
|
||||
struct Edits {
|
||||
kind: Option<SliceKind>,
|
||||
text: Vec<(&'static str, String)>,
|
||||
background: Option<String>,
|
||||
h_align: Option<CellAlign>,
|
||||
v_align: Option<CellVAlign>,
|
||||
}
|
||||
|
||||
impl Edits {
|
||||
const TEXT: [&'static str; 6] = ["name", "url", "target", "message", "alt", "text"];
|
||||
|
||||
fn parse(p: &Value) -> Result<Self> {
|
||||
const C: &str = "object.slice.options";
|
||||
let text = |k: &str| -> Result<Option<String>> {
|
||||
p.get(k).map(|v| v.as_str().map(str::to_string).ok_or_else(|| bad(C, format!("`{k}` must be a string")))).transpose()
|
||||
};
|
||||
let named = |k: &str| text(k).map(|v| v.map(|s| s.trim().to_string()));
|
||||
let mut e = Edits {
|
||||
kind: named("kind")?.map(|k| SliceKind::parse(&k).ok_or_else(|| bad(C, "`kind` must be image, noImage or htmlText"))).transpose()?,
|
||||
h_align: named("hAlign")?
|
||||
.map(|k| CellAlign::parse(&k).ok_or_else(|| bad(C, "`hAlign` must be default, left, center or right")))
|
||||
.transpose()?,
|
||||
v_align: named("vAlign")?
|
||||
.map(|k| CellVAlign::parse(&k).ok_or_else(|| bad(C, "`vAlign` must be default, top, middle, baseline or bottom")))
|
||||
.transpose()?,
|
||||
..Default::default()
|
||||
};
|
||||
e.background = named("background")?
|
||||
.map(|b| match b.to_ascii_lowercase().as_str() {
|
||||
"" | "none" => Ok(String::new()),
|
||||
"matte" => Ok("matte".to_string()),
|
||||
_ => vectorcraft_color::Color::from_hex(&b)
|
||||
.map(|c| c.to_hex())
|
||||
.ok_or_else(|| bad(C, "`background` must be \"\", \"matte\" or #rrggbb")),
|
||||
})
|
||||
.transpose()?;
|
||||
for k in Self::TEXT {
|
||||
// Names, links and frames are trimmed; the cell text and alt text keep their spaces.
|
||||
let v = if matches!(k, "text" | "alt" | "message") { text(k)? } else { named(k)? };
|
||||
if let Some(v) = v {
|
||||
e.text.push((k, v));
|
||||
}
|
||||
}
|
||||
Ok(e)
|
||||
}
|
||||
|
||||
fn is_empty(&self) -> bool {
|
||||
self.kind.is_none() && self.text.is_empty() && self.background.is_none() && self.h_align.is_none() && self.v_align.is_none()
|
||||
}
|
||||
|
||||
fn apply(&self, o: &mut SliceOptions) {
|
||||
if let Some(k) = self.kind {
|
||||
o.kind = k;
|
||||
}
|
||||
for (k, v) in &self.text {
|
||||
let field = match *k {
|
||||
"name" => &mut o.name,
|
||||
"url" => &mut o.url,
|
||||
"target" => &mut o.target,
|
||||
"message" => &mut o.message,
|
||||
"alt" => &mut o.alt,
|
||||
_ => &mut o.text,
|
||||
};
|
||||
field.clone_from(v);
|
||||
}
|
||||
if let Some(b) = &self.background {
|
||||
o.background.clone_from(b);
|
||||
}
|
||||
if let Some(a) = self.h_align {
|
||||
o.h_align = a;
|
||||
}
|
||||
if let Some(a) = self.v_align {
|
||||
o.v_align = a;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Can slice `id` be HTML Text: an object slice of a type object?
|
||||
fn html_text_allowed(d: &Document, id: NodeId) -> bool {
|
||||
d.slice(id).is_none() && d.node(id).is_some_and(|n| matches!(n.kind, NodeKind::Text(_)))
|
||||
}
|
||||
|
||||
fn options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let targets = selected(s)?;
|
||||
let edits = Edits::parse(p)?;
|
||||
let html_ok = |d: &Document| targets.iter().all(|id| html_text_allowed(d, *id));
|
||||
if edits.kind == Some(SliceKind::HtmlText) && !html_ok(&s.doc()?.doc) {
|
||||
return Err(bad("object.slice.options", "HTML Text applies to object slices of type only"));
|
||||
}
|
||||
if !edits.is_empty() {
|
||||
s.edit("Slice Options", |d, _| {
|
||||
for id in &targets {
|
||||
let o = match d.slice_mut(*id) {
|
||||
Some(sl) => Some(&mut sl.options),
|
||||
None => d.node_mut(*id).and_then(|n| n.slice.as_deref_mut()),
|
||||
};
|
||||
if let Some(o) = o {
|
||||
edits.apply(o);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
let d = &s.doc()?.doc;
|
||||
let o = targets.first().and_then(|id| d.slice_options(*id)).cloned().unwrap_or_default();
|
||||
let mut v = options_json(&o);
|
||||
v["htmlText"] = json!(html_ok(d));
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
fn clip_to_artboard(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let on = p.get("on").and_then(Value::as_bool).unwrap_or(!s.doc()?.doc.slices_clip_to_artboard);
|
||||
if on != s.doc()?.doc.slices_clip_to_artboard {
|
||||
s.edit("Clip to Artboard", |d, _| {
|
||||
d.slices_clip_to_artboard = on;
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
Ok(json!({ "on": on }))
|
||||
}
|
||||
|
||||
fn hide(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let v = p.get("hidden").and_then(Value::as_bool).unwrap_or(!s.menu.slices_hidden);
|
||||
s.menu.slices_hidden = v;
|
||||
Ok(json!({ "hidden": v }))
|
||||
}
|
||||
|
||||
fn lock(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let v = p.get("locked").and_then(Value::as_bool).unwrap_or(!s.menu.slices_locked);
|
||||
s.menu.slices_locked = v;
|
||||
Ok(json!({ "locked": v }))
|
||||
}
|
||||
|
||||
fn list(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let d = &st.doc;
|
||||
let slices: Vec<Value> = d
|
||||
.slice_layout()
|
||||
.iter()
|
||||
.map(|a| {
|
||||
let o = a.id.and_then(|id| d.slice_options(id)).cloned().unwrap_or_default();
|
||||
json!({
|
||||
"number": a.number, "id": a.id.map(|i| i.0), "source": a.source.key(), "name": d.slice_name(a),
|
||||
"x": a.rect.x0, "y": a.rect.y0, "width": a.rect.width(), "height": a.rect.height(),
|
||||
"options": options_json(&o), "selected": a.id.is_some_and(|id| st.selection.slices.contains(&id)),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Ok(json!({ "slices": slices, "clipToArtboard": d.slices_clip_to_artboard, "hidden": s.menu.slices_hidden, "locked": s.menu.slices_locked }))
|
||||
}
|
||||
|
||||
fn select_all(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let ids = s.doc()?.doc.slice_ids();
|
||||
let n = ids.len();
|
||||
s.select(|_, sel| sel.set_slices(ids))?;
|
||||
Ok(json!({ "count": n }))
|
||||
}
|
||||
|
||||
// ---------- the Slice and Slice Selection tools ----------
|
||||
|
||||
/// `{x, y, width, height}` as a rectangle a slice can have.
|
||||
fn rect_param(p: &Value, cmd: &str) -> Result<Rect> {
|
||||
let (x, y) = (f64_req(p, "x", cmd)?, f64_req(p, "y", cmd)?);
|
||||
let r = Rect::new(x, y, x + f64_req(p, "width", cmd)?, y + f64_req(p, "height", cmd)?).abs();
|
||||
if slice_rect_ok(r) { Ok(r) } else { Err(bad(cmd, "the rectangle needs a size and must lie on the canvas")) }
|
||||
}
|
||||
|
||||
fn create(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let r = rect_param(p, "object.slice.create")?;
|
||||
let id = s.edit("Slice", |d, sel| {
|
||||
let id = add_slice(d, r, SliceOptions::default());
|
||||
sel.set_slices([id]);
|
||||
Ok(id)
|
||||
})?;
|
||||
Ok(json!({ "id": id.0 }))
|
||||
}
|
||||
|
||||
fn set_rect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "object.slice.setRect";
|
||||
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?;
|
||||
let r = rect_param(p, C)?;
|
||||
let d = &s.doc()?.doc;
|
||||
if d.slice(id).is_none() {
|
||||
return Err(bad(C, if d.is_slice(id) { format!("{id} is an object slice: it follows its object") } else { format!("{id} is not a slice") }));
|
||||
}
|
||||
s.edit("Resize Slice", |d, _| {
|
||||
if let Some(sl) = d.slice_mut(id) {
|
||||
sl.rect = r;
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({ "id": id.0 }))
|
||||
}
|
||||
|
||||
fn move_slices(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "object.slice.move";
|
||||
let targets = slice_targets(s, p, C)?;
|
||||
let v = vectorcraft_geom::Vec2::new(f64_req(p, "dx", C)?, f64_req(p, "dy", C)?);
|
||||
if !(v.x.is_finite() && v.y.is_finite()) {
|
||||
return Err(bad(C, "dx and dy must be numbers"));
|
||||
}
|
||||
s.edit("Move Slice", |d, _| {
|
||||
for id in &targets {
|
||||
match d.slice_mut(*id) {
|
||||
Some(sl) if slice_rect_ok(sl.rect + v) => sl.rect = sl.rect + v,
|
||||
Some(_) => return Err(bad(C, "the slice would leave the canvas")),
|
||||
None => {
|
||||
if let Some(n) = d.node_mut(*id) {
|
||||
n.transform(vectorcraft_geom::Affine::translate(v), vectorcraft_doc::Scaling::default());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({ "ids": ids_json(&targets) }))
|
||||
}
|
||||
|
||||
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let targets = slice_targets(s, p, "object.slice.delete")?;
|
||||
if targets.is_empty() {
|
||||
return Ok(json!({ "count": 0 }));
|
||||
}
|
||||
s.edit("Delete Slice", |d, _| {
|
||||
for id in &targets {
|
||||
remove_slice(d, *id);
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({ "count": targets.len() }))
|
||||
}
|
||||
|
||||
fn select(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = slice_targets(s, &json!({ "slices": p.get("slices").cloned().unwrap_or(json!([])) }), "object.slice.select")?;
|
||||
let toggle = bool_or(p, "toggle", false);
|
||||
s.select(|_, sel| {
|
||||
let mut now = if toggle { sel.slices.clone() } else { vec![] };
|
||||
for id in ids {
|
||||
match now.iter().position(|x| *x == id) {
|
||||
Some(i) if toggle => {
|
||||
now.remove(i);
|
||||
}
|
||||
Some(_) => {}
|
||||
None => now.push(id),
|
||||
}
|
||||
}
|
||||
sel.set_slices(now);
|
||||
})?;
|
||||
Ok(json!({ "selected": ids_json(&s.doc()?.selection.slices) }))
|
||||
}
|
||||
|
|
@ -1116,6 +1116,8 @@ mod tests_save;
|
|||
#[cfg(test)]
|
||||
mod tests_scalestrokes;
|
||||
#[cfg(test)]
|
||||
mod tests_slices;
|
||||
#[cfg(test)]
|
||||
mod tests_strokegeom;
|
||||
#[cfg(test)]
|
||||
mod tests_strokegradient;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,329 @@
|
|||
//! Object → Slice, View → Hide/Lock Slices, Select → Object → Slices and `slice.list`.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::SliceKind;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 300, "height": 300})).unwrap();
|
||||
// Unstroked art: visual bounds are the geometry.
|
||||
s.paint.stroke = vectorcraft_color::Paint::None;
|
||||
s
|
||||
}
|
||||
|
||||
fn run(s: &mut Session, id: &str, p: Value) -> Value {
|
||||
s.execute(id, &p).unwrap_or_else(|e| panic!("{id}: {e}"))
|
||||
}
|
||||
|
||||
fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> u64 {
|
||||
run(s, "shape.rectangle", json!({"x": x, "y": y, "width": w, "height": h}))["id"].as_u64().unwrap()
|
||||
}
|
||||
|
||||
/// `slice.list`'s slices.
|
||||
fn slices(s: &mut Session) -> Vec<Value> {
|
||||
run(s, "slice.list", json!({}))["slices"].as_array().unwrap().clone()
|
||||
}
|
||||
|
||||
/// The laid-out rectangle of slice `id` as [x, y, width, height].
|
||||
fn rect_of(s: &mut Session, id: u64) -> [f64; 4] {
|
||||
let v = slices(s).into_iter().find(|v| v["id"] == json!(id)).unwrap();
|
||||
["x", "y", "width", "height"].map(|k| v[k].as_f64().unwrap())
|
||||
}
|
||||
|
||||
fn user_count(s: &Session) -> usize {
|
||||
s.doc().unwrap().doc.slices.len()
|
||||
}
|
||||
|
||||
/// A user slice over `[x, y, w, h]`, made from a temporary rectangle.
|
||||
fn user_slice(s: &mut Session, r: [f64; 4]) -> u64 {
|
||||
let tmp = rect(s, r[0], r[1], r[2], r[3]);
|
||||
let id = run(s, "object.slice.fromSelection", json!({"ids": [tmp]}))["id"].as_u64().unwrap();
|
||||
run(s, "select.set", json!({"ids": [tmp]}));
|
||||
run(s, "edit.clear", json!({}));
|
||||
id
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_object_slice_follows_its_object_and_releases_back_to_it() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 100.0, 100.0, 50.0, 40.0);
|
||||
let made = run(&mut s, "object.slice.make", json!({}));
|
||||
assert_eq!(made["ids"], json!([a]));
|
||||
assert_eq!(rect_of(&mut s, a), [100.0, 100.0, 50.0, 40.0]);
|
||||
let list = slices(&mut s);
|
||||
assert!(list.iter().any(|v| v["source"] == "auto"), "auto slices fill the artboard");
|
||||
let area: f64 = list.iter().map(|v| v["width"].as_f64().unwrap() * v["height"].as_f64().unwrap()).sum();
|
||||
assert_eq!(area, 300.0 * 300.0);
|
||||
// Moving the object moves its slice; one undo step brings it back.
|
||||
run(&mut s, "object.move", json!({"dx": 20, "dy": 10}));
|
||||
assert_eq!(rect_of(&mut s, a), [120.0, 110.0, 50.0, 40.0]);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(rect_of(&mut s, a), [100.0, 100.0, 50.0, 40.0]);
|
||||
// Make again changes nothing; Release leaves the object.
|
||||
assert_eq!(run(&mut s, "object.slice.make", json!({}))["ids"], json!([]));
|
||||
assert_eq!(run(&mut s, "object.slice.release", json!({}))["ids"], json!([a]));
|
||||
assert!(s.doc().unwrap().doc.node(NodeId(a)).unwrap().slice.is_none());
|
||||
assert!(slices(&mut s).is_empty());
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!(s.doc().unwrap().doc.node(NodeId(a)).unwrap().slice.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn releasing_a_user_slice_leaves_an_unpainted_rectangle() {
|
||||
let mut s = session();
|
||||
let id = user_slice(&mut s, [10.0, 20.0, 30.0, 40.0]);
|
||||
run(&mut s, "select.object.slices", json!({}));
|
||||
let r = run(&mut s, "object.slice.release", json!({}));
|
||||
assert_eq!(s.doc().unwrap().doc.slice(NodeId(id)), None);
|
||||
let rid = r["ids"][0].as_u64().unwrap();
|
||||
let d = &s.doc().unwrap().doc;
|
||||
assert!(d.slices.is_empty());
|
||||
let n = d.node(NodeId(rid)).unwrap();
|
||||
assert_eq!(n.geometric_bounds().unwrap(), vectorcraft_geom::Rect::new(10.0, 20.0, 40.0, 60.0));
|
||||
assert!(matches!(n.appearance.fill_paint(), vectorcraft_color::Paint::None));
|
||||
assert_eq!(s.doc().unwrap().selection.objects, vec![NodeId(rid)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guides_give_nine_slices() {
|
||||
let mut s = session();
|
||||
for (vertical, pos) in [(true, 100.0), (true, 200.0), (false, 50.0), (false, 250.0), (true, 400.0)] {
|
||||
run(&mut s, "guide.add", json!({"vertical": vertical, "pos": pos}));
|
||||
}
|
||||
user_slice(&mut s, [0.0, 0.0, 10.0, 10.0]);
|
||||
let ids = run(&mut s, "object.slice.fromGuides", json!({}))["ids"].as_array().unwrap().len();
|
||||
assert_eq!(ids, 9, "the guide off the artboard cuts nothing");
|
||||
assert_eq!(user_count(&s), 9, "the earlier slice is replaced");
|
||||
let list = slices(&mut s);
|
||||
assert_eq!(list.len(), 9, "the grid covers the artboard: no auto slices");
|
||||
assert_eq!(list[4]["x"], 100.0);
|
||||
assert_eq!(list[4]["width"], 100.0);
|
||||
assert_eq!(list[4]["height"], 200.0);
|
||||
assert_eq!(list.iter().map(|v| v["number"].as_u64().unwrap()).collect::<Vec<_>>(), (1..=9).collect::<Vec<_>>());
|
||||
// One undo step takes the grid back.
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(user_count(&s), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn divide_two_by_three_and_by_size() {
|
||||
let mut s = session();
|
||||
user_slice(&mut s, [0.0, 0.0, 90.0, 60.0]);
|
||||
run(&mut s, "select.object.slices", json!({}));
|
||||
run(&mut s, "object.slice.options", json!({"name": "hero", "url": "https://example.com"}));
|
||||
let pieces = run(&mut s, "object.slice.divide", json!({"rows": 2, "columns": 3}))["ids"].as_array().unwrap().clone();
|
||||
assert_eq!(pieces.len(), 6);
|
||||
let d = &s.doc().unwrap().doc;
|
||||
let rects: Vec<_> = d.slices.iter().map(|sl| (sl.rect.x0, sl.rect.y0, sl.rect.width(), sl.rect.height())).collect();
|
||||
assert_eq!(
|
||||
rects,
|
||||
[
|
||||
(0.0, 0.0, 30.0, 30.0),
|
||||
(30.0, 0.0, 30.0, 30.0),
|
||||
(60.0, 0.0, 30.0, 30.0),
|
||||
(0.0, 30.0, 30.0, 30.0),
|
||||
(30.0, 30.0, 30.0, 30.0),
|
||||
(60.0, 30.0, 30.0, 30.0)
|
||||
]
|
||||
);
|
||||
assert!(d.slices.iter().all(|sl| sl.options.url == "https://example.com"));
|
||||
assert_eq!(d.slices.iter().filter(|sl| sl.options.name == "hero").count(), 1, "only the first piece keeps the name");
|
||||
assert_eq!(s.doc().unwrap().selection.slices.len(), 6, "the pieces are selected");
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(user_count(&s), 1);
|
||||
// By size: 25 pt rows of a 60 pt slice: 25, 25 and 10.
|
||||
run(&mut s, "select.object.slices", json!({}));
|
||||
run(&mut s, "object.slice.divide", json!({"rowHeight": 25}));
|
||||
let hs: Vec<f64> = s.doc().unwrap().doc.slices.iter().map(|sl| sl.rect.height()).collect();
|
||||
assert_eq!(hs, [25.0, 25.0, 10.0]);
|
||||
assert!(s.execute("object.slice.divide", &json!({"rows": 0})).is_err());
|
||||
assert!(s.execute("object.slice.divide", &json!({"columns": 5000})).is_err());
|
||||
assert!(s.execute("object.slice.divide", &json!({})).is_err(), "nothing to divide by");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn combine_and_duplicate() {
|
||||
let mut s = session();
|
||||
user_slice(&mut s, [0.0, 0.0, 20.0, 20.0]);
|
||||
run(&mut s, "select.object.slices", json!({}));
|
||||
assert!(s.execute("object.slice.combine", &json!({})).is_err(), "two or more");
|
||||
let b = rect(&mut s, 50.0, 40.0, 10.0, 10.0);
|
||||
run(&mut s, "object.slice.make", json!({}));
|
||||
run(&mut s, "select.object.slices", json!({}));
|
||||
let c = run(&mut s, "object.slice.combine", json!({}))["id"].as_u64().unwrap();
|
||||
assert_eq!(rect_of(&mut s, c), [0.0, 0.0, 60.0, 50.0]);
|
||||
assert_eq!(user_count(&s), 1);
|
||||
assert!(s.doc().unwrap().doc.node(NodeId(b)).unwrap().slice.is_none(), "the object slice was released");
|
||||
assert_eq!(s.doc().unwrap().selection.slices, vec![NodeId(c)]);
|
||||
let dup = run(&mut s, "object.slice.duplicate", json!({}))["ids"][0].as_u64().unwrap();
|
||||
assert_eq!(rect_of(&mut s, dup), [10.0, 10.0, 60.0, 50.0]);
|
||||
assert_eq!(s.doc().unwrap().selection.slices, vec![NodeId(dup)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slice_options_set_read_and_undo_in_one_step() {
|
||||
let mut s = session();
|
||||
user_slice(&mut s, [0.0, 0.0, 50.0, 50.0]);
|
||||
run(&mut s, "select.object.slices", json!({}));
|
||||
let o = run(&mut s, "object.slice.options", json!({}));
|
||||
assert_eq!((o["kind"].clone(), o["htmlText"].clone()), (json!("image"), json!(false)));
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
let o = run(
|
||||
&mut s,
|
||||
"object.slice.options",
|
||||
json!({"kind": "No Image", "name": " cell ", "text": " <b>Hi</b>", "background": "#FF0000", "hAlign": "center", "vAlign": "bottom"}),
|
||||
);
|
||||
assert_eq!(
|
||||
o,
|
||||
json!({"kind": "noImage", "name": "cell", "url": "", "target": "", "message": "", "alt": "", "text": " <b>Hi</b>", "background": "#ff0000", "hAlign": "center", "vAlign": "bottom", "htmlText": false})
|
||||
);
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1);
|
||||
assert_eq!(slices(&mut s)[0]["name"], "cell");
|
||||
assert!(s.execute("object.slice.options", &json!({"kind": "htmlText"})).is_err(), "HTML Text is for type");
|
||||
assert!(s.execute("object.slice.options", &json!({"background": "pink-ish"})).is_err());
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.doc().unwrap().doc.slices[0].options, Default::default());
|
||||
// An object slice of type can be HTML Text.
|
||||
let t = run(&mut s, "text.create", json!({"x": 10, "y": 100, "text": "Hello"}))["id"].as_u64().unwrap();
|
||||
run(&mut s, "object.slice.make", json!({"ids": [t]}));
|
||||
run(&mut s, "select.set", json!({"ids": [t]}));
|
||||
let o = run(&mut s, "object.slice.options", json!({"kind": "htmlText"}));
|
||||
assert_eq!((o["kind"].clone(), o["htmlText"].clone()), (json!("htmlText"), json!(true)));
|
||||
assert_eq!(s.doc().unwrap().doc.node(NodeId(t)).unwrap().slice.as_ref().unwrap().kind, SliceKind::HtmlText);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clip_to_artboard_hide_lock_select_and_delete_all() {
|
||||
let mut s = session();
|
||||
let id = user_slice(&mut s, [250.0, 250.0, 100.0, 100.0]);
|
||||
assert_eq!(rect_of(&mut s, id), [250.0, 250.0, 50.0, 50.0], "clipped to the artboard");
|
||||
assert_eq!(run(&mut s, "object.slice.clipToArtboard", json!({}))["on"], false);
|
||||
assert_eq!(rect_of(&mut s, id), [250.0, 250.0, 100.0, 100.0]);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!(s.doc().unwrap().doc.slices_clip_to_artboard);
|
||||
assert_eq!(run(&mut s, "object.slice.clipToArtboard", json!({"on": true}))["on"], true);
|
||||
|
||||
assert_eq!(run(&mut s, "view.slices.hide", json!({}))["hidden"], true);
|
||||
assert!(s.slices_hidden());
|
||||
assert_eq!(run(&mut s, "view.slices.lock", json!({"locked": true}))["locked"], true);
|
||||
let l = run(&mut s, "slice.list", json!({}));
|
||||
assert_eq!((l["hidden"].clone(), l["locked"].clone(), l["clipToArtboard"].clone()), (json!(true), json!(true), json!(true)));
|
||||
run(&mut s, "view.slices.hide", json!({"hidden": false}));
|
||||
assert!(!s.slices_hidden());
|
||||
|
||||
let a = rect(&mut s, 10.0, 10.0, 10.0, 10.0);
|
||||
run(&mut s, "object.slice.make", json!({}));
|
||||
assert_eq!(run(&mut s, "select.object.slices", json!({}))["count"], 2);
|
||||
let sel = &s.doc().unwrap().selection;
|
||||
assert_eq!((sel.slices.clone(), sel.objects.is_empty()), (vec![NodeId(id), NodeId(a)], true));
|
||||
assert!(slices(&mut s).iter().filter(|v| v["selected"] == true).count() == 2);
|
||||
// Selecting objects drops the slice selection.
|
||||
run(&mut s, "select.all", json!({}));
|
||||
assert!(s.doc().unwrap().selection.slices.is_empty());
|
||||
|
||||
assert_eq!(run(&mut s, "object.slice.deleteAll", json!({}))["count"], 2);
|
||||
assert_eq!(user_count(&s), 0);
|
||||
assert!(s.doc().unwrap().doc.node(NodeId(a)).unwrap().slice.is_none());
|
||||
assert!(s.execute("object.slice.deleteAll", &json!({})).is_err(), "nothing to delete");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slices_survive_a_native_round_trip() {
|
||||
let mut s = session();
|
||||
user_slice(&mut s, [100.0, 100.0, 40.0, 40.0]);
|
||||
run(&mut s, "select.object.slices", json!({}));
|
||||
run(&mut s, "object.slice.options", json!({"kind": "noImage", "text": "x"}));
|
||||
let a = rect(&mut s, 10.0, 10.0, 10.0, 10.0);
|
||||
run(&mut s, "object.slice.make", json!({}));
|
||||
run(&mut s, "object.slice.options", json!({"alt": "logo"}));
|
||||
run(&mut s, "object.slice.clipToArtboard", json!({"on": false}));
|
||||
let doc = s.doc().unwrap().doc.clone();
|
||||
vectorcraft_testkit::invariants::check_native_roundtrip_exact(&doc).unwrap();
|
||||
let back = vectorcraft_format::load(&vectorcraft_format::save(&doc, false)).unwrap();
|
||||
assert_eq!(back.slices, doc.slices);
|
||||
assert_eq!(back.node(NodeId(a)).unwrap().slice.as_ref().unwrap().alt, "logo");
|
||||
assert!(!back.slices_clip_to_artboard);
|
||||
// A copy of an object slice is an object slice too, under its own id.
|
||||
run(&mut s, "select.set", json!({"ids": [a]}));
|
||||
run(&mut s, "edit.duplicate", json!({}));
|
||||
assert_eq!(s.doc().unwrap().doc.object_slices().len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slice_tool_gestures_are_one_undo_step_each() {
|
||||
use vectorcraft_tools::{Mods, PointerEvent, PointerKind as K, ToolKey};
|
||||
let mut s = session();
|
||||
let view = ViewInfo { smart_guides: false, ..Default::default() };
|
||||
let undo = |s: &Session| s.doc().unwrap().history.undo.len();
|
||||
let gesture = |s: &mut Session, pts: &[(K, f64, f64)]| {
|
||||
for (k, x, y) in pts {
|
||||
s.pointer(&PointerEvent::new(*k, *x, *y), view).unwrap();
|
||||
}
|
||||
};
|
||||
s.select_tool("slice", view).unwrap();
|
||||
let n = undo(&s);
|
||||
gesture(&mut s, &[(K::Down, 10.0, 10.0), (K::Drag, 50.0, 30.0), (K::Drag, 110.0, 60.0), (K::Up, 110.0, 60.0)]);
|
||||
assert_eq!(undo(&s), n + 1);
|
||||
let id = s.doc().unwrap().doc.slices[0].id;
|
||||
assert_eq!(s.doc().unwrap().doc.slices.len(), 1, "the previews left one slice");
|
||||
assert_eq!(rect_of(&mut s, id.0), [10.0, 10.0, 100.0, 50.0]);
|
||||
assert_eq!(s.doc().unwrap().selection.slices, vec![id]);
|
||||
|
||||
// Slice Selection: drag the slice, then its bottom-right handle, then Delete.
|
||||
s.select_tool("sliceSelection", view).unwrap();
|
||||
gesture(&mut s, &[(K::Down, 20.0, 20.0), (K::Drag, 30.0, 40.0), (K::Drag, 40.0, 50.0), (K::Up, 40.0, 50.0)]);
|
||||
assert_eq!(undo(&s), n + 2);
|
||||
assert_eq!(rect_of(&mut s, id.0), [30.0, 40.0, 100.0, 50.0]);
|
||||
gesture(&mut s, &[(K::Down, 130.0, 90.0), (K::Drag, 140.0, 95.0), (K::Drag, 150.0, 100.0), (K::Up, 150.0, 100.0)]);
|
||||
assert_eq!(undo(&s), n + 3);
|
||||
assert_eq!(rect_of(&mut s, id.0), [30.0, 40.0, 120.0, 60.0]);
|
||||
assert!(s.tool_claims_key(ToolKey::Delete, view));
|
||||
s.tool_key(ToolKey::Delete, Mods::default(), view).unwrap();
|
||||
assert!(s.doc().unwrap().doc.slices.is_empty());
|
||||
assert_eq!(undo(&s), n + 4);
|
||||
// Undo walks the gestures back one at a time.
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(rect_of(&mut s, id.0), [30.0, 40.0, 120.0, 60.0]);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(rect_of(&mut s, id.0), [30.0, 40.0, 100.0, 50.0]);
|
||||
|
||||
// An object slice moves its object; locked slices stay put.
|
||||
let a = rect(&mut s, 200.0, 200.0, 40.0, 40.0);
|
||||
run(&mut s, "object.slice.make", json!({}));
|
||||
gesture(&mut s, &[(K::Down, 220.0, 220.0), (K::Drag, 230.0, 220.0), (K::Drag, 240.0, 230.0), (K::Up, 240.0, 230.0)]);
|
||||
assert_eq!(s.doc().unwrap().doc.node(NodeId(a)).unwrap().geometric_bounds().unwrap().x0, 220.0);
|
||||
run(&mut s, "view.slices.lock", json!({"locked": true}));
|
||||
let before = undo(&s);
|
||||
gesture(&mut s, &[(K::Down, 250.0, 240.0), (K::Drag, 280.0, 260.0), (K::Up, 280.0, 260.0)]);
|
||||
assert_eq!(undo(&s), before);
|
||||
// The journal replays the gestures as commands.
|
||||
let journal: Vec<String> = s.journal.iter().map(|(c, _)| c.clone()).collect();
|
||||
for c in ["object.slice.create", "object.slice.move", "object.slice.setRect", "object.slice.delete", "object.slice.select"] {
|
||||
assert!(journal.iter().any(|j| j == c), "{c} journaled: {journal:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slice_commands_check_their_params() {
|
||||
let mut s = session();
|
||||
assert!(s.execute("object.slice.create", &json!({"x": 0, "y": 0, "width": 0, "height": 10})).is_err(), "no area");
|
||||
assert!(s.execute("object.slice.create", &json!({"x": 1e9, "y": 0, "width": 10, "height": 10})).is_err(), "off the canvas");
|
||||
let id = run(&mut s, "object.slice.create", json!({"x": 50, "y": 60, "width": -20, "height": 10}))["id"].as_u64().unwrap();
|
||||
assert_eq!(rect_of(&mut s, id), [30.0, 60.0, 20.0, 10.0], "a negative size draws the other way");
|
||||
run(&mut s, "object.slice.setRect", json!({"id": id, "x": 0, "y": 0, "width": 5, "height": 5}));
|
||||
assert_eq!(rect_of(&mut s, id), [0.0, 0.0, 5.0, 5.0]);
|
||||
let a = rect(&mut s, 100.0, 100.0, 10.0, 10.0);
|
||||
run(&mut s, "object.slice.make", json!({}));
|
||||
assert!(
|
||||
s.execute("object.slice.setRect", &json!({"id": a, "x": 0, "y": 0, "width": 5, "height": 5})).is_err(),
|
||||
"object slices follow their object"
|
||||
);
|
||||
assert!(s.execute("object.slice.move", &json!({"slices": [12345], "dx": 1, "dy": 1})).is_err(), "not a slice");
|
||||
assert_eq!(run(&mut s, "object.slice.select", json!({"slices": [id, a]}))["selected"], json!([id, a]));
|
||||
assert_eq!(run(&mut s, "object.slice.select", json!({"slices": [a], "toggle": true}))["selected"], json!([id]));
|
||||
assert_eq!(run(&mut s, "object.slice.delete", json!({"slices": [a]}))["count"], 1);
|
||||
assert!(s.doc().unwrap().doc.node(NodeId(a)).is_some(), "deleting an object slice keeps its object");
|
||||
assert_eq!(run(&mut s, "object.slice.select", json!({}))["selected"], json!([]));
|
||||
}
|
||||
|
|
@ -89,6 +89,8 @@ impl Session {
|
|||
unit,
|
||||
stroke_unit,
|
||||
paste_plain_text: self.prefs.paste_text_formatting == "plain",
|
||||
slices_hidden: self.menu.slices_hidden,
|
||||
slices_locked: self.menu.slices_locked,
|
||||
};
|
||||
let tool = &mut self.tool;
|
||||
match crate::guard::catch_panic(|| f(tool.as_mut(), &cx)) {
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ pub mod pen;
|
|||
pub mod place;
|
||||
pub mod select;
|
||||
pub mod shape;
|
||||
pub mod slice;
|
||||
pub mod symbolism;
|
||||
pub mod text;
|
||||
pub mod xform;
|
||||
|
|
@ -209,6 +210,10 @@ pub struct ToolContext<'a> {
|
|||
/// Clipboard Handling → When pasting text: Keep Plain Text. Text pasted while typing takes
|
||||
/// the style at the caret, even the text the Type tool copied with its formatting.
|
||||
pub paste_plain_text: bool,
|
||||
/// View → Hide Slices: the Slice Selection tool can't pick hidden slices.
|
||||
pub slices_hidden: bool,
|
||||
/// View → Lock Slices: the Slice Selection tool leaves locked slices alone.
|
||||
pub slices_locked: bool,
|
||||
}
|
||||
|
||||
impl ToolContext<'_> {
|
||||
|
|
@ -286,6 +291,10 @@ pub enum Cursor {
|
|||
AddStop,
|
||||
/// A gradient stop dragged off the bar: releasing deletes it.
|
||||
RemoveStop,
|
||||
/// The Slice tool: a crosshair with a blade.
|
||||
Slice,
|
||||
/// The Slice Selection tool: the arrow with a slice badge.
|
||||
SliceSelect,
|
||||
}
|
||||
|
||||
/// A tool state machine.
|
||||
|
|
@ -347,6 +356,7 @@ pub fn create(id: &str) -> Box<dyn Tool> {
|
|||
.or_else(|| meshblend::create(other))
|
||||
.or_else(|| distort::create(other))
|
||||
.or_else(|| extra::create(other))
|
||||
.or_else(|| slice::create(other))
|
||||
.unwrap_or_else(|| Box::new(NoopTool(tool_info(other).map(|t| t.id).unwrap_or("selection")))),
|
||||
}
|
||||
}
|
||||
|
|
@ -409,6 +419,8 @@ pub(crate) mod testutil {
|
|||
unit: Unit::Points,
|
||||
stroke_unit: Unit::Points,
|
||||
paste_plain_text: false,
|
||||
slices_hidden: false,
|
||||
slices_locked: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -80,12 +80,7 @@ impl ShapeTool {
|
|||
("shape.line".into(), json!({ "x1": a.x, "y1": a.y, "x2": b.x, "y2": b.y }))
|
||||
}
|
||||
_ => {
|
||||
let mut d = p - start;
|
||||
if m.shift {
|
||||
let s = d.x.abs().max(d.y.abs());
|
||||
d = vectorcraft_geom::Vec2::new(s * d.x.signum(), s * d.y.signum());
|
||||
}
|
||||
let r = if m.alt { Rect::from_points(start - d, start + d) } else { Rect::from_points(start, start + d) };
|
||||
let r = drag_rect(start, p, m);
|
||||
let cmd = if self.id == "ellipse" { "shape.ellipse" } else { "shape.rectangle" };
|
||||
let mut v = json!({ "x": r.x0, "y": r.y0, "width": r.width(), "height": r.height() });
|
||||
if self.id == "roundedRectangle" {
|
||||
|
|
@ -97,6 +92,17 @@ impl ShapeTool {
|
|||
}
|
||||
}
|
||||
|
||||
/// The rectangle a drag from `start` to `p` draws: Shift makes it a square, Alt draws it from its
|
||||
/// centre.
|
||||
pub(crate) fn drag_rect(start: Point, p: Point, m: Mods) -> Rect {
|
||||
let mut d = p - start;
|
||||
if m.shift {
|
||||
let s = d.x.abs().max(d.y.abs());
|
||||
d = vectorcraft_geom::Vec2::new(s * d.x.signum(), s * d.y.signum());
|
||||
}
|
||||
if m.alt { Rect::from_points(start - d, start + d) } else { Rect::from_points(start, start + d) }
|
||||
}
|
||||
|
||||
impl Tool for ShapeTool {
|
||||
fn id(&self) -> &'static str {
|
||||
self.id
|
||||
|
|
|
|||
|
|
@ -0,0 +1,432 @@
|
|||
//! Slice tool (Shift+K) and Slice Selection tool.
|
||||
//!
|
||||
//! The Slice tool drags out a user slice (Shift: a square, Alt: from its centre), snapping to the
|
||||
//! ruler guides and, like the drawing tools, to art: the drag previews `object.slice.create` and
|
||||
//! commits it as one undo step. The Slice Selection tool clicks a slice to select it (Shift adds or
|
||||
//! drops it), drags the selected slices to move them (`object.slice.move`: an object slice moves
|
||||
//! its object), drags a selected user slice's edge or corner handle to resize it
|
||||
//! (`object.slice.setRect`), opens Slice Options on a double click and deletes the selected slices
|
||||
//! with Delete. Hidden or locked slices (View menu) can't be picked.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{NodeId, SliceSource};
|
||||
use vectorcraft_geom::{Point, Rect, Vec2};
|
||||
|
||||
use crate::bbox::{Handle, hit_handle, move_delta, scale_for_drag};
|
||||
use crate::shape::drag_rect;
|
||||
use crate::xform::{BLUE, polygon, rect_corners};
|
||||
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey, json_ids};
|
||||
|
||||
/// Create a slice tool by id (None = not one of ours).
|
||||
pub fn create(id: &str) -> Option<Box<dyn Tool>> {
|
||||
Some(match id {
|
||||
"slice" => Box::new(SliceTool::default()),
|
||||
"sliceSelection" => Box::new(SliceSelectionTool::default()),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
fn rect_json(r: Rect) -> Value {
|
||||
json!({ "x": r.x0, "y": r.y0, "width": r.width(), "height": r.height() })
|
||||
}
|
||||
|
||||
/// A rectangle with a size (a drag along one axis has none).
|
||||
fn has_area(r: Rect) -> bool {
|
||||
r.width() > 1e-6 && r.height() > 1e-6
|
||||
}
|
||||
|
||||
/// Snap `p` as the drawing tools do, then to the nearest ruler guide within reach (guides win).
|
||||
fn snap(cx: &ToolContext, p: Point) -> (Point, Vec<Overlay>) {
|
||||
let (mut q, ov) = crate::guides::snap_draw(cx, p, &[]);
|
||||
let tol = cx.tol(5.0);
|
||||
let nearest = |vertical: bool, v: f64| {
|
||||
cx.doc
|
||||
.guides
|
||||
.iter()
|
||||
.filter(|g| g.vertical == vertical && (g.pos - v).abs() <= tol)
|
||||
.min_by(|a, b| (a.pos - v).abs().total_cmp(&(b.pos - v).abs()))
|
||||
};
|
||||
if let Some(g) = nearest(true, p.x) {
|
||||
q.x = g.pos;
|
||||
}
|
||||
if let Some(g) = nearest(false, p.y) {
|
||||
q.y = g.pos;
|
||||
}
|
||||
(q, ov)
|
||||
}
|
||||
|
||||
/// The Slice tool: drag to make a user slice.
|
||||
#[derive(Default)]
|
||||
pub struct SliceTool {
|
||||
start: Option<Point>,
|
||||
last: Point,
|
||||
began: bool,
|
||||
guides: Vec<Overlay>,
|
||||
}
|
||||
|
||||
impl Tool for SliceTool {
|
||||
fn id(&self) -> &'static str {
|
||||
"slice"
|
||||
}
|
||||
|
||||
fn busy(&self) -> bool {
|
||||
self.start.is_some()
|
||||
}
|
||||
|
||||
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
|
||||
match ev.kind {
|
||||
PointerKind::Down => {
|
||||
let (p, _) = snap(cx, ev.pos);
|
||||
(self.start, self.last, self.began) = (Some(p), p, false);
|
||||
vec![]
|
||||
}
|
||||
PointerKind::Drag => {
|
||||
let Some(start) = self.start else { return vec![] };
|
||||
let (p, guides) = snap(cx, ev.pos);
|
||||
(self.last, self.guides) = (p, guides);
|
||||
let mut out = vec![];
|
||||
if !self.began {
|
||||
if p.distance(start) < cx.tol(2.0) {
|
||||
return out;
|
||||
}
|
||||
self.began = true;
|
||||
out.push(Action::Begin("Slice".into()));
|
||||
}
|
||||
let r = drag_rect(start, p, ev.mods);
|
||||
if has_area(r) {
|
||||
out.push(Action::Preview("object.slice.create".into(), rect_json(r)));
|
||||
}
|
||||
out
|
||||
}
|
||||
PointerKind::Up => {
|
||||
self.guides.clear();
|
||||
let began = std::mem::take(&mut self.began);
|
||||
if self.start.take().is_some() && began { vec![Action::Commit] } else { vec![] }
|
||||
}
|
||||
_ => vec![],
|
||||
}
|
||||
}
|
||||
|
||||
fn key(&mut self, _cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec<Action> {
|
||||
if key == ToolKey::Escape && self.began { self.deactivate(_cx) } else { vec![] }
|
||||
}
|
||||
|
||||
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
|
||||
match self.start {
|
||||
Some(s) if self.began => {
|
||||
let d = self.last - s;
|
||||
let mut o = self.guides.clone();
|
||||
o.push(Overlay::Measure { p: self.last, text: cx.size_label(d.x.abs(), d.y.abs()) });
|
||||
o
|
||||
}
|
||||
_ => vec![],
|
||||
}
|
||||
}
|
||||
|
||||
fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor {
|
||||
Cursor::Slice
|
||||
}
|
||||
|
||||
fn deactivate(&mut self, _cx: &ToolContext) -> Vec<Action> {
|
||||
self.start = None;
|
||||
self.guides.clear();
|
||||
if std::mem::take(&mut self.began) { vec![Action::Cancel] } else { vec![] }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
enum Drag {
|
||||
Move { start: Point, ids: Vec<NodeId>, began: bool },
|
||||
Resize { id: NodeId, handle: Handle, rect: Rect, began: bool },
|
||||
}
|
||||
|
||||
impl Drag {
|
||||
fn began(&self) -> bool {
|
||||
match self {
|
||||
Drag::Move { began, .. } | Drag::Resize { began, .. } => *began,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The Slice Selection tool: select, move, resize and delete slices.
|
||||
#[derive(Default)]
|
||||
pub struct SliceSelectionTool {
|
||||
drag: Option<Drag>,
|
||||
}
|
||||
|
||||
/// Can the slices be picked (not hidden or locked)?
|
||||
fn pickable(cx: &ToolContext) -> bool {
|
||||
!cx.slices_hidden && !cx.slices_locked
|
||||
}
|
||||
|
||||
/// The user or object slice under `p` (the smallest one where several overlap).
|
||||
fn slice_at(cx: &ToolContext, p: Point) -> Option<NodeId> {
|
||||
let lay = cx.doc.slice_layout();
|
||||
let hit = lay.iter().filter(|a| a.source != SliceSource::Auto && a.rect.contains(p));
|
||||
hit.min_by(|a, b| a.rect.area().total_cmp(&b.rect.area())).and_then(|a| a.id)
|
||||
}
|
||||
|
||||
/// A handle of a selected user slice under `p`: (slice, handle, its rectangle).
|
||||
fn handle_at(cx: &ToolContext, p: Point) -> Option<(NodeId, Handle, Rect)> {
|
||||
cx.selection.slices.iter().filter_map(|id| cx.doc.slice(*id)).find_map(|s| hit_handle(s.rect, p, cx.tol(5.0)).map(|h| (s.id, h, s.rect)))
|
||||
}
|
||||
|
||||
impl SliceSelectionTool {
|
||||
/// Select `ids` only.
|
||||
fn select(ids: &[NodeId]) -> Action {
|
||||
Action::Exec("object.slice.select".into(), json!({ "slices": json_ids(ids) }))
|
||||
}
|
||||
}
|
||||
|
||||
impl Tool for SliceSelectionTool {
|
||||
fn id(&self) -> &'static str {
|
||||
"sliceSelection"
|
||||
}
|
||||
|
||||
fn busy(&self) -> bool {
|
||||
self.drag.is_some()
|
||||
}
|
||||
|
||||
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
|
||||
let (p, m) = (ev.pos, ev.mods);
|
||||
match (ev.kind, self.drag.clone()) {
|
||||
(PointerKind::Down, _) => {
|
||||
self.drag = None;
|
||||
if !pickable(cx) {
|
||||
return vec![];
|
||||
}
|
||||
if let Some((id, handle, rect)) = handle_at(cx, p) {
|
||||
self.drag = Some(Drag::Resize { id, handle, rect, began: false });
|
||||
return vec![];
|
||||
}
|
||||
let selected = &cx.selection.slices;
|
||||
match slice_at(cx, p) {
|
||||
Some(id) if m.shift => vec![Action::Exec("object.slice.select".into(), json!({ "slices": [id.0], "toggle": true }))],
|
||||
Some(id) if selected.contains(&id) => {
|
||||
self.drag = Some(Drag::Move { start: p, ids: selected.clone(), began: false });
|
||||
vec![]
|
||||
}
|
||||
Some(id) => {
|
||||
self.drag = Some(Drag::Move { start: p, ids: vec![id], began: false });
|
||||
vec![Self::select(&[id])]
|
||||
}
|
||||
None if !m.shift && !selected.is_empty() => vec![Self::select(&[])],
|
||||
None => vec![],
|
||||
}
|
||||
}
|
||||
(PointerKind::Drag, Some(Drag::Move { start, ids, began })) => {
|
||||
let mut out = vec![];
|
||||
if !began {
|
||||
if p.distance(start) < cx.tol(3.0) {
|
||||
return out;
|
||||
}
|
||||
out.push(Action::Begin("Move Slice".into()));
|
||||
}
|
||||
let d = move_delta(start, p, m.shift);
|
||||
out.push(Action::Preview("object.slice.move".into(), json!({ "slices": json_ids(&ids), "dx": d.x, "dy": d.y })));
|
||||
self.drag = Some(Drag::Move { start, ids, began: true });
|
||||
out
|
||||
}
|
||||
(PointerKind::Drag, Some(Drag::Resize { id, handle, rect, began })) => {
|
||||
let mut out = vec![];
|
||||
if !began {
|
||||
out.push(Action::Begin("Resize Slice".into()));
|
||||
}
|
||||
self.drag = Some(Drag::Resize { id, handle, rect, began: true });
|
||||
let r = scale_for_drag(rect, handle, p, m.shift, m.alt).transform_rect_bbox(rect);
|
||||
if has_area(r) {
|
||||
let mut v = rect_json(r);
|
||||
v["id"] = json!(id.0);
|
||||
out.push(Action::Preview("object.slice.setRect".into(), v));
|
||||
}
|
||||
out
|
||||
}
|
||||
(PointerKind::Up, Some(d)) => {
|
||||
self.drag = None;
|
||||
if d.began() { vec![Action::Commit] } else { vec![] }
|
||||
}
|
||||
(PointerKind::DoubleClick, _) => {
|
||||
self.drag = None;
|
||||
match slice_at(cx, p).filter(|_| pickable(cx)) {
|
||||
Some(id) if cx.selection.slices == [id] => vec![Action::Dialog("sliceOptions".into(), json!({}))],
|
||||
Some(id) => vec![Self::select(&[id]), Action::Dialog("sliceOptions".into(), json!({}))],
|
||||
None => vec![],
|
||||
}
|
||||
}
|
||||
_ => vec![],
|
||||
}
|
||||
}
|
||||
|
||||
fn claims_key(&self, cx: &ToolContext, key: ToolKey) -> bool {
|
||||
matches!(key, ToolKey::Delete | ToolKey::Backspace) && !cx.selection.slices.is_empty() && pickable(cx)
|
||||
}
|
||||
|
||||
fn key(&mut self, cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec<Action> {
|
||||
match key {
|
||||
ToolKey::Delete | ToolKey::Backspace if self.drag.is_none() && self.claims_key(cx, key) => {
|
||||
vec![Action::Exec("object.slice.delete".into(), json!({}))]
|
||||
}
|
||||
ToolKey::Escape => self.deactivate(cx),
|
||||
_ => vec![],
|
||||
}
|
||||
}
|
||||
|
||||
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
|
||||
let mut o = vec![];
|
||||
for id in &cx.selection.slices {
|
||||
let Some(r) = cx.doc.slice_bounds(*id) else { continue };
|
||||
o.push(Overlay::Path { path: polygon(&rect_corners(r), true), color: BLUE, width: 1.0, dashed: false });
|
||||
if cx.doc.slice(*id).is_some() && pickable(cx) {
|
||||
o.extend(Handle::ALL.iter().map(|h| Overlay::Anchor { p: h.pos(r), color: BLUE, filled: false, size: 7.0 }));
|
||||
}
|
||||
}
|
||||
match &self.drag {
|
||||
Some(Drag::Resize { id, began: true, .. }) => {
|
||||
if let Some(r) = cx.doc.slice_bounds(*id) {
|
||||
o.push(Overlay::Measure {
|
||||
p: Point::new(r.x1, r.y1) + Vec2::new(cx.tol(12.0), cx.tol(12.0)),
|
||||
text: cx.size_label(r.width(), r.height()),
|
||||
});
|
||||
}
|
||||
}
|
||||
Some(Drag::Move { ids, began: true, .. }) => {
|
||||
if let Some(r) = ids.iter().filter_map(|id| cx.doc.slice_bounds(*id)).reduce(|a, b| a.union(b)) {
|
||||
o.push(Overlay::Measure {
|
||||
p: Point::new(r.x1, r.y1) + Vec2::new(cx.tol(12.0), cx.tol(12.0)),
|
||||
text: cx.size_label(r.width(), r.height()),
|
||||
});
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
o
|
||||
}
|
||||
|
||||
fn cursor(&self, cx: &ToolContext, p: Point, _m: Mods) -> Cursor {
|
||||
match handle_at(cx, p).filter(|_| pickable(cx)).map(|(_, h, _)| h) {
|
||||
Some(Handle::Top | Handle::Bottom) => Cursor::ResizeV,
|
||||
Some(Handle::Left | Handle::Right) => Cursor::ResizeH,
|
||||
Some(Handle::TopLeft | Handle::BottomRight) => Cursor::ResizeNwSe,
|
||||
Some(Handle::TopRight | Handle::BottomLeft) => Cursor::ResizeNeSw,
|
||||
None => Cursor::SliceSelect,
|
||||
}
|
||||
}
|
||||
|
||||
fn deactivate(&mut self, _cx: &ToolContext) -> Vec<Action> {
|
||||
if self.drag.take().is_some_and(|d| d.began()) { vec![Action::Cancel] } else { vec![] }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::testutil::*;
|
||||
use vectorcraft_doc::{Document, Guide, Selection, Slice};
|
||||
|
||||
fn ev(kind: PointerKind, x: f64, y: f64) -> PointerEvent {
|
||||
PointerEvent::new(kind, x, y)
|
||||
}
|
||||
|
||||
fn with(kind: PointerKind, x: f64, y: f64, m: Mods) -> PointerEvent {
|
||||
ev(kind, x, y).with_mods(m)
|
||||
}
|
||||
|
||||
fn create_preview(a: &[Action]) -> Option<Value> {
|
||||
a.iter().find_map(|a| match a {
|
||||
Action::Preview(c, v) if c == "object.slice.create" => Some(v.clone()),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_slice_tool_drags_out_a_slice_in_one_undo_step() {
|
||||
let (mut d, _) = doc_with_rect();
|
||||
d.guides.push(Guide { vertical: true, pos: 403.0 });
|
||||
let s = Selection::default();
|
||||
let p = paint();
|
||||
let mut c = cx(&d, &s, &p);
|
||||
c.smart_guides = false;
|
||||
let mut t = SliceTool::default();
|
||||
assert!(t.pointer(&c, &ev(PointerKind::Down, 300.0, 300.0)).is_empty());
|
||||
assert!(t.pointer(&c, &ev(PointerKind::Drag, 300.5, 300.0)).is_empty(), "a jitter is no drag");
|
||||
let a = t.pointer(&c, &ev(PointerKind::Drag, 350.0, 320.0));
|
||||
assert_eq!(a[0], Action::Begin("Slice".into()));
|
||||
assert_eq!(create_preview(&a), Some(json!({"x": 300.0, "y": 300.0, "width": 50.0, "height": 20.0})));
|
||||
assert!(matches!(t.overlays(&c).last(), Some(Overlay::Measure { .. })));
|
||||
// Shift: a square; Alt: from the centre.
|
||||
let sq = t.pointer(&c, &with(PointerKind::Drag, 350.0, 320.0, Mods { shift: true, ..Default::default() }));
|
||||
assert_eq!(create_preview(&sq), Some(json!({"x": 300.0, "y": 300.0, "width": 50.0, "height": 50.0})));
|
||||
let mid = t.pointer(&c, &with(PointerKind::Drag, 350.0, 320.0, Mods { alt: true, ..Default::default() }));
|
||||
assert_eq!(create_preview(&mid), Some(json!({"x": 250.0, "y": 280.0, "width": 100.0, "height": 40.0})));
|
||||
// The edge near a guide snaps to it.
|
||||
let g = t.pointer(&c, &ev(PointerKind::Drag, 401.0, 320.0));
|
||||
assert_eq!(create_preview(&g), Some(json!({"x": 300.0, "y": 300.0, "width": 103.0, "height": 20.0})));
|
||||
// A drag along one axis has no area: no preview.
|
||||
assert_eq!(create_preview(&t.pointer(&c, &ev(PointerKind::Drag, 350.0, 300.0))), None);
|
||||
assert_eq!(t.pointer(&c, &ev(PointerKind::Up, 350.0, 320.0)), vec![Action::Commit]);
|
||||
assert!(!t.busy());
|
||||
// A click makes nothing; Escape cancels a drag.
|
||||
t.pointer(&c, &ev(PointerKind::Down, 10.0, 10.0));
|
||||
assert!(t.pointer(&c, &ev(PointerKind::Up, 10.0, 10.0)).is_empty());
|
||||
t.pointer(&c, &ev(PointerKind::Down, 10.0, 10.0));
|
||||
t.pointer(&c, &ev(PointerKind::Drag, 40.0, 40.0));
|
||||
assert_eq!(t.key(&c, ToolKey::Escape, Mods::default()), vec![Action::Cancel]);
|
||||
assert_eq!(t.cursor(&c, Point::ZERO, Mods::default()), Cursor::Slice);
|
||||
}
|
||||
|
||||
fn doc_with_slices() -> (Document, NodeId, NodeId) {
|
||||
let (mut d, _) = doc_with_rect();
|
||||
let a = d.alloc_id();
|
||||
d.slices.push(Slice { id: a, rect: Rect::new(10.0, 10.0, 60.0, 40.0), options: Default::default() });
|
||||
let b = d.alloc_id();
|
||||
d.slices.push(Slice { id: b, rect: Rect::new(300.0, 300.0, 400.0, 400.0), options: Default::default() });
|
||||
(d, a, b)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_slice_selection_tool_selects_moves_resizes_and_deletes() {
|
||||
let (d, a, b) = doc_with_slices();
|
||||
let p = paint();
|
||||
let none = Selection::default();
|
||||
let c = cx(&d, &none, &p);
|
||||
let mut t = SliceSelectionTool::default();
|
||||
// A click selects the slice under the pointer; a drag moves it as one gesture.
|
||||
assert_eq!(t.pointer(&c, &ev(PointerKind::Down, 20.0, 20.0)), vec![Action::Exec("object.slice.select".into(), json!({"slices": [a.0]}))]);
|
||||
let mut sel = Selection::default();
|
||||
sel.set_slices([a]);
|
||||
let c = cx(&d, &sel, &p);
|
||||
let m = t.pointer(&c, &ev(PointerKind::Drag, 30.0, 25.0));
|
||||
assert_eq!(
|
||||
m,
|
||||
vec![Action::Begin("Move Slice".into()), Action::Preview("object.slice.move".into(), json!({"slices": [a.0], "dx": 10.0, "dy": 5.0}))]
|
||||
);
|
||||
assert_eq!(t.pointer(&c, &ev(PointerKind::Up, 30.0, 25.0)), vec![Action::Commit]);
|
||||
// Shift-click toggles another slice.
|
||||
let shift = Mods { shift: true, ..Default::default() };
|
||||
assert_eq!(
|
||||
t.pointer(&c, &with(PointerKind::Down, 350.0, 350.0, shift)),
|
||||
vec![Action::Exec("object.slice.select".into(), json!({"slices": [b.0], "toggle": true}))]
|
||||
);
|
||||
t.pointer(&c, &ev(PointerKind::Up, 350.0, 350.0));
|
||||
// A corner handle of the selected user slice resizes it.
|
||||
assert_eq!(t.cursor(&c, Point::new(60.0, 40.0), Mods::default()), Cursor::ResizeNwSe);
|
||||
assert!(t.pointer(&c, &ev(PointerKind::Down, 60.0, 40.0)).is_empty());
|
||||
let r = t.pointer(&c, &ev(PointerKind::Drag, 80.0, 50.0));
|
||||
assert_eq!(r[0], Action::Begin("Resize Slice".into()));
|
||||
assert_eq!(r[1], Action::Preview("object.slice.setRect".into(), json!({"id": a.0, "x": 10.0, "y": 10.0, "width": 70.0, "height": 40.0})));
|
||||
assert_eq!(t.pointer(&c, &ev(PointerKind::Up, 80.0, 50.0)), vec![Action::Commit]);
|
||||
assert!(t.overlays(&c).iter().filter(|o| matches!(o, Overlay::Anchor { .. })).count() == 8);
|
||||
// A double click opens Slice Options; Delete deletes the selected slices.
|
||||
assert_eq!(t.pointer(&c, &ev(PointerKind::DoubleClick, 20.0, 20.0)), vec![Action::Dialog("sliceOptions".into(), json!({}))]);
|
||||
assert!(t.claims_key(&c, ToolKey::Delete));
|
||||
assert_eq!(t.key(&c, ToolKey::Delete, Mods::default()), vec![Action::Exec("object.slice.delete".into(), json!({}))]);
|
||||
// A click on no slice deselects.
|
||||
assert_eq!(t.pointer(&c, &ev(PointerKind::Down, 200.0, 5.0)), vec![Action::Exec("object.slice.select".into(), json!({"slices": []}))]);
|
||||
// Locked slices can't be picked.
|
||||
let mut locked = cx(&d, &sel, &p);
|
||||
locked.slices_locked = true;
|
||||
assert!(t.pointer(&locked, &ev(PointerKind::Down, 20.0, 20.0)).is_empty());
|
||||
assert!(!t.claims_key(&locked, ToolKey::Delete));
|
||||
assert_eq!(t.cursor(&locked, Point::new(60.0, 40.0), Mods::default()), Cursor::SliceSelect);
|
||||
}
|
||||
}
|
||||
|
|
@ -286,6 +286,10 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
|||
}
|
||||
}
|
||||
|
||||
if !app.session.slices_hidden() {
|
||||
slice_overlay(app, &painter, &xf);
|
||||
}
|
||||
|
||||
// Selection visuals and tool overlays.
|
||||
if app.ui.view.edges {
|
||||
hover_highlight(app, &painter, &xf);
|
||||
|
|
@ -365,6 +369,8 @@ fn cursor_icon(c: Cursor) -> egui::CursorIcon {
|
|||
Cursor::NotAllowed => C::NotAllowed,
|
||||
Cursor::AddStop => C::Copy,
|
||||
Cursor::RemoveStop => C::NotAllowed,
|
||||
Cursor::Slice => C::Crosshair,
|
||||
Cursor::SliceSelect => C::Default,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -902,6 +908,53 @@ fn thread_overlay(app: &VectorcraftApp, p: &egui::Painter, xf: &Xf) {
|
|||
}
|
||||
}
|
||||
|
||||
/// The slices (unless View → Hide Slices): user and object slices outlined in the Slices
|
||||
/// preferences' line colour, auto slices dashed and dimmer, each numbered at its top left (Show
|
||||
/// Slice Numbers); selected slices have a bolder outline. The layout is cached per revision.
|
||||
fn slice_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: &Xf) {
|
||||
let Some(st) = app.session.active() else { return };
|
||||
let key = (st.uid, st.revision);
|
||||
let slices = match &app.canvas.slices {
|
||||
Some((k, s)) if *k == key => s.clone(),
|
||||
_ => {
|
||||
let s = std::sync::Arc::new(st.doc.slice_layout());
|
||||
app.canvas.slices = Some((key, s.clone()));
|
||||
s
|
||||
}
|
||||
};
|
||||
if slices.is_empty() {
|
||||
return;
|
||||
}
|
||||
let prefs = &app.session.prefs;
|
||||
let line = vectorcraft_color::Color::from_hex(&prefs.slice_line_color).map_or(Color32::from_rgb(0xff, 0x3f, 0x3f), |c| {
|
||||
let [r, g, b, _] = c.to_rgba8(1.0);
|
||||
Color32::from_rgb(r, g, b)
|
||||
});
|
||||
let dim = line.gamma_multiply(0.55);
|
||||
let selected = &st.selection.slices;
|
||||
let objects = &st.selection.objects;
|
||||
for a in slices.iter() {
|
||||
let auto = a.source == vectorcraft_doc::SliceSource::Auto;
|
||||
let on = a.id.is_some_and(|id| selected.contains(&id) || objects.contains(&id));
|
||||
let q = xf.quad(a.rect);
|
||||
let ring: Vec<Pos2> = q.iter().chain(q.first()).copied().collect();
|
||||
if auto {
|
||||
p.extend(Shape::dashed_line(&ring, Stroke::new(1.0, dim), 3.0, 3.0));
|
||||
} else {
|
||||
p.add(Shape::line(ring, Stroke::new(if on { 2.0 } else { 1.0 }, line)));
|
||||
}
|
||||
if prefs.show_slice_numbers
|
||||
&& let Some(tl) = q.first()
|
||||
{
|
||||
let font = egui::FontId::proportional(9.0);
|
||||
let galley = p.layout_no_wrap(format!("{:02}", a.number), font, Color32::WHITE);
|
||||
let badge = egui::Rect::from_min_size(*tl + vec2(1.0, 1.0), galley.size() + vec2(6.0, 2.0));
|
||||
p.rect_filled(badge, CornerRadius::ZERO, if auto { dim } else { line });
|
||||
p.galley(badge.min + vec2(3.0, 1.0), galley, Color32::WHITE);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn selection_overlay(app: &VectorcraftApp, p: &egui::Painter, xf: &Xf) {
|
||||
let Some(st) = app.session.active() else { return };
|
||||
let tool = app.session.tool_id();
|
||||
|
|
|
|||
|
|
@ -118,6 +118,21 @@ fn stop_badge(p: &Painter, o: Pos2, add: bool) {
|
|||
}
|
||||
}
|
||||
|
||||
/// A slice badge: a small rectangle cut by a line, at `b` (its top left).
|
||||
fn slice_badge(p: &Painter, b: Pos2) {
|
||||
let pts = vec![b, b + vec2(8.0, 0.0), b + vec2(8.0, 6.0), b + vec2(0.0, 6.0)];
|
||||
poly(p, pts, HALO, INK);
|
||||
line(p, b + vec2(4.0, 0.0), b + vec2(4.0, 6.0));
|
||||
}
|
||||
|
||||
/// The Slice tool: a crosshair with a blade below right of the hotspot.
|
||||
fn slice(p: &Painter, o: Pos2) {
|
||||
crosshair(p, o);
|
||||
let b = o + vec2(7.0, 7.0);
|
||||
poly(p, vec![b, b + vec2(9.0, 4.0), b + vec2(10.0, 7.0), b + vec2(3.0, 6.0)], HALO, INK);
|
||||
line(p, b + vec2(8.0, 6.0), b + vec2(12.0, 12.0));
|
||||
}
|
||||
|
||||
fn ibeam(p: &Painter, o: Pos2) {
|
||||
line(p, o + vec2(0.0, -8.0), o + vec2(0.0, 8.0));
|
||||
line(p, o + vec2(-3.0, -8.0), o + vec2(3.0, -8.0));
|
||||
|
|
@ -150,6 +165,11 @@ pub fn paint(painter: &Painter, c: Cursor, p: Pos2) -> bool {
|
|||
Cursor::Text => ibeam(painter, p),
|
||||
Cursor::AddStop => stop_badge(painter, p, true),
|
||||
Cursor::RemoveStop => stop_badge(painter, p, false),
|
||||
Cursor::Slice => slice(painter, p),
|
||||
Cursor::SliceSelect => {
|
||||
arrow(painter, p, false);
|
||||
slice_badge(painter, p + vec2(11.0, 14.0));
|
||||
}
|
||||
_ => return false,
|
||||
}
|
||||
let _ = pos2;
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ mod save_pdf;
|
|||
pub mod save_style_library;
|
||||
pub mod save_swatch_library;
|
||||
mod shapes;
|
||||
pub mod slices;
|
||||
pub mod spot_colors;
|
||||
pub(crate) mod svg_options;
|
||||
pub mod swatch_conflict;
|
||||
|
|
@ -213,6 +214,8 @@ registry! {
|
|||
DxfOptions: [dxf_options::KIND] => dxf_options::SPEC,
|
||||
PlacementOptions: [placement_options::KIND] => placement_options::SPEC,
|
||||
Package: [package::KIND] => package::SPEC,
|
||||
SliceOptions: [slices::OPTIONS] => slices::OPTIONS_SPEC,
|
||||
DivideSlices: [slices::DIVIDE] => slices::DIVIDE_SPEC,
|
||||
}
|
||||
|
||||
/// The spec for a `Dialog::kind` ([`DialogSpec::FORM`] when unregistered).
|
||||
|
|
|
|||
|
|
@ -0,0 +1,257 @@
|
|||
//! Object → Slice → Slice Options… and Divide Slices….
|
||||
//!
|
||||
//! Slice Options (`sliceOptions`, also a double click with the Slice Selection tool) edits the
|
||||
//! selected slices' options: fields `kind` (`image`, `noImage`, `htmlText`), `name`, `url`,
|
||||
//! `target`, `message`, `alt`, `text`, `background` (`""`, `matte` or `#rrggbb`), `hAlign`,
|
||||
//! `vAlign`, and `__html` (whether HTML Text applies). OK runs `object.slice.options`.
|
||||
//!
|
||||
//! Divide Slices (`divideSlices`): `divideRows` with `rows` (slices down) or, with `rowMode:
|
||||
//! "size"`, `rowHeight` (pt); `divideColumns` with `columns` or `columnWidth`. OK runs
|
||||
//! `object.slice.divide`.
|
||||
|
||||
use serde_json::{Map, Value, json};
|
||||
use vectorcraft_color::Color;
|
||||
use vectorcraft_doc::{CellAlign, CellVAlign, SliceKind};
|
||||
|
||||
use super::{DialogSpec, form, run_and_close};
|
||||
use crate::state::Dialog;
|
||||
use crate::{VectorcraftApp, widgets};
|
||||
|
||||
/// The dialog kind of Slice Options.
|
||||
pub const OPTIONS: &str = "sliceOptions";
|
||||
/// The dialog kind of Divide Slices.
|
||||
pub const DIVIDE: &str = "divideSlices";
|
||||
|
||||
pub(super) const OPTIONS_SPEC: DialogSpec =
|
||||
DialogSpec { heading: |_| "Slice Options".into(), body: options_body, confirm: options_confirm, min_width: 380.0, ..DialogSpec::FORM };
|
||||
|
||||
pub(super) const DIVIDE_SPEC: DialogSpec =
|
||||
DialogSpec { heading: |_| "Divide Slices".into(), body: divide_body, confirm: divide_confirm, min_width: 320.0, ..DialogSpec::FORM };
|
||||
|
||||
/// Label column and field widths of the dialogs.
|
||||
const LABEL_W: f32 = 110.0;
|
||||
const FIELD_W: f32 = 220.0;
|
||||
|
||||
/// Open Slice Options on the selected slices' options (those of the first one).
|
||||
pub fn open_options(app: &mut VectorcraftApp) -> Result<Value, String> {
|
||||
let v = app.session.execute("object.slice.options", &json!({})).map_err(|e| e.to_string())?;
|
||||
let mut fields: Map<String, Value> = v.as_object().cloned().unwrap_or_default();
|
||||
let html = fields.remove("htmlText").unwrap_or(json!(false));
|
||||
fields.insert("__html".into(), html);
|
||||
app.ui.dialog = Some(Dialog { kind: OPTIONS.into(), fields });
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
/// Open Divide Slices: two rows and two columns, the sizes halving the first selected slice.
|
||||
pub fn open_divide(app: &mut VectorcraftApp) -> Result<Value, String> {
|
||||
let st = app.session.active().ok_or("no document open")?;
|
||||
let first = st.selection.slices.iter().chain(&st.selection.objects).find_map(|id| st.doc.slice_bounds(*id));
|
||||
let (w, h) = first.map_or((50.0, 50.0), |r| (r.width() / 2.0, r.height() / 2.0));
|
||||
app.ui.dialog = Some(Dialog::new(
|
||||
DIVIDE,
|
||||
json!({"divideRows": true, "rows": 2, "rowMode": "count", "rowHeight": h, "divideColumns": true, "columns": 2, "columnMode": "count", "columnWidth": w}),
|
||||
));
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
/// `(value, label)` pairs of a keyed enum's values.
|
||||
fn pairs<T: Copy>(all: &[T], key: fn(T) -> &'static str, label: fn(T) -> &'static str) -> Vec<(&'static str, &'static str)> {
|
||||
all.iter().map(|v| (key(*v), label(*v))).collect()
|
||||
}
|
||||
|
||||
fn options_body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
let kinds: Vec<_> = SliceKind::ALL.iter().filter(|k| **k != SliceKind::HtmlText || d.bool("__html")).copied().collect();
|
||||
form::choice(ui, d, "kind", "Slice Type:", (LABEL_W, FIELD_W), &pairs(&kinds, SliceKind::key, SliceKind::label));
|
||||
ui.add_space(6.0);
|
||||
let kind = SliceKind::parse(&d.str("kind")).unwrap_or_default();
|
||||
match kind {
|
||||
SliceKind::Image => {
|
||||
for (key, label) in [("name", "Name:"), ("url", "URL:"), ("target", "Target:"), ("message", "Message:"), ("alt", "Alt:")] {
|
||||
widgets::label_row(ui, label, LABEL_W, |ui| {
|
||||
form::text(ui, d, key, FIELD_W - 12.0);
|
||||
});
|
||||
}
|
||||
}
|
||||
SliceKind::NoImage => {
|
||||
widgets::dim_label(ui, "Text Displayed in Cell:");
|
||||
form::text_area(ui, d, "text", LABEL_W + FIELD_W - 12.0, 4);
|
||||
}
|
||||
SliceKind::HtmlText => {
|
||||
widgets::dim_label(ui, "The cell shows the type object's text as HTML.");
|
||||
}
|
||||
}
|
||||
if kind != SliceKind::Image {
|
||||
ui.add_space(6.0);
|
||||
form::choice(ui, d, "hAlign", "Horizontal:", (LABEL_W, FIELD_W), &pairs(CellAlign::ALL, CellAlign::key, CellAlign::label));
|
||||
form::choice(ui, d, "vAlign", "Vertical:", (LABEL_W, FIELD_W), &pairs(CellVAlign::ALL, CellVAlign::key, CellVAlign::label));
|
||||
}
|
||||
ui.add_space(6.0);
|
||||
background_row(ui, d);
|
||||
false
|
||||
}
|
||||
|
||||
/// Background: None, Matte, White, Black or Other (a colour button for any colour).
|
||||
fn background_row(ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
const NAMES: [&str; 5] = ["None", "Matte", "White", "Black", "Other"];
|
||||
let cur = d.str("background").to_ascii_lowercase();
|
||||
let shown = match cur.as_str() {
|
||||
"" => 0,
|
||||
"matte" => 1,
|
||||
"#ffffff" => 2,
|
||||
"#000000" => 3,
|
||||
_ => 4,
|
||||
};
|
||||
widgets::label_row(ui, "Background:", LABEL_W, |ui| {
|
||||
if let Some(i) = widgets::dropdown(ui, "slice-background", NAMES[shown], &NAMES, 120.0) {
|
||||
let v = match i {
|
||||
0 => "",
|
||||
1 => "matte",
|
||||
2 => "#ffffff",
|
||||
3 => "#000000",
|
||||
_ => "#808080",
|
||||
};
|
||||
d.fields.insert("background".into(), json!(v));
|
||||
}
|
||||
if let Some(c) = Color::from_hex(&cur) {
|
||||
let [r, g, b, _] = c.to_rgba8(1.0);
|
||||
let mut rgb = [r, g, b];
|
||||
if ui.color_edit_button_srgb(&mut rgb).changed() {
|
||||
d.fields.insert("background".into(), json!(Color::rgb8(rgb[0], rgb[1], rgb[2]).to_hex()));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn options_confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
|
||||
let p: Map<String, Value> = d.fields.iter().filter(|(k, _)| !k.starts_with("__")).map(|(k, v)| (k.clone(), v.clone())).collect();
|
||||
run_and_close(app, "object.slice.options", Value::Object(p))
|
||||
}
|
||||
|
||||
/// One direction of Divide Slices: the checkbox, then "N slices …, evenly spaced" or "N pt per
|
||||
/// slice".
|
||||
fn divide_section(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, keys: [&str; 4], labels: [&str; 2]) {
|
||||
let [on, count, mode, size] = keys;
|
||||
if widgets::check(ui, labels[0], d.bool(on), true) {
|
||||
d.fields.insert(on.into(), json!(!d.bool(on)));
|
||||
}
|
||||
let enabled = d.bool(on);
|
||||
let by_size = d.str(mode) == "size";
|
||||
ui.add_enabled_ui(enabled, |ui| {
|
||||
ui.indent(on, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
if widgets::radio(ui, "", !by_size, enabled) {
|
||||
d.fields.insert(mode.into(), json!("count"));
|
||||
}
|
||||
if let Some(n) = widgets::plain_field(ui, ("divide", count), d.f64(count, 2.0), "", 0, 50.0) {
|
||||
d.fields.insert(count.into(), json!(n.round().max(1.0) as u64));
|
||||
}
|
||||
widgets::dim_label(ui, labels[1]);
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
if widgets::radio(ui, "", by_size, enabled) {
|
||||
d.fields.insert(mode.into(), json!("size"));
|
||||
}
|
||||
form::length(ui, d, size, app.session.general_unit(), 70.0);
|
||||
widgets::dim_label(ui, "per slice");
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn divide_body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
divide_section(app, ui, d, ["divideRows", "rows", "rowMode", "rowHeight"], ["Divide Horizontally Into", "slices down, evenly spaced"]);
|
||||
ui.add_space(8.0);
|
||||
divide_section(app, ui, d, ["divideColumns", "columns", "columnMode", "columnWidth"], ["Divide Vertically Into", "slices across, evenly spaced"]);
|
||||
false
|
||||
}
|
||||
|
||||
fn divide_confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
|
||||
let mut p = json!({});
|
||||
for [on, count, mode, size] in [["divideRows", "rows", "rowMode", "rowHeight"], ["divideColumns", "columns", "columnMode", "columnWidth"]] {
|
||||
if !d.bool(on) {
|
||||
continue;
|
||||
}
|
||||
if d.str(mode) == "size" {
|
||||
p[size] = json!(d.f64(size, 0.0));
|
||||
} else {
|
||||
p[count] = json!(d.f64(count, 2.0).round().max(1.0) as u64);
|
||||
}
|
||||
}
|
||||
if p.as_object().is_none_or(|o| o.is_empty()) {
|
||||
return Err("choose a direction to divide in".into());
|
||||
}
|
||||
run_and_close(app, "object.slice.divide", p)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use vectorcraft_engine::Session;
|
||||
|
||||
fn app() -> VectorcraftApp {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 200, "height": 200})).unwrap();
|
||||
app.session.paint.stroke = vectorcraft_color::Paint::None;
|
||||
let id = app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 60, "height": 40})).unwrap()["id"].as_u64().unwrap();
|
||||
app.run("object.slice.make", json!({"ids": [id]})).unwrap();
|
||||
app
|
||||
}
|
||||
|
||||
/// Draw the open dialog once, headlessly.
|
||||
fn draw(app: &mut VectorcraftApp) {
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
let mut out = ctx.run_ui(Default::default(), |ui| super::super::show(app, ui.ctx()));
|
||||
out.textures_delta.clear();
|
||||
}
|
||||
|
||||
fn set(app: &mut VectorcraftApp, k: &str, v: Value) {
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert(k.into(), v);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slice_options_opens_on_the_selection_and_ok_sets_them() {
|
||||
let mut app = app();
|
||||
// The menu item (no params) opens the dialog through the app.
|
||||
app.run("object.slice.options", json!({})).unwrap();
|
||||
assert_eq!(app.ui.dialog.as_ref().unwrap().kind, OPTIONS);
|
||||
assert_eq!(app.ui.dialog.as_ref().unwrap().str("kind"), "image");
|
||||
for kind in ["image", "noImage"] {
|
||||
set(&mut app, "kind", json!(kind));
|
||||
draw(&mut app);
|
||||
}
|
||||
set(&mut app, "text", json!("Hello <i>cell</i>"));
|
||||
set(&mut app, "background", json!("#00ff00"));
|
||||
draw(&mut app);
|
||||
super::super::confirm(&mut app).unwrap();
|
||||
assert!(app.ui.dialog.is_none());
|
||||
let l = app.run("slice.list", json!({})).unwrap();
|
||||
let own = l["slices"].as_array().unwrap().iter().find(|s| s["source"] == "object").unwrap().clone();
|
||||
assert_eq!(own["options"]["kind"], "noImage");
|
||||
assert_eq!(own["options"]["text"], "Hello <i>cell</i>");
|
||||
assert_eq!(own["options"]["background"], "#00ff00");
|
||||
// One undo step.
|
||||
app.run("edit.undo", json!({})).unwrap();
|
||||
let l = app.run("slice.list", json!({})).unwrap();
|
||||
assert!(l["slices"].as_array().unwrap().iter().any(|s| s["options"]["kind"] == "image" && s["source"] == "object"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn divide_slices_cuts_by_count_or_size() {
|
||||
let mut app = app();
|
||||
app.run("object.slice.divide", json!({})).unwrap();
|
||||
assert_eq!(app.ui.dialog.as_ref().unwrap().kind, DIVIDE);
|
||||
assert_eq!(app.ui.dialog.as_ref().unwrap().f64("columnWidth", 0.0), 30.0);
|
||||
draw(&mut app);
|
||||
set(&mut app, "columns", json!(3));
|
||||
set(&mut app, "rowMode", json!("size"));
|
||||
set(&mut app, "rowHeight", json!(15));
|
||||
super::super::confirm(&mut app).unwrap();
|
||||
// 40 pt in 15 pt rows: 3 rows; 3 columns.
|
||||
assert_eq!(app.session.active().unwrap().doc.slices.len(), 9);
|
||||
app.run("object.slice.divide", json!({})).unwrap();
|
||||
set(&mut app, "divideRows", json!(false));
|
||||
set(&mut app, "divideColumns", json!(false));
|
||||
assert!(super::super::confirm(&mut app).is_err());
|
||||
}
|
||||
}
|
||||
|
|
@ -56,6 +56,13 @@ pub fn open_tool_dialog(app: &mut VectorcraftApp, kind: &str, p: Value) {
|
|||
}
|
||||
return;
|
||||
}
|
||||
// Double-clicking a slice with the Slice Selection tool: Slice Options.
|
||||
super::slices::OPTIONS => {
|
||||
if let Err(e) = super::slices::open_options(app) {
|
||||
app.status(e);
|
||||
}
|
||||
return;
|
||||
}
|
||||
_ => return,
|
||||
};
|
||||
app.ui.dialog = Some(d);
|
||||
|
|
|
|||
|
|
@ -56,6 +56,8 @@ mod tests_recolor;
|
|||
#[cfg(test)]
|
||||
mod tests_save;
|
||||
#[cfg(test)]
|
||||
mod tests_slices;
|
||||
#[cfg(test)]
|
||||
mod tests_svg;
|
||||
#[cfg(test)]
|
||||
mod tests_svgsave;
|
||||
|
|
@ -156,8 +158,13 @@ pub struct CanvasCache {
|
|||
pub last_ms: f64,
|
||||
pub worker: Option<render_worker::Worker>,
|
||||
pub worker_started: bool,
|
||||
/// The slices as laid out for (document uid, revision): the canvas draws them every frame.
|
||||
pub slices: Option<SliceCache>,
|
||||
}
|
||||
|
||||
/// [`CanvasCache::slices`]: the layout of the slices of (document uid, revision).
|
||||
pub type SliceCache = ((u64, u64), std::sync::Arc<Vec<vectorcraft_doc::SliceArea>>);
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct CacheKey {
|
||||
pub doc: usize,
|
||||
|
|
@ -253,6 +260,7 @@ impl VectorcraftApp {
|
|||
last_ms: 0.0,
|
||||
worker: None,
|
||||
worker_started: false,
|
||||
slices: None,
|
||||
},
|
||||
perf: Perf::default(),
|
||||
integrated_titlebar: false,
|
||||
|
|
|
|||
|
|
@ -841,6 +841,9 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<R
|
|||
"ui.packageDialog" => crate::dialogs::package::open(app),
|
||||
"file.showPackage" => crate::dialogs::package::show_package(app, p),
|
||||
"docInfo.save" => crate::panels::doc_info::save_report(app, p),
|
||||
// Slice Options… and Divide Slices…: their dialogs, on the selected slices.
|
||||
"object.slice.options" if p.as_object().is_none_or(|o| o.is_empty()) => crate::dialogs::slices::open_options(app),
|
||||
"object.slice.divide" if p.as_object().is_none_or(|o| o.is_empty()) => crate::dialogs::slices::open_divide(app),
|
||||
_ => return None,
|
||||
};
|
||||
Some(r)
|
||||
|
|
@ -883,6 +886,8 @@ pub fn checked(app: &VectorcraftApp, id: &str, p: &Value) -> Option<bool> {
|
|||
}
|
||||
"window.workspace" => p.get("name").and_then(Value::as_str) == Some(app.ui.workspace.as_str()),
|
||||
"window.brightness" => p.get("brightness").and_then(Value::as_str).and_then(Brightness::parse) == Some(app.ui.brightness),
|
||||
"view.slices.lock" => app.session.slices_locked(),
|
||||
"object.slice.clipToArtboard" => app.session.active().is_some_and(|d| d.doc.slices_clip_to_artboard),
|
||||
"view.proofColors" => vectorcraft_render::proof::view().proof_colors,
|
||||
"view.overprintPreview" => vectorcraft_render::proof::view().overprint,
|
||||
"view.proofSetup" => p.get("target").and_then(Value::as_str) == Some(vectorcraft_render::proof::view().setup.target.id().as_str()),
|
||||
|
|
@ -926,6 +931,7 @@ pub fn dynamic_label(app: &VectorcraftApp, id: &str, label: &str) -> String {
|
|||
"view.guides" => if v.guides { "Hide Guides" } else { "Show Guides" }.into(),
|
||||
"view.grid" => if v.grid { "Hide Grid" } else { "Show Grid" }.into(),
|
||||
"view.guides.lock" => if app.session.guides_locked() { "Unlock Guides" } else { "Lock Guides" }.into(),
|
||||
"view.slices.hide" => if app.session.slices_hidden() { "Show Slices" } else { "Hide Slices" }.into(),
|
||||
id if id.starts_with("file.openRecent") => recent_slot(app, id)
|
||||
.map(|p| std::path::Path::new(p).file_name().map_or(p.clone(), |f| f.to_string_lossy().to_string()))
|
||||
.unwrap_or_else(|| "—".into()),
|
||||
|
|
@ -1285,7 +1291,23 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<Item>)> {
|
|||
Sep,
|
||||
c("Make Pixel Perfect", "object.makePixelPerfect"),
|
||||
Sep,
|
||||
sub("Slice", vec![todo("Make"), todo("Release"), todo("Create from Guides"), todo("Create from Selection")]),
|
||||
sub(
|
||||
"Slice",
|
||||
vec![
|
||||
c("Make", "object.slice.make"),
|
||||
c("Release", "object.slice.release"),
|
||||
c("Create from Guides", "object.slice.fromGuides"),
|
||||
c("Create from Selection", "object.slice.fromSelection"),
|
||||
Sep,
|
||||
c("Duplicate Slice", "object.slice.duplicate"),
|
||||
c("Combine Slices", "object.slice.combine"),
|
||||
c("Divide Slices…", "object.slice.divide"),
|
||||
Sep,
|
||||
c("Delete All", "object.slice.deleteAll"),
|
||||
c("Slice Options…", "object.slice.options"),
|
||||
c("Clip to Artboard", "object.slice.clipToArtboard"),
|
||||
],
|
||||
),
|
||||
Sep,
|
||||
sub(
|
||||
"Path",
|
||||
|
|
@ -1512,6 +1534,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<Item>)> {
|
|||
c("Brush Strokes", "select.object.brushStrokes"),
|
||||
c("Bristle Brush Strokes", "select.object.bristleBrushStrokes"),
|
||||
c("Clipping Masks", "select.object.clippingMasks"),
|
||||
c("Slices", "select.object.slices"),
|
||||
c("Stray Points", "select.object.strayPoints"),
|
||||
c("Open Paths", "select.object.openPaths"),
|
||||
Sep,
|
||||
|
|
@ -1569,6 +1592,8 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<Item>)> {
|
|||
c("Hide Edges", "view.edges"),
|
||||
c("Hide Artboards", "view.artboards"),
|
||||
todo("Show Print Tiling"),
|
||||
c("Hide Slices", "view.slices.hide"),
|
||||
c("Lock Slices", "view.slices.lock"),
|
||||
Sep,
|
||||
sub("Rulers", vec![c("Show Rulers", "view.rulers"), todos("Change to Global Rulers", "Cmd+Alt+R"), todo("Show Video Rulers")]),
|
||||
c("Hide Bounding Box", "view.boundingBox"),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,102 @@
|
|||
//! Slices on the canvas and in the menus.
|
||||
|
||||
use egui::{Pos2, vec2};
|
||||
use serde_json::json;
|
||||
use vectorcraft_engine::Session;
|
||||
|
||||
use crate::VectorcraftApp;
|
||||
|
||||
fn app() -> VectorcraftApp {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
|
||||
app.session.paint.stroke = vectorcraft_color::Paint::None;
|
||||
app
|
||||
}
|
||||
|
||||
/// One headless canvas frame on an 800 × 600 window; the texts it painted.
|
||||
fn frame_texts(app: &mut VectorcraftApp, ctx: &egui::Context) -> Vec<String> {
|
||||
let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), ..Default::default() };
|
||||
let mut out = ctx.run_ui(raw, |ui| crate::canvas::show(app, ui));
|
||||
out.textures_delta.clear();
|
||||
out.shapes
|
||||
.iter()
|
||||
.filter_map(|c| match &c.shape {
|
||||
egui::Shape::Text(t) => Some(t.galley.text().to_string()),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_canvas_numbers_slices_until_they_are_hidden() {
|
||||
let mut app = app();
|
||||
let ctx = egui::Context::default();
|
||||
assert!(!frame_texts(&mut app, &ctx).iter().any(|t| t == "01"), "no slices, no numbers");
|
||||
app.run("shape.rectangle", json!({"x": 100, "y": 100, "width": 50, "height": 50})).unwrap();
|
||||
app.run("object.slice.make", json!({})).unwrap();
|
||||
let texts = frame_texts(&mut app, &ctx);
|
||||
// The object slice and the four auto slices around it.
|
||||
for n in ["01", "02", "03", "04", "05"] {
|
||||
assert!(texts.iter().any(|t| t == n), "slice {n} numbered: {texts:?}");
|
||||
}
|
||||
let cached = app.canvas.slices.as_ref().map(|(_, s)| s.len());
|
||||
assert_eq!(cached, Some(5));
|
||||
// Show Slice Numbers off: outlines only.
|
||||
app.run("prefs.set", json!({"key": "showSliceNumbers", "value": false})).unwrap();
|
||||
assert!(!frame_texts(&mut app, &ctx).iter().any(|t| t == "01"));
|
||||
app.run("prefs.set", json!({"key": "showSliceNumbers", "value": true})).unwrap();
|
||||
app.run("view.slices.hide", json!({})).unwrap();
|
||||
assert!(!frame_texts(&mut app, &ctx).iter().any(|t| t == "01"), "hidden");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slice_menu_items_follow_the_state() {
|
||||
let mut app = app();
|
||||
let en = |app: &VectorcraftApp, id: &str| crate::menus::enabled(app, id);
|
||||
assert!(!en(&app, "object.slice.make") && !en(&app, "object.slice.release") && !en(&app, "object.slice.deleteAll"));
|
||||
assert!(en(&app, "object.slice.fromGuides"));
|
||||
app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap();
|
||||
assert!(en(&app, "object.slice.make") && !en(&app, "object.slice.release"));
|
||||
app.run("object.slice.make", json!({})).unwrap();
|
||||
for id in ["object.slice.release", "object.slice.duplicate", "object.slice.divide", "object.slice.options", "object.slice.deleteAll"] {
|
||||
assert!(en(&app, id), "{id}");
|
||||
}
|
||||
assert!(app.session.execute("object.slice.combine", &json!({})).is_err(), "one slice can't be combined");
|
||||
assert_eq!(crate::menus::checked(&app, "object.slice.clipToArtboard", &json!({})), Some(true));
|
||||
app.run("object.slice.clipToArtboard", json!({})).unwrap();
|
||||
assert_eq!(crate::menus::checked(&app, "object.slice.clipToArtboard", &json!({})), Some(false));
|
||||
assert_eq!(crate::menus::dynamic_label(&app, "view.slices.hide", "Hide Slices"), "Hide Slices");
|
||||
app.run("view.slices.hide", json!({})).unwrap();
|
||||
assert_eq!(crate::menus::dynamic_label(&app, "view.slices.hide", "Hide Slices"), "Show Slices");
|
||||
assert_eq!(crate::menus::checked(&app, "view.slices.lock", &json!({})), Some(false));
|
||||
app.run("view.slices.lock", json!({})).unwrap();
|
||||
assert_eq!(crate::menus::checked(&app, "view.slices.lock", &json!({})), Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slice_tool_cursors_are_drawn_in_code() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut out = ctx.run_ui(Default::default(), |ui| {
|
||||
for c in [vectorcraft_tools::Cursor::Slice, vectorcraft_tools::Cursor::SliceSelect] {
|
||||
assert!(crate::cursors::paint(ui.painter(), c, egui::pos2(50.0, 50.0)), "{c:?}");
|
||||
}
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
assert!(!out.shapes.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_slice_tools_are_real_tools() {
|
||||
let mut app = app();
|
||||
for t in ["slice", "sliceSelection"] {
|
||||
app.run("tool.select", json!({"tool": t})).unwrap();
|
||||
assert_eq!(app.session.tool_id(), t);
|
||||
}
|
||||
// Double-clicking a slice opens Slice Options.
|
||||
let id = app.run("object.slice.create", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap()["id"].as_u64().unwrap();
|
||||
let view = app.view_info();
|
||||
let ev = vectorcraft_tools::PointerEvent::new(vectorcraft_tools::PointerKind::DoubleClick, 20.0, 20.0);
|
||||
crate::canvas::dispatch(&mut app, &ev, view);
|
||||
assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some(crate::dialogs::slices::OPTIONS));
|
||||
assert_eq!(app.session.active().unwrap().selection.slices, vec![vectorcraft_doc::NodeId(id)]);
|
||||
}
|
||||
|
|
@ -318,3 +318,15 @@ gets a `confirm` dialog whose OK runs `file.saveAs`). Fields: `folder` (the loca
|
|||
`copyLinks`, `linksFolder`, `relink`, `copyFonts`, `report`. `ui.dialog.confirm` saves unsaved changes, runs
|
||||
`file.package` and then opens a `confirm` dialog whose OK runs `file.showPackage {folder}`; the web downloads the zip
|
||||
instead. Document Info's flyout picks a category and Save… runs `docInfo.save {path?, selectionOnly?}`.
|
||||
|
||||
Slices: through the app, `object.slice.options` and `object.slice.divide` with no params open their dialogs on the
|
||||
selected slices. `sliceOptions` (fields `kind`: `image`/`noImage`/`htmlText`, `name`, `url`, `target`, `message`,
|
||||
`alt`, `text`, `background`: `""`/`matte`/`#rrggbb`, `hAlign`, `vAlign`; `__html` says whether HTML Text applies)
|
||||
runs `object.slice.options` on `ui.dialog.confirm`; `divideSlices` (fields `divideRows`, `rows`, `rowMode`:
|
||||
`count`/`size`, `rowHeight`, and `divideColumns`, `columns`, `columnMode`, `columnWidth`) runs `object.slice.divide`.
|
||||
Read slice options with `slice.list`.
|
||||
|
||||
The Slice tools: `ui.pointer` drags with the `slice` tool make a user slice (Shift square, Alt from the centre; one
|
||||
undo step). With `sliceSelection` a click selects a slice (Shift toggles), a drag moves the selected slices, a drag on
|
||||
a selected user slice's handle resizes it, a double click opens `sliceOptions` and `ui.key` Delete deletes the
|
||||
selected slices. Hidden or locked slices (`view.slices.hide`, `view.slices.lock`) can't be picked.
|
||||
|
|
|
|||
35
docs/mcp.md
35
docs/mcp.md
|
|
@ -1114,3 +1114,38 @@ the package report write.
|
|||
{"name":"run_command","arguments":{"command":"document.info","params":{"format":"text","category":"fontDetails"}}}
|
||||
```
|
||||
|
||||
## Slices
|
||||
|
||||
Object → Slice cuts the art into the pieces web output saves. User slices are rectangles of their own
|
||||
(`document.slices`, ids from the object id counter); `object.slice.make` turns the selected objects into object
|
||||
slices, whose slice follows the object's bounds; auto slices fill what no other slice covers (the artboards with
|
||||
Clip to Artboard on, the default, else the art and the slices). `slice.list` returns every slice as laid out,
|
||||
numbered left to right and top to bottom: `{number, id (null for auto slices), source: user|object|auto, name, x, y,
|
||||
width, height, options, selected}`, plus `clipToArtboard`, `hidden` and `locked`.
|
||||
|
||||
The Object → Slice commands act on the selected slices: the ones `select.object.slices` (Select → Object → Slices)
|
||||
selects, and the selected objects that are object slices. `object.slice.release` (an object slice leaves its object,
|
||||
a user slice becomes an unpainted rectangle), `object.slice.fromGuides` (the grid the ruler guides cut the artboards
|
||||
into), `object.slice.fromSelection`, `object.slice.duplicate {dx?, dy?}`, `object.slice.combine`,
|
||||
`object.slice.divide {rows? | rowHeight?, columns? | columnWidth?}`, `object.slice.deleteAll`,
|
||||
`object.slice.options {kind?: image|noImage|htmlText, name?, url?, target?, message?, alt?, text?, background?:
|
||||
""|matte|#rrggbb, hAlign?, vAlign?}` and `object.slice.clipToArtboard {on?}` are each one undo step.
|
||||
`view.slices.hide {hidden?}` and `view.slices.lock {locked?}` are session view state; the canvas draws the slices in
|
||||
the `sliceLineColor` preference, numbered while `showSliceNumbers` is on.
|
||||
|
||||
```json
|
||||
{"name":"run_command","arguments":{"command":"object.slice.make","params":{}}}
|
||||
{"name":"run_command","arguments":{"command":"object.slice.options","params":{"name":"logo","url":"https://example.com","alt":"Logo"}}}
|
||||
{"name":"run_command","arguments":{"command":"slice.list","params":{}}}
|
||||
```
|
||||
|
||||
The Slice tool (`slice`, Shift+K) and the Slice Selection tool (`sliceSelection`) reduce to commands:
|
||||
`object.slice.create {x, y, width, height}` (the Slice tool's drag; it snaps to ruler guides), `object.slice.select
|
||||
{slices?, toggle?}`, `object.slice.move {slices?, dx, dy}` (an object slice moves its object),
|
||||
`object.slice.setRect {id, x, y, width, height}` (user slices) and `object.slice.delete {slices?}`. Slice ids are user
|
||||
slice ids and the ids of objects with an object slice.
|
||||
|
||||
```json
|
||||
{"name":"run_command","arguments":{"command":"object.slice.create","params":{"x":0,"y":0,"width":200,"height":80}}}
|
||||
{"name":"run_command","arguments":{"command":"object.slice.move","params":{"dx":10,"dy":0}}}
|
||||
```
|
||||
|
|
|
|||
Loading…
Reference in New Issue