From ee391899d7def297c3576f0d0f7340a984574e15 Mon Sep 17 00:00:00 2001 From: Jose Gregorio Rincon Albarracin Date: Sun, 25 Oct 2026 07:54:49 -0500 Subject: [PATCH 1/3] feat(labels): a class header in the Labeling pane, with Remove set apart The chosen class's chooser sits at the top of the pane, with New, Rename and the move arrows under it and Remove at the far end of that row, in red. Visible stays under them. The labels.class.* commands name the pane as their location. Refs #858. --- crates/engine/src/cmd/labels.rs | 12 ++-- crates/ui-egui/src/labelpane.rs | 102 ++++++++++++++++++-------------- docs/user-guide/cartography.md | 9 +-- 3 files changed, 67 insertions(+), 56 deletions(-) diff --git a/crates/engine/src/cmd/labels.rs b/crates/engine/src/cmd/labels.rs index ac4322bfd..dc7893c72 100644 --- a/crates/engine/src/cmd/labels.rs +++ b/crates/engine/src/cmd/labels.rs @@ -65,10 +65,10 @@ pub fn commands() -> Vec { ), // Label classes: each labels every feature its clause takes, as in ArcGIS Pro; the classes // listed first, Default last, are placed first (`Labeling`, #732). - C::new("labels.class.add", "Add Label Class", "Style › Label Class", add_class) + C::new("labels.class.add", "Add Label Class", "View › Windows › Labeling", add_class) .when(need_feature_layer) .params(r#"{"layer"?, "name": string, "where"?: sql (a definition query's language; none takes every feature), "from"?: class (whose field and text symbol it starts with; none: the Default class)}"#), - C::new("labels.class.remove", "Remove Label Class", "Style › Label Class", |s, p| { + C::new("labels.class.remove", "Remove Label Class", "View › Windows › Labeling", |s, p| { let name = s_req(p, "class")?.trim().to_string(); with_labels(s, p, |labels| { let i = extra_class(labels, &name)?; @@ -79,7 +79,7 @@ pub fn commands() -> Vec { .when(need_feature_layer) .params(r#"{"layer"?, "class": name (not Default)}"#), // Every class but Default; none to remove is no error, so a script can start with it. - C::new("labels.class.clear", "Remove All Label Classes", "Style › Label Class", |s, p| { + C::new("labels.class.clear", "Remove All Label Classes", "View › Windows › Labeling", |s, p| { let mut removed = 0; with_labels(s, p, |labels| { removed = labels.classes.len(); @@ -90,7 +90,7 @@ pub fn commands() -> Vec { }) .when(need_feature_layer) .params(r#"{"layer"?}"#), - C::new("labels.class.rename", "Rename Label Class", "Style › Label Class", |s, p| { + C::new("labels.class.rename", "Rename Label Class", "View › Windows › Labeling", |s, p| { let class = s_req(p, "class")?.trim().to_string(); let name = s_req(p, "name")?.to_string(); with_labels(s, p, |labels| { @@ -101,7 +101,7 @@ pub fn commands() -> Vec { }) .when(need_feature_layer) .params(r#"{"layer"?, "class": name, "name": string}"#), - C::new("labels.class.update", "Update Label Class", "Style › Label Class", |s, p| { + C::new("labels.class.update", "Update Label Class", "View › Windows › Labeling", |s, p| { let id = s.layer_id(p)?; let clause = s.map().layer(id).map(|l| clause(p, &l.dataset)).transpose()?.flatten(); let visible = b(p, "visible"); @@ -116,7 +116,7 @@ pub fn commands() -> Vec { }) .when(need_feature_layer) .params(r#"{"layer"?, "class"?: name (none: the Default class, by whatever name it has), "where"?: sql|null, "visible"?: bool}"#), - C::new("labels.class.move", "Move Label Class", "Style › Label Class", |s, p| { + C::new("labels.class.move", "Move Label Class", "View › Windows › Labeling", |s, p| { let name = s_req(p, "class")?.trim().to_string(); let to = f(p, "to").filter(|v| v.is_finite()).ok_or("to is required: the class's place in the list, 0 first")?; with_labels(s, p, |labels| { diff --git a/crates/ui-egui/src/labelpane.rs b/crates/ui-egui/src/labelpane.rs index 4a85b1112..2147def49 100644 --- a/crates/ui-egui/src/labelpane.rs +++ b/crates/ui-egui/src/labelpane.rs @@ -2,8 +2,8 @@ //! from a layer's right-click menu (Labeling Properties…) or View › Windows › Labeling. The ribbon //! keeps its label groups; both run the same `labels.*` commands, so the pane holds no state of its //! own (it reads the layer each frame, as the Symbology pane does) and every change undoes. It -//! edits one label class at a time, the one chosen in its Class section (#722); the ribbon's label -//! groups edit and choose the same one (#858). +//! edits one label class at a time, the one chosen in its header (#722); the ribbon's label groups +//! edit and choose the same one (#858). use egui::{RichText, Ui, vec2}; use rustgis_engine::map::symbology::{DEFAULT_LABEL_CLASS, LABEL_SIZE, LabelClass, Labeling, MAX_LABEL_CLASSES, label_class_name}; @@ -42,6 +42,7 @@ pub fn show(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { run(app, "labels.enable", json!({"on": on})); } let c = chosen(ui, app, &l); + classes(app, ui, &l, &c, t); class(app, ui, &l, &c, t); text_symbol(app, ui, &l, &c); position(app, ui, &l, &c); @@ -164,12 +165,9 @@ enum ClassTab { Sql, } -/// The class chosen, what makes, renames, removes and orders classes (#722), and the chosen -/// class's field and SQL clause (#618) choosing the features it labels. +/// The chosen class's field and SQL clause (#618) choosing the features it labels. fn class(app: &mut RustGisApp, ui: &mut Ui, l: &Layer, c: &Class, t: &Tokens) { section(ui, "Class", |ui| { - classes(app, ui, l, c); - ui.add_space(4.0); let key = egui::Id::unique("label_pane_class_tab"); let mut tab = ui.data(|d| d.get_temp::(key)).unwrap_or(ClassTab::Field); ui.horizontal(|ui| { @@ -199,9 +197,9 @@ struct Renaming { pass: u64, } -/// The class chooser, and New, Rename, Move Up, Move Down, Remove and Visible for the chosen -/// class. Default, last in the list, can be renamed or hidden, but not removed or moved. -fn classes(app: &mut RustGisApp, ui: &mut Ui, l: &Layer, c: &Class) { +/// The pane's header for the chosen class (#858): its chooser, a row of what makes, renames, +/// orders and removes classes (#722), and Visible. +fn classes(app: &mut RustGisApp, ui: &mut Ui, l: &Layer, c: &Class, t: &Tokens) { let rename_key = egui::Id::unique("label_pane_rename"); let owner = egui::Id::unique((layer_key(app, l), c.name)); // A rename started on another class or layer (chosen since, or by an undo), or left when the @@ -219,42 +217,56 @@ fn classes(app: &mut RustGisApp, ui: &mut Ui, l: &Layer, c: &Class) { } ui.end_row(); }); + if ui.horizontal(|ui| class_actions(app, ui, l, c, t)).inner { + let r = Renaming { owner, text: c.it.name.clone(), focused: false, pass }; + ui.data_mut(|d| d.insert_temp(rename_key, r)); + } + let mut visible = c.it.visible; + if ui.checkbox(&mut visible, tl!("Visible")).on_hover_text(tl!("A hidden class leaves its features unlabelled.")).changed() { + run(app, "labels.class.update", c.params(json!({"visible": visible}))); + } +} + +/// New, Rename, Move Up and Move Down for the chosen class, then Remove; whether Rename was +/// clicked. Default, last in the list, can be renamed, but not removed or moved. +fn class_actions(app: &mut RustGisApp, ui: &mut Ui, l: &Layer, c: &Class, t: &Tokens) -> bool { let count = extras(l).count(); let at = c.name.and_then(|n| extras(l).position(|k| k.name == n)); - ui.horizontal_wrapped(|ui| { - let full = l.labels.classes.len() >= MAX_LABEL_CLASSES; - let max = MAX_LABEL_CLASSES.to_string(); - let new = ui - .add_enabled(!full, egui::Button::new(tl!("New"))) - .on_hover_text(tl!("Add a class with this one's field, text symbol, placement and range.")) - .on_disabled_hover_text(crate::i18n::fmt("A layer keeps at most {max} label classes besides Default.", &[("max", &max)])); - if new.clicked() { - let p = named(json!({"name": new_name(&l.labels)}), "from", c.name); - if let Some(added) = run(app, "labels.class.add", p) { - choose(ui, app, l, added.get("class").and_then(Value::as_str)); - } + let full = l.labels.classes.len() >= MAX_LABEL_CLASSES; + let max = MAX_LABEL_CLASSES.to_string(); + let new = ui + .add_enabled(!full, egui::Button::new(tl!("New"))) + .on_hover_text(tl!("Add a class with this one's field, text symbol, placement and range.")) + .on_disabled_hover_text(crate::i18n::fmt("A layer keeps at most {max} label classes besides Default.", &[("max", &max)])); + if new.clicked() { + let p = named(json!({"name": new_name(&l.labels)}), "from", c.name); + if let Some(added) = run(app, "labels.class.add", p) { + choose(ui, app, l, added.get("class").and_then(Value::as_str)); } - if ui.button(tl!("Rename")).clicked() { - let r = Renaming { owner, text: c.it.name.clone(), focused: false, pass }; - ui.data_mut(|d| d.insert_temp(rename_key, r)); - } - let up = at.filter(|i| *i > 0).map(|i| i - 1); - let down = at.filter(|i| i + 1 < count).map(|i| i + 1); - for (label, to) in [("Move Up", up), ("Move Down", down)] { - let moved = ui - .add_enabled(to.is_some(), egui::Button::new(tl!(label))) - .on_hover_text(tl!("Labels of the classes listed first are placed first when they compete for room.")); - if moved.clicked() { - run(app, "labels.class.move", c.params(json!({"to": to}))); - } + } + let rename = ui.button(tl!("Rename")).clicked(); + let up = at.filter(|i| *i > 0).map(|i| i - 1); + let down = at.filter(|i| i + 1 < count).map(|i| i + 1); + for (icon, label, to) in [("up", "Move Up", up), ("down", "Move Down", down)] { + let moved = ui + .add_enabled_ui(to.is_some(), |ui| { + // Named for screen readers (and tests) by its label, as the Bookmarks pane's icons. + let r = crate::widgets::icon_button(ui, icon, tl!(label)); + r.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, to.is_some(), tl!(label))); + r + }) + .inner + .on_hover_text(tl!("Labels of the classes listed first are placed first when they compete for room.")); + if moved.clicked() { + run(app, "labels.class.move", c.params(json!({"to": to}))); } - // Last and set apart: it is the one that deletes, and its name is close to Rename's. - ui.add_space(12.0); - if ui - .add_enabled(at.is_some(), egui::Button::new(tl!("Remove"))) - .on_disabled_hover_text(tl!("Default can't be removed. You can rename or hide it.")) - .clicked() - { + } + // At the far end and in red: it is the one that deletes, and its name is close to Rename's. + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let remove = ui + .add_enabled(at.is_some(), egui::Button::new(RichText::new(tl!("Remove")).color(t.red))) + .on_disabled_hover_text(tl!("Default can't be removed. You can rename or hide it.")); + if remove.clicked() { // Default chosen outright: a class added later by the removed one's name (an undo, a // script) isn't chosen again by the name left behind. if run(app, "labels.class.remove", c.params(json!({}))).is_some() { @@ -262,10 +274,7 @@ fn classes(app: &mut RustGisApp, ui: &mut Ui, l: &Layer, c: &Class) { } } }); - let mut visible = c.it.visible; - if ui.checkbox(&mut visible, tl!("Visible")).on_hover_text(tl!("A hidden class leaves its features unlabelled.")).changed() { - run(app, "labels.class.update", c.params(json!({"visible": visible}))); - } + rename } /// The box the chosen class is renamed in: Enter (or clicking away) renames it, Escape leaves it as @@ -1189,7 +1198,8 @@ mod tests { assert!(shown(&h, "Default")); } - /// New, Move Up, Rename and Remove each run their command once: one undo step each. + /// New, Move Up, Rename and Remove, under the header's chooser (#858), each run their command + /// once: one undo step each. #[test] fn classes_are_made_renamed_ordered_and_removed_from_the_pane() { let app = with_cities(); diff --git a/docs/user-guide/cartography.md b/docs/user-guide/cartography.md index f4899ff12..ec46f7455 100644 --- a/docs/user-guide/cartography.md +++ b/docs/user-guide/cartography.md @@ -286,10 +286,11 @@ Turn labels on per layer (View › Labeling › Label, or the Style tab), then: `pop_max <= 5000000` for Default. Classes are placed in list order, Default last; a hidden class or one outside its visibility range labels nothing without excluding features from other classes. There can be up to 64 classes besides Default. In the Labeling pane, the - **Class** list chooses the class every section shows and edits; **New** adds one with the - chosen class's field, text symbol, placement and range, listed before Default, and - **Rename**, **Move Up** / **Move Down**, **Remove** and **Visible** act on the chosen class - (Default can be renamed or hidden, not moved or removed). The ribbon's label groups edit the + **Class** list at the top chooses the class every section shows and edits, and **Visible** + under it hides or shows that class. The row under the list has **New**, which adds a class + with the chosen class's field, text symbol, placement and range, listed before Default; + **Rename** and **Move Up** / **Move Down** (the arrows) for the chosen class; and, at its far + end, **Remove** (Default can be renamed or hidden, not moved or removed). The ribbon's label groups edit the same chosen class, and **Style › Label Class › Class** chooses it as the pane's list does. By command, make them with `labels.class.add` (a new class copies Default's field, text symbol, placement and range, or From 6cb27f6d10f88cef9979378069885afca17234e5 Mon Sep 17 00:00:00 2001 From: Jose Gregorio Rincon Albarracin Date: Sun, 25 Oct 2026 08:15:19 -0500 Subject: [PATCH 2/3] fix(labels): keep the class actions off while a class is renamed A click on Remove, a move arrow or Visible while the rename box was open renamed the class and then ran the action by its old name, which failed. They are now off until the rename ends. The class actions go back into the header's one function. --- crates/ui-egui/src/labelpane.rs | 121 +++++++++++++++++--------------- 1 file changed, 64 insertions(+), 57 deletions(-) diff --git a/crates/ui-egui/src/labelpane.rs b/crates/ui-egui/src/labelpane.rs index 2147def49..66d0ac8bf 100644 --- a/crates/ui-egui/src/labelpane.rs +++ b/crates/ui-egui/src/labelpane.rs @@ -197,8 +197,9 @@ struct Renaming { pass: u64, } -/// The pane's header for the chosen class (#858): its chooser, a row of what makes, renames, -/// orders and removes classes (#722), and Visible. +/// The pane's header for the chosen class (#858): its chooser, a row with New, Rename, Move Up, +/// Move Down and Remove for it (#722), and Visible. Default, last in the list, can be renamed or +/// hidden, but not removed or moved. fn classes(app: &mut RustGisApp, ui: &mut Ui, l: &Layer, c: &Class, t: &Tokens) { let rename_key = egui::Id::unique("label_pane_rename"); let owner = egui::Id::unique((layer_key(app, l), c.name)); @@ -207,6 +208,7 @@ fn classes(app: &mut RustGisApp, ui: &mut Ui, l: &Layer, c: &Class, t: &Tokens) let pass = ui.ctx().cumulative_pass_nr(); let renaming = ui.data(|d| d.get_temp::(rename_key)).filter(|r| r.owner == owner && r.pass + 1 >= pass); ui.data_mut(|d| d.remove::(rename_key)); + let editing = renaming.is_some(); grid(ui, "class_pick", |ui| { let input_label = ui.label(tl!("Class")); match renaming { @@ -217,64 +219,66 @@ fn classes(app: &mut RustGisApp, ui: &mut Ui, l: &Layer, c: &Class, t: &Tokens) } ui.end_row(); }); - if ui.horizontal(|ui| class_actions(app, ui, l, c, t)).inner { - let r = Renaming { owner, text: c.it.name.clone(), focused: false, pass }; - ui.data_mut(|d| d.insert_temp(rename_key, r)); - } - let mut visible = c.it.visible; - if ui.checkbox(&mut visible, tl!("Visible")).on_hover_text(tl!("A hidden class leaves its features unlabelled.")).changed() { - run(app, "labels.class.update", c.params(json!({"visible": visible}))); - } -} - -/// New, Rename, Move Up and Move Down for the chosen class, then Remove; whether Rename was -/// clicked. Default, last in the list, can be renamed, but not removed or moved. -fn class_actions(app: &mut RustGisApp, ui: &mut Ui, l: &Layer, c: &Class, t: &Tokens) -> bool { - let count = extras(l).count(); - let at = c.name.and_then(|n| extras(l).position(|k| k.name == n)); - let full = l.labels.classes.len() >= MAX_LABEL_CLASSES; - let max = MAX_LABEL_CLASSES.to_string(); - let new = ui - .add_enabled(!full, egui::Button::new(tl!("New"))) - .on_hover_text(tl!("Add a class with this one's field, text symbol, placement and range.")) - .on_disabled_hover_text(crate::i18n::fmt("A layer keeps at most {max} label classes besides Default.", &[("max", &max)])); - if new.clicked() { - let p = named(json!({"name": new_name(&l.labels)}), "from", c.name); - if let Some(added) = run(app, "labels.class.add", p) { - choose(ui, app, l, added.get("class").and_then(Value::as_str)); - } - } - let rename = ui.button(tl!("Rename")).clicked(); - let up = at.filter(|i| *i > 0).map(|i| i - 1); - let down = at.filter(|i| i + 1 < count).map(|i| i + 1); - for (icon, label, to) in [("up", "Move Up", up), ("down", "Move Down", down)] { - let moved = ui - .add_enabled_ui(to.is_some(), |ui| { - // Named for screen readers (and tests) by its label, as the Bookmarks pane's icons. - let r = crate::widgets::icon_button(ui, icon, tl!(label)); - r.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, to.is_some(), tl!(label))); - r - }) - .inner - .on_hover_text(tl!("Labels of the classes listed first are placed first when they compete for room.")); - if moved.clicked() { - run(app, "labels.class.move", c.params(json!({"to": to}))); - } - } - // At the far end and in red: it is the one that deletes, and its name is close to Rename's. - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - let remove = ui - .add_enabled(at.is_some(), egui::Button::new(RichText::new(tl!("Remove")).color(t.red))) - .on_disabled_hover_text(tl!("Default can't be removed. You can rename or hide it.")); - if remove.clicked() { - // Default chosen outright: a class added later by the removed one's name (an undo, a - // script) isn't chosen again by the name left behind. - if run(app, "labels.class.remove", c.params(json!({}))).is_some() { - choose(ui, app, l, None); + // Off while the class is renamed: a click on them ends the rename (the box loses focus), so + // they would act on the class by the name it had before. + ui.add_enabled_ui(!editing, |ui| { + let count = extras(l).count(); + let at = c.name.and_then(|n| extras(l).position(|k| k.name == n)); + ui.horizontal(|ui| { + let full = l.labels.classes.len() >= MAX_LABEL_CLASSES; + let max = MAX_LABEL_CLASSES.to_string(); + let new = ui + .add_enabled(!full, egui::Button::new(tl!("New"))) + .on_hover_text(tl!("Add a class with this one's field, text symbol, placement and range.")) + .on_disabled_hover_text(crate::i18n::fmt("A layer keeps at most {max} label classes besides Default.", &[("max", &max)])); + if new.clicked() { + let p = named(json!({"name": new_name(&l.labels)}), "from", c.name); + if let Some(added) = run(app, "labels.class.add", p) { + choose(ui, app, l, added.get("class").and_then(Value::as_str)); + } + } + if ui.button(tl!("Rename")).clicked() { + let r = Renaming { owner, text: c.it.name.clone(), focused: false, pass }; + ui.data_mut(|d| d.insert_temp(rename_key, r)); + } + let up = at.filter(|i| *i > 0).map(|i| i - 1); + let down = at.filter(|i| i + 1 < count).map(|i| i + 1); + for (icon, label, to) in [("up", "Move Up", up), ("down", "Move Down", down)] { + let moved = ui + .add_enabled_ui(to.is_some(), |ui| { + // Named for screen readers (and tests) by its label, as the Bookmarks + // pane's icons. + let enabled = ui.is_enabled(); + let r = crate::widgets::icon_button(ui, icon, tl!(label)); + r.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, enabled, tl!(label))); + r + }) + .inner + .on_hover_text(tl!("Labels of the classes listed first are placed first when they compete for room.")); + if moved.clicked() { + run(app, "labels.class.move", c.params(json!({"to": to}))); + } } + // At the far end and in red: it is the one that deletes, and its name is close to + // Rename's. + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let remove = ui + .add_enabled(at.is_some(), egui::Button::new(RichText::new(tl!("Remove")).color(t.red))) + .on_disabled_hover_text(tl!("Default can't be removed. You can rename or hide it.")); + if remove.clicked() { + // Default chosen outright: a class added later by the removed one's name (an + // undo, a script) isn't chosen again by the name left behind. + if run(app, "labels.class.remove", c.params(json!({}))).is_some() { + choose(ui, app, l, None); + } + } + }); + }); + let mut visible = c.it.visible; + if ui.checkbox(&mut visible, tl!("Visible")).on_hover_text(tl!("A hidden class leaves its features unlabelled.")).changed() { + run(app, "labels.class.update", c.params(json!({"visible": visible}))); } }); - rename } /// The box the chosen class is renamed in: Enter (or clicking away) renames it, Escape leaves it as @@ -1256,6 +1260,9 @@ mod tests { assert_eq!(names(h.state()), ["Big", "Big 2", "Default"]); assert!(h.state().session.notices.iter().any(|n| n.error && n.text.contains("already a label class"))); assert!(h.query_by_role(egui::accesskit::Role::TextInput).is_some(), "the box stays open"); + for off in ["New", "Rename", "Move Up", "Move Down", "Remove", "Visible"] { + assert!(h.get_by_label(off).accesskit_node().is_disabled(), "{off} waits for the rename"); + } h.key_press(egui::Key::Escape); h.run(); assert!(h.query_by_role(egui::accesskit::Role::TextInput).is_none()); From 69b73c56f31149cbb86b1edf37afeac27258b0ba Mon Sep 17 00:00:00 2001 From: Jose Gregorio Rincon Albarracin Date: Sun, 25 Oct 2026 08:25:05 -0500 Subject: [PATCH 3/3] fix(labels): one tooltip on each class move arrow icon_button already shows its hover text, so the extra on_hover_text stacked a second tooltip under it. Each arrow now has one, naming the move and why the order matters. --- crates/ui-egui/src/labelpane.rs | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/labelpane.rs b/crates/ui-egui/src/labelpane.rs index 66d0ac8bf..a5d0f53e3 100644 --- a/crates/ui-egui/src/labelpane.rs +++ b/crates/ui-egui/src/labelpane.rs @@ -243,18 +243,21 @@ fn classes(app: &mut RustGisApp, ui: &mut Ui, l: &Layer, c: &Class, t: &Tokens) } let up = at.filter(|i| *i > 0).map(|i| i - 1); let down = at.filter(|i| i + 1 < count).map(|i| i + 1); - for (icon, label, to) in [("up", "Move Up", up), ("down", "Move Down", down)] { + let moves = [ + ("up", "Move Up", "Move up: labels of the classes listed first are placed first when they compete for room.", up), + ("down", "Move Down", "Move down: labels of the classes listed first are placed first when they compete for room.", down), + ]; + for (icon, label, tip, to) in moves { let moved = ui .add_enabled_ui(to.is_some(), |ui| { // Named for screen readers (and tests) by its label, as the Bookmarks - // pane's icons. + // pane's icons; its one tooltip says why the order matters. let enabled = ui.is_enabled(); - let r = crate::widgets::icon_button(ui, icon, tl!(label)); + let r = crate::widgets::icon_button(ui, icon, tl!(tip)); r.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, enabled, tl!(label))); r }) - .inner - .on_hover_text(tl!("Labels of the classes listed first are placed first when they compete for room.")); + .inner; if moved.clicked() { run(app, "labels.class.move", c.params(json!({"to": to}))); }