mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-27 03:06:57 -06:00
A plug-in's dialog labelled each field with its parameter's name spaced
out ("Line_spacing_mm:"), showed numbers without their unit and listed
at most 64 parameters in one column. A plug-in with many settings, such
as a generator's or an exporter's, could neither explain nor sort them,
and its dialog showed settings that don't apply to the choices made.
Each parameter may now say how dialogs show it, in keys that hosts
from before them ignore (plugins::dialog):
- label; unit, after the value ("0.25 mm"); help, on the label; and
helpUrl, an https:// page opened from a button after the field.
- group: the parameters outside any group come first, then each group
as a section that folds, in the order the groups first appear, the
fields lined up down the dialog.
- visibleWhen: {"pattern": ["lines"]} shows a parameter only
while each checkbox or choice it names, declared before it, has one
of the values listed and is shown itself. Manifest::visible decides
every parameter in one pass, in declaration order. A hidden parameter
still goes to the plug-in with its value, so a run never depends on
what the dialog showed.
Two types join number, int, bool and choice: string, a text field cut
to maxLength, and numberList, numbers typed separated by commas or
spaces, each clamped, minItems to maxItems of them. A plug-in declares
up to 256 parameters, at most 64 outside groups. The filter, live-effect
and exporter dialogs use every key. The Appearance panel's editor of a
plug-in effect uses the labels, units, help and conditions, and reads a
typed number list as the dialog does.
dialog.rs tests parsing, the round trip of a manifest written out,
sections, visibility (also of 256 overlapping conditions, which a
recursive check would take forever on) and the keys refused;
manifest.rs the new types and limits. tests_plugin_fields.rs draws a
dialog with kittest: labels and units, sections in order with their
fields lined up, visibleWhen hiding and showing rows, and the help
button opening its page.
appearance.rs checks the editor's labels, units, conditions and a
typed number list, and form.rs units after numbers (a unit starting
with a digit is spaced from it) and typed lists.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HhvnnnD5fTFupK8WWq35un
# Conflicts:
# docs/gaps.md
# docs/target-app-parity.md
164 lines
7.0 KiB
Rust
164 lines
7.0 KiB
Rust
//! WebAssembly plug-ins in the menus and their generated dialogs.
|
||
//!
|
||
//! Object › Plug-ins lists the installed object filters (`plugin.dialog {id}`), then Install
|
||
//! Plug-in… and Reload Plug-ins; Effect › Plug-ins lists the live-effect plug-ins, which open the
|
||
//! effect dialog (`effect.dialog`). A filter with parameters opens dialog `plugin`: one field per
|
||
//! declared parameter (fields named after them; `__plugin` is the plug-in id) with a live
|
||
//! Preview, and OK keeps the run as one undo step (`plugin.run`). A filter without parameters runs
|
||
//! at once. Exporters are formats of File › Export › Export As; one with parameters opens dialog
|
||
//! `pluginExport` once the file is picked, and its Export button writes the file.
|
||
|
||
use std::sync::Arc;
|
||
|
||
use serde_json::{Map, Value, json};
|
||
use vectorcraft_plugins::{Kind, Manifest, Plugin, registry};
|
||
|
||
use super::{DialogSpec, form};
|
||
use crate::VectorcraftApp;
|
||
use crate::menus::Item;
|
||
use crate::state::Dialog;
|
||
|
||
/// The dialog kind of plug-in filters.
|
||
pub const KIND: &str = "plugin";
|
||
|
||
const CMD: &str = "plugin.run";
|
||
|
||
/// Object › Plug-ins' header while no filter plug-in is installed.
|
||
pub const NO_FILTERS: &str = "No filter plug-ins installed";
|
||
|
||
/// Headed by the plug-in's name, as it is (plug-in text is never translated).
|
||
pub(super) const SPEC: DialogSpec =
|
||
DialogSpec { heading: |d| d.str("__label"), body, confirm, preview: true, max_width: Some(420.0), ..DialogSpec::FORM };
|
||
|
||
/// A menu label for plug-in `name`: with an ellipsis when it opens a dialog.
|
||
fn label(name: &str, has_params: bool) -> &'static str {
|
||
let dots = if has_params && !name.ends_with('…') { "…" } else { "" };
|
||
registry::intern(&format!("{name}{dots}"))
|
||
}
|
||
|
||
/// Object › Plug-ins: the installed object filters, then Install Plug-in… and Reload Plug-ins.
|
||
pub fn object_menu() -> Vec<Item> {
|
||
let mut items: Vec<Item> = registry::list()
|
||
.iter()
|
||
.filter(|p| p.manifest().kind == Kind::Filter)
|
||
.map(|p| Item::Cmd(label(&p.manifest().name, !p.manifest().params.is_empty()), "plugin.dialog", json!({ "id": p.id() })))
|
||
.collect();
|
||
if items.is_empty() {
|
||
items.push(Item::Header(NO_FILTERS));
|
||
}
|
||
items.extend([
|
||
Item::Sep,
|
||
Item::Cmd("Install Plug-in…", "ui.installPlugin", Value::Null),
|
||
Item::Cmd("Reload Plug-ins", "plugin.reload", Value::Null),
|
||
]);
|
||
items
|
||
}
|
||
|
||
/// Effect › Plug-ins: the live-effect plug-ins (`None` while none is installed). Effects without
|
||
/// options apply at once, as built-in ones do.
|
||
pub fn effect_menu() -> Option<Item> {
|
||
let items: Vec<Item> = vectorcraft_effects::plugin_effects()
|
||
.into_iter()
|
||
.map(|e| match e.defaults.as_object().is_some_and(|o| o.is_empty()) {
|
||
true => Item::Cmd(e.label, "effect.apply", json!({ "effect": e.id })),
|
||
false => Item::Cmd(e.label, "effect.dialog", json!({ "effect": e.id })),
|
||
})
|
||
.collect();
|
||
(!items.is_empty()).then_some(Item::Sub("Plug-ins", items))
|
||
}
|
||
|
||
/// `plugin.dialog {id, params?}`: run filter `id` at once when it takes no parameters or `params`
|
||
/// are given, else open its dialog prefilled with its defaults.
|
||
pub fn open(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
|
||
let id = p.get("id").and_then(Value::as_str).ok_or("missing `id` (see plugin.list)")?;
|
||
let plugin = registry::get(id).ok_or_else(|| format!("no plug-in {id:?} is installed"))?;
|
||
let m = plugin.manifest();
|
||
match m.kind {
|
||
Kind::Filter => {}
|
||
Kind::Effect => return Err(format!("{} is a live effect (Effect › Plug-ins)", m.name)),
|
||
Kind::Exporter => return Err(format!("{} is an exporter (File › Export › Export As…)", m.name)),
|
||
}
|
||
let given = p.get("params").filter(|v| v.as_object().is_some_and(|o| !o.is_empty()));
|
||
if m.params.is_empty() || given.is_some() {
|
||
return app.run(CMD, json!({"id": id, "params": given.cloned().unwrap_or(Value::Null)}));
|
||
}
|
||
let mut fields = fields_for(m);
|
||
fields.insert("preview".into(), json!(true));
|
||
app.ui.dialog = Some(Dialog { kind: KIND.into(), fields });
|
||
Ok(Value::Null)
|
||
}
|
||
|
||
/// The fields of plug-in `m`'s dialog: its parameters at their defaults, `__plugin` (its id) and
|
||
/// `__label` (its name, the heading).
|
||
fn fields_for(m: &Manifest) -> Map<String, Value> {
|
||
let mut fields = m.defaults();
|
||
fields.insert("__plugin".into(), json!(m.id));
|
||
fields.insert("__label".into(), json!(m.name.trim_end_matches('…')));
|
||
fields
|
||
}
|
||
|
||
/// The plug-in dialog `d` is for, its description drawn above the fields. `None` once it was
|
||
/// uninstalled, which closes the dialog.
|
||
fn described(ui: &mut egui::Ui, d: &Dialog) -> Option<Arc<Plugin>> {
|
||
let plugin = registry::get(&d.str("__plugin"))?;
|
||
let m = plugin.manifest();
|
||
if !m.description.is_empty() {
|
||
ui.label(egui::RichText::new(&m.description).color(crate::theme::Tokens::get(ui.ctx()).text_dim));
|
||
ui.add_space(8.0);
|
||
}
|
||
Some(plugin)
|
||
}
|
||
|
||
fn params(d: &Dialog) -> Value {
|
||
json!({"id": d.str("__plugin"), "params": form::params(d)})
|
||
}
|
||
|
||
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||
let Some(plugin) = described(ui, d) else { return true };
|
||
form::schema_fields(app, ui, d, plugin.manifest());
|
||
let (label, p) = (d.str("__label"), params(d));
|
||
form::preview(app, ui, d, &label, CMD, p);
|
||
false
|
||
}
|
||
|
||
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
|
||
form::commit_preview(app, CMD, params(d))
|
||
}
|
||
|
||
/// The dialog kind of an exporter's parameters, after Export As picked the file.
|
||
pub const EXPORT_KIND: &str = "pluginExport";
|
||
|
||
/// Headed by the plug-in's name, as it is; Export writes the file.
|
||
pub(super) const EXPORT_SPEC: DialogSpec = DialogSpec {
|
||
heading: |d| d.str("__label"),
|
||
body: export_body,
|
||
confirm: export_confirm,
|
||
ok: Some("Export"),
|
||
max_width: Some(420.0),
|
||
..DialogSpec::FORM
|
||
};
|
||
|
||
/// Export As with exporter `plugin`, which has parameters: its fields at their defaults, and
|
||
/// `export`, the export's own parameters (`format`, `path`, …), kept for Export.
|
||
pub fn open_export(app: &mut VectorcraftApp, plugin: &Plugin, export: Value) {
|
||
let mut fields = fields_for(plugin.manifest());
|
||
fields.insert("__export".into(), export);
|
||
app.ui.dialog = Some(Dialog { kind: EXPORT_KIND.into(), fields });
|
||
}
|
||
|
||
fn export_body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||
let Some(plugin) = described(ui, d) else { return true };
|
||
form::schema_fields(app, ui, d, plugin.manifest());
|
||
false
|
||
}
|
||
|
||
/// Writes the file with the export's parameters and the plug-in's, as `params`.
|
||
fn export_confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
|
||
let mut p = d.fields.get("__export").cloned().filter(Value::is_object).ok_or("no format to export to")?;
|
||
p["params"] = form::params(d);
|
||
let format = p.get("format").and_then(Value::as_str).map(str::to_string);
|
||
let path = p.get("path").and_then(Value::as_str).map(str::to_string);
|
||
app.ui.dialog = None;
|
||
crate::io::export(app, format.as_deref(), path, &p)
|
||
}
|