Files
vectorcraft/crates/ui-egui/src/panels/mod.rs
T
bflatasticandClaude Opus 5.5 64d366e2ae M3.41: Stroke panel follows Units > Stroke, shows mixed values blank and reaches the Control bar
- Weights and dash lengths show in Units > Stroke in the Stroke panel,
  the Control bar and the Properties panel (Session::stroke_unit).
- Values the selected objects don't share show blank (weight, miter
  limit, dashes) or light no button (cap, join, align), from a
  per-revision cached DocState::stroke_mixed.
- Align Stroke Inside/Outside are disabled for open paths and type.
- The Control bar weight is a preset spinner with a width profile
  dropdown, and its Stroke link (and the Properties panel's) opens the
  Stroke panel as an anchored popover instead of docking it.
- The panels show a group's first painted object's stroke, as the
  Stroke proxy does (Session::shown_stroke).
- stroke.set on a group skips images and symbol instances.
- document.inspect reports each object's strokeOptions (cap, join,
  miterLimit, align, dash, dashOffset, alignDashes, arrows, arrowAlign,
  profile) in stroke.set terms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 01:10:14 +07:00

386 lines
15 KiB
Rust

//! Panels. Each panel reads engine state and acts only through `app.run(...)` (slider drags
//! preview through the session's interaction so one drag is one undo step).
//!
//! Every icon panel has a body (`show_icon_panel`) and a panel (≡) menu (`panel_menu`).
pub mod actions;
pub mod align;
pub mod appearance;
pub mod artboards;
pub mod brushes;
pub mod character;
pub mod color;
pub mod color_guide;
pub mod doc_info;
pub mod glyphs;
pub mod gradient;
pub mod graphic_styles;
pub mod history;
pub mod image_trace;
pub mod info;
pub mod layers;
pub mod library_panel;
pub mod magic_wand;
pub mod navigator;
pub mod opentype;
pub mod paragraph;
pub mod pathfinder;
pub mod pattern_options;
pub mod properties;
pub mod separations;
pub mod stroke;
pub mod swatches;
pub mod symbols;
pub mod tabs;
pub mod text_styles;
pub mod transform;
pub mod transparency;
use egui::{Rect, Sense, Ui, vec2};
use serde_json::{Value, json};
use vectorcraft_color::{BlendMode, Color, Paint};
use vectorcraft_doc::{Node, StrokeLayer};
use vectorcraft_engine::inspect::StrokeMixed;
use crate::theme::Tokens;
use crate::widgets::{Live, dim_label};
use crate::{VectorcraftApp, icons};
/// The first selected node (cloned), if any.
pub fn first_selected(app: &VectorcraftApp) -> Option<Node> {
first_node(app).cloned()
}
/// Number of selected objects.
pub(crate) fn selection_len(app: &VectorcraftApp) -> usize {
app.session.active().map(|d| d.selection.len()).unwrap_or(0)
}
pub fn show_icon_panel(app: &mut VectorcraftApp, ui: &mut Ui, id: &str) {
match id {
"swatches" => swatches::show(app, ui),
"color" => color::show(app, ui),
"colorGuide" => color_guide::show(app, ui),
"stroke" => stroke::show(app, ui),
"transparency" => transparency::show(app, ui),
"appearance" => appearance::show(app, ui),
"graphicStyles" => graphic_styles::show(app, ui),
"align" => align::show(app, ui),
"pathfinder" => pathfinder::show(app, ui),
"transform" => transform::show(app, ui),
"history" => history::show(app, ui),
"actions" => actions::show(app, ui),
"info" => info::show(app, ui),
"separations" => separations::show(app, ui),
"artboards" => artboards::show(app, ui),
"gradient" => gradient::show(app, ui),
"character" => character::show(app, ui),
"paragraph" => paragraph::show(app, ui),
"glyphs" => glyphs::show(app, ui),
"navigator" => navigator::show(app, ui),
"brushes" => brushes::show(app, ui),
"symbols" => symbols::show(app, ui),
"patternOptions" => pattern_options::show(app, ui),
"imageTrace" => image_trace::show(app, ui),
"openType" => opentype::show(app, ui),
"docInfo" => doc_info::show(app, ui),
"charStyles" => text_styles::show(app, ui, text_styles::Kind::Char),
"paraStyles" => text_styles::show(app, ui, text_styles::Kind::Para),
"magicWand" => magic_wand::show(app, ui),
"tabs" => tabs::show(app, ui),
_ => {
dim_label(ui, "This panel is on the roadmap (see the parity plan).");
}
}
}
/// Items of a panel's (≡) menu. Unimplemented Illustrator items are listed disabled.
pub fn panel_menu_items(app: &mut VectorcraftApp, ui: &mut Ui, id: &str) {
match id {
"swatches" => swatches::menu(app, ui),
"color" => color::menu(app, ui),
"colorGuide" => color_guide::menu(app, ui),
"stroke" => stroke::menu(app, ui),
"transparency" => transparency::menu(app, ui),
"appearance" => appearance::menu(app, ui),
"graphicStyles" => graphic_styles::menu(app, ui),
"align" => align::menu(app, ui),
"pathfinder" => pathfinder::menu(app, ui),
"transform" => transform::menu(app, ui),
"history" => history::menu(app, ui),
"info" => info::menu(app, ui),
"separations" => separations::menu(app, ui),
"artboards" => artboards::menu(app, ui),
"gradient" => gradient::menu(app, ui),
"character" => character::menu(app, ui),
"paragraph" => paragraph::menu(app, ui),
"glyphs" => glyphs::menu(app, ui),
"navigator" => navigator::menu(app, ui),
"brushes" => brushes::menu(app, ui),
"symbols" => symbols::menu(app, ui),
"patternOptions" => pattern_options::menu(app, ui),
"imageTrace" => image_trace::menu(app, ui),
"openType" => opentype::menu(app, ui),
"docInfo" => doc_info::menu(app, ui),
"charStyles" => text_styles::menu(app, ui, text_styles::Kind::Char),
"paraStyles" => text_styles::menu(app, ui, text_styles::Kind::Para),
"magicWand" => magic_wand::menu(app, ui),
"tabs" => tabs::menu(app, ui),
_ => {
ui.add_enabled(false, egui::Button::new("No options").frame(false));
}
}
}
/// The ≡ panel-menu button drawn into `rect` (the right end of a panel's title/tab strip).
pub fn panel_menu(app: &mut VectorcraftApp, ui: &mut Ui, id: &str, rect: Rect) {
let t = Tokens::get(ui.ctx());
let resp = ui.interact(rect, ui.id().with(("panel-menu", id)), Sense::click());
icons::paint(ui, "menu", rect.shrink(1.0), if resp.hovered() { t.text_strong } else { t.text_dim });
let resp = resp.on_hover_text("Panel menu");
egui::Popup::menu(&resp).show(|ui| {
ui.set_min_width(220.0);
panel_menu_items(app, ui, id);
});
}
pub fn libraries(_app: &mut VectorcraftApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
ui.add_space(20.0);
ui.vertical_centered(|ui| {
icons::icon(ui, "library", 40.0, t.text_dim);
ui.add_space(8.0);
ui.label(egui::RichText::new("Local Libraries").size(14.0).color(t.text));
dim_label(
ui,
"Drag art, colors and text styles here to reuse them across documents. Libraries are stored on this machine — no account required.",
);
});
}
// ---------- shared helpers ----------
/// The paint command of the active proxy (Fill or Stroke), or of the inactive one.
pub(crate) fn proxy_cmd(app: &VectorcraftApp, inactive: bool) -> &'static str {
if app.session.fill_active != inactive { "paint.setFill" } else { "paint.setStroke" }
}
/// The first selected node, borrowed (for per-frame reads that need no copy of it).
fn first_node(app: &VectorcraftApp) -> Option<&Node> {
let st = app.session.active()?;
st.selection.objects.first().and_then(|id| st.doc.node(*id))
}
/// Is Alt held? A click on a colour then paints the inactive proxy.
pub(crate) fn alt_held(ui: &Ui) -> bool {
ui.input(|i| i.modifiers.alt)
}
/// Apply a clicked colour, swatch or None (`params`: `{color}`, `{swatch}` or `{none}`) to the
/// active proxy, or with Alt held to the inactive one, which stays behind.
pub(crate) fn apply_click(app: &mut VectorcraftApp, ui: &Ui, mut params: Value) {
let alt = alt_held(ui);
params["focus"] = json!(!alt);
app.run(proxy_cmd(app, alt), params).ok();
}
/// Fill and stroke as the proxies show them ([`vectorcraft_engine::Session::proxy_paints`]: the
/// Appearance panel's active item for the proxy of its kind, else the first selected object's, a
/// group's first painted object's, else the defaults for new art).
pub(crate) fn current_paints(app: &VectorcraftApp) -> (Paint, Paint) {
app.session.proxy_paints()
}
/// Whether the selected objects' fills and strokes differ (the proxies show "?"), cached per
/// document revision.
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") {
Some((k, mixed)) if k == key => mixed,
_ => {
let mixed = st.proxy_mixed();
set_pstate(ctx, "proxy-mixed", Some((key, mixed)));
mixed
}
}
}
/// The stroke the Stroke panel, Control bar and Properties show: while the Type tool edits text,
/// the selected characters' stroke, else [`vectorcraft_engine::Session::shown_stroke`].
pub(crate) fn current_stroke(app: &VectorcraftApp) -> Option<StrokeLayer> {
if character::text_editing(app).is_some() {
return character::text_style(app).map(|(c, _)| c.stroke_layer());
}
app.session.shown_stroke()
}
/// Which Stroke panel values the selection doesn't share and whether Align Stroke applies
/// ([`vectorcraft_engine::DocState::stroke_mixed`]), cached per document revision. While the Type
/// tool edits text, the selected characters' stroke shows as it is.
pub(crate) fn stroke_mixed(app: &VectorcraftApp, ctx: &egui::Context) -> StrokeMixed {
let Some(st) = app.session.active().filter(|_| character::text_editing(app).is_none()) else { return StrokeMixed::default() };
let key = (st.uid, st.revision);
match pstate::<Option<((u64, u64), StrokeMixed)>>(ctx, "stroke-mixed") {
Some((k, mixed)) if k == key => mixed,
_ => {
let mixed = st.stroke_mixed();
set_pstate(ctx, "stroke-mixed", Some((key, mixed)));
mixed
}
}
}
/// Opacity and blend mode as the Transparency panel and Control bar show them: the Appearance
/// panel's active item's, else the first selected object's.
pub(crate) fn current_transparency(app: &VectorcraftApp) -> Option<(f32, BlendMode)> {
let n = first_node(app)?;
Some(match app.session.appearance_item().and_then(|i| n.appearance.items.get(i)) {
Some(it) => (it.opacity(), it.blend()),
None => (n.opacity, n.blend),
})
}
/// Is the active proxy "?" (the selected objects' paints differ)?
pub(crate) fn active_mixed(app: &VectorcraftApp, ctx: &egui::Context) -> bool {
let (f, s) = mixed_paints(app, ctx);
if app.session.fill_active { f } else { s }
}
/// The paint behind the active proxy.
pub(crate) fn active_paint(app: &VectorcraftApp) -> Paint {
let (f, s) = current_paints(app);
if app.session.fill_active { f } else { s }
}
/// Draw the Fill/Stroke proxy and handle its clicks through commands.
pub(crate) fn proxy(app: &mut VectorcraftApp, ui: &mut Ui, size: f32) {
let (f, s) = current_paints(app);
let mixed = mixed_paints(app, ui.ctx());
let c = crate::widgets::fill_stroke_proxy(ui, &f, &s, mixed, app.session.fill_active, size);
if (c.fill && !app.session.fill_active) || (c.stroke && app.session.fill_active) {
app.run("paint.toggleActive", json!({})).ok();
}
if c.swap {
app.run("paint.swap", json!({})).ok();
}
if c.default {
app.run("paint.default", json!({})).ok();
}
if let Some(stroke) = c.pick {
app.run("ui.colorPicker", json!({ "stroke": stroke })).ok();
}
}
/// 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) {
match phase {
Live::Idle => {}
Live::Dragging | Live::Released => {
if app.session.begin_interaction(label).is_ok() {
if let Err(e) = app.session.preview(cmd, &params) {
app.ui.status = e.to_string();
}
if phase == Live::Released {
app.session.commit_interaction().ok();
}
app.sync_views();
} else if phase == Live::Released {
app.run(cmd, params).ok();
}
}
}
}
/// Per-panel UI state kept in egui memory (not document state).
pub(crate) fn pstate<T: Clone + Default + Send + Sync + 'static>(ctx: &egui::Context, key: &str) -> T {
ctx.data(|d| d.get_temp::<T>(egui::Id::new(("panel-state", key)))).unwrap_or_default()
}
pub(crate) fn set_pstate<T: Clone + Send + Sync + 'static>(ctx: &egui::Context, key: &str, v: T) {
ctx.data_mut(|d| d.insert_temp(egui::Id::new(("panel-state", key)), v));
}
/// "Recent Colors" header + a row of chips (the Session's recent colours, which every paint
/// command feeds); clicking one applies it to the active proxy (Alt: the inactive one).
pub(crate) fn recent_colors_row(app: &mut VectorcraftApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
crate::widgets::subheader(ui, "Recent Colors");
let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::hover());
ui.painter().rect_stroke(r, 0.0, egui::Stroke::new(1.0, t.input_border), egui::StrokeKind::Inside);
let mut chosen = None;
for (i, c) in app.session.recent_colors.iter().enumerate() {
let cell = Rect::from_min_size(r.min + vec2(3.0 + i as f32 * 19.0, 3.0), vec2(16.0, 16.0));
if cell.right() > r.right() - 2.0 {
break;
}
let resp = ui.interact(cell, ui.id().with(("recent", i)), Sense::click());
crate::widgets::swatch_tile(ui, cell, &Paint::solid(*c), false, resp.hovered());
if resp.on_hover_text(c.to_hex()).clicked() {
chosen = Some(*c);
}
}
if let Some(c) = chosen {
apply_click(app, ui, json!({"color": color_json(&c)}));
}
}
/// A colour as command JSON, keeping its model.
pub(crate) use vectorcraft_tools::params::color_json;
/// Paint as command params (`{color}`, `{swatch}`, `{none}`, `{gradient}` (lossless) or `{pattern}`).
pub(crate) fn paint_params(p: &Paint) -> Value {
match p {
Paint::None => json!({"none": true}),
Paint::Solid { swatch: Some(n), .. } => json!({"swatch": n}),
Paint::Solid { color, .. } => json!({"color": color_json(color)}),
Paint::Gradient(g) => json!({"gradient": vectorcraft_tools::params::gradient_params(g)}),
Paint::Pattern { pattern, .. } => json!({"pattern": pattern}),
}
}
/// Egui colour of a document colour.
pub(crate) fn c32(c: &Color) -> egui::Color32 {
let [r, g, b, _] = c.to_rgba8(1.0);
egui::Color32::from_rgb(r, g, b)
}
/// A labelled empty-state message used by list panels.
pub(crate) fn empty_state(ui: &mut Ui, icon: &str, title: &str, body: &str) {
let t = Tokens::get(ui.ctx());
ui.add_space(12.0);
ui.vertical_centered(|ui| {
icons::icon(ui, icon, 28.0, t.text_disabled);
ui.add_space(4.0);
ui.label(egui::RichText::new(title).size(12.5).color(t.text));
ui.label(egui::RichText::new(body).size(11.5).color(t.text_dim));
});
ui.add_space(12.0);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn paint_params_roundtrip_shapes() {
assert_eq!(paint_params(&Paint::None), json!({"none": true}));
let p = paint_params(&Paint::solid(Color::cmyk(0.1, 0.2, 0.3, 0.4)));
assert!((p["color"]["k"].as_f64().unwrap() - 0.4).abs() < 1e-6);
let g = Paint::Gradient(Box::new(vectorcraft_color::GradientPaint::new(Default::default())));
let p = paint_params(&g);
assert_eq!(p["gradient"]["kind"], "linear");
assert_eq!(p["gradient"]["stops"].as_array().unwrap().len(), 2);
assert_eq!(paint_params(&vectorcraft_doc::pattern::pattern_paint("Dots")), json!({"pattern": "Dots"}));
assert_eq!(p["gradient"]["stops"][0]["midpoint"], json!(0.5));
}
}
#[cfg(test)]
mod tests_appearance;
#[cfg(test)]
mod tests_effectedit;
#[cfg(test)]
mod tests_stroke;
#[cfg(test)]
mod tests_strokeux;