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:<control>`), 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 <noreply@anthropic.com>
This commit is contained in:
Nathaniel Ledford
2026-10-09 13:23:10 -04:00
co-authored by Claude Opus 5.5
parent 4efd92b38f
commit 7baf93ecdb
3 changed files with 37 additions and 18 deletions
+22
View File
@@ -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]
+4 -1
View File
@@ -25,6 +25,8 @@ pub struct TextFieldResponse {
pub response: Response,
/// Set on the frame an edit ends.
pub ending: Option<Ending>,
/// 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 }
}
}
+11 -17
View File
@@ -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;