mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-14 19:26:56 -04:00
M3.51: Fill/Stroke chips open swatch and mixer popovers; panel shortcuts; Recolor quick action
The Control bar and Properties Fill/Stroke chips show the proxies' paints
("?" for mixed selections) and open a popover with the Swatches panel body
(Shift-click or the toggle: the Color panel's mixer); a click brings the
chip's proxy forward through paint.toggleActive {fill}. The Properties
panel offers Recolor for multi-colour selections. Window panel shortcuts
(Color F6, Color Guide Shift+F3, Appearance Shift+F6, Graphic Styles
Shift+F5, Stroke Cmd+F10, Gradient Cmd+F9, Transparency Shift+Cmd+F10)
run window.panel {panel}, show in the Window menu and are editable.
paint.invert / paint.complement and the "?" state honour the Appearance
panel's active item; objects without bounds still show their paints.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
512e96e7c8
commit
2e6662c93b
16 files changed
+565
-142
No files matched your search
@@ -574,7 +574,8 @@ impl Node {
|
||||
let b = t.local_bounds();
|
||||
return Some(if stroke { (&st.stroke, t.xf, crate::appearance::stroke_paint_bounds(b, st.stroke_width)) } else { (&st.fill, t.xf, b) });
|
||||
}
|
||||
let b = self.geometric_bounds()?;
|
||||
// An object without bounds (an empty path) still shows its paints, fitted to an empty box.
|
||||
let b = self.geometric_bounds().unwrap_or(Rect::ZERO);
|
||||
if stroke {
|
||||
self.appearance.stroke_at(item).map(|s| (&s.paint, Affine::IDENTITY, s.paint_bounds(b)))
|
||||
} else {
|
||||
|
||||
@@ -41,10 +41,18 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_doc,
|
||||
default_paint
|
||||
),
|
||||
cmd!("paint.toggleActive", "Toggle Fill/Stroke Focus", [], Some("X"), "{}", always, |s, _| {
|
||||
s.fill_active = !s.fill_active;
|
||||
Ok(json!({ "fillActive": s.fill_active }))
|
||||
}),
|
||||
cmd!(
|
||||
"paint.toggleActive",
|
||||
"Toggle Fill/Stroke Focus",
|
||||
[],
|
||||
Some("X"),
|
||||
"{fill?: bool (true: bring the Fill proxy forward, false: the Stroke proxy; omitted: swap which is in front)} → {fillActive}",
|
||||
always,
|
||||
|s, p| {
|
||||
s.fill_active = p.get("fill").and_then(Value::as_bool).unwrap_or(!s.fill_active);
|
||||
Ok(json!({ "fillActive": s.fill_active }))
|
||||
}
|
||||
),
|
||||
cmd!("paint.none", "None", [], Some("/"), "{} set the active proxy (fill or stroke) to None", has_doc, |s, _| {
|
||||
let f = s.fill_active;
|
||||
set_paint(s, &json!({"none": true}), f)
|
||||
@@ -69,18 +77,18 @@ pub fn proxy_specs() -> Vec<CommandSpec> {
|
||||
"Invert",
|
||||
[],
|
||||
None,
|
||||
"{stroke?: bool (default: the active proxy), ids?} invert the active proxy's colours of the selection (or ids; groups recolour their contents) keeping each colour's model, as edit.colors.invert does; with nothing selected, invert the default → {changed}",
|
||||
"{stroke?: bool (default: the active proxy), ids?} invert the active proxy's colours of the selection (or ids; groups recolour their contents) keeping each colour's model, as edit.colors.invert does; the Appearance panel's active fill/stroke item (appearance.setActiveItem) is inverted instead when it is of that kind and no ids are given; with nothing selected, invert the default → {changed}",
|
||||
has_doc,
|
||||
|s, p| proxy_recolor(s, p, "Invert", &super::colorcmds::invert)
|
||||
|s, p| proxy_recolor(s, p, "paint.invert", "Invert", &super::colorcmds::invert)
|
||||
),
|
||||
cmd!(
|
||||
"paint.complement",
|
||||
"Complement",
|
||||
[],
|
||||
None,
|
||||
"{stroke?: bool (default: the active proxy), ids?} replace the active proxy's colours by their complements ((highest + lowest) − each component, over RGB or CMY; grey unchanged) keeping each colour's model; with nothing selected, the default → {changed}",
|
||||
"{stroke?: bool (default: the active proxy), ids?} replace the active proxy's colours by their complements ((highest + lowest) − each component, over RGB or CMY; grey unchanged) keeping each colour's model; honours the Appearance panel's active item as paint.invert does; with nothing selected, the default → {changed}",
|
||||
has_doc,
|
||||
|s, p| proxy_recolor(s, p, "Complement", &|c: Color| c.complement_keep_model())
|
||||
|s, p| proxy_recolor(s, p, "paint.complement", "Complement", &|c: Color| c.complement_keep_model())
|
||||
),
|
||||
cmd!(
|
||||
"paint.lastColor",
|
||||
@@ -231,36 +239,44 @@ pub(crate) fn painted<'a>(n: &'a Node, top: bool, out: &mut Vec<&'a Node>) {
|
||||
|
||||
impl DocState {
|
||||
/// Whether the selected objects' fills and strokes differ as the proxies show them (a "?"
|
||||
/// proxy; a group whose contents differ counts). One walk of the document, so callers that ask
|
||||
/// proxy; a group whose contents differ counts). `item` (the Appearance panel's active item)
|
||||
/// stands for the proxy of its kind, as in [`Session::proxy_paints`]: that fill or stroke of
|
||||
/// each selected object's own stack is compared. One walk of the document, so callers that ask
|
||||
/// every frame cache it by revision.
|
||||
pub fn proxy_mixed(&self) -> (bool, bool) {
|
||||
pub fn proxy_mixed(&self, item: Option<usize>) -> (bool, bool) {
|
||||
if self.selection.is_empty() {
|
||||
return (false, false);
|
||||
}
|
||||
let ids: std::collections::HashSet<NodeId> = self.selection.objects.iter().copied().collect();
|
||||
let mut nodes = vec![];
|
||||
let (mut selected, mut nodes) = (vec![], vec![]);
|
||||
self.doc.walk(|n| {
|
||||
if ids.contains(&n.id) {
|
||||
selected.push(n);
|
||||
painted(n, true, &mut nodes);
|
||||
}
|
||||
});
|
||||
let mut paints = nodes.iter().map(|n| (proxy_paint(n, false, None), proxy_paint(n, true, None)));
|
||||
let Some((f0, s0)) = paints.next() else { return (false, false) };
|
||||
let mut mixed = (false, false);
|
||||
for (f, s) in paints {
|
||||
mixed.0 |= !same_in_proxy(&f0, &f);
|
||||
mixed.1 |= !same_in_proxy(&s0, &s);
|
||||
if mixed.0 && mixed.1 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
mixed
|
||||
let first = self.selection.objects.first().and_then(|id| self.doc.node(*id));
|
||||
let differ = |stroke: bool| {
|
||||
let item = first.and_then(|f| f.appearance.item_of_kind(item, !stroke));
|
||||
let set = if item.is_some() { &selected } else { &nodes };
|
||||
let mut paints = set.iter().map(|n| proxy_paint(n, stroke, item));
|
||||
let Some(p0) = paints.next() else { return false };
|
||||
paints.any(|p| !same_in_proxy(&p0, &p))
|
||||
};
|
||||
(differ(false), differ(true))
|
||||
}
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// [`DocState::proxy_mixed`] of the active document for the Appearance panel's active item.
|
||||
pub fn proxy_mixed(&self) -> (bool, bool) {
|
||||
self.active().map_or((false, false), |st| st.proxy_mixed(self.appearance_item()))
|
||||
}
|
||||
}
|
||||
|
||||
fn proxies(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let (fill, stroke) = s.proxy_paints();
|
||||
let (fill_mixed, stroke_mixed) = s.active().map_or((false, false), DocState::proxy_mixed);
|
||||
let (fill_mixed, stroke_mixed) = s.proxy_mixed();
|
||||
Ok(json!({"fill": fill, "stroke": stroke, "fillActive": s.fill_active, "fillMixed": fill_mixed, "strokeMixed": stroke_mixed}))
|
||||
}
|
||||
|
||||
@@ -428,9 +444,10 @@ fn default_paint(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
ok()
|
||||
}
|
||||
|
||||
/// Invert / Complement: recolour the active proxy of the targets (or the default when there are
|
||||
/// none) and remember the first target's new paint.
|
||||
fn proxy_recolor(s: &mut Session, p: &Value, label: &str, f: &dyn Fn(Color) -> Color) -> Result<Value> {
|
||||
/// Invert / Complement (`cmd`, undone as `label`): recolour the active proxy of the targets (or the
|
||||
/// default when there are none) and remember the first target's new paint. The Appearance panel's
|
||||
/// active item of the proxy's kind is recoloured in each selected object's own stack instead.
|
||||
fn proxy_recolor(s: &mut Session, p: &Value, cmd: &str, label: &str, f: &dyn Fn(Color) -> Color) -> Result<Value> {
|
||||
let stroke = stroke_param(s, p);
|
||||
let ids = match ids_param(p, "ids") {
|
||||
Some(ids) => ids,
|
||||
@@ -443,11 +460,27 @@ fn proxy_recolor(s: &mut Session, p: &Value, label: &str, f: &dyn Fn(Color) -> C
|
||||
s.remember_paint(&shown);
|
||||
return Ok(json!({ "changed": changed as usize }));
|
||||
}
|
||||
// The proxy's colours only: images and pattern tiles stay as they are.
|
||||
let q = json!({"fill": !stroke, "stroke": stroke, "includeImages": false, "includePatterns": false, "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()});
|
||||
let r = super::colorcmds::recolor(s, &q, label, f)?;
|
||||
let first = leaf_targets(s, &ids)?.first().and_then(|id| s.doc().ok()?.doc.node(*id).map(|n| proxy_paint(n, stroke, None)));
|
||||
if let Some(shown) = first {
|
||||
let item = item_target(s, p, cmd)?.of_kind(s, !stroke);
|
||||
let (r, shown_item) = match item {
|
||||
ItemTarget::Item { index, .. } => {
|
||||
let ids = item.targets(s, p)?;
|
||||
let mut changed = 0;
|
||||
edit_items(s, &ids, item, cmd, label, !stroke, |n, i| {
|
||||
let paint =
|
||||
if stroke { n.appearance.stroke_at_mut(i).map(|l| &mut l.paint) } else { n.appearance.fill_at_mut(i).map(|l| &mut l.paint) };
|
||||
changed += paint.is_some_and(|paint| super::colorcmds::map_paint(paint, f)) as usize;
|
||||
Ok(())
|
||||
})?;
|
||||
(json!({ "changed": changed }), Some(index))
|
||||
}
|
||||
// The proxy's colours only: images and pattern tiles stay as they are.
|
||||
ItemTarget::Top => {
|
||||
let q = json!({"fill": !stroke, "stroke": stroke, "includeImages": false, "includePatterns": false, "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()});
|
||||
(super::colorcmds::recolor(s, &q, label, f)?, None)
|
||||
}
|
||||
};
|
||||
let shown_ids = if shown_item.is_some() { ids } else { leaf_targets(s, &ids)? };
|
||||
if let Some(shown) = shown_ids.first().and_then(|id| s.doc().ok()?.doc.node(*id).map(|n| proxy_paint(n, stroke, shown_item))) {
|
||||
s.remember_paint(&shown);
|
||||
}
|
||||
Ok(r)
|
||||
|
||||
@@ -820,6 +820,8 @@ mod tests_pattern;
|
||||
#[cfg(test)]
|
||||
mod tests_prefs;
|
||||
#[cfg(test)]
|
||||
mod tests_proxyitems;
|
||||
#[cfg(test)]
|
||||
mod tests_strokegeom;
|
||||
#[cfg(test)]
|
||||
mod tests_strokeux;
|
||||
|
||||
@@ -191,7 +191,7 @@ fn a_mixed_selection_shows_a_question_mark_proxy() {
|
||||
assert_eq!(q(&mut s)["fillMixed"], false);
|
||||
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
|
||||
s.execute("paint.setFill", &json!({"gradient": {"stops": [{"offset": 0, "color": "#000000"}, {"offset": 1, "color": "#ffffff"}]}})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().proxy_mixed(), (false, false));
|
||||
assert_eq!(s.proxy_mixed(), (false, false));
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
assert_eq!(q(&mut s)["fillMixed"], false);
|
||||
}
|
||||
@@ -209,7 +209,7 @@ fn every_selection_change_bumps_the_revision() {
|
||||
s.execute("select.magicWand", &json!({"id": a.0})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().selection.len(), 2, "{a:?} and {b:?} are within the tolerance");
|
||||
assert!(s.doc().unwrap().revision > rev);
|
||||
assert_eq!(s.doc().unwrap().proxy_mixed(), (true, false));
|
||||
assert_eq!(s.proxy_mixed(), (true, false));
|
||||
let r = s.execute("text.create", &json!({"x": 10, "y": 150, "text": "Hi"})).unwrap();
|
||||
let family = match &node(&s, NodeId(r["id"].as_u64().unwrap())).kind {
|
||||
NodeKind::Text(t) => t.first_style().font_family,
|
||||
@@ -265,7 +265,7 @@ fn a_group_shows_its_contents_in_the_proxies() {
|
||||
// The group shows what the paint commands change: its contents' red fill, not mixed.
|
||||
assert_eq!(s.doc().unwrap().selection.len(), 1);
|
||||
assert_eq!(s.proxy_paints().0.color().unwrap().to_hex(), "#ff0000");
|
||||
assert_eq!(s.doc().unwrap().proxy_mixed(), (false, false));
|
||||
assert_eq!(s.proxy_mixed(), (false, false));
|
||||
// Contents that differ make even a single selected group a "?" proxy.
|
||||
s.execute("paint.setFill", &json!({"color": "#00ff00", "ids": [b.0]})).unwrap();
|
||||
assert_eq!(s.execute("paint.proxies", &json!({})).unwrap()["fillMixed"], true);
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
//! The Fill/Stroke proxies and the Appearance panel's active item: Invert / Complement and the "?"
|
||||
//! state follow the active fill or stroke, objects without bounds still show their paints, and the
|
||||
//! proxy focus can be set directly (M3.51).
|
||||
|
||||
use serde_json::json;
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::{Appearance, Node};
|
||||
use vectorcraft_geom::PathData;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
/// A selected rectangle with a red fill below its stroke and a blue fill on top: [Fill, Stroke, Fill].
|
||||
fn two_fills(s: &mut Session, x: f64) -> NodeId {
|
||||
let r = s.execute("shape.rectangle", &json!({"x": x, "y": 10, "width": 50, "height": 50})).unwrap();
|
||||
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
|
||||
s.execute("appearance.addFill", &json!({})).unwrap();
|
||||
s.execute("appearance.setItem", &json!({"index": 2, "color": "#0000ff"})).unwrap();
|
||||
NodeId(r["id"].as_u64().unwrap())
|
||||
}
|
||||
|
||||
fn item_hex(s: &Session, id: NodeId, index: usize) -> String {
|
||||
s.doc().unwrap().doc.node(id).unwrap().appearance.items[index].paint().color().unwrap().to_hex()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invert_and_complement_recolour_the_active_item() {
|
||||
let mut s = session();
|
||||
let id = two_fills(&mut s, 10.0);
|
||||
// Without an active item the proxy's colours go (every fill, as Edit Colors does).
|
||||
s.execute("paint.invert", &json!({})).unwrap();
|
||||
assert_eq!((item_hex(&s, id, 0), item_hex(&s, id, 2)), ("#00ffff".into(), "#ffff00".into()));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
// The bottom fill is the Appearance panel's active row: only it changes, as one undo step.
|
||||
s.execute("appearance.setActiveItem", &json!({"index": 0})).unwrap();
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
assert_eq!(s.execute("paint.invert", &json!({})).unwrap(), json!({"changed": 1}));
|
||||
assert_eq!((item_hex(&s, id, 0), item_hex(&s, id, 2)), ("#00ffff".into(), "#0000ff".into()));
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1);
|
||||
assert_eq!(s.proxy_paints().0.color().unwrap().to_hex(), "#00ffff", "the proxy shows the active row");
|
||||
assert_eq!(s.recent_colors[0].to_hex(), "#00ffff");
|
||||
s.execute("paint.complement", &json!({})).unwrap();
|
||||
assert_eq!((item_hex(&s, id, 0), item_hex(&s, id, 2)), ("#ff0000".into(), "#0000ff".into()));
|
||||
// The Stroke proxy isn't the active row's kind: the stroke is complemented as usual.
|
||||
s.execute("paint.complement", &json!({"stroke": true})).unwrap();
|
||||
assert_eq!(item_hex(&s, id, 1), "#000000", "black's complement is black");
|
||||
assert_eq!((item_hex(&s, id, 0), item_hex(&s, id, 2)), ("#ff0000".into(), "#0000ff".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_mixed_proxy_compares_the_active_item() {
|
||||
let mut s = session();
|
||||
let a = two_fills(&mut s, 10.0);
|
||||
let b = two_fills(&mut s, 100.0);
|
||||
// Same top fills, different bottom fills.
|
||||
s.execute("appearance.setItem", &json!({"index": 0, "color": "#00ff00", "ids": [b.0]})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
|
||||
assert_eq!(s.proxy_mixed(), (false, false));
|
||||
s.execute("appearance.setActiveItem", &json!({"index": 0})).unwrap();
|
||||
assert_eq!(s.proxy_mixed(), (true, false), "the active bottom fills differ");
|
||||
assert_eq!(s.execute("paint.proxies", &json!({})).unwrap()["fillMixed"], true);
|
||||
// A stroke row leaves the fill proxy on the top fills.
|
||||
s.execute("appearance.setActiveItem", &json!({"index": 1})).unwrap();
|
||||
assert_eq!(s.proxy_mixed(), (false, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_object_without_bounds_shows_its_paints() {
|
||||
let mut s = session();
|
||||
let id = s
|
||||
.edit("Add", |d, _| {
|
||||
let l = d.layers[0].id;
|
||||
let id = d.alloc_id();
|
||||
let ap = Appearance::basic(Paint::solid(Color::from_hex("#123456").unwrap()), Paint::solid(Color::from_hex("#654321").unwrap()), 2.0);
|
||||
d.insert(Some(l), 0, Node::path(id, PathData::default(), ap)).map_err(|e| crate::EngineError::Other(e.to_string()))
|
||||
})
|
||||
.unwrap();
|
||||
assert!(s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().is_none());
|
||||
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
|
||||
let (f, st) = s.proxy_paints();
|
||||
assert_eq!((f.color().unwrap().to_hex(), st.color().unwrap().to_hex()), ("#123456".into(), "#654321".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toggle_active_can_name_the_proxy() {
|
||||
let mut s = Session::new();
|
||||
assert_eq!(s.execute("paint.toggleActive", &json!({"fill": false})).unwrap(), json!({"fillActive": false}));
|
||||
assert_eq!(s.execute("paint.toggleActive", &json!({"fill": false})).unwrap(), json!({"fillActive": false}), "already in front");
|
||||
assert_eq!(s.execute("paint.toggleActive", &json!({})).unwrap(), json!({"fillActive": true}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_last_colour_keeps_its_model() {
|
||||
let mut s = session();
|
||||
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap();
|
||||
s.execute("paint.setFill", &json!({"color": {"c": 0.1, "m": 0.9, "y": 0.2, "k": 0.0}})).unwrap();
|
||||
s.execute("paint.setFill", &json!({"none": true})).unwrap();
|
||||
s.execute("paint.lastColor", &json!({})).unwrap();
|
||||
assert_eq!(s.proxy_paints().0.color(), Some(Color::cmyk(0.1, 0.9, 0.2, 0.0)));
|
||||
}
|
||||
@@ -7,7 +7,7 @@ use vectorcraft_doc::NodeKind;
|
||||
use crate::panels::stroke as stroke_panel;
|
||||
use crate::state::{ZOOM_STOPS, zoom_label};
|
||||
use crate::theme::{self, Tokens};
|
||||
use crate::widgets::{self, paint_chip};
|
||||
use crate::widgets;
|
||||
use crate::{VectorcraftApp, icons, menus, titlebar};
|
||||
|
||||
/// The application bar: brand mark, Home, menus, then Discord, search and the workspace switcher
|
||||
@@ -88,17 +88,6 @@ pub fn app_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
}
|
||||
}
|
||||
|
||||
/// A Control bar chip that opens something: `draw` paints the chip, a chevron follows it.
|
||||
fn chip_button(ui: &mut Ui, tip: &str, draw: impl FnOnce(&Ui, egui::Rect)) -> egui::Response {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(34.0, 22.0), Sense::click());
|
||||
let chip = egui::Rect::from_min_size(r.min + vec2(0.0, 2.0), vec2(18.0, 18.0));
|
||||
draw(ui, chip);
|
||||
ui.painter().rect_stroke(chip, 0.0, Stroke::new(1.0, t.input_border), StrokeKind::Outside);
|
||||
icons::paint(ui, "chevron-down", egui::Rect::from_min_size(r.min + vec2(21.0, 5.0), vec2(12.0, 12.0)), t.text_dim);
|
||||
resp.on_hover_text(tip)
|
||||
}
|
||||
|
||||
/// The Control bar (Window → Control), context-sensitive like Illustrator's.
|
||||
pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
@@ -127,24 +116,12 @@ pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
};
|
||||
ui.label(egui::RichText::new(label).font(theme::semibold(12.0)).color(t.text));
|
||||
ui.add_space(6.0);
|
||||
let (fill, stroke) = crate::panels::current_paints(app);
|
||||
let shown_stroke = crate::panels::current_stroke(app);
|
||||
let mixed = crate::panels::stroke_mixed(app, ui.ctx());
|
||||
let weight = stroke_panel::shown_weight(app, shown_stroke.as_ref(), &mixed);
|
||||
let opacity = if first.is_some() { crate::panels::current_transparency(app).map_or(1.0, |t| t.0) } else { 1.0 };
|
||||
if chip_button(ui, "Fill", |ui, r| paint_chip(ui, r, &fill)).clicked() {
|
||||
app.run("paint.toggleActive", json!({})).ok();
|
||||
app.session.fill_active = true;
|
||||
app.ui.open_panel = Some("swatches".into());
|
||||
}
|
||||
let stroke_chip = |ui: &Ui, r: egui::Rect| {
|
||||
paint_chip(ui, r, &stroke);
|
||||
ui.painter().rect_filled(r.shrink(5.0), 0.0, t.panel);
|
||||
};
|
||||
if chip_button(ui, "Stroke", stroke_chip).clicked() {
|
||||
app.session.fill_active = false;
|
||||
app.ui.open_panel = Some("swatches".into());
|
||||
}
|
||||
crate::panels::paint_chip(app, ui, false, 22.0, true);
|
||||
crate::panels::paint_chip(app, ui, true, 22.0, true);
|
||||
// The link opens the Stroke panel as a popover under it; then the weight spinner
|
||||
// (with presets) and the width profile.
|
||||
let link = ui.link(egui::RichText::new("Stroke:").size(12.0).color(t.text).underline()).on_hover_text("Stroke options");
|
||||
@@ -170,7 +147,8 @@ pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
if ui.link(egui::RichText::new("Style:").size(12.0).color(t.text).underline()).clicked() {
|
||||
app.ui.open_panel = Some("graphicStyles".into());
|
||||
}
|
||||
let resp = chip_button(ui, "Graphic Style", |ui, r| crate::panels::graphic_styles::paint_linked(app, ui, r));
|
||||
let resp = widgets::chip_button(ui, 22.0, true, |ui, r| crate::panels::graphic_styles::paint_linked(app, ui, r))
|
||||
.on_hover_text("Graphic Style");
|
||||
egui::Popup::menu(&resp).show(|ui| crate::panels::graphic_styles::picker(app, ui));
|
||||
}
|
||||
ui.separator();
|
||||
|
||||
@@ -36,6 +36,8 @@ pub mod workspaces;
|
||||
mod tests_labels;
|
||||
#[cfg(test)]
|
||||
mod tests_overprint;
|
||||
#[cfg(test)]
|
||||
mod tests_paintchips;
|
||||
|
||||
use std::sync::mpsc::{Receiver, Sender};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
@@ -1456,7 +1456,7 @@ fn render_items(app: &VectorcraftApp, ui: &mut egui::Ui, items: &[Item], clicked
|
||||
continue;
|
||||
}
|
||||
let label = dynamic_label(app, id, label);
|
||||
let sc = if p.is_null() { shortcut_of(id).map(pretty_shortcut).unwrap_or_default() } else { String::new() };
|
||||
let sc = item_shortcut(id, p).map(pretty_shortcut).unwrap_or_default();
|
||||
let chk = checked(app, id, p);
|
||||
let text = match chk {
|
||||
Some(true) => format!("✓ {label}"),
|
||||
@@ -1638,7 +1638,7 @@ pub fn menu_entries(app: &VectorcraftApp) -> Vec<MenuEntry> {
|
||||
command: Some(id.to_string()),
|
||||
params: p.clone(),
|
||||
enabled: enabled(app, id),
|
||||
shortcut: shortcut_of(id).unwrap_or("").to_string(),
|
||||
shortcut: item_shortcut(id, p).or_else(|| shortcut_of(id)).unwrap_or("").to_string(),
|
||||
}),
|
||||
Item::Todo(l, sc) => out.push(MenuEntry {
|
||||
path: path.clone(),
|
||||
@@ -1787,6 +1787,16 @@ fn effect_menu() -> Vec<Item> {
|
||||
out
|
||||
}
|
||||
|
||||
/// The shortcut a menu item shows: its command's (items without params), or a panel's for the
|
||||
/// Window menu's `window.panel {panel}` items.
|
||||
pub fn item_shortcut(id: &str, p: &Value) -> Option<&'static str> {
|
||||
match (id, p.get("panel").and_then(Value::as_str)) {
|
||||
("window.panel", Some(panel)) => crate::shortcut_editor::panel_shortcut(panel),
|
||||
_ if p.is_null() => shortcut_of(id),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -192,14 +192,14 @@ pub(crate) fn current_paints(app: &VectorcraftApp) -> (Paint, Paint) {
|
||||
}
|
||||
|
||||
/// Whether the selected objects' fills and strokes differ (the proxies show "?"), cached per
|
||||
/// document revision.
|
||||
/// document revision and Appearance panel item.
|
||||
pub(crate) fn mixed_paints(app: &VectorcraftApp, ctx: &egui::Context) -> (bool, bool) {
|
||||
let Some(st) = app.session.active() else { return (false, false) };
|
||||
let key = (st.uid, st.revision);
|
||||
match pstate::<Option<((u64, u64), (bool, bool))>>(ctx, "proxy-mixed") {
|
||||
let key = (st.uid, st.revision, app.session.appearance_item());
|
||||
match pstate::<Option<((u64, u64, Option<usize>), (bool, bool))>>(ctx, "proxy-mixed") {
|
||||
Some((k, mixed)) if k == key => mixed,
|
||||
_ => {
|
||||
let mixed = st.proxy_mixed();
|
||||
let mixed = app.session.proxy_mixed();
|
||||
set_pstate(ctx, "proxy-mixed", Some((key, mixed)));
|
||||
mixed
|
||||
}
|
||||
@@ -272,6 +272,56 @@ pub(crate) fn proxy(app: &mut VectorcraftApp, ui: &mut Ui, size: f32) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The Fill (or `stroke`) chip of the Control bar and the Properties panel, `size` square (with a
|
||||
/// `chevron` after it): the proxy's paint, "?" when the selected objects' paints differ. A click
|
||||
/// brings that proxy forward and opens a popover with the Swatches panel; Shift-click (or the
|
||||
/// popover's toggle) shows the Color panel's mixer instead.
|
||||
pub(crate) fn paint_chip(app: &mut VectorcraftApp, ui: &mut Ui, stroke: bool, size: f32, chevron: bool) {
|
||||
let (fill, stroke_paint) = current_paints(app);
|
||||
let mixed = mixed_paints(app, ui.ctx());
|
||||
let (paint, mixed) = if stroke { (stroke_paint, mixed.1) } else { (fill, mixed.0) };
|
||||
let resp = crate::widgets::chip_button(ui, size, chevron, |ui, chip| {
|
||||
crate::widgets::proxy_chip(ui, chip, &paint, mixed, stroke.then_some(size * 0.25));
|
||||
});
|
||||
let resp = resp.on_hover_text(if stroke {
|
||||
"Stroke: click for swatches, Shift-click for the color mixer"
|
||||
} else {
|
||||
"Fill: click for swatches, Shift-click for the color mixer"
|
||||
});
|
||||
if resp.clicked() {
|
||||
app.run("paint.toggleActive", json!({ "fill": !stroke })).ok();
|
||||
set_pstate(ui.ctx(), MIXER, ui.input(|i| i.modifiers.shift));
|
||||
}
|
||||
egui::Popup::from_toggle_button_response(&resp)
|
||||
.close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside)
|
||||
.width(POPOVER_WIDTH)
|
||||
.show(|ui| paint_popover(app, ui));
|
||||
}
|
||||
|
||||
/// The chip popovers' width (a narrow Swatches or Color panel).
|
||||
const POPOVER_WIDTH: f32 = 248.0;
|
||||
/// Panel state: the chip popovers show the Color panel's mixer instead of the swatches.
|
||||
const MIXER: &str = "paint-popover-mixer";
|
||||
|
||||
/// The body of a Fill/Stroke chip popover: a Swatches / Color mixer toggle above that panel's body.
|
||||
fn paint_popover(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
ui.set_width(POPOVER_WIDTH);
|
||||
let mixer: bool = pstate(ui.ctx(), MIXER);
|
||||
ui.horizontal(|ui| {
|
||||
for (icon, tip, m) in [("swatch-book", "Swatches", false), ("palette", "Color Mixer (Shift-click the chip)", true)] {
|
||||
if crate::widgets::icon_button(ui, icon, tip, mixer == m, 22.0).clicked() {
|
||||
set_pstate(ui.ctx(), MIXER, m);
|
||||
}
|
||||
}
|
||||
});
|
||||
crate::widgets::divider(ui);
|
||||
if mixer {
|
||||
color::show(app, ui);
|
||||
} else {
|
||||
swatches::popover(app, ui);
|
||||
}
|
||||
}
|
||||
|
||||
/// Run `cmd` live: while dragging, preview on top of an interaction snapshot; on release, commit
|
||||
/// it as one undo step. Falls back to a plain run when no document is open.
|
||||
pub(crate) fn live_run(app: &mut VectorcraftApp, label: &str, cmd: &str, params: Value, phase: Live) {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
//! The Properties panel: context-sensitive sections like Illustrator's.
|
||||
|
||||
use egui::{Sense, Stroke, StrokeKind, Ui, vec2};
|
||||
use egui::Ui;
|
||||
use serde_json::json;
|
||||
use vectorcraft_doc::{NodeKind, Unit};
|
||||
|
||||
use super::first_selected;
|
||||
use super::{first_selected, pstate, set_pstate};
|
||||
use crate::theme::Tokens;
|
||||
use crate::widgets::{self, dim_label, divider, section_header};
|
||||
use crate::{VectorcraftApp, icons};
|
||||
@@ -90,6 +90,9 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
if is_live {
|
||||
actions.push(("Expand Shape", "object.expandShape"));
|
||||
}
|
||||
if multi_color(app, ui.ctx()) {
|
||||
actions.push(("Recolor", "ui.recolorDialog"));
|
||||
}
|
||||
actions.push(("Offset Path", "object.path.offsetPath"));
|
||||
actions.push(("Simplify", "object.path.simplify"));
|
||||
actions.push(("Arrange: Bring to Front", "object.arrange.bringToFront"));
|
||||
@@ -106,6 +109,22 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the selection uses more than one colour (`recolor.colors`), which offers the Recolor
|
||||
/// quick action; cached per document revision.
|
||||
fn multi_color(app: &mut VectorcraftApp, ctx: &egui::Context) -> bool {
|
||||
let Some(st) = app.session.active() else { return false };
|
||||
let key = (st.uid, st.revision);
|
||||
match pstate::<Option<((u64, u64), bool)>>(ctx, "props-multi-color") {
|
||||
Some((k, multi)) if k == key => multi,
|
||||
_ => {
|
||||
let r = app.session.execute("recolor.colors", &json!({})).unwrap_or_default();
|
||||
let multi = r["colors"].as_array().is_some_and(|c| c.len() > 1);
|
||||
set_pstate(ctx, "props-multi-color", Some((key, multi)));
|
||||
multi
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn document_sections(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
let units = app.session.active().map(|d| d.doc.units).unwrap_or_default();
|
||||
section_header(ui, "Document");
|
||||
@@ -262,25 +281,12 @@ pub fn transform_section(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
}
|
||||
|
||||
fn appearance_section(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let Some(n) = first_selected(app) else { return };
|
||||
section_header(ui, "Appearance");
|
||||
let (fill, stroke) = super::current_paints(app);
|
||||
let weight = super::stroke::shown_weight(app, super::current_stroke(app).as_ref(), &super::stroke_mixed(app, ui.ctx()));
|
||||
for (label, paint, is_fill) in [("Fill", fill, true), ("Stroke", stroke, false)] {
|
||||
for (label, is_fill) in [("Fill", true), ("Stroke", false)] {
|
||||
ui.horizontal(|ui| {
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(22.0, 22.0), Sense::click());
|
||||
widgets::paint_chip(ui, r.shrink(2.0), &paint);
|
||||
if !is_fill {
|
||||
ui.painter().rect_filled(r.shrink(7.0), 0.0, t.panel);
|
||||
}
|
||||
ui.painter().rect_stroke(r.shrink(2.0), 0.0, Stroke::new(1.0, t.input_border), StrokeKind::Outside);
|
||||
if resp.double_clicked() {
|
||||
app.run("ui.colorPicker", json!({ "stroke": !is_fill })).ok();
|
||||
} else if resp.clicked() {
|
||||
app.session.fill_active = is_fill;
|
||||
app.ui.open_panel = Some("swatches".into());
|
||||
}
|
||||
super::paint_chip(app, ui, !is_fill, 22.0, false);
|
||||
ui.label(egui::RichText::new(label).size(12.0));
|
||||
if !is_fill {
|
||||
ui.add_space(8.0);
|
||||
|
||||
@@ -578,6 +578,21 @@ fn find_id() -> egui::Id {
|
||||
}
|
||||
|
||||
pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
body(app, ui, TILE);
|
||||
}
|
||||
|
||||
/// The panel in a Fill/Stroke chip popover (the Control bar's and Properties'): the same body, its
|
||||
/// tiles under their own ids so it can be open beside the panel.
|
||||
pub(crate) fn popover(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
body(app, ui, POPOVER_TILE);
|
||||
}
|
||||
|
||||
/// Id salts of the panel's tiles and of a popover's.
|
||||
const TILE: &str = "swatch-tile";
|
||||
const POPOVER_TILE: &str = "swatch-pop-tile";
|
||||
|
||||
/// The panel's body; `salt` keys its tiles (see [`tile_id`]).
|
||||
fn body(app: &mut VectorcraftApp, ui: &mut Ui, salt: &'static str) {
|
||||
if app.session.active().is_none() {
|
||||
super::empty_state(ui, "swatch-book", "No document", "Open a document to see its swatches.");
|
||||
return;
|
||||
@@ -621,7 +636,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
if view.is_list() {
|
||||
for e in &items {
|
||||
let name = e.name();
|
||||
let (r, resp, chip) = list_row(ui, tile_id(e), view, is_sel(name));
|
||||
let (r, resp, chip) = list_row(ui, tile_id(salt, e), view, is_sel(name));
|
||||
match e {
|
||||
Entry::Registration => draw_registration(ui, chip),
|
||||
Entry::Swatch { paint, global, spot, .. } => {
|
||||
@@ -636,7 +651,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
}
|
||||
} else {
|
||||
for (e, cell) in tile_grid(ui, &items, view, Entry::is_folder) {
|
||||
let resp = ui.interact(cell, tile_id(e), Sense::click_and_drag());
|
||||
let resp = ui.interact(cell, tile_id(salt, e), Sense::click_and_drag());
|
||||
let name = e.name();
|
||||
match e {
|
||||
Entry::Registration => draw_registration(ui, cell),
|
||||
@@ -740,9 +755,9 @@ pub(crate) fn folder_tile(ui: &Ui, cell: Rect, selected: bool) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The interaction id of a tile or list row (stable per swatch or group name).
|
||||
fn tile_id(e: &Entry) -> egui::Id {
|
||||
egui::Id::new(("swatch-tile", e.name()))
|
||||
/// The interaction id of a tile or list row (stable per swatch or group name and host `salt`).
|
||||
fn tile_id(salt: &'static str, e: &Entry) -> egui::Id {
|
||||
egui::Id::new((salt, e.name()))
|
||||
}
|
||||
|
||||
/// The selected swatches and groups that can be deleted (all but None and Registration).
|
||||
|
||||
@@ -79,11 +79,35 @@ pub fn default_command_shortcut(id: &str) -> Option<&'static str> {
|
||||
menus::UI_COMMANDS.iter().find(|c| c.0 == id).map(|c| c.2).filter(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
/// Default shortcut of an entry key (`tool:<id>` or a command id).
|
||||
/// Window menu items that show a panel (`window.panel {panel}`) with a default shortcut: (panel
|
||||
/// id, shortcut). Every icon panel can be given one in the editor (entry key `panel:<id>`).
|
||||
pub const PANEL_SHORTCUTS: &[(&str, &str)] = &[
|
||||
("color", "F6"),
|
||||
("colorGuide", "Shift+F3"),
|
||||
("appearance", "Shift+F6"),
|
||||
("graphicStyles", "Shift+F5"),
|
||||
("stroke", "Cmd+F10"),
|
||||
("gradient", "Cmd+F9"),
|
||||
("transparency", "Cmd+Shift+F10"),
|
||||
];
|
||||
|
||||
/// Default shortcut of an entry key (`tool:<id>`, `panel:<id>` or a command id).
|
||||
pub fn default_of(key: &str) -> Option<&'static str> {
|
||||
match key.strip_prefix("tool:") {
|
||||
Some(t) => vectorcraft_tools::tool_info(t).and_then(|t| t.shortcut),
|
||||
None => default_command_shortcut(key),
|
||||
if let Some(t) = key.strip_prefix("tool:") {
|
||||
return vectorcraft_tools::tool_info(t).and_then(|t| t.shortcut);
|
||||
}
|
||||
if let Some(p) = key.strip_prefix("panel:") {
|
||||
return PANEL_SHORTCUTS.iter().find(|(id, _)| *id == p).map(|(_, sc)| *sc);
|
||||
}
|
||||
default_command_shortcut(key)
|
||||
}
|
||||
|
||||
/// Effective shortcut of an entry key, honouring the user's overrides.
|
||||
fn effective(key: &str) -> Option<&'static str> {
|
||||
match global_override(key) {
|
||||
Some("") => None,
|
||||
Some(s) => Some(s),
|
||||
None => default_of(key),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,20 +122,17 @@ pub fn effective_in(overrides: &BTreeMap<String, String>, key: &str) -> Option<S
|
||||
|
||||
/// Effective shortcut of a command, honouring the user's overrides.
|
||||
pub fn command_shortcut(id: &str) -> Option<&'static str> {
|
||||
match global_override(id) {
|
||||
Some("") => None,
|
||||
Some(s) => Some(s),
|
||||
None => default_command_shortcut(id),
|
||||
}
|
||||
effective(id)
|
||||
}
|
||||
|
||||
/// Effective shortcut of a tool.
|
||||
pub fn tool_shortcut(tool: &str) -> Option<&'static str> {
|
||||
match global_override(&format!("tool:{tool}")) {
|
||||
Some("") => None,
|
||||
Some(s) => Some(s),
|
||||
None => vectorcraft_tools::tool_info(tool).and_then(|t| t.shortcut),
|
||||
}
|
||||
effective(&format!("tool:{tool}"))
|
||||
}
|
||||
|
||||
/// Effective shortcut of the Window menu item showing `panel` (`window.panel {panel}`).
|
||||
pub fn panel_shortcut(panel: &str) -> Option<&'static str> {
|
||||
effective(&format!("panel:{panel}"))
|
||||
}
|
||||
|
||||
/// Tool whose (effective) shortcut is `key` ("V", "Shift+M").
|
||||
@@ -205,6 +226,12 @@ pub fn entries() -> &'static [Entry] {
|
||||
}
|
||||
v.push(Entry { key: c.id.into(), label: c.label.into(), group: c.menu.join(" › "), is_tool: false });
|
||||
}
|
||||
v.extend(crate::state::ICON_PANELS.iter().map(|(id, label, _)| Entry {
|
||||
key: format!("panel:{id}"),
|
||||
label: label.to_string(),
|
||||
group: "Window".into(),
|
||||
is_tool: false,
|
||||
}));
|
||||
for (id, label, sc, params) in menus::UI_COMMANDS {
|
||||
if !seen.insert(id) || (sc.is_empty() && !params.starts_with("{}") && !params.starts_with("{path")) {
|
||||
continue;
|
||||
|
||||
@@ -43,21 +43,27 @@ pub fn parse(s: &str) -> Option<KeyboardShortcut> {
|
||||
Some(KeyboardShortcut::new(m, key))
|
||||
}
|
||||
|
||||
/// Every command shortcut in effect (user overrides from Edit → Keyboard Shortcuts win).
|
||||
fn all_shortcuts() -> Vec<(KeyboardShortcut, &'static str)> {
|
||||
let mut v: Vec<(KeyboardShortcut, &'static str)> = vec![];
|
||||
/// Every command shortcut in effect (user overrides from Edit → Keyboard Shortcuts win), with the
|
||||
/// command's params: `{}`, or `{panel}` for the panel shortcuts (`window.panel`).
|
||||
pub(crate) fn all_shortcuts() -> Vec<(KeyboardShortcut, &'static str, serde_json::Value)> {
|
||||
let mut v = vec![];
|
||||
for c in vectorcraft_engine::command_specs() {
|
||||
if let Some(sc) = crate::menus::shortcut_of(c.id).and_then(parse) {
|
||||
v.push((sc, c.id));
|
||||
v.push((sc, c.id, json!({})));
|
||||
}
|
||||
}
|
||||
for c in crate::menus::UI_COMMANDS {
|
||||
if let Some(sc) = crate::menus::shortcut_of(c.0).and_then(parse) {
|
||||
v.push((sc, c.0));
|
||||
v.push((sc, c.0, json!({})));
|
||||
}
|
||||
}
|
||||
for (panel, _, _) in crate::state::ICON_PANELS {
|
||||
if let Some(sc) = crate::shortcut_editor::panel_shortcut(panel).and_then(parse) {
|
||||
v.push((sc, "window.panel", json!({ "panel": panel })));
|
||||
}
|
||||
}
|
||||
// Most specific (most modifiers) first so Cmd+Shift+Z isn't eaten by Cmd+Z.
|
||||
v.sort_by_key(|(sc, _)| {
|
||||
v.sort_by_key(|(sc, ..)| {
|
||||
std::cmp::Reverse(sc.modifiers.shift as u8 + sc.modifiers.alt as u8 + sc.modifiers.command as u8 + sc.modifiers.ctrl as u8)
|
||||
});
|
||||
v
|
||||
@@ -105,13 +111,9 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
// Editing keys with modifiers, clipboard and Cmd+A.
|
||||
crate::panels::character::route_type_input(app, ctx);
|
||||
// Enter was already delivered above as ToolKey::Enter (newline).
|
||||
let fire = all_shortcuts()
|
||||
.into_iter()
|
||||
.filter(|(sc, _)| sc.modifiers.command)
|
||||
.find(|(sc, _)| ctx.input_mut(|i| i.consume_shortcut(sc)))
|
||||
.map(|(_, id)| id);
|
||||
if let Some(id) = fire {
|
||||
crate::menus::invoke(app, id, json!({}));
|
||||
let fire = all_shortcuts().into_iter().filter(|(sc, ..)| sc.modifiers.command).find(|(sc, ..)| ctx.input_mut(|i| i.consume_shortcut(sc)));
|
||||
if let Some((_, id, p)) = fire {
|
||||
crate::menus::invoke(app, id, p);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -156,8 +158,8 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
}
|
||||
}
|
||||
// Command shortcuts.
|
||||
let mut fire: Option<&'static str> = None;
|
||||
for (sc, id) in all_shortcuts() {
|
||||
let mut fire = None;
|
||||
for (sc, id, p) in all_shortcuts() {
|
||||
// Letter / punctuation keys without Cmd/Alt/Ctrl are handled below as text (tool shortcuts,
|
||||
// X, D, /, `,` and `.`).
|
||||
let plain = !(sc.modifiers.command || sc.modifiers.alt || sc.modifiers.ctrl);
|
||||
@@ -168,12 +170,12 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
continue;
|
||||
}
|
||||
if ctx.input_mut(|i| i.consume_shortcut(&sc)) {
|
||||
fire = Some(id);
|
||||
fire = Some((id, p));
|
||||
break;
|
||||
}
|
||||
}
|
||||
if let Some(id) = fire {
|
||||
crate::menus::invoke(app, id, json!({}));
|
||||
if let Some((id, p)) = fire {
|
||||
crate::menus::invoke(app, id, p);
|
||||
return;
|
||||
}
|
||||
// Arrow nudges (Shift = ×10, Alt = copy).
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
//! The Fill/Stroke proxies and chips (toolbar, Control bar, Properties), their swatch and mixer
|
||||
//! popovers, the Recolor quick action and the panel shortcuts (M3.51).
|
||||
|
||||
use egui::{Color32, Event, Modifiers, PointerButton, Pos2, Rect, Shape, vec2};
|
||||
use serde_json::json;
|
||||
use vectorcraft_engine::Session;
|
||||
|
||||
use crate::{VectorcraftApp, chrome, panels, shortcut_editor, shortcuts, toolbar};
|
||||
|
||||
fn app() -> VectorcraftApp {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 300, "height": 300})).unwrap();
|
||||
app
|
||||
}
|
||||
|
||||
fn context() -> egui::Context {
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
ctx
|
||||
}
|
||||
|
||||
/// One headless frame of `draw` with `events` (and `modifiers` held); returns the painted shapes.
|
||||
fn frame(
|
||||
app: &mut VectorcraftApp,
|
||||
ctx: &egui::Context,
|
||||
events: Vec<Event>,
|
||||
modifiers: Modifiers,
|
||||
draw: fn(&mut VectorcraftApp, &mut egui::Ui),
|
||||
) -> Vec<Shape> {
|
||||
let events = std::iter::once(Event::ModifiersChanged(modifiers)).chain(events).collect();
|
||||
let screen_rect = Some(Rect::from_min_size(Pos2::ZERO, vec2(1400.0, 1000.0)));
|
||||
let mut out = ctx.run_ui(egui::RawInput { events, screen_rect, ..Default::default() }, |ui| draw(app, ui));
|
||||
out.textures_delta.clear();
|
||||
out.shapes.into_iter().map(|c| c.shape).collect()
|
||||
}
|
||||
|
||||
/// Click at `at` with `modifiers` held (press and release in two frames), then settle a frame.
|
||||
fn click(app: &mut VectorcraftApp, ctx: &egui::Context, at: Pos2, modifiers: Modifiers, draw: fn(&mut VectorcraftApp, &mut egui::Ui)) {
|
||||
let button = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers };
|
||||
frame(app, ctx, vec![Event::PointerMoved(at), button(true)], modifiers, draw);
|
||||
frame(app, ctx, vec![button(false)], modifiers, draw);
|
||||
frame(app, ctx, vec![], Modifiers::NONE, draw);
|
||||
}
|
||||
|
||||
/// Whether `shapes` paint a rectangle filled with `color`.
|
||||
fn paints_rect(shapes: &[Shape], color: Color32) -> bool {
|
||||
shapes.iter().any(|s| match s {
|
||||
Shape::Rect(r) => r.fill == color,
|
||||
Shape::Vec(v) => paints_rect(v, color),
|
||||
_ => false,
|
||||
})
|
||||
}
|
||||
|
||||
/// The Control bar's Fill and Stroke chips (22 pt squares with a chevron), left to right.
|
||||
fn chips(ctx: &egui::Context) -> (Rect, Rect) {
|
||||
let mut r: Vec<Rect> = ctx.viewport(|vp| {
|
||||
vp.prev_pass.widgets.layers().flat_map(|(_, w)| w.iter()).filter(|w| w.rect.size() == vec2(38.0, 22.0)).map(|w| w.rect).collect()
|
||||
});
|
||||
r.sort_by(|a, b| a.left().total_cmp(&b.left()));
|
||||
(r[0], r[1])
|
||||
}
|
||||
|
||||
fn control_bar(app: &mut VectorcraftApp, ui: &mut egui::Ui) {
|
||||
chrome::control_bar(app, ui);
|
||||
}
|
||||
|
||||
fn fill_hex(app: &VectorcraftApp) -> String {
|
||||
panels::current_paints(app).0.color().map(|c| c.to_hex()).unwrap_or_default()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn red_text_shows_a_red_toolbar_fill() {
|
||||
let mut app = app();
|
||||
app.run("text.create", json!({"x": 20, "y": 60, "text": "Red"})).unwrap();
|
||||
app.run("paint.setFill", json!({"color": "#ff0000"})).unwrap();
|
||||
let ctx = context();
|
||||
frame(&mut app, &ctx, vec![], Modifiers::NONE, toolbar::show);
|
||||
let shapes = frame(&mut app, &ctx, vec![], Modifiers::NONE, toolbar::show);
|
||||
assert!(paints_rect(&shapes, Color32::from_rgb(255, 0, 0)), "the toolbar's Fill proxy shows the type's red");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_chips_show_a_question_mark_for_mixed_fills() {
|
||||
let mut app = app();
|
||||
let a = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap()["id"].clone();
|
||||
app.run("paint.setFill", json!({"color": "#ff0000"})).unwrap();
|
||||
let b = app.run("shape.rectangle", json!({"x": 100, "y": 10, "width": 50, "height": 50})).unwrap()["id"].clone();
|
||||
app.run("paint.setFill", json!({"color": "#00ff00"})).unwrap();
|
||||
let text = |app: &mut VectorcraftApp| crate::tests_labels::painted_text(app, control_bar);
|
||||
assert!(!text(&mut app).lines().any(|l| l == "?"));
|
||||
app.run("select.set", json!({"ids": [a, b]})).unwrap();
|
||||
assert!(text(&mut app).lines().any(|l| l == "?"), "the Fill chip shows ?");
|
||||
let props = crate::tests_labels::painted_text(&mut app, panels::properties::show);
|
||||
assert!(props.lines().any(|l| l == "?"), "so does the Properties panel's");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_popover_tile_click_sets_the_fill() {
|
||||
let mut app = app();
|
||||
app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap();
|
||||
app.run("paint.toggleActive", json!({"fill": false})).unwrap();
|
||||
let ctx = context();
|
||||
frame(&mut app, &ctx, vec![], Modifiers::NONE, control_bar);
|
||||
let (fill, stroke) = chips(&ctx);
|
||||
click(&mut app, &ctx, fill.center(), Modifiers::NONE, control_bar);
|
||||
assert!(app.session.fill_active, "the Fill chip brings its proxy forward");
|
||||
let tile = ctx.read_response(egui::Id::new(("swatch-pop-tile", "Red"))).expect("the popover shows the swatches").rect;
|
||||
click(&mut app, &ctx, tile.center(), Modifiers::NONE, control_bar);
|
||||
assert_eq!(fill_hex(&app), "#ed1c24");
|
||||
assert_eq!(app.session.doc().unwrap().history.undo.last().unwrap().label, "Fill Color", "through paint.setFill");
|
||||
// The Stroke chip: its proxy comes forward; Shift-click shows the mixer instead of swatches.
|
||||
click(&mut app, &ctx, Pos2::new(1300.0, 900.0), Modifiers::NONE, control_bar);
|
||||
click(&mut app, &ctx, stroke.center(), Modifiers::SHIFT, control_bar);
|
||||
assert!(!app.session.fill_active);
|
||||
assert!(ctx.read_response(egui::Id::new(("swatch-pop-tile", "Red"))).is_none(), "no swatches in the mixer");
|
||||
let text = {
|
||||
let mut t = String::new();
|
||||
for s in frame(&mut app, &ctx, vec![], Modifiers::NONE, control_bar) {
|
||||
if let Shape::Text(g) = s {
|
||||
t.push_str(g.galley.text());
|
||||
t.push('\n');
|
||||
}
|
||||
}
|
||||
t
|
||||
};
|
||||
assert!(text.contains("Recent Colors"), "the Color panel's body: {text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recolor_is_a_quick_action_for_multicolour_selections() {
|
||||
let mut app = app();
|
||||
app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap();
|
||||
app.run("paint.setStroke", json!({"none": true})).unwrap();
|
||||
let text = |app: &mut VectorcraftApp| crate::tests_labels::painted_text(app, panels::properties::show);
|
||||
assert!(!text(&mut app).contains("Recolor"), "one colour");
|
||||
app.run("paint.setStroke", json!({"color": "#ff0000"})).unwrap();
|
||||
assert!(text(&mut app).contains("Recolor"), "white and red");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_shortcut_table_maps_f6_to_color() {
|
||||
assert_eq!(shortcut_editor::panel_shortcut("color"), Some("F6"));
|
||||
assert_eq!(shortcut_editor::panel_shortcut("transparency"), Some("Cmd+Shift+F10"));
|
||||
let all = shortcuts::all_shortcuts();
|
||||
let f6 = all.iter().find(|(sc, ..)| sc.logical_key == egui::Key::F6 && sc.modifiers == Modifiers::NONE).expect("F6");
|
||||
assert_eq!((f6.1, &f6.2), ("window.panel", &json!({"panel": "color"})));
|
||||
// The keys toggle their panels; Shift+F6 isn't taken by F6.
|
||||
let mut app = app();
|
||||
let ctx = context();
|
||||
let key =
|
||||
|key, modifiers| vec![Event::ModifiersChanged(modifiers), Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }];
|
||||
let press = |app: &mut VectorcraftApp, k, m| {
|
||||
let mut out = ctx.run_ui(egui::RawInput { events: key(k, m), ..Default::default() }, |ui| shortcuts::handle(app, ui.ctx()));
|
||||
out.textures_delta.clear();
|
||||
};
|
||||
press(&mut app, egui::Key::F6, Modifiers::NONE);
|
||||
assert_eq!(app.ui.open_panel.as_deref(), Some("color"));
|
||||
press(&mut app, egui::Key::F6, Modifiers::SHIFT);
|
||||
assert_eq!(app.ui.open_panel.as_deref(), Some("appearance"));
|
||||
press(&mut app, egui::Key::F9, Modifiers::COMMAND);
|
||||
assert_eq!(app.ui.open_panel.as_deref(), Some("gradient"));
|
||||
// The Window menu lists them.
|
||||
let color = crate::menus::menu_entries(&app).into_iter().find(|e| e.path == ["Window"] && e.label == "Color").unwrap();
|
||||
assert_eq!(color.shortcut, "F6");
|
||||
}
|
||||
@@ -236,6 +236,45 @@ pub fn gradient_chip(ui: &Ui, rect: Rect, gradient: &vectorcraft_color::Gradient
|
||||
}
|
||||
}
|
||||
|
||||
/// A Fill chip of `paint` into `rect` or, with `ring`, a Stroke chip: a frame `ring` wide round a
|
||||
/// panel-coloured hole (returned; the whole chip for fills). A selection whose paints differ
|
||||
/// (`mixed`) shows a white chip with a "?" (in the hole for strokes).
|
||||
pub fn proxy_chip(ui: &Ui, rect: Rect, paint: &Paint, mixed: bool, ring: Option<f32>) -> Rect {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
if mixed {
|
||||
ui.painter().rect_filled(rect, 0.0, Color32::WHITE);
|
||||
} else {
|
||||
paint_chip(ui, rect, paint);
|
||||
}
|
||||
let (hole, mark) = match ring {
|
||||
Some(w) => {
|
||||
let inner = rect.shrink(w);
|
||||
ui.painter().rect_filled(inner, 0.0, t.panel);
|
||||
(inner, t.text_strong)
|
||||
}
|
||||
None => (rect, Color32::BLACK),
|
||||
};
|
||||
if mixed {
|
||||
ui.painter().text(hole.center(), egui::Align2::CENTER_CENTER, "?", egui::FontId::proportional(hole.height() * 0.8), mark);
|
||||
}
|
||||
hole
|
||||
}
|
||||
|
||||
/// A chip that opens something (the Control bar's and Properties' Fill/Stroke chips, the Control
|
||||
/// bar's Style picker): `draw` paints the `size`-square chip, which gets a frame, and with
|
||||
/// `chevron` a chevron follows it.
|
||||
pub fn chip_button(ui: &mut Ui, size: f32, chevron: bool, draw: impl FnOnce(&Ui, Rect)) -> Response {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(if chevron { size + 16.0 } else { size }, size), Sense::click());
|
||||
let chip = Rect::from_min_size(r.min, vec2(size, size)).shrink(2.0);
|
||||
draw(ui, chip);
|
||||
ui.painter().rect_stroke(chip, 0.0, Stroke::new(1.0, t.input_border), StrokeKind::Outside);
|
||||
if chevron {
|
||||
icons::paint(ui, "chevron-down", Rect::from_center_size(pos2(chip.right() + 9.0, r.center().y), vec2(12.0, 12.0)), t.text_dim);
|
||||
}
|
||||
resp
|
||||
}
|
||||
|
||||
/// What the user did on a [`fill_stroke_proxy`].
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct ProxyClicks {
|
||||
@@ -263,31 +302,13 @@ pub fn fill_stroke_proxy(ui: &mut Ui, fill: &Paint, stroke: &Paint, mixed: (bool
|
||||
drag_source(ui, resp, || PanelDrag::paint(paint.clone()));
|
||||
}
|
||||
}
|
||||
let question = |ui: &Ui, r: Rect, color: Color32| {
|
||||
ui.painter().text(r.center(), egui::Align2::CENTER_CENTER, "?", egui::FontId::proportional(r.height() * 0.8), color);
|
||||
};
|
||||
let draw_fill = |ui: &Ui| {
|
||||
if mixed.0 {
|
||||
ui.painter().rect_filled(fill_r, 0.0, Color32::WHITE);
|
||||
question(ui, fill_r, Color32::BLACK);
|
||||
} else {
|
||||
paint_chip(ui, fill_r, fill);
|
||||
}
|
||||
proxy_chip(ui, fill_r, fill, mixed.0, None);
|
||||
ui.painter().rect_stroke(fill_r, 0.0, Stroke::new(1.0, Color32::from_gray(20)), StrokeKind::Inside);
|
||||
ui.painter().rect_stroke(fill_r.expand(1.0), 0.0, Stroke::new(1.0, Color32::from_gray(150)), StrokeKind::Outside);
|
||||
};
|
||||
let draw_stroke = |ui: &Ui| {
|
||||
let w = s * 0.28;
|
||||
if mixed.1 {
|
||||
ui.painter().rect_filled(stroke_r, 0.0, Color32::WHITE);
|
||||
} else {
|
||||
paint_chip(ui, stroke_r, stroke);
|
||||
}
|
||||
let inner = stroke_r.shrink(w);
|
||||
ui.painter().rect_filled(inner, 0.0, t.panel);
|
||||
if mixed.1 {
|
||||
question(ui, inner, t.text_strong);
|
||||
}
|
||||
let inner = proxy_chip(ui, stroke_r, stroke, mixed.1, Some(s * 0.28));
|
||||
ui.painter().rect_stroke(stroke_r, 0.0, Stroke::new(1.0, Color32::from_gray(20)), StrokeKind::Inside);
|
||||
ui.painter().rect_stroke(inner, 0.0, Stroke::new(1.0, Color32::from_gray(20)), StrokeKind::Outside);
|
||||
ui.painter().rect_stroke(stroke_r.expand(1.0), 0.0, Stroke::new(1.0, Color32::from_gray(150)), StrokeKind::Outside);
|
||||
|
||||
@@ -100,3 +100,9 @@ caption buttons (Minimize, Maximize/Restore, Close) sit at the bar's right end,
|
||||
not commands, so drive them with `ui.click` if needed. Close runs `app.quit` (the same `saveChanges` questions for
|
||||
modified documents), and empty bar space and the 5 pt window edges move and resize the window. macOS and the web
|
||||
build are unchanged.
|
||||
|
||||
Fill/Stroke chips and panel shortcuts: the Control bar's and Properties' Fill and Stroke chips bring their proxy
|
||||
forward (`paint.toggleActive {fill}`) and open a popover with the Swatches panel (Shift-click: the Color panel's mixer);
|
||||
a swatch clicked there runs `paint.setFill` / `paint.setStroke`. Panel keys (Color F6, Color Guide Shift+F3,
|
||||
Appearance Shift+F6, Graphic Styles Shift+F5, Stroke Cmd+F10, Gradient Cmd+F9, Transparency Cmd+Shift+F10) run
|
||||
`window.panel {panel}` and can be pressed with `ui.key`; `ui.menu.list` shows them on the Window menu's items.
|
||||
Reference in new issue
Block a user