From 7baf93ecdb3ac06ff61db7b5e2e22036e9953237 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 12:40:10 -0400 Subject: [PATCH] LR-BEHAV-TEXTFIELD: a slider's typed value is a shared text field The value typed after clicking a slider's number gets the Cut / Copy / Paste / Select All menu (`sliderField:`), and using the menu no longer closes the field: it used to close as soon as anything took the focus. Selecting the old value and Esc now come from the shared field; `TextFieldResponse::editing` says an edit is under way, the menu included. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/headless.rs | 22 ++++++++++++++++++++++ crates/ui-egui/src/text_field.rs | 5 ++++- crates/ui-egui/src/widgets.rs | 28 +++++++++++----------------- 3 files changed, 37 insertions(+), 18 deletions(-) diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index 8b326a06b..788811fab 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -2005,6 +2005,28 @@ mod tests { assert_eq!(h.app.session.undo.len(), undo0 + 1); } + /// A slider's typed value is a shared text field: right-click ▸ Paste replaces the value + /// (selected when the field opens) and Return applies it; the menu doesn't close the field. + #[test] + fn a_slider_value_pastes_from_its_menu() { + let mut h = demo([1300.0, 900.0]); + let t = Duration::from_secs(10); + h.request("ui.set", json!({"view": "detail", "right": "edit"}), t); + h.settle(SETTLE); + h.view.clipboard = "0.75".into(); + for (id, button) in + [("sliderValue:light.exposure", "left"), ("sliderField:light.exposure", "right"), ("sliderField:light.exposure:paste", "left")] + { + let r = h.request("ui.clickWidget", json!({"id": id, "button": button}), t); + assert_eq!(r["ok"], true, "{id}: {r}"); + h.settle(SETTLE); + } + h.request("ui.key", json!({"key": "Enter"}), t); + h.settle(SETTLE); + let exposure = h.app.session.develop_of(h.app.session.active().unwrap()).unwrap().light.exposure; + assert!((exposure - 0.75).abs() < 1e-9, "{exposure}"); + } + /// The eye on a section header switches the section off and on again, one undo step each /// (issue #316). #[test] diff --git a/crates/ui-egui/src/text_field.rs b/crates/ui-egui/src/text_field.rs index 8174d5072..960170eeb 100644 --- a/crates/ui-egui/src/text_field.rs +++ b/crates/ui-egui/src/text_field.rs @@ -25,6 +25,8 @@ pub struct TextFieldResponse { pub response: Response, /// Set on the frame an edit ends. pub ending: Option, + /// An edit is under way: the field has the focus, or its menu does. + pub editing: bool, } impl TextFieldResponse { @@ -221,8 +223,9 @@ impl<'a> TextField<'a> { if ending.is_some() { memo = Memo::default(); } + let editing = ending.is_none() && (response.has_focus() || memo.away || memo.menu_open || memo.action.is_some()); ui.data_mut(|d| d.insert_temp(id.with("memo"), memo)); - TextFieldResponse { response, ending } + TextFieldResponse { response, ending, editing } } } diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index c98baf41f..2517df108 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -181,7 +181,6 @@ pub fn slider(ui: &mut Ui, spec: &ControlSpec, value: f64, enabled: bool, label_ let value_resp = ui.interact(value_rect, id.with("value"), if enabled { Sense::click() } else { Sense::hover() }); register(ui.ctx(), format!("sliderValue:{}", spec.id), value_rect); let typing_id = id.with("typing"); - let field_id = id.with("typingField"); // the text being typed and how many frames the field has been up: it takes the keyboard on its // first frames (not on the click's own, whose release would take the focus straight back) let mut typing: Option<(String, u8)> = ui.data(|m| m.get_temp(typing_id)); @@ -194,28 +193,23 @@ pub fn slider(ui: &mut Ui, spec: &ControlSpec, value: f64, enabled: bool, label_ let from_x = |x: f32| spec.min + ((x - track_rect.left()) / track_rect.width()).clamp(0.0, 1.0) as f64 * span; let mut v = value; if let Some((mut text, frames)) = typing.take() { - let field = egui::TextEdit::singleline(&mut text) - .id(field_id) - .font(t.font(12.5)) - .horizontal_align(egui::Align::RIGHT) - .desired_width(value_rect.width()) - .margin(egui::Margin::ZERO); // a child over the value, so the field never moves the rows around it let mut child = ui.new_child(egui::UiBuilder::new().max_rect(value_rect).layout(egui::Layout::right_to_left(egui::Align::Center))); - let te = child.add(field); + // the old value is selected when the field takes the focus, so typing replaces it + let field = crate::text_field::TextField::singleline(&format!("sliderField:{}", spec.id), &mut text) + .font(t.font(12.5)) + .horizontal_align(egui::Align::RIGHT) + .width(value_rect.width()) + .margin(egui::Margin::ZERO) + .select_on_focus(true) + .show(&mut child); if frames < 2 { - te.request_focus(); - // the old value is selected, so typing replaces it (set again once the field has the - // focus, which places the cursor) - let mut state = egui::text_edit::TextEditState::load(ui.ctx(), field_id).unwrap_or_default(); - let all = egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(text.chars().count())); - state.cursor.set_char_range(Some(all)); - state.store(ui.ctx(), field_id); + field.response.request_focus(); typing = Some((text.clone(), frames + 1)); ui.data_mut(|m| m.insert_temp(typing_id, (text, frames + 1))); - } else if !te.has_focus() || !enabled { + } else if field.ending.is_some() || !field.editing || !enabled { // Esc (or the slider turning off) keeps the old value; Return or clicking away applies - let cancelled = !enabled || ui.input(|i| i.key_pressed(egui::Key::Escape)); + let cancelled = !enabled || field.cancelled(); if let Some(nv) = typed_value(spec, &text).filter(|nv| !cancelled && (nv - value).abs() > 1e-12) { out.value = Some(nv); out.drag_started = true;