PR #12268: Tolerance range alert follows the last typed text and a field that goes away

CodeRabbit's two notes on the shared whole-number Tolerance field (Color
Replacement and Background Eraser):

- typing 150, then replacing it with "abc" and leaving the field still
  opened the range alert; text that is no number now clears the owed alert
  (number fields note whether their typed text parses);
- typing 150 and changing the tool by a shortcut the field passes by
  clamped the value without the alert; the options bar now shows the alert
  the field owed when the field was not drawn again.

Co-Authored-By: Hector Villarreal <1501713+hectorviov@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
committed 2026-11-09 17:44:10 +01:00
1 parent 42ebea1c9f
commit c36d5ae588
5 files changed
+87 -4

No files matched your search

@@ -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 [
+1
View File
@@ -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")
+42 -2
View File
@@ -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::<bool>(out_id));
ui.ctx().data_mut(|d| {
d.remove_temp::<bool>(out_id);
d.remove_temp::<bool>(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::<bool>(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::<bool>(out_id)).unwrap_or(false)
let alert = r.lost_focus() && ui.ctx().data_mut(|d| d.remove_temp::<bool>(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::<bool>(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::<u64>(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::<u64>(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::<u64>(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
+2 -2
View File
@@ -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::{
+8
View File
@@ -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<bool> {
ctx.data(|d| d.get_temp::<bool>(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).