mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-26 19:17:02 -06:00
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 <codex@openai.com>
This commit is contained in:
@@ -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::<Vec<_>>(),
|
||||
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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,6 +53,7 @@ pub struct TextField<'a> {
|
||||
hint: Option<String>,
|
||||
width: Option<f32>,
|
||||
select_on_focus: bool,
|
||||
begin_edit: bool,
|
||||
frame: bool,
|
||||
font: Option<egui::FontId>,
|
||||
color: Option<egui::Color32>,
|
||||
@@ -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::<Memo>(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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<Event>) -> 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");
|
||||
}
|
||||
@@ -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`) |
|
||||
|
||||
Reference in New Issue
Block a user