M3.44: graphic styles keep opacity and blend, capture groups and type, add on Alt-click
Styles now store the object's opacity, blend mode, isolate and knockout and apply them. New Graphic Style from a group without its own paint captures its topmost object's appearance, and from type its characters' fill and stroke. Names are made unique. graphicStyle.apply takes ids and add (Alt-click in the panel adds the style on top of the existing appearance). graphicStyle.list reports every style. Applying links objects to a stable style id (Node.graphic_style), which the management commands build on. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
6bd27a05da
commit
eb83efdb19
|
|
@ -254,11 +254,49 @@ impl Default for GridPrefs {
|
|||
}
|
||||
}
|
||||
|
||||
/// A named graphic style (Graphic Styles panel).
|
||||
/// A named graphic style (Graphic Styles panel): an appearance plus the object's transparency.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct GraphicStyle {
|
||||
pub name: String,
|
||||
pub appearance: Appearance,
|
||||
/// Stable id linked objects refer to ([`Node::graphic_style`]); 0 until first needed (styles
|
||||
/// from older files get one when an object is first linked to them).
|
||||
#[serde(default, skip_serializing_if = "skip::is_default")]
|
||||
pub id: u32,
|
||||
#[serde(default = "one", skip_serializing_if = "skip::is_one")]
|
||||
pub opacity: f32,
|
||||
#[serde(default, skip_serializing_if = "skip::is_default")]
|
||||
pub blend: vectorcraft_color::BlendMode,
|
||||
#[serde(default, skip_serializing_if = "skip::is_default")]
|
||||
pub isolate: bool,
|
||||
#[serde(default, skip_serializing_if = "skip::is_default")]
|
||||
pub knockout: bool,
|
||||
}
|
||||
|
||||
fn one() -> f32 {
|
||||
1.0
|
||||
}
|
||||
|
||||
/// The name of the style new documents list first (Sort by Name keeps it first).
|
||||
pub const DEFAULT_GRAPHIC_STYLE: &str = "Default Graphic Style";
|
||||
|
||||
impl GraphicStyle {
|
||||
/// A style with `appearance` and default transparency (no id yet).
|
||||
pub fn new(name: impl Into<String>, appearance: Appearance) -> Self {
|
||||
Self { name: name.into(), appearance, id: 0, opacity: 1.0, blend: Default::default(), isolate: false, knockout: false }
|
||||
}
|
||||
/// A style capturing `n`'s transparency and `appearance`.
|
||||
pub fn of(name: impl Into<String>, appearance: Appearance, n: &Node) -> Self {
|
||||
Self { opacity: n.opacity, blend: n.blend, isolate: n.isolate, knockout: n.knockout, ..Self::new(name, appearance) }
|
||||
}
|
||||
/// Give `n` this style's transparency.
|
||||
pub fn apply_transparency(&self, n: &mut Node) {
|
||||
(n.opacity, n.blend, n.isolate, n.knockout) = (self.opacity, self.blend, self.isolate, self.knockout);
|
||||
}
|
||||
/// Does `n` have this style's transparency?
|
||||
pub fn transparency_matches(&self, n: &Node) -> bool {
|
||||
(n.opacity, n.blend, n.isolate, n.knockout) == (self.opacity, self.blend, self.isolate, self.knockout)
|
||||
}
|
||||
}
|
||||
|
||||
/// A symbol definition.
|
||||
|
|
@ -633,20 +671,51 @@ impl Document {
|
|||
}
|
||||
}
|
||||
|
||||
/// Graphic styles.
|
||||
impl Document {
|
||||
/// Index of the graphic style named `name`.
|
||||
pub fn graphic_style_index(&self, name: &str) -> Option<usize> {
|
||||
self.graphic_styles.iter().position(|g| g.name == name)
|
||||
}
|
||||
/// The graphic style named `name`.
|
||||
pub fn graphic_style(&self, name: &str) -> Option<&GraphicStyle> {
|
||||
self.graphic_styles.iter().find(|g| g.name == name)
|
||||
}
|
||||
/// The graphic style with id `id` (0 never matches).
|
||||
pub fn graphic_style_by_id(&self, id: u32) -> Option<&GraphicStyle> {
|
||||
self.graphic_styles.iter().find(|g| g.id != 0 && g.id == id)
|
||||
}
|
||||
/// An id no graphic style has.
|
||||
pub fn next_graphic_style_id(&self) -> u32 {
|
||||
self.graphic_styles.iter().map(|g| g.id).max().unwrap_or(0) + 1
|
||||
}
|
||||
/// The id of graphic style `index`, assigning one if it has none yet.
|
||||
pub fn graphic_style_id(&mut self, index: usize) -> u32 {
|
||||
if self.graphic_styles[index].id == 0 {
|
||||
self.graphic_styles[index].id = self.next_graphic_style_id();
|
||||
}
|
||||
self.graphic_styles[index].id
|
||||
}
|
||||
/// The default name of a new graphic style: the first free "Graphic Style N", N counting on
|
||||
/// from the number of styles.
|
||||
pub fn new_graphic_style_name(&self) -> String {
|
||||
(self.graphic_styles.len() + 1..).map(|i| format!("Graphic Style {i}")).find(|n| self.graphic_style(n).is_none()).unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
fn default_graphic_styles() -> Vec<GraphicStyle> {
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
vec![
|
||||
GraphicStyle { name: "Default Graphic Style".into(), appearance: Appearance::default_art() },
|
||||
GraphicStyle { name: "Black Outline".into(), appearance: Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0) },
|
||||
GraphicStyle {
|
||||
name: "Heavy Ink".into(),
|
||||
appearance: Appearance::basic(Paint::solid(Color::from_hex("#1b1464").unwrap()), Paint::solid(Color::BLACK), 4.0),
|
||||
},
|
||||
GraphicStyle {
|
||||
name: "Sunshine".into(),
|
||||
appearance: Appearance::basic(Paint::solid(Color::from_hex("#fbb03b").unwrap()), Paint::solid(Color::from_hex("#f15a24").unwrap()), 2.0),
|
||||
},
|
||||
let solid = |hex| Paint::solid(Color::from_hex(hex).unwrap());
|
||||
[
|
||||
GraphicStyle::new(DEFAULT_GRAPHIC_STYLE, Appearance::default_art()),
|
||||
GraphicStyle::new("Black Outline", Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0)),
|
||||
GraphicStyle::new("Heavy Ink", Appearance::basic(solid("#1b1464"), Paint::solid(Color::BLACK), 4.0)),
|
||||
GraphicStyle::new("Sunshine", Appearance::basic(solid("#fbb03b"), solid("#f15a24"), 2.0)),
|
||||
]
|
||||
.into_iter()
|
||||
.zip(1..)
|
||||
.map(|(g, id)| GraphicStyle { id, ..g })
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
|
|||
|
|
@ -270,6 +270,10 @@ pub struct Node {
|
|||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub graph: Option<Box<crate::graph::GraphSpec>>,
|
||||
pub kind: NodeKind,
|
||||
/// The [`crate::GraphicStyle::id`] last applied to this object. It stays linked while it keeps
|
||||
/// that style's look: editing its appearance or transparency breaks the link.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub graphic_style: Option<u32>,
|
||||
}
|
||||
|
||||
/// Opacity mask: the luminance of the mask art sets the object's opacity (white = opaque).
|
||||
|
|
@ -314,6 +318,7 @@ impl Node {
|
|||
wrap: None,
|
||||
graph: None,
|
||||
kind,
|
||||
graphic_style: None,
|
||||
}
|
||||
}
|
||||
pub fn path(id: NodeId, path: PathData, appearance: Appearance) -> Self {
|
||||
|
|
|
|||
|
|
@ -1,34 +1,271 @@
|
|||
//! The Graphic Styles panel: apply, new, delete and duplicate.
|
||||
//! The Graphic Styles panel: new, apply (replace or add), link, redefine, break link, rename,
|
||||
//! delete, duplicate, sort and list.
|
||||
//!
|
||||
//! Applying a style links the targeted objects to it ([`Node::graphic_style`]). An object stays
|
||||
//! linked while it keeps the style's look; editing its appearance or transparency breaks the link
|
||||
//! ([`in_sync`]), and Redefine Graphic Style updates only the objects still linked.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{Appearance, DEFAULT_GRAPHIC_STYLE, Document, GraphicStyle, Node, NodeId, NodeKind};
|
||||
|
||||
use super::*;
|
||||
use crate::EngineError;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!("graphicStyle.apply", "Apply Graphic Style", ["Window", "Graphic Styles"], None, "{name}", has_selection, style_apply),
|
||||
cmd!("graphicStyle.new", "New Graphic Style", ["Window", "Graphic Styles"], None, "{name?} from the selection", has_selection, style_new),
|
||||
cmd!("graphicStyle.delete", "Delete Graphic Style", ["Window", "Graphic Styles"], None, "{name}", has_doc, style_delete),
|
||||
cmd!("graphicStyle.duplicate", "Duplicate Graphic Style", ["Window", "Graphic Styles"], None, "{name}", has_doc, style_duplicate),
|
||||
cmd!(
|
||||
"graphicStyle.apply",
|
||||
"Apply Graphic Style",
|
||||
["Window", "Graphic Styles"],
|
||||
None,
|
||||
"{name, add?: bool, ids?} give the objects (default: the selection; a group's contents) the style's appearance, and themselves its opacity, blend mode, isolate and knockout, and link them to it; add: true (Alt-click) adds its fills, strokes and effects on top of the existing appearance instead and unlinks",
|
||||
has_doc,
|
||||
style_apply
|
||||
),
|
||||
cmd!(
|
||||
"graphicStyle.new",
|
||||
"New Graphic Style",
|
||||
["Window", "Graphic Styles"],
|
||||
None,
|
||||
"{name?, id?} a style from object `id` or the first selected object: its appearance, opacity, blend mode, isolate and knockout (a group without fills or strokes of its own lends its topmost object's, type its characters'); links the object. Names are made unique → {name}",
|
||||
has_doc,
|
||||
style_new
|
||||
),
|
||||
cmd!(
|
||||
"graphicStyle.delete",
|
||||
"Delete Graphic Style",
|
||||
["Window", "Graphic Styles"],
|
||||
None,
|
||||
"{name} | {names: [name…]} delete styles; their objects keep their look but are unlinked",
|
||||
has_doc,
|
||||
style_delete
|
||||
),
|
||||
cmd!("graphicStyle.duplicate", "Duplicate Graphic Style", ["Window", "Graphic Styles"], None, "{name} → {name}", has_doc, style_duplicate),
|
||||
cmd!(
|
||||
query "graphicStyle.list",
|
||||
"List Graphic Styles",
|
||||
[],
|
||||
None,
|
||||
"{} → {styles: [{id, name, fill, stroke, strokeWidth, fills, strokes, effects: [effect id…], opacity: 0..100, blend, isolate, knockout, linked: [object ids]}], selected: the style the first selected object is linked to, or null}",
|
||||
has_doc,
|
||||
style_list
|
||||
),
|
||||
cmd!(
|
||||
"graphicStyle.redefine",
|
||||
"Redefine Graphic Style",
|
||||
["Window", "Appearance"],
|
||||
None,
|
||||
"{name?, id?} replace the style (default: the one the object was last linked to) with the look of object `id` or the first selected object; the objects still linked to it update, and that object becomes linked → {name}",
|
||||
has_doc,
|
||||
style_redefine
|
||||
),
|
||||
cmd!(
|
||||
"graphicStyle.breakLink",
|
||||
"Break Link to Graphic Style",
|
||||
["Window", "Graphic Styles"],
|
||||
None,
|
||||
"{ids?} unlink the objects (default: the selection) from their graphic style; they keep their look",
|
||||
has_doc,
|
||||
style_break_link
|
||||
),
|
||||
cmd!(
|
||||
"graphicStyle.rename",
|
||||
"Rename Graphic Style",
|
||||
["Window", "Graphic Styles"],
|
||||
None,
|
||||
"{name, to} (Graphic Style Options; names are unique and linked objects stay linked) → {name}",
|
||||
has_doc,
|
||||
style_rename
|
||||
),
|
||||
cmd!(
|
||||
query "graphicStyle.unused",
|
||||
"Select All Unused",
|
||||
["Window", "Graphic Styles"],
|
||||
None,
|
||||
"{} → {names: [the styles no object is linked to]} (the panel selects them)",
|
||||
has_doc,
|
||||
style_unused
|
||||
),
|
||||
cmd!(
|
||||
"graphicStyle.sortByName",
|
||||
"Sort by Name",
|
||||
["Window", "Graphic Styles"],
|
||||
None,
|
||||
"{} sort the styles by name (the Default Graphic Style stays first)",
|
||||
has_doc,
|
||||
style_sort
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
// ---------- links ----------
|
||||
|
||||
/// Does a style applied to `n` (`top`: `n` is the target itself) descend into its children? Groups
|
||||
/// and layers style their contents, and so do other containers (blends…) inside them, while a
|
||||
/// compound path takes it as a whole, as with paint commands ([`leaf_targets`]).
|
||||
fn descends(n: &Node, top: bool) -> bool {
|
||||
match n.kind {
|
||||
NodeKind::Group { .. } | NodeKind::Layer { .. } => true,
|
||||
NodeKind::Compound { .. } => false,
|
||||
_ => !top && n.is_container(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The objects whose appearance a style applied to `n` sets: `n`, or its painted contents.
|
||||
fn painted<'a>(n: &'a Node, top: bool, out: &mut Vec<&'a Node>) {
|
||||
match n.children() {
|
||||
Some(ch) if descends(n, top) => ch.iter().for_each(|c| painted(c, false, out)),
|
||||
_ => out.push(n),
|
||||
}
|
||||
}
|
||||
|
||||
/// [`painted`], mutably (copying only the nodes on the way).
|
||||
fn paint_mut(n: &mut Node, top: bool, f: &mut impl FnMut(&mut Node)) {
|
||||
if !descends(n, top) {
|
||||
return f(n);
|
||||
}
|
||||
for c in n.children_mut().into_iter().flatten() {
|
||||
paint_mut(std::sync::Arc::make_mut(c), false, f);
|
||||
}
|
||||
}
|
||||
|
||||
/// Does `n` still have style `g`'s look (its transparency, and its appearance on every painted
|
||||
/// object)? Linked objects that don't are no longer linked.
|
||||
pub(crate) fn in_sync(n: &Node, g: &GraphicStyle) -> bool {
|
||||
let mut leaves = vec![];
|
||||
painted(n, true, &mut leaves);
|
||||
g.transparency_matches(n) && leaves.iter().all(|l| l.appearance == g.appearance)
|
||||
}
|
||||
|
||||
/// The style `n` is linked to (and still looks like).
|
||||
pub(crate) fn linked_style<'a>(d: &'a Document, n: &Node) -> Option<&'a GraphicStyle> {
|
||||
d.graphic_style_by_id(n.graphic_style?).filter(|g| in_sync(n, g))
|
||||
}
|
||||
|
||||
/// Objects anywhere in the art that `f` accepts.
|
||||
fn objects_where(d: &Document, mut f: impl FnMut(&Node) -> bool) -> Vec<NodeId> {
|
||||
let mut ids = vec![];
|
||||
for l in &d.layers {
|
||||
l.walk(&mut |n| {
|
||||
if f(n) {
|
||||
ids.push(n.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
ids
|
||||
}
|
||||
|
||||
/// The objects linked to each style id.
|
||||
fn links(d: &Document) -> HashMap<u32, Vec<NodeId>> {
|
||||
let mut out: HashMap<u32, Vec<NodeId>> = HashMap::new();
|
||||
for l in &d.layers {
|
||||
l.walk(&mut |n| {
|
||||
if let Some(g) = linked_style(d, n) {
|
||||
out.entry(g.id).or_default().push(n.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The appearance a new or redefined style takes from `n`: its own. A group without fills or
|
||||
/// strokes of its own lends those of its topmost painted object, and type its characters' fill
|
||||
/// and stroke.
|
||||
fn captured(n: &Node) -> Appearance {
|
||||
let mut ap = n.appearance.clone();
|
||||
if !ap.items.is_empty() {
|
||||
return ap;
|
||||
}
|
||||
match &n.kind {
|
||||
NodeKind::Text(t) => {
|
||||
if let Some(r) = t.runs.first() {
|
||||
ap.items = Appearance::basic(r.style.fill.clone(), r.style.stroke.clone(), r.style.stroke_width).items;
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
let mut leaves = vec![];
|
||||
painted(n, true, &mut leaves);
|
||||
if let Some(top) = leaves.last().filter(|l| l.id != n.id) {
|
||||
// The topmost object's own effects apply before the group's.
|
||||
let top = captured(top);
|
||||
ap.items = top.items;
|
||||
ap.effects.splice(0..0, top.effects);
|
||||
}
|
||||
}
|
||||
}
|
||||
ap
|
||||
}
|
||||
|
||||
/// Give `id` style `g` (its painted objects the appearance, itself the transparency) and link it.
|
||||
fn apply_style(d: &mut Document, id: NodeId, g: &GraphicStyle) {
|
||||
if let Some(n) = d.node_mut(id) {
|
||||
paint_mut(n, true, &mut |l| l.appearance = g.appearance.clone());
|
||||
g.apply_transparency(n);
|
||||
n.graphic_style = Some(g.id);
|
||||
}
|
||||
}
|
||||
|
||||
fn set_link(d: &mut Document, ids: &[NodeId], link: Option<u32>) {
|
||||
for id in ids {
|
||||
if let Some(n) = d.node_mut(*id) {
|
||||
n.graphic_style = link;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn style_index(d: &Document, name: &str) -> Result<usize> {
|
||||
d.graphic_style_index(name).ok_or_else(|| EngineError::Other(format!("no graphic style `{name}`")))
|
||||
}
|
||||
|
||||
/// The object a style is made from: `id`, else the first selected object.
|
||||
fn source<'a>(s: &'a Session, p: &Value, cmd: &str) -> Result<(NodeId, &'a Node)> {
|
||||
let id = match id_param(p, "id") {
|
||||
Some(id) => id,
|
||||
None => super::edit::selected_roots(s)?.first().copied().ok_or_else(|| bad(cmd, "select an object (or pass `id`)"))?,
|
||||
};
|
||||
Ok((id, s.doc()?.doc.node(id).ok_or_else(|| bad(cmd, format!("no object {}", id.0)))?))
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// The graphic style of the first selected object: the one it was last linked to, and whether
|
||||
/// it is still linked (still has that style's look).
|
||||
pub fn selection_graphic_style(&self) -> Option<(&GraphicStyle, bool)> {
|
||||
let st = self.active()?;
|
||||
let n = st.doc.node(*st.selection.objects.first()?)?;
|
||||
let g = st.doc.graphic_style_by_id(n.graphic_style?)?;
|
||||
Some((g, in_sync(n, g)))
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- commands ----------
|
||||
|
||||
fn style_apply(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name = str_param(p, "name").ok_or_else(|| bad("graphicStyle.apply", "missing name"))?;
|
||||
let ap = s
|
||||
.doc()?
|
||||
.doc
|
||||
.graphic_styles
|
||||
.iter()
|
||||
.find(|g| g.name == name)
|
||||
.map(|g| g.appearance.clone())
|
||||
.ok_or_else(|| EngineError::Other(format!("no graphic style `{name}`")))?;
|
||||
let ids = paint_targets(s, p)?;
|
||||
let i = style_index(&s.doc()?.doc, name)?;
|
||||
let add = bool_or(p, "add", false);
|
||||
let ids = super::appearance::appearance_targets(s, p)?;
|
||||
if ids.is_empty() {
|
||||
return Err(bad("graphicStyle.apply", "nothing selected (or pass `ids`)"));
|
||||
}
|
||||
s.edit("Apply Graphic Style", |d, _| {
|
||||
for id in &ids {
|
||||
if let Some(n) = d.node_mut(*id) {
|
||||
n.appearance = ap.clone();
|
||||
if add {
|
||||
let ap = d.graphic_styles[i].appearance.clone();
|
||||
for id in &ids {
|
||||
if let Some(n) = d.node_mut(*id) {
|
||||
paint_mut(n, true, &mut |l| {
|
||||
l.appearance.items.extend(ap.items.iter().cloned());
|
||||
l.appearance.effects.extend(ap.effects.iter().cloned());
|
||||
});
|
||||
n.graphic_style = None;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
d.graphic_style_id(i);
|
||||
let g = d.graphic_styles[i].clone();
|
||||
for id in &ids {
|
||||
apply_style(d, *id, &g);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
|
|
@ -37,23 +274,37 @@ fn style_apply(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
}
|
||||
|
||||
fn style_new(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let id = *st.selection.objects.first().unwrap();
|
||||
let ap = st.doc.node(id).map(|n| n.appearance.clone()).unwrap_or_default();
|
||||
let name = str_param(p, "name").map(str::to_string).unwrap_or_else(|| format!("Graphic Style {}", st.doc.graphic_styles.len() + 1));
|
||||
let (src, n) = source(s, p, "graphicStyle.new")?;
|
||||
let d = &s.doc()?.doc;
|
||||
let name = match str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()) {
|
||||
Some(n) => unique_name(n, |x| d.graphic_style(x).is_some()),
|
||||
None => d.new_graphic_style_name(),
|
||||
};
|
||||
let g = GraphicStyle { id: d.next_graphic_style_id(), ..GraphicStyle::of(name.clone(), captured(n), n) };
|
||||
s.edit("New Graphic Style", |d, _| {
|
||||
d.graphic_styles.push(vectorcraft_doc::GraphicStyle { name: name.clone(), appearance: ap });
|
||||
set_link(d, &[src], Some(g.id));
|
||||
d.graphic_styles.push(g);
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({ "name": name }))
|
||||
}
|
||||
|
||||
fn style_delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name = str_param(p, "name").ok_or_else(|| bad("graphicStyle.delete", "missing name"))?.to_string();
|
||||
s.edit("Delete Graphic Style", |d, _| {
|
||||
let before = d.graphic_styles.len();
|
||||
d.graphic_styles.retain(|g| g.name != name);
|
||||
if d.graphic_styles.len() == before { Err(EngineError::Other(format!("no graphic style `{name}`"))) } else { Ok(()) }
|
||||
let names: Vec<String> = match (p.get("names").and_then(Value::as_array), str_param(p, "name")) {
|
||||
(Some(a), _) => a.iter().filter_map(Value::as_str).map(str::to_string).collect(),
|
||||
(None, Some(n)) => vec![n.to_string()],
|
||||
(None, None) => return Err(bad("graphicStyle.delete", "missing name")),
|
||||
};
|
||||
s.edit(if names.len() > 1 { "Delete Graphic Styles" } else { "Delete Graphic Style" }, |d, _| {
|
||||
for name in &names {
|
||||
let i = style_index(d, name)?;
|
||||
let id = d.graphic_styles.remove(i).id;
|
||||
if id != 0 {
|
||||
let ids = objects_where(d, |n| n.graphic_style == Some(id));
|
||||
set_link(d, &ids, None);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
ok()
|
||||
}
|
||||
|
|
@ -61,12 +312,112 @@ fn style_delete(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
fn style_duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name = str_param(p, "name").ok_or_else(|| bad("graphicStyle.duplicate", "missing name"))?.to_string();
|
||||
let new = s.edit("Duplicate Graphic Style", |d, _| {
|
||||
let pos = d.graphic_styles.iter().position(|g| g.name == name).ok_or_else(|| EngineError::Other(format!("no graphic style `{name}`")))?;
|
||||
let mut g = d.graphic_styles[pos].clone();
|
||||
let nm = unique_name(&format!("{name} copy"), |n| d.graphic_styles.iter().any(|x| x.name == n));
|
||||
g.name = nm.clone();
|
||||
let pos = style_index(d, &name)?;
|
||||
let nm = unique_name(&format!("{name} copy"), |n| d.graphic_style(n).is_some());
|
||||
let g = GraphicStyle { name: nm.clone(), id: d.next_graphic_style_id(), ..d.graphic_styles[pos].clone() };
|
||||
d.graphic_styles.insert(pos + 1, g);
|
||||
Ok(nm)
|
||||
})?;
|
||||
Ok(json!({"name": new}))
|
||||
}
|
||||
|
||||
fn style_list(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let d = &s.doc()?.doc;
|
||||
let links = links(d);
|
||||
let styles: Vec<Value> = d
|
||||
.graphic_styles
|
||||
.iter()
|
||||
.map(|g| {
|
||||
let ap = &g.appearance;
|
||||
let count = |fill: bool| ap.items.iter().filter(|i| i.is_fill() == fill).count();
|
||||
json!({
|
||||
"id": g.id,
|
||||
"name": g.name,
|
||||
"fill": ap.fill_paint().label(),
|
||||
"stroke": ap.stroke_paint().label(),
|
||||
"strokeWidth": ap.stroke_width(),
|
||||
"fills": count(true),
|
||||
"strokes": count(false),
|
||||
"effects": ap.effects.iter().map(|e| e.id.as_str()).collect::<Vec<_>>(),
|
||||
"opacity": (g.opacity * 100.0).round(),
|
||||
"blend": g.blend.label(),
|
||||
"isolate": g.isolate,
|
||||
"knockout": g.knockout,
|
||||
"linked": links.get(&g.id).map(|v| v.iter().map(|i| i.0).collect::<Vec<_>>()).unwrap_or_default(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let selected = s.selection_graphic_style().filter(|(_, linked)| *linked).map(|(g, _)| g.name.clone());
|
||||
Ok(json!({ "styles": styles, "selected": selected }))
|
||||
}
|
||||
|
||||
fn style_redefine(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "graphicStyle.redefine";
|
||||
let (src, n) = source(s, p, cmd)?;
|
||||
let d = &s.doc()?.doc;
|
||||
let i = match str_param(p, "name") {
|
||||
Some(name) => style_index(d, name)?,
|
||||
None => n
|
||||
.graphic_style
|
||||
.and_then(|id| d.graphic_style_by_id(id))
|
||||
.and_then(|g| d.graphic_style_index(&g.name))
|
||||
.ok_or_else(|| bad(cmd, "the object has no graphic style (pass `name`)"))?,
|
||||
};
|
||||
let look = GraphicStyle::of(String::new(), captured(n), n);
|
||||
let name = d.graphic_styles[i].name.clone();
|
||||
s.edit(&format!("Redefine Graphic Style \u{201c}{name}\u{201d}"), |d, _| {
|
||||
let id = d.graphic_style_id(i);
|
||||
let old = std::mem::replace(&mut d.graphic_styles[i], GraphicStyle { name: name.clone(), id, ..look });
|
||||
// Objects still linked follow the new definition; edited ones are unlinked (they keep
|
||||
// their look), and the source keeps its own.
|
||||
let linked = objects_where(d, |n| n.graphic_style == Some(id) && n.id != src);
|
||||
let (follow, edited): (Vec<NodeId>, Vec<NodeId>) = linked.into_iter().partition(|nid| d.node(*nid).is_some_and(|n| in_sync(n, &old)));
|
||||
set_link(d, &edited, None);
|
||||
let g = d.graphic_styles[i].clone();
|
||||
for nid in follow {
|
||||
apply_style(d, nid, &g);
|
||||
}
|
||||
set_link(d, &[src], Some(id));
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({ "name": name }))
|
||||
}
|
||||
|
||||
fn style_break_link(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = super::appearance::appearance_targets(s, p)?;
|
||||
s.edit("Break Link to Graphic Style", |d, _| {
|
||||
set_link(d, &ids, None);
|
||||
Ok(())
|
||||
})?;
|
||||
ok()
|
||||
}
|
||||
|
||||
fn style_rename(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "graphicStyle.rename";
|
||||
let name = str_param(p, "name").ok_or_else(|| bad(cmd, "missing name"))?.to_string();
|
||||
let to = str_param(p, "to").map(str::trim).filter(|t| !t.is_empty()).ok_or_else(|| bad(cmd, "missing `to`"))?.to_string();
|
||||
s.edit("Graphic Style Options", |d, _| {
|
||||
let i = style_index(d, &name)?;
|
||||
if to != name && d.graphic_style(&to).is_some() {
|
||||
return Err(EngineError::Other(format!("a graphic style named `{to}` already exists")));
|
||||
}
|
||||
d.graphic_styles[i].name = to.clone();
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({ "name": to }))
|
||||
}
|
||||
|
||||
fn style_unused(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let d = &s.doc()?.doc;
|
||||
let links = links(d);
|
||||
let names: Vec<&str> = d.graphic_styles.iter().filter(|g| !links.contains_key(&g.id)).map(|g| g.name.as_str()).collect();
|
||||
Ok(json!({ "names": names }))
|
||||
}
|
||||
|
||||
fn style_sort(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
s.edit("Sort by Name", |d, _| {
|
||||
d.graphic_styles.sort_by_cached_key(|g| (g.name != DEFAULT_GRAPHIC_STYLE, g.name.to_lowercase()));
|
||||
Ok(())
|
||||
})?;
|
||||
ok()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -772,6 +772,8 @@ mod tests_prefs;
|
|||
#[cfg(test)]
|
||||
mod tests_strokegeom;
|
||||
#[cfg(test)]
|
||||
mod tests_styles;
|
||||
#[cfg(test)]
|
||||
mod tests_swatches;
|
||||
#[cfg(test)]
|
||||
mod tests_textedit;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,220 @@
|
|||
//! Graphic styles: transparency, capture from groups and type, add-apply, unique names, links
|
||||
//! (redefine, break link, delete, rename), Select All Unused, Sort by Name and Select > Same.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::{BlendMode, Color, Paint};
|
||||
use vectorcraft_doc::{GraphicStyle, Node};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 600, "height": 400})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn run(s: &mut Session, id: &str, p: Value) -> Value {
|
||||
s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"))
|
||||
}
|
||||
|
||||
/// A selected 50×50 rectangle at `x` with the default appearance.
|
||||
fn rect(s: &mut Session, x: f64) -> NodeId {
|
||||
let id = NodeId(run(s, "shape.rectangle", json!({"x": x, "y": 10, "width": 50, "height": 50}))["id"].as_u64().unwrap());
|
||||
run(s, "select.set", json!({"ids": [id.0]}));
|
||||
id
|
||||
}
|
||||
|
||||
fn node(s: &Session, id: NodeId) -> Node {
|
||||
s.doc().unwrap().doc.node(id).unwrap().clone()
|
||||
}
|
||||
|
||||
fn style(s: &Session, name: &str) -> GraphicStyle {
|
||||
s.doc().unwrap().doc.graphic_style(name).unwrap_or_else(|| panic!("no style {name}")).clone()
|
||||
}
|
||||
|
||||
fn fill(s: &Session, id: NodeId) -> Paint {
|
||||
node(s, id).appearance.fill_paint()
|
||||
}
|
||||
|
||||
fn set_fill(s: &mut Session, id: NodeId, hex: &str) {
|
||||
run(s, "paint.setFill", json!({"color": hex, "ids": [id.0]}));
|
||||
}
|
||||
|
||||
fn linked(s: &mut Session, name: &str) -> Vec<u64> {
|
||||
let l = run(s, "graphicStyle.list", json!({}));
|
||||
let st = l["styles"].as_array().unwrap().iter().find(|g| g["name"] == name).unwrap_or_else(|| panic!("{name} not listed")).clone();
|
||||
st["linked"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn styles_carry_opacity_and_blend_through_a_save() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 10.0);
|
||||
run(&mut s, "transparency.set", json!({"item": null, "opacity": 50, "blend": "multiply", "knockout": true}));
|
||||
run(&mut s, "graphicStyle.new", json!({"name": "Half"}));
|
||||
let g = style(&s, "Half");
|
||||
assert_eq!((g.opacity, g.blend, g.knockout), (0.5, BlendMode::Multiply, true));
|
||||
let b = rect(&mut s, 100.0);
|
||||
run(&mut s, "graphicStyle.apply", json!({"name": "Half"}));
|
||||
let nb = node(&s, b);
|
||||
assert_eq!((nb.opacity, nb.blend, nb.knockout), (0.5, BlendMode::Multiply, true));
|
||||
let l = run(&mut s, "graphicStyle.list", json!({}));
|
||||
let half = l["styles"].as_array().unwrap().iter().find(|g| g["name"] == "Half").unwrap();
|
||||
assert_eq!((half["opacity"].as_f64(), half["blend"].as_str()), (Some(50.0), Some("Multiply")));
|
||||
assert_eq!(linked(&mut s, "Half"), vec![a.0, b.0]);
|
||||
assert_eq!(l["selected"], "Half");
|
||||
|
||||
// Native round trip: the style's transparency, its id and the objects' links survive.
|
||||
let doc = s.doc().unwrap().doc.clone();
|
||||
let back = vectorcraft_format::load(&vectorcraft_format::save(&doc, false)).unwrap();
|
||||
assert_eq!(back.graphic_styles, doc.graphic_styles);
|
||||
assert_eq!(back.node(b).unwrap().graphic_style, Some(g.id));
|
||||
// Styles and objects from files without links still load (and get an id when first linked).
|
||||
let old: GraphicStyle = serde_json::from_value(json!({"name": "Old", "appearance": {"items": []}})).unwrap();
|
||||
assert_eq!((old.id, old.opacity, old.blend, old.isolate), (0, 1.0, BlendMode::Normal, false));
|
||||
let mut d = (*doc).clone();
|
||||
d.graphic_styles.push(old);
|
||||
let i = d.graphic_styles.len() - 1;
|
||||
assert_eq!(d.graphic_style_id(i), d.graphic_styles.iter().map(|g| g.id).max().unwrap());
|
||||
assert!(!serde_json::to_string(&GraphicStyle::new("Plain", Default::default())).unwrap().contains("opacity"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_style_from_a_group_or_type_is_not_empty() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 10.0);
|
||||
let b = rect(&mut s, 100.0);
|
||||
run(&mut s, "select.set", json!({"ids": [a.0, b.0]}));
|
||||
run(&mut s, "paint.setFill", json!({"color": "#ff0000"}));
|
||||
let g = NodeId(run(&mut s, "object.group", json!({}))["id"].as_u64().unwrap());
|
||||
assert!(node(&s, g).appearance.items.is_empty());
|
||||
run(&mut s, "graphicStyle.new", json!({"name": "Group Look"}));
|
||||
let gs = style(&s, "Group Look");
|
||||
assert_eq!(gs.appearance.fill_paint(), Paint::solid(Color::from_hex("#ff0000").unwrap()));
|
||||
// Both members share the look, so the group is linked.
|
||||
assert_eq!(linked(&mut s, "Group Look"), vec![g.0]);
|
||||
|
||||
let t = NodeId(run(&mut s, "text.create", json!({"x": 10, "y": 200, "text": "Hi"}))["id"].as_u64().unwrap());
|
||||
run(&mut s, "select.set", json!({"ids": [t.0]}));
|
||||
run(&mut s, "graphicStyle.new", json!({"name": "Type Look"}));
|
||||
assert!(!style(&s, "Type Look").appearance.fill_paint().is_none());
|
||||
|
||||
// Applying to a group styles its contents and links the group.
|
||||
let c = rect(&mut s, 200.0);
|
||||
run(&mut s, "graphicStyle.apply", json!({"name": "Black Outline", "ids": [g.0]}));
|
||||
assert_eq!(node(&s, a).appearance, style(&s, "Black Outline").appearance);
|
||||
assert_eq!(node(&s, g).graphic_style, Some(style(&s, "Black Outline").id));
|
||||
assert_ne!(node(&s, c).appearance, style(&s, "Black Outline").appearance);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alt_apply_adds_the_style_on_top() {
|
||||
let mut s = session();
|
||||
rect(&mut s, 10.0);
|
||||
run(&mut s, "appearance.clear", json!({}));
|
||||
run(&mut s, "paint.setFill", json!({"color": "#00ff00"}));
|
||||
run(&mut s, "effect.apply", json!({"effect": "distort.roughen"}));
|
||||
run(&mut s, "graphicStyle.new", json!({"name": "Rough"}));
|
||||
let rough = style(&s, "Rough");
|
||||
let b = rect(&mut s, 100.0);
|
||||
let before = node(&s, b).appearance;
|
||||
run(&mut s, "graphicStyle.apply", json!({"name": "Rough", "add": true}));
|
||||
let after = node(&s, b).appearance;
|
||||
assert_eq!(after.items.len(), before.items.len() + rough.appearance.items.len());
|
||||
assert_eq!(after.items[..before.items.len()], before.items[..]);
|
||||
assert_eq!(after.items.last(), rough.appearance.items.last());
|
||||
assert_eq!(after.effects.len(), before.effects.len() + 1);
|
||||
assert_eq!(node(&s, b).graphic_style, None);
|
||||
// One undo step.
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(node(&s, b).appearance, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn style_names_are_unique() {
|
||||
let mut s = session();
|
||||
rect(&mut s, 10.0);
|
||||
assert_eq!(run(&mut s, "graphicStyle.new", json!({"name": "X"}))["name"], "X");
|
||||
assert_eq!(run(&mut s, "graphicStyle.new", json!({"name": "X"}))["name"], "X 2");
|
||||
let n = s.doc().unwrap().doc.graphic_styles.len();
|
||||
assert_eq!(run(&mut s, "graphicStyle.new", json!({}))["name"], format!("Graphic Style {}", n + 1));
|
||||
assert!(s.execute("graphicStyle.rename", &json!({"name": "X 2", "to": "X"})).is_err());
|
||||
assert!(s.execute("graphicStyle.rename", &json!({"name": "X 2", "to": " "})).is_err());
|
||||
assert_eq!(run(&mut s, "graphicStyle.rename", json!({"name": "X 2", "to": "Y"}))["name"], "Y");
|
||||
assert_eq!(run(&mut s, "graphicStyle.duplicate", json!({"name": "Y"}))["name"], "Y copy");
|
||||
assert_ne!(style(&s, "Y copy").id, style(&s, "Y").id);
|
||||
}
|
||||
|
||||
/// Three rectangles: `a` (the source of style "S"), `b` and `c`, all linked to "S".
|
||||
fn linked_three(s: &mut Session) -> (NodeId, NodeId, NodeId) {
|
||||
let a = rect(s, 10.0);
|
||||
run(s, "graphicStyle.new", json!({"name": "S"}));
|
||||
let b = rect(s, 100.0);
|
||||
let c = rect(s, 200.0);
|
||||
run(s, "graphicStyle.apply", json!({"name": "S", "ids": [b.0, c.0]}));
|
||||
assert_eq!(linked(s, "S"), vec![a.0, b.0, c.0]);
|
||||
(a, b, c)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redefine_updates_every_linked_object() {
|
||||
let mut s = session();
|
||||
let (a, b, c) = linked_three(&mut s);
|
||||
set_fill(&mut s, a, "#0000ff");
|
||||
// Editing the source broke its link, but it remembers the style Redefine replaces.
|
||||
assert_eq!(linked(&mut s, "S"), vec![b.0, c.0]);
|
||||
run(&mut s, "select.set", json!({"ids": [a.0]}));
|
||||
assert_eq!(s.selection_graphic_style().map(|(g, linked)| (g.name.clone(), linked)), Some(("S".into(), false)));
|
||||
run(&mut s, "graphicStyle.redefine", json!({}));
|
||||
let blue = Paint::solid(Color::from_hex("#0000ff").unwrap());
|
||||
assert_eq!((fill(&s, b), fill(&s, c)), (blue.clone(), blue));
|
||||
assert_eq!(linked(&mut s, "S"), vec![a.0, b.0, c.0]);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(fill(&s, b), Paint::solid(Color::WHITE));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redefine_leaves_unlinked_and_edited_objects_alone() {
|
||||
let mut s = session();
|
||||
let (a, b, c) = linked_three(&mut s);
|
||||
run(&mut s, "graphicStyle.breakLink", json!({"ids": [c.0]}));
|
||||
assert_eq!(node(&s, c).graphic_style, None);
|
||||
let d = rect(&mut s, 300.0);
|
||||
run(&mut s, "graphicStyle.apply", json!({"name": "S"}));
|
||||
set_fill(&mut s, d, "#00ff00");
|
||||
set_fill(&mut s, a, "#0000ff");
|
||||
run(&mut s, "graphicStyle.redefine", json!({"name": "S", "id": a.0}));
|
||||
assert_eq!(fill(&s, b), Paint::solid(Color::from_hex("#0000ff").unwrap()));
|
||||
assert_eq!(fill(&s, c), Paint::solid(Color::WHITE));
|
||||
// The edited object keeps its look and is unlinked.
|
||||
assert_eq!(fill(&s, d), Paint::solid(Color::from_hex("#00ff00").unwrap()));
|
||||
assert_eq!(node(&s, d).graphic_style, None);
|
||||
assert!(s.execute("graphicStyle.redefine", &json!({"id": c.0})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_a_style_unlinks_and_renaming_keeps_links() {
|
||||
let mut s = session();
|
||||
let (_, b, c) = linked_three(&mut s);
|
||||
run(&mut s, "graphicStyle.rename", json!({"name": "S", "to": "T"}));
|
||||
assert_eq!(linked(&mut s, "T").len(), 3);
|
||||
let look = node(&s, c).appearance;
|
||||
run(&mut s, "graphicStyle.delete", json!({"name": "T"}));
|
||||
assert_eq!((node(&s, b).graphic_style, node(&s, c).graphic_style), (None, None));
|
||||
assert_eq!(node(&s, c).appearance, look);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unused_styles_and_sort_by_name() {
|
||||
let mut s = session();
|
||||
linked_three(&mut s);
|
||||
let unused = run(&mut s, "graphicStyle.unused", json!({}));
|
||||
let names: Vec<&str> = unused["names"].as_array().unwrap().iter().filter_map(Value::as_str).collect();
|
||||
assert!(names.contains(&"Sunshine") && !names.contains(&"S"));
|
||||
run(&mut s, "graphicStyle.delete", json!({"names": names}));
|
||||
assert_eq!(s.doc().unwrap().doc.graphic_styles.len(), 1);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
run(&mut s, "graphicStyle.sortByName", json!({}));
|
||||
let names: Vec<String> = s.doc().unwrap().doc.graphic_styles.iter().map(|g| g.name.clone()).collect();
|
||||
assert_eq!(names, ["Default Graphic Style", "Black Outline", "Heavy Ink", "S", "Sunshine"]);
|
||||
}
|
||||
|
|
@ -1,14 +1,23 @@
|
|||
//! Graphic Styles panel: thumbnail grid or list of the document's styles.
|
||||
//! Graphic Styles panel: thumbnail grid or list of the document's styles. Clicking a style applies
|
||||
//! it to the selection (Alt-click adds it on top), Shift/Cmd-click selects several, double-click
|
||||
//! opens Graphic Style Options.
|
||||
|
||||
use egui::{Rect, Sense, Stroke, StrokeKind, Ui, vec2};
|
||||
use egui::{Rect, Response, Sense, Stroke, StrokeKind, Ui, vec2};
|
||||
use serde_json::json;
|
||||
use vectorcraft_doc::Appearance;
|
||||
use vectorcraft_doc::{Appearance, GraphicStyle};
|
||||
|
||||
use super::{pstate, selection_len, set_pstate};
|
||||
use super::{alt_held, pstate, selection_len, set_pstate};
|
||||
use crate::VectorcraftApp;
|
||||
use crate::theme::Tokens;
|
||||
use crate::widgets::{self, menu_item};
|
||||
|
||||
/// What a click on a style does (run after drawing, once the document is no longer borrowed).
|
||||
enum Click {
|
||||
Apply(String),
|
||||
Toggle(String),
|
||||
Options(String),
|
||||
}
|
||||
|
||||
fn preview(ui: &Ui, r: Rect, ap: &Appearance) {
|
||||
ui.painter().rect_filled(r, 0.0, egui::Color32::WHITE);
|
||||
let inner = r.shrink(r.width() * 0.18);
|
||||
|
|
@ -29,13 +38,31 @@ fn preview(ui: &Ui, r: Rect, ap: &Appearance) {
|
|||
}
|
||||
}
|
||||
|
||||
/// The click on a style's row or tile.
|
||||
fn clicked(ui: &Ui, resp: &Response, name: &str) -> Option<Click> {
|
||||
if resp.double_clicked() {
|
||||
Some(Click::Options(name.to_string()))
|
||||
} else if resp.clicked() {
|
||||
let m = ui.input(|i| i.modifiers);
|
||||
Some(if m.shift || m.command { Click::Toggle(name.to_string()) } else { Click::Apply(name.to_string()) })
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// The styles selected in the panel.
|
||||
fn selected(ctx: &egui::Context) -> Vec<String> {
|
||||
pstate(ctx, "gs-sel")
|
||||
}
|
||||
|
||||
pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let styles: Vec<(String, Appearance)> =
|
||||
app.session.active().map(|d| d.doc.graphic_styles.iter().map(|g| (g.name.clone(), g.appearance.clone())).collect()).unwrap_or_default();
|
||||
let sel: Option<String> = pstate(ui.ctx(), "gs-sel");
|
||||
// The document is shared (an `Arc`): holding it while drawing copies nothing.
|
||||
let doc = app.session.active().map(|d| d.doc.clone());
|
||||
let styles: &[GraphicStyle] = doc.as_ref().map_or(&[], |d| &d.graphic_styles);
|
||||
let sel = selected(ui.ctx());
|
||||
let list: bool = pstate(ui.ctx(), "gs-list");
|
||||
let mut clicked = None;
|
||||
let mut click = None;
|
||||
widgets::list_box(ui, |ui| {
|
||||
ui.set_min_height(120.0);
|
||||
ui.set_width(ui.available_width());
|
||||
|
|
@ -44,93 +71,127 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
|||
return;
|
||||
}
|
||||
if list {
|
||||
for (name, ap) in &styles {
|
||||
for g in styles {
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 24.0), Sense::click());
|
||||
if sel.as_deref() == Some(name) {
|
||||
if sel.contains(&g.name) {
|
||||
ui.painter().rect_filled(r, 0.0, t.row_selected);
|
||||
} else if resp.hovered() {
|
||||
ui.painter().rect_filled(r, 0.0, t.hover);
|
||||
}
|
||||
preview(ui, Rect::from_min_size(r.left_center() + vec2(4.0, -9.0), vec2(18.0, 18.0)), ap);
|
||||
ui.painter().text(r.left_center() + vec2(30.0, 0.0), egui::Align2::LEFT_CENTER, name, egui::FontId::proportional(12.0), t.text);
|
||||
if resp.clicked() {
|
||||
clicked = Some(name.clone());
|
||||
preview(ui, Rect::from_min_size(r.left_center() + vec2(4.0, -9.0), vec2(18.0, 18.0)), &g.appearance);
|
||||
ui.painter().text(r.left_center() + vec2(30.0, 0.0), egui::Align2::LEFT_CENTER, &g.name, egui::FontId::proportional(12.0), t.text);
|
||||
if let Some(c) = clicked(ui, &resp, &g.name) {
|
||||
click = Some(c);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.spacing_mut().item_spacing = vec2(3.0, 3.0);
|
||||
for (name, ap) in &styles {
|
||||
for g in styles {
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(40.0, 40.0), Sense::click());
|
||||
preview(ui, r, ap);
|
||||
let on = sel.as_deref() == Some(name.as_str());
|
||||
ui.painter().rect_stroke(
|
||||
r,
|
||||
0.0,
|
||||
Stroke::new(
|
||||
if on { 2.0 } else { 1.0 },
|
||||
if on {
|
||||
t.accent
|
||||
} else if resp.hovered() {
|
||||
t.text
|
||||
} else {
|
||||
t.border
|
||||
},
|
||||
),
|
||||
StrokeKind::Inside,
|
||||
);
|
||||
if resp.on_hover_text(name).clicked() {
|
||||
clicked = Some(name.clone());
|
||||
preview(ui, r, &g.appearance);
|
||||
let on = sel.contains(&g.name);
|
||||
let color = if on {
|
||||
t.accent
|
||||
} else if resp.hovered() {
|
||||
t.text
|
||||
} else {
|
||||
t.border
|
||||
};
|
||||
ui.painter().rect_stroke(r, 0.0, Stroke::new(if on { 2.0 } else { 1.0 }, color), StrokeKind::Inside);
|
||||
let resp = resp.on_hover_text(&g.name);
|
||||
if let Some(c) = clicked(ui, &resp, &g.name) {
|
||||
click = Some(c);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
if let Some(name) = clicked {
|
||||
set_pstate(ui.ctx(), "gs-sel", Some(name.clone()));
|
||||
if selection_len(app) > 0 {
|
||||
app.run("graphicStyle.apply", json!({"name": name})).ok();
|
||||
match click {
|
||||
Some(Click::Apply(name)) => {
|
||||
if selection_len(app) > 0 {
|
||||
app.run("graphicStyle.apply", json!({"name": name, "add": alt_held(ui)})).ok();
|
||||
}
|
||||
set_pstate(ui.ctx(), "gs-sel", vec![name]);
|
||||
}
|
||||
Some(Click::Toggle(name)) => {
|
||||
let mut sel = sel;
|
||||
match sel.iter().position(|n| *n == name) {
|
||||
Some(i) => {
|
||||
sel.remove(i);
|
||||
}
|
||||
None => sel.push(name),
|
||||
}
|
||||
set_pstate(ui.ctx(), "gs-sel", sel);
|
||||
}
|
||||
Some(Click::Options(name)) => {
|
||||
app.run("ui.graphicStyleOptions", json!({ "name": name })).ok();
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
let sel: Option<String> = pstate(ui.ctx(), "gs-sel");
|
||||
let sel = selected(ui.ctx());
|
||||
let has_sel = selection_len(app) > 0;
|
||||
let linked = app.session.selection_graphic_style().is_some();
|
||||
widgets::bottom_bar(ui, |ui| {
|
||||
widgets::icon_button_enabled(ui, "library", "Graphic Style Libraries (on the roadmap)", false, false, 24.0);
|
||||
widgets::icon_button_enabled(ui, "link-2-off", "Break Link to Graphic Style (on the roadmap)", false, false, 24.0);
|
||||
ui.add_space((ui.available_width() - 2.0 * 28.0).max(0.0));
|
||||
if widgets::icon_button_enabled(ui, "dc-new-item", "New Graphic Style", false, has_sel, 24.0).clicked() {
|
||||
app.run("graphicStyle.new", json!({})).ok();
|
||||
if widgets::icon_button_enabled(ui, "link-2-off", "Break Link to Graphic Style", false, linked, 24.0).clicked() {
|
||||
app.run("graphicStyle.breakLink", json!({})).ok();
|
||||
}
|
||||
if widgets::icon_button_enabled(ui, "trash-2", "Delete Graphic Style", false, sel.is_some(), 24.0).clicked()
|
||||
&& let Some(n) = &sel
|
||||
&& app.run("graphicStyle.delete", json!({"name": n})).is_ok()
|
||||
{
|
||||
set_pstate::<Option<String>>(ui.ctx(), "gs-sel", None);
|
||||
ui.add_space((ui.available_width() - 2.0 * 28.0).max(0.0));
|
||||
if widgets::icon_button_enabled(ui, "dc-new-item", "New Graphic Style (Alt-click to name it)", false, has_sel, 24.0).clicked() {
|
||||
new_style(app, alt_held(ui));
|
||||
}
|
||||
if widgets::icon_button_enabled(ui, "trash-2", "Delete Graphic Style", false, !sel.is_empty(), 24.0).clicked() {
|
||||
delete(app, ui.ctx(), &sel);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
let sel: Option<String> = pstate(ui.ctx(), "gs-sel");
|
||||
let list: bool = pstate(ui.ctx(), "gs-list");
|
||||
if menu_item(ui, "New Graphic Style…", selection_len(app) > 0, false) {
|
||||
/// A new style from the selection, first asking for its name (`ask`) in Graphic Style Options.
|
||||
fn new_style(app: &mut VectorcraftApp, ask: bool) {
|
||||
if ask {
|
||||
app.run("ui.graphicStyleOptions", json!({})).ok();
|
||||
} else {
|
||||
app.run("graphicStyle.new", json!({})).ok();
|
||||
}
|
||||
if menu_item(ui, "Duplicate Graphic Style", sel.is_some(), false)
|
||||
&& let Some(n) = &sel
|
||||
}
|
||||
|
||||
fn delete(app: &mut VectorcraftApp, ctx: &egui::Context, sel: &[String]) {
|
||||
if app.run("graphicStyle.delete", json!({ "names": sel })).is_ok() {
|
||||
set_pstate(ctx, "gs-sel", Vec::<String>::new());
|
||||
}
|
||||
}
|
||||
|
||||
pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
let sel = selected(ui.ctx());
|
||||
let one = (sel.len() == 1).then(|| sel[0].clone());
|
||||
let has_doc = app.session.active().is_some();
|
||||
let list: bool = pstate(ui.ctx(), "gs-list");
|
||||
if menu_item(ui, "New Graphic Style…", selection_len(app) > 0, false) {
|
||||
new_style(app, true);
|
||||
}
|
||||
if menu_item(ui, "Duplicate Graphic Style", one.is_some(), false)
|
||||
&& let Some(n) = &one
|
||||
{
|
||||
app.run("graphicStyle.duplicate", json!({"name": n})).ok();
|
||||
app.run("graphicStyle.duplicate", json!({ "name": n })).ok();
|
||||
}
|
||||
menu_item(ui, "Merge Graphic Styles", false, false);
|
||||
if menu_item(ui, "Delete Graphic Style", sel.is_some(), false)
|
||||
&& let Some(n) = &sel
|
||||
{
|
||||
app.run("graphicStyle.delete", json!({"name": n})).ok();
|
||||
if menu_item(ui, "Delete Graphic Style", !sel.is_empty(), false) {
|
||||
delete(app, ui.ctx(), &sel);
|
||||
}
|
||||
if menu_item(ui, "Break Link to Graphic Style", app.session.selection_graphic_style().is_some(), false) {
|
||||
app.run("graphicStyle.breakLink", json!({})).ok();
|
||||
}
|
||||
menu_item(ui, "Break Link to Graphic Style", false, false);
|
||||
ui.separator();
|
||||
menu_item(ui, "Select All Unused", false, false);
|
||||
menu_item(ui, "Sort by Name", false, false);
|
||||
if menu_item(ui, "Select All Unused", has_doc, false)
|
||||
&& let Ok(r) = app.run("graphicStyle.unused", json!({}))
|
||||
{
|
||||
let names: Vec<String> = serde_json::from_value(r["names"].clone()).unwrap_or_default();
|
||||
set_pstate(ui.ctx(), "gs-sel", names);
|
||||
}
|
||||
if menu_item(ui, "Sort by Name", has_doc, false) {
|
||||
app.run("graphicStyle.sortByName", json!({})).ok();
|
||||
}
|
||||
ui.separator();
|
||||
if menu_item(ui, "Thumbnail View", true, !list) {
|
||||
set_pstate(ui.ctx(), "gs-list", false);
|
||||
|
|
@ -139,6 +200,56 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
|
|||
set_pstate(ui.ctx(), "gs-list", true);
|
||||
}
|
||||
ui.separator();
|
||||
menu_item(ui, "Graphic Style Options…", false, false);
|
||||
if menu_item(ui, "Graphic Style Options…", one.is_some(), false)
|
||||
&& let Some(n) = &one
|
||||
{
|
||||
app.run("ui.graphicStyleOptions", json!({ "name": n })).ok();
|
||||
}
|
||||
menu_item(ui, "Open Graphic Style Library", false, false);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use vectorcraft_engine::Session;
|
||||
|
||||
/// The texts `draw` paints in one headless frame.
|
||||
fn frame(ctx: &egui::Context, draw: impl FnMut(&mut Ui)) -> Vec<String> {
|
||||
fn texts(s: &egui::Shape, out: &mut Vec<String>) {
|
||||
match s {
|
||||
egui::Shape::Text(t) => out.push(t.galley.text().to_string()),
|
||||
egui::Shape::Vec(v) => v.iter().for_each(|s| texts(s, out)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), draw);
|
||||
out.textures_delta.clear();
|
||||
let mut v = vec![];
|
||||
out.shapes.iter().for_each(|c| texts(&c.shape, &mut v));
|
||||
v
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_view_names_the_styles_and_deletes_the_selected_ones() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
|
||||
app.session.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap();
|
||||
app.session.execute("graphicStyle.new", &json!({"name": "Mine"})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
set_pstate(&ctx, "gs-list", true);
|
||||
let texts = frame(&ctx, |ui| {
|
||||
show(&mut app, ui);
|
||||
menu(&mut app, ui);
|
||||
});
|
||||
assert!(texts.iter().any(|t| t == "Mine") && texts.iter().any(|t| t == "Sunshine"), "{texts:?}");
|
||||
// Select All Unused picks every style but the one the rectangle is linked to.
|
||||
let unused: Vec<String> = serde_json::from_value(app.session.execute("graphicStyle.unused", &json!({})).unwrap()["names"].clone()).unwrap();
|
||||
assert_eq!(unused.len(), 4);
|
||||
set_pstate(&ctx, "gs-sel", unused.clone());
|
||||
frame(&ctx, |ui| show(&mut app, ui));
|
||||
delete(&mut app, &ctx, &unused);
|
||||
assert!(selected(&ctx).is_empty());
|
||||
let names: Vec<&str> = app.session.active().unwrap().doc.graphic_styles.iter().map(|g| g.name.as_str()).collect();
|
||||
assert_eq!(names, ["Mine"]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue