From dbb6b470b3b3b016c8e87168846448678fa34e97 Mon Sep 17 00:00:00 2001 From: Louise Fox <2503492+KaboomFox@users.noreply.github.com> Date: Sun, 18 Oct 2026 02:38:00 +0000 Subject: [PATCH 1/2] Menus: leave installed plug-ins' names out of the strings to translate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit menu_strings() lists the strings a complete language catalog has to cover, and it leaves out names that are user data: fonts, recent files, saved views and libraries. An installed plug-in's name stayed in. Plug-ins are installed for the whole process, and a test that installs one before complete_languages_translate_every_menu_string runs made that test fail with the plug-in's name as an untranslated menu string. lists_plugin() tells an Object › Plug-ins filter or an Effect › Plug-ins effect by its command and params. Those are part of the menu item itself and stay right while another test installs or removes plug-ins. menu_strings() uses it, and so does menu_catalogs_translate_every_menu_label in place of its own copy of the rule. plugins_are_listed_in_the_object_and_effect_menus checks it. Before the change it failed with "Desaturate… is a menu string to translate". Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HhvnnnD5fTFupK8WWq35un --- crates/ui-egui/src/i18n/tests.rs | 7 +++++-- crates/ui-egui/src/menus.rs | 19 +++++++++++++------ crates/ui-egui/src/tests_plugins.rs | 6 ++++++ 3 files changed, 24 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index 63c727e15..9fb4b50cf 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -954,8 +954,11 @@ fn menu_catalogs_translate_every_menu_label() { let language_name = |l: &str| Lang::all().any(|lang| lang.name() == l); // Font names and sizes, and installed plug-ins' own names, are not interface text. let interface = |(label, id, p): &(&str, &str, serde_json::Value)| { - let plugin = *id == "plugin.dialog" || p.get("effect").and_then(serde_json::Value::as_str).is_some_and(|e| e.starts_with("plugin.")); - *id != "text.setStyle" && !plugin && shown.contains(*label) && !MENU_KEEP_AS_IS.contains(label) && !language_name(label) + *id != "text.setStyle" + && !crate::menus::lists_plugin(id, p) + && shown.contains(*label) + && !MENU_KEEP_AS_IS.contains(label) + && !language_name(label) }; let mut all: Vec = labels.iter().filter(|l| interface(l)).map(|(l, ..)| l.to_string()).collect(); all.extend(toggled_labels()); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 3db77498c..92af3c9f6 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1917,6 +1917,13 @@ fn shows_a_name(id: &str, label: &str) -> bool { || (matches!(id, "effect.apply" | "effect.dialog") && vectorcraft_effects::plugin_effects().iter().any(|e| e.label == label)) } +/// Does the menu item `id` with `params` list an installed plug-in (an Object › Plug-ins filter or +/// an Effect › Plug-ins effect), by the plug-in's own name? Told by the item itself, so it holds +/// while another thread installs or removes plug-ins. +pub(crate) fn lists_plugin(id: &str, params: &Value) -> bool { + id == "plugin.dialog" || params.get("effect").and_then(Value::as_str).and_then(vectorcraft_plugins::effect::plugin_id).is_some() +} + /// The label of a menu item as drawn: [`dynamic_label`] in the UI language. Labels assembled /// around a name (Undo *Move*, Reset *Essentials*, Last Effect: *Drop Shadow*) are translated as /// templates so the name can move; names that are user data (files, views, fonts, custom @@ -3952,17 +3959,17 @@ pub const DYNAMIC_LABELS: &[&str] = &[ /// Every English string the menus, the command palette, the panel registry, the toolbar and the /// Preferences dialog can show: what a complete language catalog has to cover. Names that are user -/// data (fonts, recent files, saved views, libraries), the point sizes and the view angles are -/// left out. +/// data (fonts, recent files, saved views, libraries, installed plug-ins), the point sizes and the +/// view angles are left out. pub fn menu_strings() -> std::collections::BTreeSet { use std::collections::BTreeSet; - fn user_data(id: &str) -> bool { - hidden_when_disabled(id) || id.starts_with("type.recentFont") + fn user_data(id: &str, params: &Value) -> bool { + hidden_when_disabled(id) || id.starts_with("type.recentFont") || lists_plugin(id, params) } fn walk(items: &[Item], under: &str, out: &mut BTreeSet) { for it in items { match it { - Item::Cmd(l, id, _) => { + Item::Cmd(l, id, params) => { let size = l.strip_suffix(" pt").is_some_and(|n| n.chars().all(|c| c.is_ascii_digit())); // View › Rotate View lists angles: an optional minus sign, digits and a degree sign. let angle = l @@ -3971,7 +3978,7 @@ pub fn menu_strings() -> std::collections::BTreeSet { .is_some_and(|n| !n.is_empty() && n.chars().all(|c| c.is_ascii_digit())); // VectorCraft › Language lists each language by its own name. let language_name = *id == "app.language" && crate::i18n::Lang::all().any(|lang| lang.name() == *l); - if !user_data(id) && !size && !angle && !language_name && *l != "—" { + if !user_data(id, params) && !size && !angle && !language_name && *l != "—" { out.insert(l.to_string()); } } diff --git a/crates/ui-egui/src/tests_plugins.rs b/crates/ui-egui/src/tests_plugins.rs index 7cd254f1d..c934ef672 100644 --- a/crates/ui-egui/src/tests_plugins.rs +++ b/crates/ui-egui/src/tests_plugins.rs @@ -58,6 +58,12 @@ fn plugins_are_listed_in_the_object_and_effect_menus() { assert_eq!((fx.command.as_deref(), &fx.params), (Some("effect.dialog"), &json!({"effect": "plugin.test.ui.echo"}))); // Menu clicks on items with params don't turn into the generic parameter dialog. assert_eq!(menus::click_target("Desaturate…", "plugin.dialog", &filter.params).0, "plugin.dialog"); + // Their names are the plug-ins' own, not interface text a language catalog has to cover (they + // are installed for the whole test run, while the catalog tests run). + let strings = menus::menu_strings(); + for name in ["Desaturate…", "Test test.ui.echo…"] { + assert!(!strings.contains(name), "{name} is a menu string to translate"); + } } #[test] From 0842941f8da435c09b1feecd51bcd59ee9a45046 Mon Sep 17 00:00:00 2001 From: Louise Fox <2503492+KaboomFox@users.noreply.github.com> Date: Sun, 18 Oct 2026 02:38:00 +0000 Subject: [PATCH 2/2] Dialogs: a plug-in's parameters scroll when they don't fit the window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A plug-in's dialog (Effect › Plug-ins, Object › Plug-ins) lays out each parameter of its manifest as a row, and a manifest may declare up to 64. With many of them the dialog ran off the top and the bottom of the window, its heading and its OK and Cancel buttons off screen. A plug-in of 64 number parameters got a dialog 2,329 px tall, its top at y = -755 in a 900 px window. modal::scrolled_rows() puts rows in a vertical scroll area as tall as the screen less the rest of the modal window and its edge gaps. modal::show_at() measures that rest once it has drawn the window, for the next frame, and asks for that frame at once if the rest changed. The dialog then fits without waiting for input. schema_fields() puts the parameter grid in it, for filters and effects alike. A dialog of a few parameters is as tall as before. a_plug_in_dialog_of_many_settings_fits_the_window opens an effect's and a filter's dialog of 64 parameters in a 1280 × 900 window, and one of 4. Before the change it failed with "effect: the dialog is at [[457.0 -755.0] - [823.0 1574.0]] in the [1280.0 900.0] window". Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HhvnnnD5fTFupK8WWq35un --- crates/ui-egui/src/dialogs/form.rs | 87 ++++++++++++----------- crates/ui-egui/src/dialogs/modal.rs | 46 ++++++++++-- crates/ui-egui/src/dialogs/tests_modal.rs | 67 ++++++++++++++++- docs/plugins.md | 3 +- 4 files changed, 156 insertions(+), 47 deletions(-) diff --git a/crates/ui-egui/src/dialogs/form.rs b/crates/ui-egui/src/dialogs/form.rs index 2a8350641..a1d3b54c1 100644 --- a/crates/ui-egui/src/dialogs/form.rs +++ b/crates/ui-egui/src/dialogs/form.rs @@ -370,54 +370,59 @@ fn typed_number(x: f64) -> Value { } /// Editor for a plug-in's parameters from its schema (plug-in filter and effect dialogs): numbers -/// within their range, whole numbers, checkboxes and dropdowns, in declaration order. Returns -/// true when a value changed. The labels (from the parameter keys) and choices are the plug-in's -/// text, shown as they are. +/// within their range, whole numbers, checkboxes and dropdowns, in declaration order, a row each. +/// Returns true when a value changed. The labels (from the parameter keys) and choices are the +/// plug-in's text, shown as they are. A plug-in may declare more of them +/// ([`MAX_PARAMS`](vectorcraft_plugins::manifest::MAX_PARAMS)) than the window has room for, and +/// the rows then scroll ([`super::modal::scrolled_rows`]). pub(super) fn schema_fields(ui: &mut egui::Ui, d: &mut Dialog, specs: &[(String, vectorcraft_plugins::ParamSpec)]) -> bool { use vectorcraft_plugins::ParamSpec; let t = Tokens::get(ui.ctx()); let mut changed = false; - egui::Grid::new("plugin-grid").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| { - for (k, spec) in specs { - ui.label(egui::RichText::new(humanize(k)).color(t.text)); - let cur = d.fields.get(k).cloned().unwrap_or_else(|| spec.default_value()); - let new = match spec { - ParamSpec::Number { min, max, .. } => { - let x = cur.as_f64().unwrap_or(*min); - crate::widgets::range_field(ui, ("plugin-num", k), x, *min..=*max, "", 2, 100.0).map(|x| json!(x)) - } - ParamSpec::Int { min, max, .. } => { - let x = cur.as_i64().unwrap_or(*min).clamp(*min, *max); - // An f64 holds every whole number up to 2^53, so the numeric field (arrows, wheel, arithmetic) is exact there. - // Past it, the number typed is read as an integer. - const EXACT: i64 = 1 << 53; - if -EXACT <= *min && *max <= EXACT { - crate::widgets::range_field(ui, ("plugin-int", k), x as f64, *min as f64..=*max as f64, "", 0, 100.0).map(|x| json!(x as i64)) - } else { - let shown = x.to_string(); - crate::widgets::take_dialog_focus(ui, ui.id().with(("plugin-int", k)), &shown); - crate::widgets::text_field(ui, ("plugin-int", k), Some(&shown), 100.0, 1) - .and_then(|text| text.parse::().ok()) - .map(|x| json!(x.clamp(*min, *max))) + super::modal::scrolled_rows(ui, "plugin-fields", |ui| { + egui::Grid::new("plugin-grid").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| { + for (k, spec) in specs { + ui.label(egui::RichText::new(humanize(k)).color(t.text)); + let cur = d.fields.get(k).cloned().unwrap_or_else(|| spec.default_value()); + let new = match spec { + ParamSpec::Number { min, max, .. } => { + let x = cur.as_f64().unwrap_or(*min); + crate::widgets::range_field(ui, ("plugin-num", k), x, *min..=*max, "", 2, 100.0).map(|x| json!(x)) } + ParamSpec::Int { min, max, .. } => { + let x = cur.as_i64().unwrap_or(*min).clamp(*min, *max); + // An f64 holds every whole number up to 2^53, so the numeric field (arrows, wheel, arithmetic) is exact there. + // Past it, the number typed is read as an integer. + const EXACT: i64 = 1 << 53; + if -EXACT <= *min && *max <= EXACT { + crate::widgets::range_field(ui, ("plugin-int", k), x as f64, *min as f64..=*max as f64, "", 0, 100.0) + .map(|x| json!(x as i64)) + } else { + let shown = x.to_string(); + crate::widgets::take_dialog_focus(ui, ui.id().with(("plugin-int", k)), &shown); + crate::widgets::text_field(ui, ("plugin-int", k), Some(&shown), 100.0, 1) + .and_then(|text| text.parse::().ok()) + .map(|x| json!(x.clamp(*min, *max))) + } + } + ParamSpec::Bool { .. } => { + let b = cur.as_bool().unwrap_or(false); + crate::widgets::check_box(ui, &humanize(k), b, true).then(|| json!(!b)) + } + ParamSpec::Choice { options, .. } => { + let labels: Vec<&str> = options.iter().map(String::as_str).collect(); + crate::widgets::dropdown_names(ui, ("plugin-choice", k), cur.as_str().unwrap_or_default(), &labels, 140.0) + .and_then(|i| options.get(i)) + .map(|o| json!(o)) + } + }; + if let Some(v) = new { + d.fields.insert(k.clone(), v); + changed = true; } - ParamSpec::Bool { .. } => { - let b = cur.as_bool().unwrap_or(false); - crate::widgets::check_box(ui, &humanize(k), b, true).then(|| json!(!b)) - } - ParamSpec::Choice { options, .. } => { - let labels: Vec<&str> = options.iter().map(String::as_str).collect(); - crate::widgets::dropdown_names(ui, ("plugin-choice", k), cur.as_str().unwrap_or_default(), &labels, 140.0) - .and_then(|i| options.get(i)) - .map(|o| json!(o)) - } - }; - if let Some(v) = new { - d.fields.insert(k.clone(), v); - changed = true; + ui.end_row(); } - ui.end_row(); - } + }); }); changed } diff --git a/crates/ui-egui/src/dialogs/modal.rs b/crates/ui-egui/src/dialogs/modal.rs index 164471fc7..4ac6d6cd5 100644 --- a/crates/ui-egui/src/dialogs/modal.rs +++ b/crates/ui-egui/src/dialogs/modal.rs @@ -1,7 +1,8 @@ //! The modal dialog window: a backdrop that keeps the rest of the app from taking clicks, and a //! window with no title bar that opens centred and moves by dragging its heading (the band from its //! top edge down to the heading), as a title bar would. Where it was moved to stays, per window, for -//! the session, and the window never leaves the screen. +//! the session, and the window never leaves the screen. Rows too many for the screen's height +//! scroll inside it ([`scrolled_rows`]), so its heading and buttons stay in view. use egui::{Id, Rect, Vec2}; @@ -22,7 +23,7 @@ pub(crate) fn show_at(ctx: &egui::Context, title: &str, id: Id, initial: Vec2 // Modal, but the canvas isn't dimmed so previews stay readable (as in the reference app). ui.allocate_rect(ctx.content_rect(), egui::Sense::click()); }); - egui::Window::new(title) + let shown = egui::Window::new(title) .id(id) .order(egui::Order::Foreground) .collapsible(false) @@ -30,8 +31,9 @@ pub(crate) fn show_at(ctx: &egui::Context, title: &str, id: Id, initial: Vec2 .title_bar(false) .anchor(egui::Align2::CENTER_CENTER, offset(ctx, id, initial)) .frame(egui::Frame::window(&ctx.global_style()).fill(t.panel).inner_margin(egui::Margin::same(margin))) - .show(ctx, add) - .and_then(|r| r.inner) + .show(ctx, add)?; + keep_rest(ctx, id, shown.response.rect.height()); + shown.inner } /// The layer of the backdrop behind a modal dialog window, which takes the clicks the rest of @@ -68,6 +70,42 @@ fn handle(id: Id) -> Id { id.with("modal-drag") } +/// The least height of [`scrolled_rows`] in a window too short for the rest of its contents: about +/// three rows. +const MIN_ROWS_H: f32 = 100.0; + +/// Under a modal window's id: how tall its [`scrolled_rows`] were drawn this frame, and how tall +/// the rest of it was when last drawn. +const ROWS_KEY: &str = "modal-rows"; +const REST_KEY: &str = "modal-rest"; + +/// Rows of the modal window `ui` is in (one list of them per window) that scroll if the window +/// would be taller than the screen. Their height is the screen's less the rest of the window as +/// last drawn (a frame late, as only the drawn window knows its height), and at least +/// [`MIN_ROWS_H`]. Rows that fit are as tall as they are. +pub(crate) fn scrolled_rows(ui: &mut egui::Ui, salt: impl egui::AsIdSalt, add: impl FnOnce(&mut egui::Ui) -> R) -> R { + let id = ui.layer_id().id; + let rest = ui.ctx().data(|m| m.get_temp::(id.with(REST_KEY))).unwrap_or(0.0); + let room = (ui.ctx().content_rect().height() - rest - 2.0 * super::EDGE_GAP).max(MIN_ROWS_H); + // As tall as the room at once: by itself a scroll area grows a frame at a time with the window. + let rows = ui.scope(|ui| egui::ScrollArea::vertical().id_salt(salt).max_height(room).min_scrolled_height(room).show(ui, add).inner); + ui.ctx().data_mut(|m| m.insert_temp(id.with(ROWS_KEY), rows.response.rect.height())); + rows.inner +} + +/// After modal window `id` is drawn `height` tall: keep the height of all of it but its +/// [`scrolled_rows`] for the next frame. If the rows were sized by a different one (the window just +/// opened, or what is around them changed), the next frame is drawn at once, for the rows to fit +/// without waiting for input. +fn keep_rest(ctx: &egui::Context, id: Id, height: f32) { + let Some(rows) = ctx.data_mut(|m| m.remove_temp::(id.with(ROWS_KEY))) else { return }; + let rest = (height - rows).max(0.0); + let used = ctx.data_mut(|m| std::mem::replace(m.get_temp_mut_or(id.with(REST_KEY), 0.0), rest)); + if (used - rest).abs() > 0.5 { + ctx.request_repaint(); + } +} + /// Where window `id` sits, from the screen's centre: `nudge` down until it is first moved. While /// its [`drag_band`] is dragged, where it sat when the drag began plus how far the pointer has moved /// since the press, kept on screen (applied before the window places itself, so it follows the diff --git a/crates/ui-egui/src/dialogs/tests_modal.rs b/crates/ui-egui/src/dialogs/tests_modal.rs index f3a266eaa..cee3d1cc6 100644 --- a/crates/ui-egui/src/dialogs/tests_modal.rs +++ b/crates/ui-egui/src/dialogs/tests_modal.rs @@ -1,5 +1,6 @@ //! #423: modal dialogs move by their heading (and stay on screen and where they were moved), and -//! the form dialogs' choice fields are dropdowns (Offset Path's Joins). +//! the form dialogs' choice fields are dropdowns (Offset Path's Joins). A plug-in's dialog of more +//! settings than the window has room for fits it, its settings scrolling. use egui::{Event, Pos2, Rect, Vec2, vec2}; use serde_json::json; @@ -117,6 +118,70 @@ fn dialogs_move_by_their_heading_stay_on_screen_and_where_they_were_moved() { } } +/// Open the dialog of a plug-in of `kind` (`effect` or `filter`) with `count` number settings over a +/// selection, settle it and `check` it, given its window and heading. The other tests share the +/// plug-in registry, and the plug-in is removed after. +fn check_plugin_dialog(kind: &str, count: usize, check: impl FnOnce(&egui::Context, &mut VectorcraftApp, egui::Id, &str)) { + use vectorcraft_plugins::wat as tpl; + let params: Vec = (0..count).map(|i| format!(r#""setting{i}":{{"type":"number","min":0,"max":10,"default":1}}"#)).collect(); + let id = format!("test.ui.{kind}{count}"); + let src = tpl::module(&tpl::manifest(&id, kind, &format!("{{{}}}", params.join(","))), tpl::ECHO, "", 1); + let (ctx, mut app) = (ctx(), app_with_selection()); + app.run("plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&wat::parse_str(src).unwrap())})).unwrap(); + let (command, params, dialog) = match kind { + "effect" => ("effect.dialog", json!({"effect": vectorcraft_plugins::effect::effect_id(&id)}), "effect"), + _ => ("plugin.dialog", json!({"id": id}), plugin::KIND), + }; + app.run(command, params).unwrap(); + settle(&ctx, &mut app); + check(&ctx, &mut app, egui::Id::new(("dialog", dialog)), &format!("Test {id}")); + app.run("plugin.remove", json!({"id": id})).unwrap(); +} + +/// A plug-in can declare more settings than the window has room for, a row each. Its dialog, an +/// effect's or a filter's, fits the window all the same, heading and buttons included. The +/// settings take the height there is, and the wheel scrolls them to the last one. A dialog of a few +/// settings is as tall as they are. +#[test] +fn a_plug_in_dialog_of_many_settings_fits_the_window() { + use vectorcraft_plugins::manifest::MAX_PARAMS; + let screen = Rect::from_min_size(Pos2::ZERO, SCREEN); + let shown = |texts: &[(String, Rect)], text: &str| texts.iter().find(|(t, r)| t == text && screen.contains_rect(*r)).map(|(_, r)| *r); + for kind in ["effect", "filter"] { + check_plugin_dialog(kind, MAX_PARAMS, |ctx, app, window, heading| { + let last = format!("Setting{}:", MAX_PARAMS - 1); + let texts = frame(ctx, app, vec![]); + let rect = rect_of(ctx, window); + assert!(screen.contains_rect(rect) && rect.height() > 0.75 * SCREEN.y, "{kind}: the dialog is at {rect:?} in the {SCREEN:?} window"); + for text in [heading, "Setting0:", "OK", "Cancel"] { + assert!(shown(&texts, text).is_some(), "{kind}: {text} isn't on screen: {texts:?}"); + } + assert!(shown(&texts, &last).is_none(), "{kind}: {last} is out of view at first"); + let over = shown(&texts, "Setting0:").unwrap().center(); + let wheel = Event::MouseWheel { + unit: egui::MouseWheelUnit::Line, + delta: vec2(0.0, -200.0), + phase: egui::TouchPhase::Move, + modifiers: Default::default(), + }; + frame(ctx, app, vec![Event::PointerMoved(over), wheel]); + for _ in 0..30 { + frame(ctx, app, vec![]); + } + let texts = frame(ctx, app, vec![]); + for text in [heading, last.as_str(), "OK", "Cancel"] { + assert!(shown(&texts, text).is_some(), "{kind}, scrolled to the end: {text} isn't on screen: {texts:?}"); + } + assert_eq!(rect_of(ctx, window), rect, "{kind}: scrolling leaves the window as it was"); + }); + } + check_plugin_dialog("effect", 4, |ctx, app, window, _| { + let texts = frame(ctx, app, vec![]); + let rect = rect_of(ctx, window); + assert!(rect.height() < SCREEN.y / 2.0 && shown(&texts, "Setting3:").is_some(), "4 settings: {rect:?} {texts:?}"); + }); +} + /// Offset Path's Joins is a dropdown of Miter, Round and Bevel (it was a text field), listed after /// Offset; choosing Round runs the command with round joins. #[test] diff --git a/docs/plugins.md b/docs/plugins.md index c26e0acc8..8a34dd7a9 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -33,7 +33,8 @@ Live effects are applied like any effect: `effect.apply {"effect": "plugin." In the app, installed filters appear under **Object › Plug-ins**, next to **Install Plug-in…** and **Reload Plug-ins**; live effects under **Effect › Plug-ins** and in the Appearance panel's effect menu. A plug-in with parameters gets a dialog generated from its schema (numbers, whole numbers, checkboxes, dropdowns) with a live -preview; without parameters it runs at once. **File › Open** of a `.wasm` file installs it too (on the web this is +preview; without parameters it runs at once. Parameters too many for the window's height scroll inside the dialog, +which keeps its heading and buttons in view. **File › Open** of a `.wasm` file installs it too (on the web this is how plug-ins get in). **Preference folder.** Edit › Preferences › Performance & Storage › *Additional Plug-ins Folder*: when set, every