mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-01 21:57:12 -06:00
261 lines
12 KiB
Rust
261 lines
12 KiB
Rust
//! Attributes panel: Overprint Fill / Overprint Stroke, Show / Don't Show Center, Reverse Path
|
|
//! Direction Off / On, the fill rule (non-zero winding or even-odd), Image Map, URL (with the
|
|
//! recent URLs and a Browser button) and the note. The menu hides the options and shows the note.
|
|
//! Everything reads `attributes.info` ([`vectorcraft_engine::Session::attributes_info`]) and acts
|
|
//! through `attributes.set`, `path.reverse` and `path.setFillRule`.
|
|
|
|
use egui::Ui;
|
|
use serde_json::{Value, json};
|
|
use vectorcraft_doc::{ImageMap, NodeId, NodeKind};
|
|
use vectorcraft_engine::cmd::AttributesInfo;
|
|
use vectorcraft_geom::FillRule;
|
|
|
|
use super::{pstate, selection_len, set_pstate};
|
|
use crate::VectorcraftApp;
|
|
use crate::widgets::{self, menu_item};
|
|
|
|
/// Panel state: the options (centre, direction, fill rule, image map, URL) are hidden.
|
|
const HIDE_OPTIONS: &str = "attr-hide-options";
|
|
/// Panel state: the note field is hidden.
|
|
const HIDE_NOTE: &str = "attr-hide-note";
|
|
const LABEL_W: f32 = 70.0;
|
|
|
|
/// The panel's values and whether the targets hold paths (direction and fill rule apply),
|
|
/// recomputed only when the document or the selection changes.
|
|
fn state(app: &VectorcraftApp, ctx: &egui::Context) -> (AttributesInfo, bool) {
|
|
type Key = (u64, u64, usize, Option<NodeId>, Option<NodeId>);
|
|
let Some(st) = app.session.active() else { return Default::default() };
|
|
let sel = &st.selection.objects;
|
|
let key: Key = (st.uid, st.revision, sel.len(), sel.first().copied(), sel.last().copied());
|
|
let id = egui::Id::unique("attr-state");
|
|
if let Some((k, v)) = ctx.data(|d| d.get_temp::<(Key, (AttributesInfo, bool))>(id))
|
|
&& k == key
|
|
{
|
|
return v;
|
|
}
|
|
let info = app.session.attributes_info();
|
|
let mut paths = false;
|
|
for n in info.ids.iter().filter_map(|id| st.doc.node(*id)) {
|
|
n.walk(&mut |c| paths |= matches!(c.kind, NodeKind::Path { guide: false, .. }));
|
|
}
|
|
let v = (info, paths);
|
|
ctx.data_mut(|d| d.insert_temp(id, (key, v.clone())));
|
|
v
|
|
}
|
|
|
|
/// Run `attributes.set` with `p` on the selection (errors show in the status bar).
|
|
fn set(app: &mut VectorcraftApp, p: Value) {
|
|
app.run("attributes.set", p).ok();
|
|
}
|
|
|
|
/// A pair of icon buttons choosing one of two values (`current`: the targets' value, `None` where
|
|
/// they differ); returns the value clicked.
|
|
fn pair<T: Copy + PartialEq>(ui: &mut Ui, enabled: bool, current: Option<T>, items: [(&str, &str, T); 2]) -> Option<T> {
|
|
let mut clicked = None;
|
|
for (icon, tip, v) in items {
|
|
if widgets::icon_button_enabled(ui, icon, tip, enabled && current == Some(v), enabled, 24.0).clicked() {
|
|
clicked = Some(v);
|
|
}
|
|
}
|
|
clicked
|
|
}
|
|
|
|
pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
|
let (info, paths) = state(app, ui.ctx());
|
|
let has = !info.ids.is_empty();
|
|
ui.horizontal(|ui| {
|
|
for (label, key, value) in
|
|
[(tl!("Overprint Fill"), "overprintFill", info.overprint_fill), (tl!("Overprint Stroke"), "overprintStroke", info.overprint_stroke)]
|
|
{
|
|
if widgets::check3(ui, label, value, has) {
|
|
set(app, json!({ key: value != Some(true) }));
|
|
}
|
|
ui.add_space(8.0);
|
|
}
|
|
});
|
|
if !pstate::<bool>(ui.ctx(), HIDE_OPTIONS) {
|
|
widgets::divider(ui);
|
|
ui.horizontal(|ui| {
|
|
let center = [("dc-center-hide", tl!("Don't Show Center"), false), ("dc-center-show", tl!("Show Center"), true)];
|
|
if let Some(v) = pair(ui, has, info.show_center, center) {
|
|
set(app, json!({ "showCenter": v }));
|
|
}
|
|
ui.add_space(10.0);
|
|
let dir = [("dc-dir-off", tl!("Reverse Path Direction Off"), false), ("dc-dir-on", tl!("Reverse Path Direction On"), true)];
|
|
if let Some(v) = pair(ui, paths, info.reversed, dir) {
|
|
app.run("path.reverse", json!({ "reversed": v })).ok();
|
|
}
|
|
ui.add_space(10.0);
|
|
let rules = [
|
|
("dc-rule-nonzero", tl!("Use Non-Zero Winding Fill Rule"), FillRule::NonZero),
|
|
("dc-rule-evenodd", tl!("Use Even-Odd Fill Rule"), FillRule::EvenOdd),
|
|
];
|
|
if let Some(r) = pair(ui, paths, info.fill_rule, rules) {
|
|
app.run("path.setFillRule", json!({ "rule": if r == FillRule::EvenOdd { "evenOdd" } else { "nonZero" } })).ok();
|
|
}
|
|
});
|
|
ui.add_space(4.0);
|
|
let url = info.url.clone().filter(|u| !u.is_empty());
|
|
widgets::label_row(ui, tl!("Image Map:"), LABEL_W, |ui| {
|
|
ui.add_enabled_ui(has, |ui| {
|
|
let labels = ImageMap::ALL.map(ImageMap::label);
|
|
if let Some(i) = widgets::dropdown(ui, "attr-map", info.image_map.map_or("", ImageMap::label), &labels, 100.0) {
|
|
set(app, json!({ "imageMap": ImageMap::ALL[i] }));
|
|
}
|
|
});
|
|
ui.add_space(6.0);
|
|
let r = ui.add_enabled_ui(url.is_some(), |ui| widgets::flat_button(ui, tl!("Browser"), 64.0)).inner;
|
|
if r.on_hover_text(tl!("Open the URL in the web browser")).clicked() {
|
|
app.run("attributes.openUrl", json!({})).ok();
|
|
}
|
|
});
|
|
widgets::label_row(ui, tl!("URL:"), LABEL_W, |ui| {
|
|
ui.add_enabled_ui(has, |ui| {
|
|
let width = (ui.available_width() - 26.0).max(80.0);
|
|
if let Some(u) = widgets::text_field(ui, "attr-url", info.url.as_deref(), width, 1) {
|
|
set(app, json!({ "url": u }));
|
|
}
|
|
recent_urls(app, ui);
|
|
});
|
|
});
|
|
}
|
|
if !pstate::<bool>(ui.ctx(), HIDE_NOTE) {
|
|
widgets::divider(ui);
|
|
widgets::subheader(ui, tl!("Note:"));
|
|
ui.add_enabled_ui(has, |ui| {
|
|
if let Some(n) = widgets::text_field_named(ui, "attr-note", Some(tl!("Note:")), info.note.as_deref(), ui.available_width(), 3) {
|
|
set(app, json!({ "note": n }));
|
|
}
|
|
});
|
|
}
|
|
if !has {
|
|
widgets::dim_label(ui, if selection_len(app) == 0 { tl!("No Selection") } else { "" });
|
|
}
|
|
}
|
|
|
|
/// The recent URLs button: a menu of the URLs given lately; picking one sets it.
|
|
fn recent_urls(app: &mut VectorcraftApp, ui: &mut Ui) {
|
|
let resp = widgets::icon_button_enabled(ui, "chevron-down", tl!("Recent URLs"), false, !app.session.recent_urls.is_empty(), 20.0);
|
|
let mut chosen = None;
|
|
egui::Popup::menu(&resp).show(|ui| {
|
|
ui.set_min_width(220.0);
|
|
for u in &app.session.recent_urls {
|
|
if widgets::menu_item_name(ui, u, true, false) {
|
|
chosen = Some(u.clone());
|
|
}
|
|
}
|
|
});
|
|
if let Some(u) = chosen {
|
|
set(app, json!({ "url": u }));
|
|
}
|
|
}
|
|
|
|
pub fn menu(_app: &mut VectorcraftApp, ui: &mut Ui) {
|
|
for (key, hide, show) in [(HIDE_OPTIONS, tl!("Hide Options"), tl!("Show Options")), (HIDE_NOTE, tl!("Hide Note"), tl!("Show Note"))] {
|
|
let hidden: bool = pstate(ui.ctx(), key);
|
|
if menu_item(ui, if hidden { show } else { hide }, true, false) {
|
|
set_pstate(ui.ctx(), key, !hidden);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// `attributes.openUrl {url?}`: open `url`, else the selection's URL, in the web browser.
|
|
pub(crate) fn open_url(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
|
|
app.refuse_automation("attributes.openUrl")?;
|
|
let url = match p.get("url").and_then(Value::as_str) {
|
|
Some(u) => u.to_string(),
|
|
None => app.session.attributes_info().url.filter(|u| !u.is_empty()).ok_or("the selection has no URL (or they differ)")?,
|
|
};
|
|
app.open_document_url(&url)?;
|
|
Ok(json!({ "url": url }))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use vectorcraft_engine::Session;
|
|
|
|
/// Run the panel and its menu for one headless frame: the texts drawn.
|
|
fn frame(app: &mut VectorcraftApp) -> Vec<String> {
|
|
fn texts(s: &egui::Shape, out: &mut Vec<String>) {
|
|
match s {
|
|
egui::Shape::Text(t) => out.push(t.galley.text().to_string()),
|
|
egui::Shape::Vec(v) => v.iter().for_each(|s| texts(s, out)),
|
|
_ => {}
|
|
}
|
|
}
|
|
let ctx = egui::Context::default();
|
|
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
|
|
show(app, ui);
|
|
menu(app, ui);
|
|
});
|
|
out.textures_delta.clear();
|
|
let mut v = vec![];
|
|
out.shapes.iter().for_each(|c| texts(&c.shape, &mut v));
|
|
v
|
|
}
|
|
|
|
fn run(app: &mut VectorcraftApp, id: &str, p: Value) -> Value {
|
|
app.session.execute(id, &p).unwrap()
|
|
}
|
|
|
|
#[test]
|
|
fn panel_shows_the_selections_attributes() {
|
|
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
|
run(&mut app, "file.new", json!({"width": 100, "height": 100}));
|
|
let texts = frame(&mut app);
|
|
assert!(texts.iter().any(|t| t == "No Selection") && texts.iter().any(|t| t == "Overprint Fill"), "{texts:?}");
|
|
run(&mut app, "shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50}));
|
|
run(&mut app, "attributes.set", json!({"url": "https://example.com/a", "note": "first note", "imageMap": "polygon"}));
|
|
let texts = frame(&mut app);
|
|
for want in ["https://example.com/a", "first note", "Polygon", "Image Map:", "URL:", "Browser", " Hide Options", " Hide Note"] {
|
|
assert!(texts.iter().any(|t| t == want), "{want} in {texts:?}");
|
|
}
|
|
assert!(!texts.iter().any(|t| t == "No Selection"));
|
|
// The URL button opens the selection's URL.
|
|
let (info, paths) = state(&app, &egui::Context::default());
|
|
assert!(paths && info.url.as_deref() == Some("https://example.com/a"));
|
|
assert_eq!(open_url(&mut app, &json!({})).unwrap()["url"], "https://example.com/a");
|
|
// Two objects whose values differ show blanks.
|
|
let b = run(&mut app, "shape.ellipse", json!({"x": 60, "y": 0, "width": 30, "height": 30}))["id"].clone();
|
|
let a = app.session.active().unwrap().doc.layers[0].children().unwrap()[0].id.0;
|
|
run(&mut app, "select.set", json!({"ids": [a, b]}));
|
|
let texts = frame(&mut app);
|
|
assert!(!texts.iter().any(|t| t == "https://example.com/a" || t == "first note"), "{texts:?}");
|
|
assert!(open_url(&mut app, &json!({})).is_err());
|
|
}
|
|
|
|
/// Only web and mail URLs reach the browser.
|
|
#[test]
|
|
fn the_browser_opens_web_and_mail_urls_only() {
|
|
for ok in ["https://example.com/a", "HTTP://example.com", "mailto:someone@example.com"] {
|
|
assert!(crate::browsable_url(ok).is_ok(), "{ok}");
|
|
}
|
|
for refused in [
|
|
"file:///tmp/a.html",
|
|
"javascript:void(0)",
|
|
"ms-settings:",
|
|
"smb://host/share",
|
|
"x-app://run",
|
|
"example.com",
|
|
" https://example.com",
|
|
"https://a\nb",
|
|
"",
|
|
] {
|
|
assert!(crate::browsable_url(refused).is_err(), "{refused}");
|
|
}
|
|
let opened = std::rc::Rc::new(std::cell::RefCell::new(Vec::<String>::new()));
|
|
let o = opened.clone();
|
|
let services = crate::Services { open_url: Some(Box::new(move |u: &str| o.borrow_mut().push(u.to_string()))), ..Default::default() };
|
|
let mut app = VectorcraftApp::new(Session::new(), services);
|
|
run(&mut app, "file.new", json!({"width": 100, "height": 100}));
|
|
run(&mut app, "shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50}));
|
|
run(&mut app, "attributes.set", json!({"url": "file:///tmp/run.desktop"}));
|
|
assert!(open_url(&mut app, &json!({})).is_err());
|
|
assert!(open_url(&mut app, &json!({"url": "vscode://file/x"})).is_err());
|
|
assert!(opened.borrow().is_empty());
|
|
open_url(&mut app, &json!({"url": "https://example.com/b"})).unwrap();
|
|
assert_eq!(*opened.borrow(), ["https://example.com/b"]);
|
|
}
|
|
}
|