variables: highlighted row owns Delete, Options and Select Bound Object

Select Bound Object ran variable.unbind on the selection, so choosing it silently
removed the bindings; Delete Variable and Delete Data Set deleted every row. Now
the panel keeps a highlighted variable row (a click highlights and selects what
it drives, as the reference panel does): Select Bound Object selects what the
highlighted variable drives via select.set, Delete Variable deletes only it, and
Delete Data Set deletes only the current set. Variable Options and Rename Data
Set open their dialogs preselected with the highlighted variable / active set
(the double-click used to invoke variable.rename with a bare name, which only
errored). Unbind enables only over a bound selection; Make Text Dynamic only for
exactly one type object.

The highlight is per-document session state with its own command
(variable.highlight, no undo step, like layer.highlight): rename carries it,
delete clears it when stale. New engine tests (18) and panel tests cover the
reviewer's regression case, verified to fail on the old wiring.
This commit is contained in:
Fahri Meral 2026-10-10 12:26:41 +03:00 committed by bflatastic
parent 06dbe5b1e8
commit f2252b0d9c
14 changed files with 262 additions and 38 deletions

View File

@ -104,6 +104,12 @@ fn rename(s: &mut Session, p: &Value) -> Result<Value> {
}
Ok(())
})?;
// The highlight names a variable, so it follows the rename rather than going stale.
let st = s.doc_mut()?;
if st.variables_highlight.as_deref() == Some(from.as_str()) {
st.variables_highlight = Some(to.clone());
}
st.revision += 1;
Ok(json!({"from": from, "name": to}))
}
@ -149,6 +155,12 @@ fn make_dynamic(s: &mut Session, kind: VariableKind, stem: &str) -> Result<Value
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
let names = names_param(p, "variable.delete")?;
let n = s.edit("Delete Variables", |d, _| Ok(d.variables.prune(&names)))?;
// A highlight of a variable that is gone highlights nothing.
let st = s.doc_mut()?;
if st.variables_highlight.as_ref().is_some_and(|h| st.doc.variables.variable(h).is_none()) {
st.variables_highlight = None;
}
st.revision += 1;
Ok(json!({"deleted": n}))
}
@ -227,6 +239,27 @@ fn unbind(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"unbound": n}))
}
/// Highlight one Variables panel row: what the panel's Delete, Options… and Select Bound
/// Object act on. Panel state, not art selection: not saved, not an undo step (an empty
/// or missing `variable` clears it), like `layer.highlight`.
fn highlight(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "variable.highlight";
let name = match str_param(p, "variable").filter(|v| !v.is_empty()) {
Some(v) => {
let v = v.to_string();
if s.doc()?.doc.variables.variable(&v).is_none() {
return Err(bad(C, format!("no variable named `{v}`")));
}
Some(v)
}
None => None,
};
let st = s.doc_mut()?;
st.variables_highlight = name.clone();
st.revision += 1;
Ok(json!({"variable": name}))
}
fn dataset_new(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "dataset.new";
let name = var_name(p, C)?;
@ -480,6 +513,15 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc,
unbind
),
cmd!(
"variable.highlight",
"Highlight Variable",
[],
None,
"{variable?} highlight one Variables panel row (a click); what the panel's Delete, Options… and Select Bound Object act on. An empty or missing `variable` clears it. Not an undo step → {variable}",
has_doc,
highlight
),
cmd!("dataset.new", "New Data Set…", ["Window", "Variables"], None, "{name, values?: {variable: value}} add a dataset", has_doc, dataset_new),
cmd!(
"dataset.set",

View File

@ -86,6 +86,7 @@ pub struct Interaction {
/// isolation).
pub active_layer: Option<NodeId>,
pub layer_rows: Vec<NodeId>,
pub variables_highlight: Option<String>,
pub isolation: Option<NodeId>,
/// The perspective transform the previews make (`perspective.transform` params): Transform
/// Again repeats it once the drag is committed.
@ -121,6 +122,10 @@ pub struct DocState {
/// Panel state, not art selection: not saved, not undoable (`layer.setCurrent`,
/// `layer.highlight`).
pub layer_rows: Vec<NodeId>,
/// The variable highlighted in the Variables panel: what its Delete, Options… and Select
/// Bound Object act on. Panel state, not art selection: not saved, not undoable
/// (`variable.highlight`). A name, so renaming a variable moves the highlight with it.
pub variables_highlight: Option<String>,
/// Isolation mode container.
pub isolation: Option<NodeId>,
pub interaction: Option<Interaction>,
@ -236,6 +241,7 @@ impl DocState {
revision: 1,
active_layer,
layer_rows: vec![],
variables_highlight: None,
isolation: None,
interaction: None,
undo_group: None,
@ -300,6 +306,7 @@ impl DocState {
self.selection = it.selection;
self.active_layer = it.active_layer;
self.layer_rows = it.layer_rows;
self.variables_highlight = it.variables_highlight;
self.isolation = it.isolation;
self.revision += 1;
}
@ -1294,6 +1301,7 @@ impl Session {
preview: None,
active_layer: st.active_layer,
layer_rows: st.layer_rows.clone(),
variables_highlight: st.variables_highlight.clone(),
isolation: st.isolation,
perspective_again: None,
});

View File

@ -436,3 +436,56 @@ fn bad_variables_requests_are_errors_not_panics() {
assert!(s.execute(cmd, &params).is_err(), "{cmd} {params}");
}
}
#[test]
fn highlight_selects_without_unbinding_and_deletes_leave_the_rest() {
// The reviewer's regression case: two variables, two rows.
let mut s = session();
let a = text(&mut s, "Alice");
let b = text(&mut s, "Bob");
for (name, id) in [("Name", a), ("Other", b)] {
s.execute("variable.define", &json!({"name": name, "kind": "text"})).unwrap();
s.execute("variable.bind", &json!({"variable": name, "ids": [id.0]})).unwrap();
}
s.execute("dataset.new", &json!({"name": "One", "values": {"Name": "Alice", "Other": "Bob"}})).unwrap();
s.execute("dataset.new", &json!({"name": "Two", "values": {"Name": "A2", "Other": "B2"}})).unwrap();
s.execute("dataset.select", &json!({"name": "One"})).unwrap();
// What a row click runs (highlight), then what Select Bound Object runs (select).
s.execute("variable.highlight", &json!({"variable": "Name"})).unwrap();
let bound: Vec<u64> = s.doc().unwrap().doc.variables.objects_of("Name").iter().map(|i| i.0).collect();
s.execute("select.set", &json!({"ids": bound})).unwrap();
assert_eq!(s.doc().unwrap().selection.objects, vec![a]);
// …and the binding is still there: selecting never unbinds.
assert_eq!(s.doc().unwrap().doc.variables.objects_of("Name"), vec![a]);
// Deleting one variable leaves the other; deleting the current dataset leaves the other.
s.execute("variable.delete", &json!({"names": ["Name"]})).unwrap();
let vars = s.execute("variable.list", &json!({})).unwrap();
assert_eq!(vars["variables"].as_array().unwrap().len(), 1);
assert_eq!(vars["variables"][0]["name"], "Other");
s.execute("dataset.delete", &json!({"names": ["One"]})).unwrap();
let sets = s.execute("dataset.list", &json!({})).unwrap();
assert_eq!(sets["datasets"].as_array().unwrap().len(), 1);
assert_eq!(sets["datasets"][0]["name"], "Two");
}
#[test]
fn highlight_follows_rename_and_clears_with_delete() {
let mut s = session();
let t = text(&mut s, "Alice");
s.execute("variable.define", &json!({"name": "Name", "kind": "text"})).unwrap();
s.execute("variable.bind", &json!({"variable": "Name", "ids": [t.0]})).unwrap();
assert!(s.execute("variable.highlight", &json!({"variable": "Missing"})).is_err(), "unknown names stay errors");
let v = s.execute("variable.highlight", &json!({"variable": "Name"})).unwrap();
assert_eq!(v["variable"], "Name");
// The highlight names a variable, so the rename carries it along.
s.execute("variable.rename", &json!({"name": "Name", "newName": "Title"})).unwrap();
assert_eq!(s.doc().unwrap().variables_highlight.as_deref(), Some("Title"));
// Empty clears it, and deleting the highlighted variable clears it too.
s.execute("variable.highlight", &json!({})).unwrap();
assert!(s.doc().unwrap().variables_highlight.is_none());
s.execute("variable.highlight", &json!({"variable": "Title"})).unwrap();
s.execute("variable.delete", &json!({"names": ["Title"]})).unwrap();
assert!(s.doc().unwrap().variables_highlight.is_none(), "a highlight of a deleted variable highlights nothing");
}

View File

@ -48,8 +48,10 @@ fn rename_of(id: &str) -> Option<&'static str> {
})
}
/// Open `id`'s dialog: OK runs the command with the fields below.
pub fn open(app: &mut VectorcraftApp, id: &str) -> bool {
/// 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());
@ -94,7 +96,7 @@ pub fn open(app: &mut VectorcraftApp, id: &str) -> bool {
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!(names[0]));
fields.insert(key.into(), json!(preselect.filter(|p| names.contains(p)).unwrap_or(names[0])));
fields.insert("newName".into(), json!(""));
}
}
@ -286,7 +288,7 @@ mod tests {
#[test]
fn defining_a_variable_from_the_dialog_works() {
let mut app = app_with_variables();
assert!(open(&mut app, "variable.define"));
assert!(open(&mut app, "variable.define", None));
{
let d = app.ui.dialog.as_ref().unwrap();
assert_eq!(d.kind, KIND);
@ -302,7 +304,7 @@ mod tests {
#[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");
open(&mut app, "dataset.new", None);
{
let d = app.ui.dialog.as_mut().unwrap();
d.fields.insert("name".into(), json!("Row 1"));
@ -321,7 +323,7 @@ mod tests {
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");
open(&mut app, "dataset.set", None);
{
let d = app.ui.dialog.as_ref().unwrap();
assert_eq!(d.str("name"), "Row 1");
@ -341,7 +343,7 @@ mod tests {
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");
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();
@ -353,7 +355,20 @@ mod tests {
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"), "and it opens nothing");
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");
}
}

View File

@ -1195,6 +1195,7 @@
Highlight Alternate Glyphs Resaltar glifos alternativos
Highlight anchors on mouse over Resaltar anclas al pasar el puntero
Highlight Rows Resaltar filas
Highlight Variable Resaltar variable
Highlight Substituted Fonts Resaltar fuentes sustituidas
Highlight Substituted Glyphs Resaltar glifos sustituidos
History Historial

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -1194,6 +1194,7 @@
Highlight Alternate Glyphs Surligner les glyphes de substitution
Highlight anchors on mouse over Mettre en évidence les points d’ancrage au survol
Highlight Rows Surligner les lignes
Highlight Variable Surligner la variable
Highlight Substituted Fonts Surligner les polices remplacées
Highlight Substituted Glyphs Surligner les glyphes remplacés
History Historique

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -1194,6 +1194,7 @@
Highlight Alternate Glyphs Evidenzia glifi alternativi
Highlight anchors on mouse over Evidenzia ancoraggi al passaggio del mouse
Highlight Rows Evidenzia righe
Highlight Variable Evidenzia variabile
Highlight Substituted Fonts Evidenzia font sostituiti
Highlight Substituted Glyphs Evidenzia glifi sostituiti
History Cronologia

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -1452,6 +1452,7 @@
High Performance (discrete) 高パフォーマンス (専用 GPU)
Highlight Alternate Glyphs 代替字形をハイライト
Highlight Rows 行をハイライト
Highlight Variable 変数をハイライト表示
Highlight Substituted Fonts 置換フォントをハイライト
Highlight Substituted Glyphs 置換字形をハイライト
Highlight anchors on mouse over マウスオーバー時にアンカーをハイライト

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -1192,6 +1192,7 @@
Highlight Alternate Glyphs Выделять альтернативные глифы
Highlight anchors on mouse over Выделять опорные точки при наведении
Highlight Rows Выделять строки
Highlight Variable Подсветить переменную
Highlight Substituted Fonts Выделять заменённые шрифты
Highlight Substituted Glyphs Выделять заменённые глифы
History История

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -1188,6 +1188,7 @@
Highlight Alternate Glyphs Виділяти альтернативні гліфи
Highlight anchors on mouse over Виділяти опорні точки при наведенні
Highlight Rows Виділяти рядки
Highlight Variable Підсвітити змінну
Highlight Substituted Fonts Виділяти замінені шрифти
Highlight Substituted Glyphs Виділяти замінені гліфи
History Історія

Can't render this file because it has a wrong number of fields in line 13.

View File

@ -3430,6 +3430,7 @@
Hide Others 隐藏其他
High Performance (discrete) 高性能 (独立显卡)
Highlight Rows 突出显示行
Highlight Variable 突出显示变量
Lock Others 锁定其他
Merge Selected 合并所选图层
Move Rows 移动行

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -1170,6 +1170,7 @@
Highlight Alternate Glyphs 醒目提示替代字符
Highlight anchors on mouse over 滑鼠移過時醒目提示錨點
Highlight Rows 反白顯示列
Highlight Variable 突出顯示變數
Highlight Substituted Fonts 醒目提示被取代的字體
Highlight Substituted Glyphs 醒目提示被取代的字符
History 步驟記錄

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -3023,7 +3023,7 @@ pub fn invoke(app: &mut VectorcraftApp, id: &str, p: Value) {
}
// Variables (data merge): a name and a kind, a name and a row of values.
if crate::dialogs::variables::opens(id) && p.as_object().is_none_or(|o| o.is_empty()) {
crate::dialogs::variables::open(app, id);
crate::dialogs::variables::open(app, id, None);
return;
}
// Repeat Options: a dialog with the selected repeat's current values.

View File

@ -10,7 +10,8 @@
use egui::Ui;
use serde_json::json;
use vectorcraft_doc::VariableKind;
use vectorcraft_doc::{NodeId, NodeKind, VariableKind};
use vectorcraft_engine::DocState;
use crate::VectorcraftApp;
use crate::widgets;
@ -36,6 +37,47 @@ struct SetRow {
matches: bool,
}
/// The row the panel acts on: Delete, Options… and Select Bound Object all mean it. A name
/// the document no longer defines highlights nothing (deleted from the palette, undone).
fn highlighted_row(st: &DocState) -> Option<String> {
st.variables_highlight.clone().filter(|h| st.doc.variables.variable(h).is_some())
}
/// What a variable drives, as object ids: what Select Bound Object selects.
fn bound_objects(st: &DocState, name: &str) -> Vec<u64> {
st.doc.variables.objects_of(name).iter().map(|i| i.0).collect()
}
/// A row click: highlight the row, and select what it drives (its bindings are left alone).
pub(crate) fn click_row(app: &mut VectorcraftApp, name: &str, bound: &[u64]) {
app.run("variable.highlight", json!({"variable": name})).ok();
if !bound.is_empty() {
app.run("select.set", json!({"ids": bound})).ok();
}
}
/// Select Bound Object: select what the highlighted variable drives; its bindings stay.
pub(crate) fn select_bound_object(app: &mut VectorcraftApp) {
let ids = app.session.active().map(|st| highlighted_row(st).map(|h| bound_objects(st, &h))).unwrap_or_default().unwrap_or_default();
if !ids.is_empty() {
app.run("select.set", json!({"ids": ids})).ok();
}
}
/// Delete Variable: the highlighted variable only; the rest of the rows stay.
pub(crate) fn delete_highlighted(app: &mut VectorcraftApp) {
if let Some(name) = app.session.active().and_then(highlighted_row) {
app.run("variable.delete", json!({"names": [name]})).ok();
}
}
/// Delete Data Set: the current one only; the rest of the rows stay.
pub(crate) fn delete_active_dataset(app: &mut VectorcraftApp) {
if let Some(name) = app.session.active().and_then(|st| st.doc.variables.active_dataset.clone()) {
app.run("dataset.delete", json!({"names": [name]})).ok();
}
}
pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
let Some(st) = app.session.active() else {
widgets::dim_label(ui, tl!("No document"));
@ -72,6 +114,11 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
let active = sets.iter().find(|s| s.active);
let selected: Vec<u64> = st.selection.objects.iter().map(|i| i.0).collect();
let has_selection = !selected.is_empty();
let highlighted = highlighted_row(st);
// Unbind needs a bound object under the selection, not just any selection.
let selection_bound = selected.iter().any(|id| st.doc.variables.bindings.contains_key(&NodeId(*id)));
// Make Text Dynamic needs exactly one type object: a text variable only drives type.
let single_type = selected.len() == 1 && st.doc.node(NodeId(selected[0])).is_some_and(|n| matches!(n.kind, NodeKind::Text(_)));
// The current data set across the top: its name, and the arrows that step through them.
ui.horizontal(|ui| {
@ -106,17 +153,17 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
VariableKind::Text => "type",
VariableKind::Visibility => "eye",
};
let icon = ui.add(egui::Image::new(crate::icons::source(icon)).fit_to_exact_size(egui::vec2(14.0, 14.0)));
let _ = icon;
ui.add(egui::Image::new(crate::icons::source(icon)).fit_to_exact_size(egui::vec2(14.0, 14.0)));
// The object a variable drives is named beside it, as the reference panel does.
let text = if row.object.is_empty() { row.name.clone() } else { format!("{} · {}", row.name, row.object) };
let r = ui.selectable_label(false, text);
// A click selects what it drives; a double click opens Variable Options.
if (r.clicked() || r.double_clicked()) && !row.bound.is_empty() {
app.run("select.set", json!({"ids": row.bound})).ok();
let r = ui.selectable_label(highlighted.as_deref() == Some(row.name.as_str()), text);
// A click highlights the row and selects what it drives; a double click opens
// Variable Options for it.
if r.clicked() {
click_row(app, &row.name, &row.bound);
}
if r.double_clicked() {
crate::menus::invoke(app, "variable.rename", json!({"name": row.name}));
crate::dialogs::variables::open(app, "variable.rename", Some(&row.name));
}
});
}
@ -141,7 +188,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
// The bottom bar: make the selection dynamic, and define and delete what the lists show.
ui.separator();
ui.horizontal(|ui| {
if widgets::icon_button_enabled(ui, "type", tl!("Make Text Dynamic"), false, has_selection, 24.0).clicked() {
if widgets::icon_button_enabled(ui, "type", tl!("Make Text Dynamic"), false, single_type, 24.0).clicked() {
app.run("variable.makeTextDynamic", json!({})).ok();
}
if widgets::icon_button_enabled(ui, "eye", tl!("Make Visibility Dynamic"), false, has_selection, 24.0).clicked() {
@ -150,14 +197,12 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
if widgets::icon_button(ui, "plus", tl!("New Variable…"), false, 24.0).clicked() {
crate::menus::invoke(app, "variable.define", json!({}));
}
let bound_to_selection = !selected.is_empty();
if widgets::icon_button_enabled(ui, "link-2-off", tl!("Unbind Variable"), false, bound_to_selection, 24.0).clicked() {
if widgets::icon_button_enabled(ui, "link-2-off", tl!("Unbind Variable"), false, selection_bound, 24.0).clicked() {
app.run("variable.unbind", json!({"ids": selected})).ok();
}
if widgets::icon_button_enabled(ui, "trash-2", tl!("Delete Variable"), false, !vars.is_empty(), 24.0).clicked() {
// Everything the document defines goes, in one undo step, as the panel's bin does.
let names: Vec<String> = vars.iter().map(|v| v.name.clone()).collect();
app.run("variable.delete", json!({"names": names})).ok();
// The bin deletes the highlighted variable only, as the reference panel's does.
if widgets::icon_button_enabled(ui, "trash-2", tl!("Delete Variable"), false, highlighted.is_some(), 24.0).clicked() {
delete_highlighted(app);
}
});
ui.horizontal(|ui| {
@ -181,15 +226,16 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
let sets: Vec<String> = st.doc.variables.datasets.iter().map(|d| d.name.clone()).collect();
let active = st.doc.variables.active_dataset.clone();
let bound: Vec<u64> = st.doc.variables.bindings.keys().map(|i| i.0).collect();
let selected: Vec<u64> = st.selection.objects.iter().map(|i| i.0).collect();
let highlighted = highlighted_row(st);
let highlighted_bound: Vec<u64> = highlighted.as_ref().map(|h| bound_objects(st, h)).unwrap_or_default();
if widgets::menu_item(ui, tl!("New Variable…"), true, false) {
crate::menus::invoke(app, "variable.define", json!({}));
}
// Variable Options renames the variable the panel has highlighted, else the first.
// Variable Options renames the highlighted variable, else the first.
if widgets::menu_item(ui, tl!("Variable Options…"), !vars.is_empty(), false) {
let name = vars.first().cloned().unwrap_or_default();
crate::menus::invoke(app, "variable.rename", json!({"name": name}));
let name = highlighted.clone().or_else(|| vars.first().cloned()).unwrap_or_default();
crate::dialogs::variables::open(app, "variable.rename", Some(&name));
}
ui.separator();
if widgets::menu_item(ui, tl!("Capture Data Set"), true, false) {
@ -205,25 +251,23 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
crate::menus::invoke(app, "dataset.set", json!({}));
}
if widgets::menu_item(ui, tl!("Rename Data Set…"), !sets.is_empty(), false) {
let name = sets.first().cloned().unwrap_or_default();
crate::menus::invoke(app, "dataset.rename", json!({"name": name}));
let name = active.clone().or_else(|| sets.first().cloned()).unwrap_or_default();
crate::dialogs::variables::open(app, "dataset.rename", Some(&name));
}
if widgets::menu_item(ui, tl!("Delete Data Set"), !sets.is_empty(), false) {
app.run("dataset.delete", json!({"names": sets})).ok();
// Only the current data set goes: the rest of the rows stay.
if widgets::menu_item(ui, tl!("Delete Data Set"), active.is_some(), false) {
delete_active_dataset(app);
}
ui.separator();
if widgets::menu_item(ui, tl!("Select Bound Object"), !selected.is_empty(), false) {
// The objects the selection is bound to, when it is bound at all.
let ids: Vec<u64> = selected.clone();
app.run("variable.unbind", json!({"ids": ids})).ok();
// What the highlighted variable drives, selected — its bindings are left alone.
if widgets::menu_item(ui, tl!("Select Bound Object"), !highlighted_bound.is_empty(), false) {
select_bound_object(app);
}
if widgets::menu_item(ui, tl!("Select All Bound Objects"), !bound.is_empty(), false) {
app.run("select.set", json!({"ids": bound})).ok();
}
}
use vectorcraft_doc::NodeId;
#[cfg(test)]
mod tests {
use super::*;
@ -285,6 +329,60 @@ mod tests {
}
}
/// What the panel acts on is the highlight, and only while it names a variable: a stale one
/// (deleted elsewhere, undone) highlights nothing, so Delete and Options… stay disabled.
#[test]
fn a_highlight_of_a_deleted_variable_highlights_nothing() {
let mut app = app_with_data();
app.session.execute("variable.highlight", &json!({"variable": "Name"})).unwrap();
assert_eq!(highlighted_row(app.session.doc().unwrap()).as_deref(), Some("Name"));
assert_eq!(bound_objects(app.session.doc().unwrap(), "Name").len(), 1);
app.session.execute("variable.delete", &json!({"names": ["Name"]})).unwrap();
assert!(highlighted_row(app.session.doc().unwrap()).is_none());
}
/// The reviewer's regression case, through the calls the panel and its menu make: highlight
/// a variable, Select Bound Object, then delete one variable and the current dataset. The
/// selection lands on what the row drives with the binding intact, and the others remain.
#[test]
fn selecting_and_deleting_act_on_the_highlighted_row_only() {
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 300, "height": 200})).unwrap();
let a = app.session.execute("text.create", &json!({"x": 10, "y": 40, "text": "Alice"})).unwrap()["id"].as_u64().unwrap();
let b = app.session.execute("text.create", &json!({"x": 10, "y": 80, "text": "Bob"})).unwrap()["id"].as_u64().unwrap();
for (name, id) in [("Name", a), ("Other", b)] {
app.session.execute("variable.define", &json!({"name": name, "kind": "text"})).unwrap();
app.session.execute("variable.bind", &json!({"variable": name, "ids": [id]})).unwrap();
}
app.session.execute("dataset.capture", &json!({})).unwrap();
app.session.execute("dataset.capture", &json!({})).unwrap();
app.session.execute("dataset.select", &json!({"name": "Data Set 1"})).unwrap();
// A row click highlights; Select Bound Object selects what it drives, binding intact.
click_row(&mut app, "Name", &[a]);
assert_eq!(app.session.doc().unwrap().selection.objects, vec![NodeId(a)]);
assert!(!app.session.doc().unwrap().doc.variables.objects_of("Name").is_empty());
select_bound_object(&mut app);
assert_eq!(app.session.doc().unwrap().selection.objects, vec![NodeId(a)]);
assert!(!app.session.doc().unwrap().doc.variables.objects_of("Name").is_empty());
// Delete Variable deletes the highlighted one; Delete Data Set the current one.
delete_highlighted(&mut app);
delete_active_dataset(&mut app);
let vars = app.run("variable.list", json!({})).unwrap();
assert_eq!(vars["variables"].as_array().unwrap().len(), 1);
assert_eq!(vars["variables"][0]["name"], "Other");
let sets = app.run("dataset.list", json!({})).unwrap();
assert_eq!(sets["datasets"].as_array().unwrap().len(), 1);
assert_eq!(sets["datasets"][0]["name"], "Data Set 2");
// The panel and its menu still draw with the highlight gone stale.
let text = crate::tests_labels::painted_text(&mut app, show);
assert!(text.contains("Other") && text.contains("Data Set 2"), "{text}");
let text = crate::tests_labels::painted_text(&mut app, menu);
assert!(text.contains("Select Bound Object"), "{text}");
}
#[test]
fn the_panel_is_registered_with_its_own_icon() {
let row = crate::state::ICON_PANELS.iter().find(|(id, ..)| *id == ID).expect("in the icon column");