diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs index 7cadd5d9..fe28a65f 100644 --- a/crates/doc/src/node.rs +++ b/crates/doc/src/node.rs @@ -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 { diff --git a/crates/engine/src/cmd/paint.rs b/crates/engine/src/cmd/paint.rs index 85268d36..9dd8148f 100644 --- a/crates/engine/src/cmd/paint.rs +++ b/crates/engine/src/cmd/paint.rs @@ -41,10 +41,18 @@ pub fn specs() -> Vec { 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 { "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) -> (bool, bool) { if self.selection.is_empty() { return (false, false); } let ids: std::collections::HashSet = 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 { 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 { 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 { +/// 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 { 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::>()}); - 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::>()}); + (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) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index cda8edf8..5be6c36a 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -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; diff --git a/crates/engine/src/tests_paintproxy.rs b/crates/engine/src/tests_paintproxy.rs index be0ee209..33f0aaa3 100644 --- a/crates/engine/src/tests_paintproxy.rs +++ b/crates/engine/src/tests_paintproxy.rs @@ -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); diff --git a/crates/engine/src/tests_proxyitems.rs b/crates/engine/src/tests_proxyitems.rs new file mode 100644 index 00000000..4ce73602 --- /dev/null +++ b/crates/engine/src/tests_proxyitems.rs @@ -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))); +} diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 8a4c233f..66e091ca 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -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(); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index dfbcd2e8..d0d46a11 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -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}; diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index cb01b4f6..d8dbcbfd 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -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 { 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 { 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::*; diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 0593cce8..cdb7fdc7 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -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::>(ctx, "proxy-mixed") { + let key = (st.uid, st.revision, app.session.appearance_item()); + match pstate::), (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) { diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 3322e984..5d9b9959 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -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::>(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); diff --git a/crates/ui-egui/src/panels/swatches.rs b/crates/ui-egui/src/panels/swatches.rs index e1f09163..ab91c3a6 100644 --- a/crates/ui-egui/src/panels/swatches.rs +++ b/crates/ui-egui/src/panels/swatches.rs @@ -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). diff --git a/crates/ui-egui/src/shortcut_editor.rs b/crates/ui-egui/src/shortcut_editor.rs index e145c7de..919bef73 100644 --- a/crates/ui-egui/src/shortcut_editor.rs +++ b/crates/ui-egui/src/shortcut_editor.rs @@ -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:` 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:`). +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:`, `panel:` 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, key: &str) -> Option 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; diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index d1ac7d4a..eaf5f988 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -43,21 +43,27 @@ pub fn parse(s: &str) -> Option { 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). diff --git a/crates/ui-egui/src/tests_paintchips.rs b/crates/ui-egui/src/tests_paintchips.rs new file mode 100644 index 00000000..c3315302 --- /dev/null +++ b/crates/ui-egui/src/tests_paintchips.rs @@ -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, + modifiers: Modifiers, + draw: fn(&mut VectorcraftApp, &mut egui::Ui), +) -> Vec { + 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 = 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"); +} diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 9a429e55..d2711516 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -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) -> 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); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index a0278e51..7b3baf67 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -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.