diff --git a/crates/ui-egui/src/dialogs/form.rs b/crates/ui-egui/src/dialogs/form.rs index ebb6c0d3..40329c8c 100644 --- a/crates/ui-egui/src/dialogs/form.rs +++ b/crates/ui-egui/src/dialogs/form.rs @@ -322,8 +322,19 @@ pub(super) fn schema_fields(ui: &mut egui::Ui, d: &mut Dialog, specs: &[(String, 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) as f64; - crate::widgets::range_field(ui, ("plugin-int", k), x, *min as f64..=*max as f64, "", 0, 100.0).map(|x| json!(x as i64)) + 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); diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index 7885236c..5095b7c0 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -392,6 +392,89 @@ fn given_headings_are_shown_as_they_are() { assert_eq!((spec(&d.kind).heading)(&d), "Black"); } +/// A plug-in dialog with one whole-number field `seed` in `min..=max`, driven by egui events. +struct PluginIntField { + ctx: egui::Context, + d: Dialog, + specs: Vec<(String, vectorcraft_plugins::ParamSpec)>, + pos: egui::Pos2, +} + +impl PluginIntField { + fn new(min: i64, max: i64, start: i64) -> Self { + let ctx = egui::Context::default(); + theme::install_fonts(&ctx); + theme::apply(&ctx, Default::default()); + let specs = vec![("seed".into(), vectorcraft_plugins::ParamSpec::Int { min, max, default: 0 })]; + let mut f = PluginIntField { ctx, d: Dialog::new(plugin::KIND, json!({ "seed": start })), specs, pos: egui::Pos2::ZERO }; + fn number_rect(shape: &egui::Shape, text: &str) -> Option { + match shape { + egui::Shape::Text(t) if t.galley.text() == text => Some(t.visual_bounding_rect()), + egui::Shape::Vec(shapes) => shapes.iter().find_map(|s| number_rect(s, text)), + _ => None, + } + } + let out = f.run(vec![]); + f.pos = out.shapes.iter().find_map(|s| number_rect(&s.shape, &start.to_string())).unwrap().center(); + f + } + + fn run(&mut self, events: Vec) -> egui::FullOutput { + let (d, specs) = (&mut self.d, &self.specs); + let mut out = self.ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| { + form::schema_fields(ui, d, specs); + }); + out.textures_delta.clear(); + out + } + + /// Clicks the field, sends `events`, presses Enter and returns the field's value. + fn commit(&mut self, events: Vec) -> serde_json::Value { + use egui::{Event, Key, Modifiers, PointerButton}; + let (pos, button) = (self.pos, PointerButton::Primary); + self.run(vec![Event::PointerMoved(pos), Event::PointerButton { pos, button, pressed: true, modifiers: Modifiers::default() }]); + self.run(vec![Event::PointerButton { pos, button, pressed: false, modifiers: Modifiers::default() }]); + self.run(events); + self.run(vec![Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::default() }]); + self.d.fields["seed"].clone() + } + + /// Replaces the field's text with `text` and commits it. + fn type_text(&mut self, text: &str) -> serde_json::Value { + let all = egui::Modifiers { ctrl: true, command: true, ..Default::default() }; + self.commit(vec![ + egui::Event::Key { key: egui::Key::A, physical_key: None, pressed: true, repeat: false, modifiers: all }, + egui::Event::Text(text.into()), + ]) + } +} + +#[test] +fn plugin_integer_field_preserves_precision_and_clamps_exactly() { + let (min, max) = (-9007199254740995_i64, 9007199254740995_i64); + let mut f = PluginIntField::new(min, max, 0); + for (text, expected) in [ + ("9007199254740993", 9007199254740993_i64), + ("9007199254740996", max), + ("-9007199254740996", min), + ("invalid", min), + ("1.5", min), + ("9223372036854775808", min), + ] { + assert_eq!(f.type_text(text), json!(expected), "{text}"); + } +} + +#[test] +fn plugin_integer_field_with_small_bounds_keeps_numeric_editing() { + let mut f = PluginIntField::new(1, 16, 3); + let up = egui::Event::Key { key: egui::Key::ArrowUp, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }; + assert_eq!(f.commit(vec![up]), json!(4)); + for (text, expected) in [("7", 7), ("2 + 3", 5), ("17", 16)] { + assert_eq!(f.type_text(text), json!(expected), "{text}"); + } +} + #[test] fn a_dialog_opens_with_its_first_field_focused_so_typing_and_enter_apply() { let mut app = app();