From 5a60d94f2ff53b88a787f801ae16b23fc189edda Mon Sep 17 00:00:00 2001 From: sanyamsingh001 <314051937+sanyamsingh001@users.noreply.github.com> Date: Sun, 11 Oct 2026 10:55:33 +0000 Subject: [PATCH] KEY-SLIDER-TYPE: fix immediate numeric entry and Reset precedence Honor Enter/Escape on the completed opening release, release focus for a cancelled opening click, and keep a numeric Escape batch out of app shortcuts. Defer the Edit panel's clicked Reset until after its controls so the normal click-away commit precedes the explicit reset. Retain the original Numeric regressions and the single active-draft Reset regression. Co-authored-by: Codex --- crates/ui-egui/src/headless.rs | 44 +++++++++++ crates/ui-egui/src/panels/edit.rs | 7 +- crates/ui-egui/src/shortcuts.rs | 4 +- crates/ui-egui/src/tests_keymap.rs | 61 +++++++++++++++ crates/ui-egui/src/text_field.rs | 24 ++++++ crates/ui-egui/src/widgets.rs | 42 +++++++--- .../src/widgets/tests_numeric_entry.rs | 77 +++++++++++++++++++ docs/parity-checklist.md | 5 +- 8 files changed, 248 insertions(+), 16 deletions(-) create mode 100644 crates/ui-egui/src/widgets/tests_numeric_entry.rs diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index c423bc35a..cfe6d4640 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -2225,6 +2225,50 @@ mod tests { assert_eq!(h.app.session.undo.len(), undo0 + 1); } + /// Reset takes precedence over the value being typed when its button is clicked. + #[test] + fn reset_button_wins_over_an_active_numeric_draft() { + let mut h = demo([960.0, 600.0]); + let t = Duration::from_secs(10); + let ask = |h: &mut Headless, method: &str, params: serde_json::Value| { + let r = h.request(method, params, t); + assert_eq!(r["ok"], true, "{method}: {r}"); + r["result"].clone() + }; + ask(&mut h, "engine.execute", json!({"command": "app.gpu", "params": {"enabled": false}})); + ask(&mut h, "ui.set", json!({"settings": {"gpu": false, "previewEdge": 1280}, "presets": false})); + ask(&mut h, "engine.execute", json!({"command": "library.select", "params": {"ids": [1], "active": 1}})); + ask( + &mut h, + "ui.set", + json!({"view": "detail", "right": "edit", "leftPanel": false, "rightWidth": 330, "openSections": ["light"], "histogram": false}), + ); + let active = h.app.session.active().unwrap(); + for (text, terminal) in [("0.75", "Enter"), ("-1,5", "Escape")] { + ask(&mut h, "ui.clickWidget", json!({"id": "sliderValue:light.exposure"})); + ask(&mut h, "ui.text", json!({"text": text})); + ask(&mut h, "ui.key", json!({"key": terminal})); + let value = ask(&mut h, "engine.execute", json!({"command": "develop.get", "params": {}})); + assert_eq!(value["light"]["exposure"].as_f64(), Some(0.75), "{terminal}: {value}"); + } + ask(&mut h, "ui.clickWidget", json!({"id": "sliderValue:light.exposure"})); + ask(&mut h, "ui.text", json!({"text": "2.0"})); + assert_eq!(h.view.ctx.memory(|m| m.focused()), Some(crate::text_field::id("sliderField:light.exposure"))); + assert!(h.app.widgets.iter().any(|(id, _)| id == "sliderField:light.exposure")); + assert!(h.painted_text().iter().any(|text| text == "2.0"), "the replacement draft must be visible before Reset"); + assert_eq!(h.app.session.develop_of(active).unwrap().light.exposure, 0.75, "the draft is not committed yet"); + ask(&mut h, "ui.clickWidget", json!({"id": "button:reset"})); + let reset = ask(&mut h, "engine.execute", json!({"command": "develop.get", "params": {}})); + assert_eq!( + reset["light"]["exposure"].as_f64(), + Some(0.0), + "Reset must win over the active numeric draft: {reset}; history={:?}; last_inverse={:?}; focus={:?}", + h.app.session.undo.iter().map(|entry| entry.label.as_str()).collect::>(), + h.app.session.undo.last().map(|entry| &entry.op), + h.view.ctx.memory(|m| m.focused()), + ); + } + /// 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] diff --git a/crates/ui-egui/src/panels/edit.rs b/crates/ui-egui/src/panels/edit.rs index 56f405ea3..a35455453 100644 --- a/crates/ui-egui/src/panels/edit.rs +++ b/crates/ui-egui/src/panels/edit.rs @@ -92,6 +92,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { soft_proofing(app, ui, id); } // header: Edit + Auto / B&W / HDR + let mut reset_clicked = false; let (hr, _) = ui.allocate_exact_size(vec2(ui.available_width(), 40.0), Sense::hover()); ui.painter().text(pos2(hr.left() + 24.0, hr.bottom() - 10.0), Align2::LEFT_CENTER, crate::i18n::tr("Edit"), t.semibold(15.0), t.text); egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 8, bottom: 14 }).show(ui, |ui| { @@ -108,7 +109,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if text_button(ui, "reset", crate::i18n::tr("Reset"), false).on_hover_text(crate::i18n::tr("Reset all edits (Cmd+Shift+R)")).clicked() { - let _ = app.run("develop.reset", json!({})); + reset_clicked = true; } }); }); @@ -373,6 +374,10 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { ui.add_space(8.0); }); ui.add_space(40.0); + // A pending numeric edit commits on blur before the explicit Reset takes precedence. + if reset_clicked { + let _ = app.run("develop.reset", json!({})); + } } /// The profile dropdown: Favorites, Recent, one submenu per group, then favourite toggle and diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 7771ffb58..5380aba7a 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -304,7 +304,9 @@ fn grid_bracket_command(keymap: &Keymap, grid: bool, id: &'static str, shortcut: /// Dock chrome owns activation, traversal, resizing and cancellation; its other keys reach commands. fn keyboard_owned(app: &LightcraftApp, ctx: &egui::Context) -> bool { if !ctx.egui_wants_keyboard_input() { - return false; + // Escape drops egui focus before shortcuts run; keep the numeric edit's whole input batch. + return ctx.input(|input| input.key_pressed(Key::Escape)) + && app.widgets.iter().any(|(widget, _)| widget.starts_with("sliderField:") && crate::text_field::was_editing(ctx, widget)); } let chrome = app .ui diff --git a/crates/ui-egui/src/tests_keymap.rs b/crates/ui-egui/src/tests_keymap.rs index 0bd55f413..1b04a46bd 100644 --- a/crates/ui-egui/src/tests_keymap.rs +++ b/crates/ui-egui/src/tests_keymap.rs @@ -176,3 +176,64 @@ fn shift_insert_pastes_plainly() { assert_eq!(h.app.ui.dialog, None, "no Paste Selected Settings"); assert_eq!(h.app.session.undo.len(), undo + 1, "the settings were pasted"); } +/// The opening release can carry both physical shortcut keys and the replacement text. +#[test] +fn opening_numeric_release_keeps_keys_in_the_editor() { + use egui::{Event, Key, Modifiers, PointerButton}; + + for terminal in [Key::Enter, Key::Escape] { + let mut h = demo(); + h.app.run("photo.rate", json!({"rating": 0})).unwrap(); + h.app.run("develop.set", json!({"values": {"light.exposure": 0.7}})).unwrap(); + h.app.run("app.setShortcut", json!({"id": "photo.increaseRating", "shortcut": "Enter"})).unwrap(); + let r = h.request("ui.set", json!({"view": "detail", "right": "edit", "openSections": ["light"], "histogram": false}), T); + assert_eq!(r["ok"], true, "{r}"); + assert!(h.settle(SETTLE)); + assert!(h.app.fonts_ready); + assert!(h.app.recording_shortcut.is_none() && h.app.ui.name_edit.is_none()); + assert!(h.app.native_shortcuts.is_empty() && h.app.synthetic.is_empty() && h.events.is_empty()); + assert!(!h.view.ctx.egui_wants_keyboard_input()); + assert_eq!(crate::shortcuts::shortcut_of(&h.app.ui.settings.keymap, "photo.increaseRating"), Some("Enter")); + let active = h.app.session.active().unwrap(); + let selection = h.app.session.selection.ids.clone(); + let (view, right, tool) = (h.app.ui.view, h.app.ui.right, h.app.ui.tool.clone()); + assert!(h.app.ui.dialog.is_none()); + assert_eq!(h.app.session.catalog.photo(active).unwrap().rating, 0); + assert!((h.app.session.develop_of(active).unwrap().light.exposure - 0.7).abs() < 1e-9); + let (undo, redo) = (h.app.session.undo.len(), h.app.session.redo.len()); + let pos = h.app.widgets.iter().rev().find(|(id, _)| id == "sliderValue:light.exposure").unwrap().1.center(); + h.events.push(Event::PointerMoved(pos)); + h.step(); + h.events.push(Event::PointerButton { pos, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.step(); + assert!(h.view.ctx.egui_wants_keyboard_input(), "the press focuses the editor before app shortcuts run"); + h.events.extend([ + Event::PointerButton { pos, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }, + Event::Key { key: Key::Num2, physical_key: Some(Key::Num2), pressed: true, repeat: false, modifiers: Modifiers::NONE }, + Event::Text("2".into()), + Event::Key { key: terminal, physical_key: Some(terminal), pressed: true, repeat: false, modifiers: Modifiers::NONE }, + ]); + h.step(); + // Observe the consuming frame directly, then exactly one empty frame: no request drains. + for next_frame in [false, true] { + if next_frame { + assert!(h.events.is_empty() && h.app.synthetic.is_empty()); + h.step(); + } + let expected = if terminal == Key::Enter { 2.0 } else { 0.7 }; + assert!((h.app.session.develop_of(active).unwrap().light.exposure - expected).abs() < 1e-9, "{terminal:?}, next={next_frame}"); + assert_eq!(h.app.session.undo.len(), undo + usize::from(terminal == Key::Enter), "{terminal:?}, next={next_frame}"); + assert_eq!(h.app.session.redo.len(), redo); + assert_eq!(h.app.session.catalog.photo(active).unwrap().rating, 0, "neither Num2 nor the saved Enter shortcut leaked"); + assert_eq!(h.app.session.active(), Some(active)); + assert_eq!(h.app.session.selection.ids, selection); + assert_eq!((h.app.ui.view, h.app.ui.right, &h.app.ui.tool), (view, right, &tool)); + assert!(h.app.ui.dialog.is_none()); + assert!(!h.view.ctx.egui_wants_keyboard_input()); + if next_frame { + assert!(h.app.widgets.iter().all(|(id, _)| id != "sliderField:light.exposure"), "the editor is gone"); + assert!(h.app.widgets.iter().any(|(id, _)| id == "sliderValue:light.exposure")); + } + } + } +} diff --git a/crates/ui-egui/src/text_field.rs b/crates/ui-egui/src/text_field.rs index d9e5dfcf3..d22b3b939 100644 --- a/crates/ui-egui/src/text_field.rs +++ b/crates/ui-egui/src/text_field.rs @@ -53,6 +53,7 @@ pub struct TextField<'a> { hint: Option, width: Option, select_on_focus: bool, + begin_edit: bool, frame: bool, font: Option, color: Option, @@ -66,6 +67,12 @@ pub fn id(widget: &str) -> egui::Id { egui::Id::new("lc-text-field").with(widget) } +/// Whether the field had an unfinished edit on the immediately preceding egui pass. +pub(crate) fn was_editing(ctx: &egui::Context, widget: &str) -> bool { + let pass = ctx.cumulative_pass_nr(); + ctx.data(|d| d.get_temp::(id(widget).with("memo")).is_some_and(|memo| memo.before.is_some() && memo.pass.checked_add(1) == Some(pass))) +} + impl<'a> TextField<'a> { /// A one-line field (Return ends the edit) registered as `widget`. pub fn singleline(widget: &'a str, text: &'a mut String) -> Self { @@ -76,6 +83,7 @@ impl<'a> TextField<'a> { hint: None, width: None, select_on_focus: false, + begin_edit: false, frame: true, font: None, color: None, @@ -139,6 +147,12 @@ impl<'a> TextField<'a> { self } + /// Initialize a newly revealed value editor before its first input is processed. + pub(crate) fn begin_edit(mut self, on: bool) -> Self { + self.begin_edit = on; + self + } + pub fn show(self, ui: &mut Ui) -> TextFieldResponse { let id = id(self.widget); let mut memo: Memo = ui.data_mut(|d| d.get_temp(id.with("memo"))).unwrap_or_default(); @@ -149,6 +163,12 @@ impl<'a> TextField<'a> { memo = Memo::default(); } memo.pass = pass; + if self.begin_edit { + memo.before = Some(self.text.clone()); + memo.select_on_release = false; + ui.memory_mut(|m| m.request_focus(id)); + select_all(ui.ctx(), id, self.text); + } // a choice from the menu last frame: the field takes the focus back and does it, with // egui's own handling of the clipboard events (a password is never copied) if let Some(action) = memo.action.take() { @@ -205,6 +225,10 @@ impl<'a> TextField<'a> { // egui's own focus: `Response::has_focus` is also false while the window is in the // background (another app in front), which neither ends nor restarts an edit let focused = ui.memory(|m| m.has_focus(id)); + // The opening press may place a cursor; keep the original selected, never replacement text. + if self.begin_edit && focused && !response.changed() { + select_all(ui.ctx(), id, self.text); + } // an edit not under way (none, or one cut short when the field went away) is forgotten let in_menu = memo.away || memo.menu_open || memo.action.is_some(); diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 4281c28ec..b20b7bf80 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -195,18 +195,31 @@ pub fn slider(ui: &mut Ui, spec: &ControlSpec, value: f64, enabled: bool, label_ // say that an exact value can be typed here (issue #534: an exact crop angle was hard to find) let value_resp = if enabled { value_resp.on_hover_text(crate::i18n::tr("Click to type a value")) } else { value_resp }; let typing_id = id.with("typing"); - // 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)); - if enabled && typing.is_none() && value_resp.clicked() { - typing = Some((shown_value(spec, value).trim_start_matches('+').to_string(), 0)); + // Keep the short focus-acquisition guard, but let an explicit Return/Escape end the edit. + let mut typing: Option<(String, u8, bool)> = ui.data(|m| m.get_temp(typing_id)); + let value_pressed = value_resp.is_pointer_button_down_on() && ui.input(|i| i.pointer.button_pressed(egui::PointerButton::Primary)); + // Focus on the owned primary press before fast typing; retain the readout's click activation. + if enabled && typing.is_none() && (value_resp.clicked() || value_pressed) { + typing = Some((shown_value(spec, value).trim_start_matches('+').to_string(), 0, value_pressed)); + } + let value_released = ui.input(|i| i.pointer.button_released(egui::PointerButton::Primary)); + // The opening press must complete the readout's original click before the edit continues. + if value_released && typing.as_ref().is_some_and(|(_, _, pressed)| *pressed) && !value_resp.clicked() { + typing = None; + ui.data_mut(|m| m.remove::<(String, u8, bool)>(typing_id)); + ui.memory_mut(|m| m.surrender_focus(crate::text_field::id(&format!("sliderField:{}", spec.id)))); } let mut out = SliderOut::default(); let span = (spec.max - spec.min).max(1e-9); let to_x = |v: f64| track_rect.left() + ((v - spec.min) / span).clamp(0.0, 1.0) as f32 * track_rect.width(); 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() { + if let Some((mut text, frames, pressed)) = typing.take() { + // Keep the owned opening release focused without restarting its draft or selection. + if pressed && value_released { + ui.memory_mut(|m| m.request_focus(crate::text_field::id(&format!("sliderField:{}", spec.id)))); + } + let pressed = pressed && !value_released; // 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))); // the old value is selected when the field takes the focus, so typing replaces it @@ -216,11 +229,12 @@ pub fn slider(ui: &mut Ui, spec: &ControlSpec, value: f64, enabled: bool, label_ .width(value_rect.width()) .margin(egui::Margin::ZERO) .select_on_focus(true) + .begin_edit(frames == 0) .show(&mut child); - if frames < 2 { + if frames < 2 && !matches!(field.ending, Some(crate::text_field::Ending::Return | crate::text_field::Ending::Escape)) { field.response.request_focus(); - typing = Some((text.clone(), frames + 1)); - ui.data_mut(|m| m.insert_temp(typing_id, (text, frames + 1))); + typing = Some((text.clone(), frames + 1, pressed)); + ui.data_mut(|m| m.insert_temp(typing_id, (text, frames + 1, pressed))); } 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 || field.cancelled(); @@ -230,10 +244,10 @@ pub fn slider(ui: &mut Ui, spec: &ControlSpec, value: f64, enabled: bool, label_ out.drag_stopped = true; v = nv; } - ui.data_mut(|m| m.remove::<(String, u8)>(typing_id)); + ui.data_mut(|m| m.remove::<(String, u8, bool)>(typing_id)); } else { - typing = Some((text.clone(), frames)); - ui.data_mut(|m| m.insert_temp(typing_id, (text, frames))); + typing = Some((text.clone(), frames, pressed)); + ui.data_mut(|m| m.insert_temp(typing_id, (text, frames, pressed))); } } else if resp.double_clicked() || label_resp.double_clicked() { out.reset = true; @@ -635,3 +649,7 @@ pub(crate) fn elide_head(text: &str, max: f32, width: impl Fn(&str) -> f32) -> S } "…".to_string() } + +#[cfg(test)] +#[path = "widgets/tests_numeric_entry.rs"] +mod numeric_entry_tests; diff --git a/crates/ui-egui/src/widgets/tests_numeric_entry.rs b/crates/ui-egui/src/widgets/tests_numeric_entry.rs new file mode 100644 index 000000000..1935cde95 --- /dev/null +++ b/crates/ui-egui/src/widgets/tests_numeric_entry.rs @@ -0,0 +1,77 @@ +//! Real egui pointer and keyboard frames on the production slider readout. +use super::{SliderOut, slider, take_registry}; +use crate::headless::HeadlessView; +use egui::{Event, Key, Modifiers, PointerButton, Rect, vec2}; +use lightcraft_develop::{ControlSpec, controls}; + +struct Harness { + view: HeadlessView, + spec: ControlSpec, + value: f64, + enabled: bool, + time: f64, + rect: Rect, +} +impl Harness { + fn new() -> Self { + let mut h = Self { + view: HeadlessView::new(), + spec: *controls::find("light.exposure").unwrap(), + value: 0.71, + enabled: true, + time: 0.0, + rect: Rect::NOTHING, + }; + for _ in 0..3 { + h.frame(vec![]); + } + h + } + fn frame(&mut self, events: Vec) -> SliderOut { + self.time += 0.02; + let mut out = SliderOut::default(); + self.view.run(HeadlessView::raw_input(vec2(330.0, 100.0), 1.0, self.time, events), |ui| { + out = slider(ui, &self.spec, self.value, self.enabled, None); + }); + self.rect = take_registry(&self.view.ctx).into_iter().find(|(id, _)| id == &format!("sliderValue:{}", self.spec.id)).unwrap().1; + if let Some(v) = out.value { + self.value = v; + } + out + } + fn key(&mut self, key: Key) -> SliderOut { + self.frame(vec![Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }]) + } +} + +#[test] +fn opening_release_honors_return_and_escape_immediately() { + for key in [Key::Enter, Key::Escape] { + let mut h = Harness::new(); + let pos = h.rect.center(); + h.frame(vec![Event::PointerMoved(pos)]); + h.frame(vec![Event::PointerButton { pos, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }]); + let out = h.frame(vec![ + Event::PointerButton { pos, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }, + Event::Text("2".into()), + Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }, + ]); + assert_eq!(out.value, (key == Key::Enter).then_some(2.0)); + assert!(!h.view.ctx.egui_wants_keyboard_input()); + assert!(h.key(Key::Enter).value.is_none(), "no retired draft can commit later"); + } +} + +#[test] +fn dragging_away_from_readout_does_not_capture_keyboard() { + let mut h = Harness::new(); + let inside = h.rect.center(); + let outside = egui::pos2(5.0, 80.0); + let button = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }; + for events in [vec![Event::PointerMoved(inside)], vec![button(inside, true)], vec![Event::PointerMoved(outside)], vec![button(outside, false)]] { + let out = h.frame(events); + assert_eq!(out.value, None, "a cancelled readout gesture must not emit a value"); + assert_eq!(h.value, 0.71, "a cancelled readout gesture must preserve the stored value"); + } + assert!(!h.view.ctx.egui_wants_keyboard_input(), "a cancelled readout gesture must not retain keyboard focus"); +} diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 099411089..7963498a6 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (rapid slider entry) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good @@ -656,7 +656,7 @@ From `06-shortcuts.md` part 1. Evidence is our binding; conflicts are explained | KEY-STRAIGHTEN | Straighten while held — ⌘ drag | P1 | ✅ | `cmd:crop.straighten`, `crates/ui-egui/src/panels/detail.rs` (`straighten_overlay`) | ⌘-drag in Crop draws a level line; the crop tool stays active | | KEY-SLIDER-RESET | Reset slider — double-click | P0 | ✅ | `crates/ui-egui/src/widgets.rs` | | | KEY-SLIDER-NUDGE | Nudge slider — ↑/↓ | P1 | ✅ | `crates/ui-egui/src/widgets.rs` (`nudged`), `cmd:develop.adjust` | ↑/↓ over any slider: ≈ 1/200 of its range (exposure 0.05, most sliders 1, temperature 50 K); ⇧ ×5; one undo step each | -| KEY-SLIDER-TYPE | Type a slider value | P1 | ✅ | `crates/ui-egui/src/widgets.rs` (`typed_value`) | click the value next to any slider's name and type one (`1.5`, `+0,25`, `-20`, `5600 K`): Return or clicking away applies it on the control's steps and range as one undo step, Esc keeps the old value; issue #322 | +| KEY-SLIDER-TYPE | Type a slider value | P1 | ✅ | `crates/ui-egui/src/widgets.rs` (`typed_value`), `crates/ui-egui/src/text_field.rs`, `crates/ui-egui/src/widgets/tests_numeric_entry.rs` (`opening_release_honors_return_and_escape_immediately`), `crates/ui-egui/src/tests_keymap.rs` (`opening_numeric_release_keeps_keys_in_the_editor`) | click the value next to any slider's name and type one (`1.5`, `+0,25`, `-20`, `5600 K`): Return or clicking away applies it on the control's steps and range as one undo step, Esc keeps the old value; issue #322; Return/Escape take effect on the opening release, retaining the stored value on Escape; typing stays in the editor without triggering global shortcuts | | KEY-SHORTCUTS | Shortcut list — ⌘/ | P1 | ✅ | `cmd:app.shortcuts` | | | KEY-HELP | Help — F1 | P2 | ✅ | `cmd:app.help` | | | KEY-VIDEO-PLAY | Play/pause video — Space | P1 | ⬜ | | | @@ -776,5 +776,6 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | KEY-SLIDER-TYPE: honor immediate Return/Escape and keep typed keys in the value editor; add regression references | | 2026-10-10 | minor | Renamed from `parity.md` (progress-docs standard); Top gaps moved to gaps.md (its duplicated items 1–2 from a bad merge resolved there), shortcut notes moved to ui-parity.md. Rows unchanged; a recount of the rows gives 81.1% of 517 in-scope rows (P0 98.0%, P1 95.1%, P2 46.9%), so the generated summary above is due a `cargo xtask parity --write` | | 2026-09-30 – 2026-10-10 | — | Rows added and updated with each feature (see `git log -- docs/parity.md docs/parity-checklist.md`) |