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