Files
vectorcraft/crates/ui-egui/src/dialogs/variables.rs
T
Fabio William Conceição 41c8bd2a2d G6: image variables swap linked files and keep bounds
Add VariableKind::Image with path values on datasets, makeImageDynamic,
panel/dialog UI, and MCP docs so data merge can drive placed images.
2026-10-16 04:45:06 +07:00

394 lines
17 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The Variables dialogs (`variable.define`, `variable.bind`, `dataset.new`, `dataset.set`
//! and `dataset.select`), opened from Window › Variables or the command palette.
//!
//! One dialog covers all of them: what it shows is what the command takes. A variable is a
//! name and a kind; a dataset is a name and one field per variable — a text field for a
//! `text` or `image` variable (path for image), a checkbox for a `visibility` one.
//! `dataset.set` opens with the row's current values, so editing a row is editing what it
//! already says.
use serde_json::{Value, json};
use vectorcraft_doc::{DataValue, VariableKind, Variables};
use super::{DialogSpec, run_and_close};
use crate::VectorcraftApp;
use crate::state::Dialog;
use crate::theme::Tokens;
use crate::widgets;
/// The dialog kind of every Variables dialog.
pub const KIND: &str = "variables";
/// The kinds a variable can have, as `variable.define` names them.
const KINDS: [&str; 3] = ["text", "visibility", "image"];
/// What the dialog shows for each of [`KINDS`].
fn kind_labels() -> [&'static str; 3] {
[tl!("Text"), tl!("Visibility"), tl!("Image")]
}
pub(super) const SPEC: DialogSpec =
DialogSpec { heading: |d| tl!(&d.str("__label")).to_string(), body, confirm, min_width: 380.0, ..DialogSpec::FORM };
/// The command the dialog runs and what it is called.
fn opened(id: &str) -> Option<&'static str> {
Some(match id {
"variable.define" => "New Variable",
"variable.rename" => "Variable Options",
"variable.bind" => "Bind Variable",
"dataset.new" => "New Data Set",
"dataset.set" => "Edit Data Set",
"dataset.rename" => "Rename Data Set",
"dataset.select" => "Select Data Set",
_ => return None,
})
}
/// The row `id` renames, which the dialog offers as a dropdown when the document has more than
/// one. Variable Options renames the one the panel has highlighted, so it starts there.
fn rename_of(id: &str) -> Option<&'static str> {
Some(match id {
"variable.rename" => "variable",
"dataset.rename" => "name",
_ => return None,
})
}
/// Open `id`'s dialog: OK runs the command with the fields below. `preselect` names the row
/// a rename starts on (the panel's highlighted variable, the active dataset); without it, or
/// when it no longer exists, the first row — the dialog still offers the dropdown.
pub fn open(app: &mut VectorcraftApp, id: &str, preselect: Option<&str>) -> bool {
let Some(label) = opened(id) else { return false };
let Some(st) = app.session.active() else {
app.status(tl!("no document").to_string());
return true;
};
let vars = st.doc.variables.clone();
let mut fields = serde_json::Map::new();
match id {
"variable.define" => {
fields.insert("name".into(), json!(""));
fields.insert("kind".into(), json!(KINDS[0]));
}
"variable.bind" => {
fields.insert("variable".into(), json!(""));
}
"dataset.new" => {
fields.insert("name".into(), json!(""));
value_fields(&vars, vars.datasets.first().map(|d| &d.values), &mut fields);
}
"dataset.set" => {
// Without a row to edit there is nothing to open.
let Some(name) = vars.active_dataset.clone().or_else(|| vars.datasets.first().map(|d| d.name.clone())) else {
app.status(tl!("no data set to edit").to_string());
return true;
};
let Some(current) = vars.dataset(&name) else { return true };
fields.insert("name".into(), json!(name));
value_fields(&vars, Some(&current.values), &mut fields);
}
"dataset.select" => {
fields.insert("name".into(), json!(""));
}
_ => {
// A rename: which row, and its new name.
let key = rename_of(id).unwrap_or("name");
let names: Vec<&str> = if key == "variable" {
vars.variables.iter().map(|v| v.name.as_str()).collect()
} else {
vars.datasets.iter().map(|d| d.name.as_str()).collect()
};
if names.is_empty() {
app.status(if key == "variable" { tl!("no variable to rename").to_string() } else { tl!("no data set to rename").to_string() });
return true;
}
fields.insert(key.into(), json!(preselect.filter(|p| names.contains(p)).or(names.first().copied()).unwrap_or_default()));
fields.insert("newName".into(), json!(""));
}
}
fields.insert("__command".into(), json!(id));
fields.insert("__label".into(), json!(label));
app.ui.dialog = Some(Dialog::new(KIND, Value::Object(fields)));
true
}
/// Whether `id` opens one of these dialogs.
pub fn opens(id: &str) -> bool {
opened(id).is_some()
}
/// One field per variable, prefilled from `current` (the row being edited, else the first row
/// so a new one starts from something rather than from nothing).
fn value_fields(vars: &Variables, current: Option<&std::collections::BTreeMap<String, DataValue>>, out: &mut serde_json::Map<String, Value>) {
for v in &vars.variables {
let value = match (current.and_then(|c| c.get(&v.name)), v.kind) {
(Some(DataValue::Text(t)), _) => json!(t),
(Some(DataValue::Visible(b)), _) => json!(b),
(Some(DataValue::Image(p)), _) => json!(p),
// Without a value in the row a text/image field starts empty (no value in this row)
// and a visibility one starts shown: its false is a value, so it is never "unset".
(None, VariableKind::Text | VariableKind::Image) => json!(""),
(None, VariableKind::Visibility) => json!(true),
};
out.insert(value_key(&v.name), value);
}
}
/// A variable name as a field key. A field key shares the dialog's flat namespace with the
/// command's own parameters, so names are prefixed to stay clear of `name` and `values`.
fn value_key(name: &str) -> String {
format!("value:{name}")
}
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let t = Tokens::get(ui.ctx());
let Some(st) = app.session.active() else {
widgets::dim_label(ui, tl!("No document"));
return false;
};
let vars = &st.doc.variables;
let variable_names: Vec<&str> = vars.variables.iter().map(|v| v.name.as_str()).collect();
let dataset_names: Vec<&str> = vars.datasets.iter().map(|x| x.name.as_str()).collect();
egui::Grid::new("variables-grid").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| {
let label = |ui: &mut egui::Ui, text: &str| widgets::field_label(ui, egui::RichText::new(text).color(t.text));
match d.str("__command").as_str() {
"variable.define" => {
label(ui, tl!("Name"));
text(ui, d, "name", 220.0);
ui.end_row();
label(ui, tl!("Kind"));
// The kinds by their translated names; the field keeps the command's own word.
let labels = kind_labels();
let shown = KINDS.iter().position(|k| *k == d.str("kind")).and_then(|i| labels.get(i)).copied().unwrap_or_default();
if let Some(kind) = widgets::dropdown_names(ui, ("variables-dd", "kind"), shown, &labels, 220.0).and_then(|i| KINDS.get(i)) {
d.fields.insert("kind".into(), json!(kind));
}
ui.end_row();
}
"variable.bind" => {
label(ui, tl!("Variable"));
dropdown(ui, d, "variable", &variable_names, 220.0);
ui.end_row();
}
"dataset.select" => {
label(ui, tl!("Data Set"));
dropdown(ui, d, "name", &dataset_names, 220.0);
ui.end_row();
}
// A rename: which row, and its new name.
"variable.rename" | "dataset.rename" => {
let (key, label_text, names) = if d.str("__command") == "variable.rename" {
("variable", tl!("Variable").to_string(), &variable_names)
} else {
("name", tl!("Data Set").to_string(), &dataset_names)
};
label(ui, &label_text);
dropdown(ui, d, key, names, 220.0);
ui.end_row();
label(ui, tl!("New Name"));
text(ui, d, "newName", 220.0);
ui.end_row();
}
_ => {
label(ui, tl!("Name"));
text(ui, d, "name", 220.0);
ui.end_row();
if vars.variables.is_empty() {
widgets::dim_label(ui, tl!("Define a variable first"));
ui.end_row();
return;
}
// One row per variable, in the order they are defined, each with the editor
// its kind takes.
for v in &vars.variables {
label(ui, &v.name);
let key = value_key(&v.name);
match v.kind {
VariableKind::Text | VariableKind::Image => {
text(ui, d, &key, 220.0);
}
VariableKind::Visibility => checkbox(ui, d, &key),
}
ui.end_row();
}
}
}
});
false
}
/// A text field for `key`, written back as it is typed.
fn text(ui: &mut egui::Ui, d: &mut Dialog, key: &str, width: f32) -> egui::Response {
let mut s = d.str(key);
let r = ui.add(egui::TextEdit::singleline(&mut s).desired_width(width));
if r.changed() {
d.fields.insert(key.into(), json!(s));
}
r
}
/// A checkbox for `key`, written back as it is toggled.
fn checkbox(ui: &mut egui::Ui, d: &mut Dialog, key: &str) {
let b = d.bool(key);
if widgets::check(ui, "", b, true) {
d.fields.insert(key.into(), json!(!b));
}
}
/// A dropdown over the document's names `options` (shown as they are, never translated) for
/// `key`, written back as it changes. The empty option is first so a fresh dialog can still be
/// filled in; a name that is no longer offered reads as empty rather than being silently
/// swapped for another one.
fn dropdown(ui: &mut egui::Ui, d: &mut Dialog, key: &str, options: &[&str], width: f32) {
let cur = d.str(key);
let mut labels: Vec<&str> = vec![""];
labels.extend_from_slice(options);
let shown = if cur.is_empty() || options.contains(&cur.as_str()) { cur.as_str() } else { "" };
if let Some(i) = widgets::dropdown_names(ui, ("variables-dd", key), shown, &labels, width) {
// The empty option (0) empties the field; the rest are `options`, one along.
d.fields.insert(key.into(), json!(i.checked_sub(1).and_then(|i| options.get(i)).copied().unwrap_or_default()));
}
}
/// The `values` object the dialog's fields mean: a text or image variable whose field is empty
/// carries no value in this row, and a visibility checkbox is always a value — its false, too.
fn values_of(d: &Dialog, vars: &Variables) -> Value {
let mut out = serde_json::Map::new();
for v in &vars.variables {
let key = value_key(&v.name);
match v.kind {
VariableKind::Text | VariableKind::Image => {
let text = d.str(&key);
if !text.is_empty() {
out.insert(v.name.clone(), json!(text));
}
}
VariableKind::Visibility => {
out.insert(v.name.clone(), json!(d.bool(&key)));
}
}
}
Value::Object(out)
}
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
let cmd = d.str("__command");
let name = d.str("name");
let p = match cmd.as_str() {
"variable.define" => json!({"name": name, "kind": d.str("kind")}),
"variable.bind" => json!({"variable": d.str("variable")}),
"variable.rename" => json!({"name": d.str("variable"), "newName": d.str("newName")}),
"dataset.rename" => json!({"name": name, "newName": d.str("newName")}),
"dataset.new" | "dataset.set" => {
let values = app.session.active().map(|st| values_of(d, &st.doc.variables)).unwrap_or_else(|| json!({}));
json!({"name": name, "values": values})
}
"dataset.select" => json!({"name": name}),
_ => json!({}),
};
run_and_close(app, &cmd, p)
}
#[cfg(test)]
mod tests {
use serde_json::json;
use vectorcraft_engine::Session;
use super::*;
fn app_with_variables() -> VectorcraftApp {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 200, "height": 200})).unwrap();
app.run("variable.define", json!({"name": "Title", "kind": "text"})).unwrap();
app.run("variable.define", json!({"name": "Badge", "kind": "visibility"})).unwrap();
app
}
#[test]
fn defining_a_variable_from_the_dialog_works() {
let mut app = app_with_variables();
assert!(open(&mut app, "variable.define", None));
{
let d = app.ui.dialog.as_ref().unwrap();
assert_eq!(d.kind, KIND);
assert_eq!(d.str("name"), "");
assert_eq!(d.str("kind"), "text", "text is the default kind");
}
app.ui.dialog.as_mut().unwrap().fields.insert("name".into(), json!("Price"));
crate::dialogs::confirm(&mut app).unwrap();
let vars = app.run("variable.list", json!({})).unwrap();
assert!(vars["variables"].as_array().unwrap().iter().any(|v| v["name"] == "Price"));
}
#[test]
fn a_new_dataset_carries_the_dialogs_values_and_omits_the_empty_ones() {
let mut app = app_with_variables();
open(&mut app, "dataset.new", None);
{
let d = app.ui.dialog.as_mut().unwrap();
d.fields.insert("name".into(), json!("Row 1"));
d.fields.insert(value_key("Title"), json!("Hello"));
d.fields.insert(value_key("Badge"), json!(false));
}
crate::dialogs::confirm(&mut app).unwrap();
let sets = app.run("dataset.list", json!({})).unwrap();
assert_eq!(sets["datasets"][0]["name"], "Row 1");
assert_eq!(sets["datasets"][0]["values"]["Title"], json!({"text": "Hello"}));
assert_eq!(sets["datasets"][0]["values"]["Badge"], json!({"visible": false}));
}
#[test]
fn editing_a_dataset_opens_with_the_rows_own_values() {
let mut app = app_with_variables();
app.run("dataset.new", json!({"name": "Row 1", "values": {"Title": "Hello", "Badge": false}})).unwrap();
app.run("dataset.set", json!({"name": "Row 1", "values": {"Badge": true}})).unwrap();
open(&mut app, "dataset.set", None);
{
let d = app.ui.dialog.as_ref().unwrap();
assert_eq!(d.str("name"), "Row 1");
assert_eq!(d.str(&value_key("Title")), "", "the row carries no value for it");
assert!(d.bool(&value_key("Badge")), "the row's own value");
}
app.ui.dialog.as_mut().unwrap().fields.insert(value_key("Title"), json!("Changed"));
crate::dialogs::confirm(&mut app).unwrap();
let sets = app.run("dataset.list", json!({})).unwrap();
assert_eq!(sets["datasets"][0]["values"]["Title"], json!({"text": "Changed"}));
assert_eq!(sets["datasets"][0]["values"]["Badge"], json!({"visible": true}));
}
#[test]
fn selecting_a_dataset_from_the_dialog_applies_it() {
let mut app = app_with_variables();
let id = app.run("text.create", json!({"x": 10, "y": 10, "text": "x"})).unwrap()["id"].as_u64().unwrap();
app.run("variable.bind", json!({"variable": "Title", "ids": [id]})).unwrap();
app.run("dataset.new", json!({"name": "Row 1", "values": {"Title": "Hi"}})).unwrap();
open(&mut app, "dataset.select", None);
assert_eq!(app.ui.dialog.as_ref().unwrap().str("name"), "");
app.ui.dialog.as_mut().unwrap().fields.insert("name".into(), json!("Row 1"));
crate::dialogs::confirm(&mut app).unwrap();
assert_eq!(app.run("text.getRange", json!({"id": id})).unwrap()["text"], "Hi");
}
#[test]
fn only_the_variables_commands_open_one() {
let mut app = app_with_variables();
assert!(opens("dataset.set") && opens("variable.define"));
assert!(!opens("dataset.list") && !opens("dataset.next"), "no dialog for a query or a step");
assert!(!open(&mut app, "dataset.list", None), "and it opens nothing");
assert!(app.ui.dialog.is_none());
}
#[test]
fn rename_dialogs_start_on_the_preselected_row() {
let mut app = app_with_variables();
app.run("dataset.new", json!({"name": "Row 1", "values": {"Title": "Hello"}})).unwrap();
open(&mut app, "variable.rename", Some("Badge"));
assert_eq!(app.ui.dialog.as_ref().unwrap().str("variable"), "Badge");
// A preselect the document no longer has falls back to the first row.
open(&mut app, "variable.rename", Some("Gone"));
assert_eq!(app.ui.dialog.as_ref().unwrap().str("variable"), "Title");
open(&mut app, "dataset.rename", Some("Row 1"));
assert_eq!(app.ui.dialog.as_ref().unwrap().str("name"), "Row 1");
}
}