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:
bflatasticandClaude Opus 5.5 committed 2026-10-05 02:05:33 +07:00
1 parent 512e96e7c8
commit 2e6662c93b
16 files changed
+565 -142

No files matched your search

+2 -1
View File
@@ -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 {
+64 -31
View File
@@ -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)
+2
View File
@@ -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;
+3 -3
View File
@@ -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);
+105
View File
@@ -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)));
}
+5 -27
View File
@@ -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();
+2
View File
@@ -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};
+12 -2
View File
@@ -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::*;
+54 -4
View File
@@ -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) {
+23 -17
View File
@@ -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);
+20 -5
View File
@@ -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).
+41 -14
View File
@@ -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;
+20 -18
View File
@@ -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).
+165
View File
@@ -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");
}
+41 -20
View File
@@ -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);
+6
View File
@@ -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.