diff --git a/crates/ui-egui/src/color_replacement_tolerance_field_tests.rs b/crates/ui-egui/src/color_replacement_tolerance_field_tests.rs index 7e237bcdfc..d6ded4c0c9 100644 --- a/crates/ui-egui/src/color_replacement_tolerance_field_tests.rs +++ b/crates/ui-egui/src/color_replacement_tolerance_field_tests.rs @@ -65,6 +65,40 @@ fn a_fraction_stores_a_whole_number_and_text_changes_nothing() { assert_eq!(alert(&h), None, "and no alert"); } +/// Types `text` into the Tolerance field and leaves it there, with the keyboard. +fn type_without_leaving(h: &mut Harness<'static, PhotocraftApp>, text: &str) { + h.query_all_by_role(Role::SpinButton).find(|n| n.accesskit_node().label().as_deref() == Some("Tolerance:")).expect("the Tolerance field").click(); + h.run_steps(1); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); + h.event(egui::Event::Text(text.into())); + h.run_steps(1); +} + +#[test] +fn text_typed_over_a_value_past_the_range_does_not_alert() { + let mut h = bar(); + type_without_leaving(&mut h, "150"); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); + h.event(egui::Event::Text("abc".into())); + h.run_steps(1); + h.key_press(egui::Key::Tab); + h.run_steps(3); + assert_eq!(alert(&h), None, "the field is left holding text that is no number: no alert"); +} + +#[test] +fn a_tool_change_while_typing_past_the_range_still_alerts() { + let mut h = bar(); + type_without_leaving(&mut h, "150"); + assert_eq!(alert(&h), None, "not yet: the field still has the keyboard"); + // A tool shortcut the field passes by (a custom function key) changes the tool before the + // field is left, so the field is never drawn again. + h.state_mut().ui.tool = Tool::Brush; + h.run_steps(4); + assert_eq!(tolerance(&h), 100.0); + assert_eq!(alert(&h).as_deref(), Some(TOLERANCE_RANGE_ALERT), "the alert the field owed"); +} + #[test] fn the_bar_has_the_reference_tooltips() { for (role, label, tip) in [ diff --git a/crates/ui-egui/src/panels/options_bar.rs b/crates/ui-egui/src/panels/options_bar.rs index 76431408c3..a5211755a5 100644 --- a/crates/ui-egui/src/panels/options_bar.rs +++ b/crates/ui-egui/src/panels/options_bar.rs @@ -43,6 +43,7 @@ pub(super) fn options_bar_height(pro: bool, narrow: bool) -> f32 { pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { crate::vector_ui::track_arrow_default_selection(app, ui.ctx()); + crate::eraser_ui::alert_for_a_field_gone(app, ui.ctx()); let t = Tokens::get(ui.ctx()); let narrow = app.session.prefs().workspace.narrow_options_bar(); egui::Panel::top("options_bar") diff --git a/crates/ui-egui/src/tools/eraser_ui.rs b/crates/ui-egui/src/tools/eraser_ui.rs index 54b45a21b7..573bcb3835 100644 --- a/crates/ui-egui/src/tools/eraser_ui.rs +++ b/crates/ui-egui/src/tools/eraser_ui.rs @@ -227,7 +227,10 @@ pub(crate) fn whole_tolerance_field(ui: &mut egui::Ui, value: &mut f32, draft: & // An edit the field never got to finish (the options bar was hidden while it had the // keyboard) has nothing left to say once the field is entered again. if r.gained_focus() { - ui.ctx().data_mut(|d| d.remove_temp::(out_id)); + ui.ctx().data_mut(|d| { + d.remove_temp::(out_id); + d.remove_temp::(r.id.with("parses")); + }); } if r.changed() && typed.is_finite() { *value = typed.round().clamp(1.0, 100.0); @@ -243,10 +246,47 @@ pub(crate) fn whole_tolerance_field(ui: &mut egui::Ui, value: &mut f32, draft: & ui.ctx().data_mut(|d| d.remove_temp::<(f32, f32)>(draft_id)); } } + // Text replaced by something that is no number (`150`, then `abc`) leaves the value alone + // when the field is left, with no alert (#12268). + if r.has_focus() && widgets::typed_text_parses(ui.ctx(), r.id) == Some(false) { + ui.ctx().data_mut(|d| d.remove_temp::(out_id)); + } if !r.has_focus() { ui.ctx().data_mut(|d| d.remove_temp::<(f32, f32)>(draft_id)); } - r.lost_focus() && ui.ctx().data_mut(|d| d.remove_temp::(out_id)).unwrap_or(false) + let alert = r.lost_focus() && ui.ctx().data_mut(|d| d.remove_temp::(out_id)).unwrap_or(false); + // While a value typed out of range waits for the field to be left, the bar notes the pass, so + // a field that goes away instead (a tool shortcut that passes the field by) still alerts. + let owed = r.has_focus() && ui.ctx().data(|d| d.get_temp::(out_id)).unwrap_or(false); + let pass = ui.ctx().cumulative_pass_nr(); + ui.ctx().data_mut(|d| { + if owed { + d.insert_temp(range_alert_owed_id(), pass); + } else { + d.remove_temp::(range_alert_owed_id()); + } + }); + alert +} + +fn range_alert_owed_id() -> egui::Id { + egui::Id::unique("whole-tolerance-range-alert-owed") +} + +/// Shows [`TOLERANCE_RANGE_ALERT`] for a whole-number Tolerance field that went away in the last +/// pass while it held a value typed out of range (the tool changed by a shortcut, #12268). The +/// options bar calls it before drawing a tool's options. An edit older than that (the bar was +/// hidden meanwhile) is dropped, as the field drops it once entered again. +pub(crate) fn alert_for_a_field_gone(app: &mut PhotocraftApp, ctx: &egui::Context) { + let Some(seen) = ctx.data(|d| d.get_temp::(range_alert_owed_id())) else { return }; + let now = ctx.cumulative_pass_nr(); + if seen.saturating_add(1) >= now { + return; + } + ctx.data_mut(|d| d.remove_temp::(range_alert_owed_id())); + if seen.saturating_add(2) == now { + open_range_alert(app); + } } /// A Tolerance as the field stores it: a whole number in 1..=100, the default 50 for a value diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 1fb94c9d72..40c03c1231 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -50,8 +50,8 @@ pub(crate) use fields::compact_wrapping_value_field; #[cfg(test)] use fields::{FieldOpts, NUMERIC_INPUT_LIMIT, value_field_in}; pub use fields::{ - POPUP_ARROW_W, PopupFieldResponse, VALUE_FIELD_H, compact_value_field, elided_label, elided_label_sensed, popup_value_field, value_field, - value_field_named, value_field_whole_alerting, + POPUP_ARROW_W, PopupFieldResponse, VALUE_FIELD_H, compact_value_field, elided_label, elided_label_sensed, popup_value_field, typed_text_parses, + value_field, value_field_named, value_field_whole_alerting, }; pub(crate) use panel::body_margin; pub use panel::{ diff --git a/crates/ui-egui/src/widgets/fields.rs b/crates/ui-egui/src/widgets/fields.rs index 4c5cadb278..ca060e38ab 100644 --- a/crates/ui-egui/src/widgets/fields.rs +++ b/crates/ui-egui/src/widgets/fields.rs @@ -538,6 +538,8 @@ fn number_edit( }; let s = ready.as_str(); let v = if hold_typed { zoom_number(s, units) } else { parse_num_in(s, units) }; + // Whether the text being typed is a number at all, for [`typed_text_parses`]. + ctx.data_mut(|d| d.insert_temp(id.with("parses"), v.is_some())); if ctx.memory(|m| m.has_focus(id)) && plain(s).is_none() { // Still typing arithmetic: hold it, and stop applying keystrokes once it parses. ctx.data_mut(|d| d.insert_temp(held, v.is_some())); @@ -570,6 +572,12 @@ fn number_edit( resp } +/// Whether the text last typed into the number field `id` (a [`value_field`]'s response id) reads +/// as a number, an arithmetic entry included once it is complete: `None` before anything was typed. +pub fn typed_text_parses(ctx: &egui::Context, id: egui::Id) -> Option { + ctx.data(|d| d.get_temp::(id.with("parses"))) +} + /// The zoom a zoom field's text stands for, with at most one decimal separator (`,` or `.`): a /// number, or a ratio (`1/2` is 50 %, `5/2` is 250 %). `None` when it is neither, or holds a /// second separator (`zoom_field::read` takes that apart).