diff --git a/crates/engine/src/cmd/variablecmds.rs b/crates/engine/src/cmd/variablecmds.rs index 9b221f6e..eae00c9b 100644 --- a/crates/engine/src/cmd/variablecmds.rs +++ b/crates/engine/src/cmd/variablecmds.rs @@ -104,6 +104,12 @@ fn rename(s: &mut Session, p: &Value) -> Result { } 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 Result { 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 { 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 { + 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 { const C: &str = "dataset.new"; let name = var_name(p, C)?; @@ -480,6 +513,15 @@ pub fn specs() -> Vec { 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", diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index c5a7104d..a0463b71 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -86,6 +86,7 @@ pub struct Interaction { /// isolation). pub active_layer: Option, pub layer_rows: Vec, + pub variables_highlight: Option, pub isolation: Option, /// 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, + /// 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, /// Isolation mode container. pub isolation: Option, pub interaction: Option, @@ -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, }); diff --git a/crates/engine/src/tests_variables.rs b/crates/engine/src/tests_variables.rs index 90502a15..a0bcf270 100644 --- a/crates/engine/src/tests_variables.rs +++ b/crates/engine/src/tests_variables.rs @@ -436,3 +436,56 @@ fn bad_variables_requests_are_errors_not_panics() { assert!(s.execute(cmd, ¶ms).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 = 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"); +} diff --git a/crates/ui-egui/src/dialogs/variables.rs b/crates/ui-egui/src/dialogs/variables.rs index bafffa79..2a21eb27 100644 --- a/crates/ui-egui/src/dialogs/variables.rs +++ b/crates/ui-egui/src/dialogs/variables.rs @@ -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"); + } } diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 4f008430..8b145dff 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 86b148b2..bdb6eb9f 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index c5d6a044..4e2ea66b 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 9765a274..375717de 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -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 マウスオーバー時にアンカーをハイライト diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 9cb692ce..a2e39113 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -1192,6 +1192,7 @@ Highlight Alternate Glyphs Выделять альтернативные глифы Highlight anchors on mouse over Выделять опорные точки при наведении Highlight Rows Выделять строки + Highlight Variable Подсветить переменную Highlight Substituted Fonts Выделять заменённые шрифты Highlight Substituted Glyphs Выделять заменённые глифы History История diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 4aa1c9da..16d5ee66 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -1188,6 +1188,7 @@ Highlight Alternate Glyphs Виділяти альтернативні гліфи Highlight anchors on mouse over Виділяти опорні точки при наведенні Highlight Rows Виділяти рядки + Highlight Variable Підсвітити змінну Highlight Substituted Fonts Виділяти замінені шрифти Highlight Substituted Glyphs Виділяти замінені гліфи History Історія diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 346536cd..ca479f77 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -3430,6 +3430,7 @@ Hide Others 隐藏其他 High Performance (discrete) 高性能 (独立显卡) Highlight Rows 突出显示行 + Highlight Variable 突出显示变量 Lock Others 锁定其他 Merge Selected 合并所选图层 Move Rows 移动行 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index dd9828dc..b409506b 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -1170,6 +1170,7 @@ Highlight Alternate Glyphs 醒目提示替代字符 Highlight anchors on mouse over 滑鼠移過時醒目提示錨點 Highlight Rows 反白顯示列 + Highlight Variable 突出顯示變數 Highlight Substituted Fonts 醒目提示被取代的字體 Highlight Substituted Glyphs 醒目提示被取代的字符 History 步驟記錄 diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 3b1f10e2..f68f5df6 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -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. diff --git a/crates/ui-egui/src/panels/variables.rs b/crates/ui-egui/src/panels/variables.rs index 6785bc0c..aaa316cc 100644 --- a/crates/ui-egui/src/panels/variables.rs +++ b/crates/ui-egui/src/panels/variables.rs @@ -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 { + 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 { + 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 = 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 = 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 = st.doc.variables.datasets.iter().map(|d| d.name.clone()).collect(); let active = st.doc.variables.active_dataset.clone(); let bound: Vec = st.doc.variables.bindings.keys().map(|i| i.0).collect(); - let selected: Vec = st.selection.objects.iter().map(|i| i.0).collect(); + let highlighted = highlighted_row(st); + let highlighted_bound: Vec = 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 = 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");