From e3551a92e4ed00c20be0f508ac7303dcd74d743b Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 15 Nov 2026 06:39:44 -0800 Subject: [PATCH] Variables: the Data Set box renames the current data set The Data Set row at the top of the Variables panel now holds a text box with the current data set's name. A new name entered there renames the data set through dataset.rename, as one undo step. The same name runs nothing, so no undo step is added. An empty name or the name of another data set is refused, and the status bar shows the error. A list button beside the box still chooses a data set, and the arrows still step through them. With no data set the box is disabled. --- crates/ui-egui/src/panels/variables.rs | 138 ++++++++++++++++++++++++- docs/target-app-parity.md | 1 + 2 files changed, 137 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/panels/variables.rs b/crates/ui-egui/src/panels/variables.rs index 3ab5fc2484..2c1c8a815c 100644 --- a/crates/ui-egui/src/panels/variables.rs +++ b/crates/ui-egui/src/panels/variables.rs @@ -97,6 +97,18 @@ pub(crate) fn delete_active_dataset(app: &mut VectorcraftApp) { app.run("dataset.delete", json!({})).ok(); } +/// The Data Set box: a new name typed over the current data set's renames it with +/// `dataset.rename`, in one undo step. The same name runs nothing, so no undo step is added; an +/// empty or taken name is refused, and the status bar says why. +pub(crate) fn rename_active_dataset(app: &mut VectorcraftApp, from: &str, to: &str) { + if from.is_empty() || to.trim() == from { + return; + } + if let Err(e) = app.run("dataset.rename", json!({"name": from, "newName": to})) { + app.status(e); + } +} + /// Draw document variables, data sets and their two-row footer of capture and binding controls. pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let Some(st) = app.session.active() else { @@ -140,12 +152,17 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { // Lock Variables: no variable is defined, deleted or renamed until they are unlocked. let locked = st.doc.variables.locked; - // The current data set across the top: its name, and the arrows that step through them. + // The current data set across the top: its name in a box that renames it, the list that + // chooses another, and the arrows that step through them. ui.horizontal(|ui| { widgets::field_label(ui, tl!("Data Set")); let names: Vec<&str> = sets.iter().map(|s| s.name.as_str()).collect(); let current = active.map(|s| s.name.as_str()).unwrap_or(""); - let picked = widgets::dropdown_names(ui, "variables-active", current, &names, 160.0); + let typed = ui.add_enabled_ui(active.is_some(), |ui| widgets::text_field(ui, "variables-active-name", Some(current), 134.0, 1)).inner; + if let Some(to) = typed { + rename_active_dataset(app, current, &to); + } + let picked = widgets::dropdown_names_named(ui, "variables-active", Some(tl!("Data Sets")), "", &names, 26.0); if let Some(name) = picked.and_then(|i| names.get(i)) && *name != current { @@ -406,6 +423,123 @@ mod tests { assert!(text.contains("Name") && text.contains("Data Sets") && text.contains("Data Set 1"), "{text}"); } + /// Frames of the panel with input events, for typing into its Data Set box. + struct Driver { + ctx: egui::Context, + time: f64, + } + + impl Driver { + fn new() -> Self { + let ctx = egui::Context::default(); + ctx.enable_accesskit(); + crate::theme::install_fonts(&ctx); + Self { ctx, time: 0.0 } + } + + fn frame(&mut self, app: &mut VectorcraftApp, events: Vec) -> egui::FullOutput { + self.time += 0.1; + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(320.0, 600.0)); + let input = egui::RawInput { events, time: Some(self.time), screen_rect: Some(screen), ..Default::default() }; + let mut out = self.ctx.run_ui(input, |ui| show(app, ui)); + out.textures_delta.clear(); + out + } + + /// The center of the panel's one text box, the Data Set box. + fn data_set_box(&mut self, app: &mut VectorcraftApp) -> egui::Pos2 { + use egui::accesskit::Role; + self.frame(app, vec![]); + let out = self.frame(app, vec![]); + let nodes = &out.platform_output.accesskit_update.as_ref().expect("an accessibility tree").nodes; + let (_, node) = nodes.iter().find(|(_, n)| matches!(n.role(), Role::TextInput | Role::MultilineTextInput)).expect("the Data Set box"); + let b = node.bounds().expect("bounds"); + egui::pos2((b.x0 + b.x1) as f32 * 0.5, (b.y0 + b.y1) as f32 * 0.5) + } + + /// Clicks the Data Set box, replaces its text with `text` and presses Enter. + fn type_name(&mut self, app: &mut VectorcraftApp, text: &str) { + use egui::{Event, Key, Modifiers, PointerButton}; + let pos = self.data_set_box(app); + let (button, none) = (PointerButton::Primary, Modifiers::default()); + self.frame(app, vec![Event::PointerMoved(pos), Event::PointerButton { pos, button, pressed: true, modifiers: none }]); + self.frame(app, vec![Event::PointerButton { pos, button, pressed: false, modifiers: none }]); + let all = Modifiers { ctrl: true, command: true, ..Default::default() }; + let select_all = Event::Key { key: Key::A, physical_key: None, pressed: true, repeat: false, modifiers: all }; + self.frame(app, vec![select_all, Event::Text(text.into())]); + self.frame(app, vec![Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: none }]); + self.frame(app, vec![]); + } + } + + fn set_names(app: &mut VectorcraftApp) -> Vec { + let sets = app.run("dataset.list", json!({})).unwrap(); + sets["datasets"].as_array().unwrap().iter().map(|d| d["name"].as_str().unwrap_or_default().to_string()).collect() + } + + fn undo_steps(app: &VectorcraftApp) -> usize { + app.session.active().unwrap().history.undo.len() + } + + /// A name typed in the Data Set box and entered renames the current data set in one undo step; + /// the list still chooses another data set afterward. + #[test] + fn the_data_set_box_renames_the_current_data_set() { + let mut app = app_with_data(); + app.session.execute("dataset.capture", &json!({})).unwrap(); + app.session.execute("dataset.select", &json!({"name": "Data Set 1"})).unwrap(); + let before = undo_steps(&app); + let mut d = Driver::new(); + d.type_name(&mut app, "Spring"); + assert_eq!(set_names(&mut app), ["Spring", "Data Set 2"]); + assert_eq!(app.session.active().unwrap().doc.variables.active_dataset.as_deref(), Some("Spring")); + assert_eq!(undo_steps(&app), before + 1); + assert_eq!(app.session.active().unwrap().history.undo.last().map(|e| e.label.as_str()), Some("Rename Data Set")); + // The new name is what the box shows, and what the list offers with the other row. + let text = crate::tests_labels::painted_text(&mut app, show); + assert!(text.contains("Spring"), "{text}"); + app.run("dataset.select", json!({"name": "Data Set 2"})).unwrap(); + assert_eq!(app.session.active().unwrap().doc.variables.active_dataset.as_deref(), Some("Data Set 2")); + } + + /// The same name adds no undo step; an empty name or another data set's name is refused with a + /// status message, and the names stay as they were. + #[test] + fn the_data_set_box_refuses_an_empty_or_taken_name() { + let mut app = app_with_data(); + app.session.execute("dataset.capture", &json!({})).unwrap(); + app.session.execute("dataset.select", &json!({"name": "Data Set 1"})).unwrap(); + let before = undo_steps(&app); + let mut d = Driver::new(); + d.type_name(&mut app, "Data Set 1"); + assert_eq!(undo_steps(&app), before, "the same name runs nothing"); + assert_eq!(app.ui.status, ""); + for name in ["Data Set 2", " "] { + app.ui.status.clear(); + d.type_name(&mut app, name); + assert_eq!(set_names(&mut app), ["Data Set 1", "Data Set 2"], "{name:?}"); + assert_eq!(undo_steps(&app), before, "{name:?}"); + assert!(!app.ui.status.is_empty(), "{name:?} gives no status message"); + } + assert!(app.ui.status.contains("newName"), "{}", app.ui.status); + // Through the helper too: an unchanged or padded name runs nothing. + rename_active_dataset(&mut app, "Data Set 1", " Data Set 1 "); + assert_eq!(undo_steps(&app), before); + } + + /// With no data set the box is disabled, so typing in it renames nothing. + #[test] + fn the_data_set_box_is_disabled_without_a_data_set() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 300, "height": 200})).unwrap(); + let before = undo_steps(&app); + let mut d = Driver::new(); + d.type_name(&mut app, "Spring"); + assert!(set_names(&mut app).is_empty()); + assert_eq!(undo_steps(&app), before); + assert_eq!(app.ui.status, ""); + } + #[test] fn a_row_the_art_left_behind_is_in_italics() { let mut app = app_with_data(); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index a8b5cb97e8..8a5195c888 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -788,6 +788,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Variables: `dataset.select`, `dataset.next` and `dataset.prev` name the area type and type on a path whose text they rewrote and that doesn't fit, in the reply's `overflow` key, as `text.setText` does. - Variables: when no object is bound to a variable that the data set has a value for, for example after Import Data Sets… defines new variables without binding them, choosing the data set in the Variables panel or stepping to it with the panel's arrows shows a status message, and `dataset.select`, `dataset.next` and `dataset.prev` add the message to the reply as `warning`. - Variables: Lock Variables (`variable.lock`, in the Variables panel's bottom bar and menu) stops variables from being defined, deleted or renamed until they are unlocked, while objects can still be bound and unbound and data sets still change and apply; the lock is saved with the document. + - Variables: the Data Set box at the top of the Variables panel takes typed text, and a new name entered there renames the current data set with `dataset.rename` in one undo step; the same name adds no undo step, an empty or taken name is refused with a status message, and the list button beside the box still chooses a data set. - Batch…: Override Save and Override Export name each file with the extension of the format the step writes, however the step spells the format (`.svg` and `SVG` give `.svg`, `png8` gives `.png`), in `action.batch`. - Batch…: Override Export writes the files of an Export for Screens or Export Assets step into a folder for each source file or data set inside the destination folder, under the names the export gives them (`action.batch`). The folder and file names drop trailing periods and spaces (`a..svg` gives `a`). When two source files would get the same name that way (compared without case), or a later file's folder or file would take the name of an output an earlier file wrote, of a source file in the destination folder or, for data sets, of a file already there, Batch adds `-2`, `-3`… to the later one's name. - Batch…: when a step fails on a file or data set, the error names the step by its place in the action's steps, from 1, counting the Open steps the batch skips (`action.batch`). This numbering is VectorCraft's own rule. Tests `cmd::fileio::action_batch::tests::{a_folder_batch_error_names_the_step_in_the_commands_sent, a_recorded_open_that_fails_is_the_first_step, a_data_set_batch_error_names_the_step_in_the_commands_sent, a_redirected_export_that_fails_keeps_its_place, a_source_that_does_not_open_names_no_step}`.