From 56e65bf49f622cdffe4fb8f270cb6e0a69740aa3 Mon Sep 17 00:00:00 2001 From: Atmik Shetty Date: Mon, 12 Oct 2026 11:21:09 +0530 Subject: [PATCH] KEYC-PANELS: F8 toggles the right panel, Shift+Tab hides and restores all panels View > Right Panel (view.rightPanel, F8) hides the right panel and shows the one that was there again (Edit when nothing was remembered; Crop, Remove, Masking and Red Eye end when hidden and come back as Edit). View > Hide All Panels (view.allPanels, Shift+Tab) hides the left sidebar, the right panel, the Presets column and the filmstrip; the next press brings back exactly what was shown, so a panel that was already hidden stays hidden. Showing any panel by hand while they are hidden makes the snapshot stale: the next Shift+Tab hides again instead of resurrecting old state. Both commands take an optional show / hide parameter and are checked in the View menu while they apply. The top bar and tool strip stay. Shift+Tab is also egui's "focus previous widget", set when the pass begins and without consuming the key; with nothing focused it focused the loupe. The shortcut consumes the key and cancels the traversal. In a focused text field neither key is a shortcut, and Shift+Tab keeps moving focus as before. On a Mac keyboard F8 is Play/Pause unless fn is held or "Use F1, F2, ... as standard function keys" is on. Docs: KEYC-PANELS is partial (Tab, T, F5 and solo remain; F6 / F7 are in PR #1078), KEYC-HELP is done (F1), KEYC-RATING's missing list drops the already implemented [ ] keys, two View menu rows are added, gap 28 shrinks, and the parity summary is refreshed with cargo xtask parity --write (it was already stale on main). The native menu leaves Shift+Tab to egui (no accelerator, so the macOS menu shows no key hint) and the native_menu accelerator test lists it with the other keys egui handles. --- apps/lightcraft/src/native_menu.rs | 6 +- crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/menubar.rs | 4 + crates/ui-egui/src/menus.rs | 30 +++ crates/ui-egui/src/shortcuts.rs | 7 + crates/ui-egui/src/state.rs | 94 +++++++++ crates/ui-egui/src/tests_panel_keys.rs | 261 +++++++++++++++++++++++++ docs/gaps.md | 5 +- docs/parity-checklist.md | 19 +- docs/ui-parity.md | 14 +- 10 files changed, 424 insertions(+), 18 deletions(-) create mode 100644 crates/ui-egui/src/tests_panel_keys.rs diff --git a/apps/lightcraft/src/native_menu.rs b/apps/lightcraft/src/native_menu.rs index 256faa1f..383d4380 100644 --- a/apps/lightcraft/src/native_menu.rs +++ b/apps/lightcraft/src/native_menu.rs @@ -26,8 +26,10 @@ const QUIT: &str = "app.quit"; const SETTINGS: &str = "app.settings"; const SETTINGS_KEY: &str = "Cmd+,"; /// Keys whose meaning depends on the panel (X = reject or swap crop aspect, ⌫ = delete the photo -/// or the active mask): left to the egui handler, which knows the context. -const CONTEXTUAL: &[&str] = &["X", "Delete"]; +/// or the active mask): left to the egui handler, which knows the context. Shift+Tab (Hide All +/// Panels) stays there too: it has no native accelerator, and the egui handler cancels egui's own +/// Shift+Tab focus traversal when it fires. +const CONTEXTUAL: &[&str] = &["X", "Delete", "Shift+Tab"]; /// Shortcuts text fields need while they have focus. const TEXT_EDIT: &[&str] = &["Cmd+A", "Cmd+C", "Cmd+V", "Cmd+X", "Cmd+Z", "Cmd+Shift+Z"]; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 9a387c77..b5f50a66 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -98,6 +98,8 @@ mod tests_offline; #[cfg(test)] mod tests_open_lightroom; #[cfg(test)] +mod tests_panel_keys; +#[cfg(test)] mod tests_panels; #[cfg(test)] mod tests_preset_import; diff --git a/crates/ui-egui/src/menubar.rs b/crates/ui-egui/src/menubar.rs index 58bcb390..bff774e8 100644 --- a/crates/ui-egui/src/menubar.rs +++ b/crates/ui-egui/src/menubar.rs @@ -129,6 +129,7 @@ const LAYOUT: &[(&str, &[&str])] = &[ "view.people", "---", "view.leftPanel", + "view.rightPanel", "@Sidebar Sections", "view.photoCounts", "view.faceBoxes", @@ -138,6 +139,7 @@ const LAYOUT: &[(&str, &[&str])] = &[ "view.infoOverlay", "@Appearance", "---", + "view.allPanels", "view.fullScreenPreview", "view.lightsOut", "view.enterFullScreen", @@ -313,6 +315,8 @@ pub fn checked(app: &LightcraftApp, id: &str) -> Option { "view.people" => Some(u.view == ViewMode::People), "view.reference" => Some(u.view == ViewMode::Reference), "view.leftPanel" => Some(u.left_panel), + "view.rightPanel" => Some(u.right != RightPanel::None), + "view.allPanels" => Some(u.all_panels_hidden()), "view.faceBoxes" => Some(u.face_boxes), "view.filmstrip" => Some(u.filmstrip), "view.histogram" => Some(u.histogram), diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index daf0f08e..086cc2d3 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -73,6 +73,10 @@ pub const UI_COMMANDS: &[UiCommand] = &[ ("view.autoAdvance", "Auto Advance", None, "Photo"), ("view.filmstrip", "Filmstrip", Some("/"), "View"), ("view.leftPanel", "My Photos Panel", Some("Cmd+Shift+L"), "View"), + // F8 / Shift+Tab as in Lightroom Classic (F7 and F6 are PR #1078; Tab toggles the side panels there). + // On a Mac keyboard F8 is Play/Pause unless fn is held or "Use F1, F2, … as standard function keys" is on. + ("view.rightPanel", "Right Panel", Some("F8"), "View"), + ("view.allPanels", "Hide All Panels", Some("Shift+Tab"), "View"), ("view.beforeAfter", "Compare Before and After", Some("Y"), "View"), ("view.beforeAfterSplit", "Before/After Split", Some("Shift+Y"), "View"), ("view.beforeAfterTopBottom", "Before/After Top/Bottom", Some("Alt+Y"), "View"), @@ -219,6 +223,14 @@ pub const UI_COMMANDS: &[UiCommand] = &[ ("app.exportPrevious", "Export with Previous", Some("Cmd+Alt+Shift+E"), "File"), ]; +/// A tool panel going away ends its gesture, as closing it from its own key does. +fn end_panel_tool(app: &mut LightcraftApp) { + if app.ui.tool != "wbPicker" { + app.ui.tool.clear(); + } + let _ = app.session.end_interaction(); +} + fn panel(app: &mut LightcraftApp, ctx: &egui::Context, p: RightPanel, name: &str) { if app.ui.right == p && crate::docking::exposed(app, crate::docking::Panel::Tools) { app.ui.right = RightPanel::None; @@ -657,6 +669,24 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { + let was = app.ui.right; + let shown = app.ui.toggle_right_panel(p.get("show").and_then(Value::as_bool)); + if was != RightPanel::None && !shown { + end_panel_tool(app); + } + Ok(json!({"shown": shown, "right": app.ui.right})) + } + // Shift+Tab: `hide` (true / false) says which; without it the panels hide, then come back + "view.allPanels" => { + let was = app.ui.right; + let hidden = app.ui.toggle_all_panels(p.get("hide").and_then(Value::as_bool)); + if was != RightPanel::None && app.ui.right == RightPanel::None { + end_panel_tool(app); + } + Ok(json!({"hidden": hidden})) + } "view.beforeAfter" => { app.ui.before_after = if app.ui.before_after == BeforeAfter::SideBySide { BeforeAfter::Off } else { BeforeAfter::SideBySide }; Ok(Value::Null) diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 129fb1d9..c612b5a2 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -388,6 +388,13 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) { } } }); + // Shift+Tab is also egui's "focus the previous widget", decided when the pass began and without + // consuming the key: with nothing focused it would put the keyboard in the last text field + // that took part in focus traversal. Hiding the panels must not do that. + if fire.iter().any(|f| f == "view.allPanels") { + ctx.input_mut(|i| i.consume_key(Modifiers::SHIFT, Key::Tab)); + ctx.memory_mut(|m| m.move_focus(egui::FocusDirection::None)); + } use crate::panels::compare; // rating/flag/label keys: in Compare/Survey they act on the active photo only; Shift+key or // Auto Advance then moves on (next candidate in Compare, next photo elsewhere) diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index b76066d5..ff501fc1 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -43,6 +43,26 @@ impl RightPanel { pub fn is_edit_tool(self) -> bool { matches!(self, RightPanel::Edit | RightPanel::Profiles | RightPanel::Crop | RightPanel::Remove | RightPanel::Masking | RightPanel::RedEye) } + + /// What comes back when the panel is shown again (F8, Shift+Tab). The tools that work on the + /// photo (crop, remove, masking, red eye) end when their panel is hidden, so they return as + /// Edit; every other panel returns as it was. + pub fn restored(self) -> RightPanel { + match self { + RightPanel::None | RightPanel::Crop | RightPanel::Remove | RightPanel::Masking | RightPanel::RedEye => RightPanel::Edit, + other => other, + } + } +} + +/// The panels Shift+Tab (Hide All Panels) hid, to bring back exactly those. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct HiddenPanels { + pub left_panel: bool, + pub right: RightPanel, + pub presets: bool, + pub filmstrip: bool, } #[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] @@ -497,6 +517,11 @@ pub struct UiState { pub view: ViewMode, pub left_panel: bool, pub right: RightPanel, + /// The panel the right side showed before F8 (or Hide All Panels) hid it; F8 brings it back. + pub right_hidden: RightPanel, + /// Set while Hide All Panels (Shift+Tab) is in effect: what to bring back. Showing any panel + /// by hand makes it stale (see [`UiState::all_panels_hidden`]). + pub hidden_panels: Option, /// Widths of the left sidebar and of the right panel in points (dragging their inner edge /// resizes them; within [`LEFT_WIDTH`] / [`RIGHT_WIDTH`]). pub left_width: f32, @@ -1030,6 +1055,8 @@ impl Default for UiState { left_width: LEFT_WIDTH.default, right_width: RIGHT_WIDTH.default, right: RightPanel::Edit, + right_hidden: RightPanel::None, + hidden_panels: None, presets: false, preset_thumbs: false, filmstrip: true, @@ -1213,3 +1240,70 @@ fn deserialize_favorite_themes<'de, D: serde::Deserializer<'de>>(d: D) -> Result } Ok(values) } + +impl UiState { + /// Every side panel and the filmstrip are hidden. + fn no_panel_shown(&self) -> bool { + !self.left_panel && self.right == RightPanel::None && !self.presets && !self.filmstrip + } + + /// Hide All Panels is in effect: its snapshot is kept and nothing has been shown since. (Showing + /// a panel by hand drops it out of this state, so the next Shift+Tab hides again instead of + /// bringing back panels the user had put away.) + pub fn all_panels_hidden(&self) -> bool { + self.hidden_panels.is_some() && self.no_panel_shown() + } + + /// F8: hide the right panel, or show the one that was there (Edit when there was none). + /// `show` forces the state. Returns whether the panel is shown now. + pub fn toggle_right_panel(&mut self, show: Option) -> bool { + let show = show.unwrap_or(self.right == RightPanel::None); + if show && self.right == RightPanel::None { + self.right = self.right_hidden.restored(); + } else if !show && self.right != RightPanel::None { + self.right_hidden = self.right.restored(); + self.right = RightPanel::None; + } + self.right != RightPanel::None + } + + /// Shift+Tab: hide the side panels, the Presets column and the filmstrip, remembering what was + /// shown; again, bring back exactly that. When nothing is shown and nothing was remembered, it + /// shows the usual panels (left sidebar, the last right panel, filmstrip). `hide` forces the + /// direction. Returns whether the panels are hidden now. + pub fn toggle_all_panels(&mut self, hide: Option) -> bool { + let hide = hide.unwrap_or_else(|| !self.all_panels_hidden() && !(self.no_panel_shown() && self.hidden_panels.is_none())); + if hide { + if self.no_panel_shown() { + return true; + } + self.hidden_panels = + Some(HiddenPanels { left_panel: self.left_panel, right: self.right, presets: self.presets, filmstrip: self.filmstrip }); + if self.right != RightPanel::None { + self.right_hidden = self.right.restored(); + } + self.left_panel = false; + self.right = RightPanel::None; + self.presets = false; + self.filmstrip = false; + } else { + match self.hidden_panels.take() { + Some(was) if self.no_panel_shown() => { + self.left_panel = was.left_panel; + self.right = if was.right == RightPanel::None { RightPanel::None } else { was.right.restored() }; + self.presets = was.presets; + self.filmstrip = was.filmstrip; + } + _ => { + self.hidden_panels = None; + self.left_panel = true; + if self.right == RightPanel::None { + self.right = self.right_hidden.restored(); + } + self.filmstrip = true; + } + } + } + self.all_panels_hidden() + } +} diff --git a/crates/ui-egui/src/tests_panel_keys.rs b/crates/ui-egui/src/tests_panel_keys.rs new file mode 100644 index 00000000..d3dd212c --- /dev/null +++ b/crates/ui-egui/src/tests_panel_keys.rs @@ -0,0 +1,261 @@ +//! Headless tests of the panel keys: F8 (Right Panel) and Shift+Tab (Hide All Panels), which hides +//! the side panels, the Presets column and the filmstrip, and brings back exactly what it hid. + +use std::time::Duration; + +use serde_json::{Value, json}; + +use crate::headless::Headless; +use crate::menubar::{MenuNode, checked, menu_bar}; +use crate::state::RightPanel; +use crate::{LightcraftApp, Services}; + +const T: Duration = Duration::from_secs(20); +const SETTLE: Duration = Duration::from_secs(120); + +fn loupe() -> Headless { + let services = Services { png: None, ..Default::default() }; + let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), services); + app.ui.settings.gpu = false; + let mut h = Headless::new(app, [1400.0, 900.0], 1.0); + let r = h.request("ui.set", json!({"view": "detail", "right": "edit"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + h +} + +fn key(h: &mut Headless, k: &str, shift: bool) { + let r = h.request("ui.key", json!({"key": k, "shift": shift}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + h.step(); +} + +fn run(h: &mut Headless, command: &str, params: Value) -> Value { + let r = h.request("ui.menu.invoke", json!({"id": command, "params": params}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + h.step(); + r +} + +/// (left panel, right panel, presets, filmstrip) +fn shown(h: &Headless) -> (bool, RightPanel, bool, bool) { + (h.app.ui.left_panel, h.app.ui.right, h.app.ui.presets, h.app.ui.filmstrip) +} + +fn arrange(h: &mut Headless, left: bool, right: RightPanel, presets: bool, filmstrip: bool) { + h.app.ui.left_panel = left; + h.app.ui.right = right; + h.app.ui.presets = presets; + h.app.ui.filmstrip = filmstrip; + h.step(); + h.step(); +} + +#[test] +fn f8_hides_the_right_panel_and_brings_back_the_tool_that_was_there() { + let mut h = loupe(); + for tool in [RightPanel::Edit, RightPanel::Keywords, RightPanel::Info, RightPanel::Versions, RightPanel::Activity, RightPanel::Profiles] { + arrange(&mut h, true, tool, false, true); + key(&mut h, "F8", false); + assert_eq!(shown(&h), (true, RightPanel::None, false, true), "F8 hides {tool:?} and nothing else"); + key(&mut h, "F8", false); + assert_eq!(shown(&h), (true, tool, false, true), "F8 shows {tool:?} again"); + } + // the tools that act on the photo end when hidden and come back as Edit + arrange(&mut h, true, RightPanel::Crop, false, true); + key(&mut h, "F8", false); + assert_eq!(h.app.ui.right, RightPanel::None); + key(&mut h, "F8", false); + assert_eq!(h.app.ui.right, RightPanel::Edit); + // closed some other way, with nothing remembered: Edit + let mut h = loupe(); + arrange(&mut h, true, RightPanel::None, false, true); + key(&mut h, "F8", false); + assert_eq!(h.app.ui.right, RightPanel::Edit); +} + +#[test] +fn shift_tab_hides_all_panels_and_again_brings_back_exactly_those() { + let mut h = loupe(); + arrange(&mut h, true, RightPanel::Keywords, true, true); + key(&mut h, "Tab", true); + assert_eq!(shown(&h), (false, RightPanel::None, false, false)); + assert!(h.app.ui.all_panels_hidden()); + key(&mut h, "Tab", true); + assert_eq!(shown(&h), (true, RightPanel::Keywords, true, true)); + assert!(!h.app.ui.all_panels_hidden()); + // a panel that was already hidden stays hidden + arrange(&mut h, false, RightPanel::Info, false, true); + key(&mut h, "Tab", true); + assert_eq!(shown(&h), (false, RightPanel::None, false, false)); + key(&mut h, "Tab", true); + assert_eq!(shown(&h), (false, RightPanel::Info, false, true)); + arrange(&mut h, true, RightPanel::None, false, false); + key(&mut h, "Tab", true); + key(&mut h, "Tab", true); + assert_eq!(shown(&h), (true, RightPanel::None, false, false), "a hidden right panel stays hidden too"); + // F8 afterwards still finds the tool it had + arrange(&mut h, true, RightPanel::Versions, false, true); + key(&mut h, "Tab", true); + key(&mut h, "F8", false); + assert_eq!(h.app.ui.right, RightPanel::Versions); +} + +#[test] +fn hide_all_with_nothing_shown_and_nothing_remembered_shows_the_usual_panels() { + let mut h = loupe(); + arrange(&mut h, false, RightPanel::None, false, false); + assert!(h.app.ui.hidden_panels.is_none()); + key(&mut h, "Tab", true); + assert_eq!(shown(&h), (true, RightPanel::Edit, false, true)); + key(&mut h, "Tab", true); + assert_eq!(shown(&h), (false, RightPanel::None, false, false)); +} + +#[test] +fn showing_one_panel_while_all_are_hidden_makes_the_next_shift_tab_hide_again() { + for show in ["view.leftPanel", "view.filmstrip", "view.rightPanel", "panel.presets", "panel.info"] { + let mut h = loupe(); + arrange(&mut h, true, RightPanel::Edit, false, true); + key(&mut h, "Tab", true); + assert!(h.app.ui.all_panels_hidden()); + run(&mut h, show, json!({})); + assert!(!h.app.ui.all_panels_hidden(), "{show} shows a panel"); + let one = shown(&h); + key(&mut h, "Tab", true); + assert_eq!( + shown(&h), + (false, RightPanel::None, false, false), + "{show}: Shift+Tab hides what is shown ({one:?}), it doesn't resurrect the old state" + ); + key(&mut h, "Tab", true); + assert_eq!(shown(&h), one, "{show}: and the next one brings back what was shown"); + } +} + +#[test] +fn shift_tab_leaves_keyboard_focus_alone() { + let mut h = loupe(); + arrange(&mut h, true, RightPanel::Edit, false, true); + assert_eq!(h.view.ctx.memory(|m| m.focused()), None, "nothing has focus to begin with"); + key(&mut h, "Tab", true); + assert_eq!(h.view.ctx.memory(|m| m.focused()), None, "Shift+Tab doesn't focus the last widget"); + assert!(!h.view.ctx.egui_wants_keyboard_input()); + key(&mut h, "Tab", true); + assert_eq!(h.view.ctx.memory(|m| m.focused()), None); + assert!(!h.view.ctx.egui_wants_keyboard_input()); + assert_eq!(shown(&h), (true, RightPanel::Edit, false, true)); +} + +#[test] +fn panel_keys_belong_to_a_focused_text_field() { + let mut h = loupe(); + arrange(&mut h, true, RightPanel::Info, false, true); + let r = h.request("ui.clickWidget", json!({"id": "field:search"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + h.step(); + assert!(h.view.ctx.egui_wants_keyboard_input(), "the search field has the keyboard"); + let field = h.view.ctx.memory(|m| m.focused()); + key(&mut h, "F8", false); + key(&mut h, "Tab", true); + assert_eq!(shown(&h), (true, RightPanel::Info, false, true), "F8 and Shift+Tab are typed into, not shortcuts"); + assert_ne!(h.view.ctx.memory(|m| m.focused()), field, "Shift+Tab still moves focus out of the field"); + // with the field left, the keys work again + key(&mut h, "Escape", false); + h.view.ctx.memory_mut(|m| m.stop_text_input()); + h.step(); + key(&mut h, "F8", false); + assert_eq!(h.app.ui.right, RightPanel::None); +} + +#[test] +fn the_panel_commands_work_without_the_keys() { + let mut h = loupe(); + arrange(&mut h, true, RightPanel::Keywords, false, true); + let r = run(&mut h, "view.rightPanel", json!({})); + assert_eq!(r["result"]["shown"], false, "{r}"); + assert_eq!(h.app.ui.right, RightPanel::None); + // `show` says which way, whatever the state + run(&mut h, "view.rightPanel", json!({"show": false})); + assert_eq!(h.app.ui.right, RightPanel::None); + run(&mut h, "view.rightPanel", json!({"show": true})); + assert_eq!(h.app.ui.right, RightPanel::Keywords); + run(&mut h, "view.rightPanel", json!({"show": true})); + assert_eq!(h.app.ui.right, RightPanel::Keywords, "showing a shown panel keeps it"); + + let r = run(&mut h, "view.allPanels", json!({})); + assert_eq!(r["result"]["hidden"], true, "{r}"); + assert_eq!(shown(&h), (false, RightPanel::None, false, false)); + run(&mut h, "view.allPanels", json!({"hide": true})); + assert_eq!(shown(&h), (false, RightPanel::None, false, false), "hiding hidden panels keeps what was remembered"); + let r = run(&mut h, "view.allPanels", json!({})); + assert_eq!(r["result"]["hidden"], false, "{r}"); + assert_eq!(shown(&h), (true, RightPanel::Keywords, false, true)); +} + +#[test] +fn hiding_the_panels_ends_the_tool_in_use() { + let mut h = loupe(); + run(&mut h, "panel.crop", json!({})); + assert_eq!(h.app.ui.right, RightPanel::Crop); + key(&mut h, "F8", false); + assert_eq!(h.app.ui.right, RightPanel::None); + assert!(h.app.ui.tool.is_empty()); + run(&mut h, "panel.masking", json!({})); + run(&mut h, "view.allPanels", json!({})); + assert_eq!(h.app.ui.right, RightPanel::None); + run(&mut h, "view.allPanels", json!({})); + assert_eq!(h.app.ui.right, RightPanel::Edit); +} + +#[test] +fn the_view_menu_lists_the_panel_commands_with_their_state() { + let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services::default()); + let item = |app: &LightcraftApp, id: &str| -> Option<(String, Option, Option)> { + fn find(nodes: &[MenuNode], id: &str) -> Option<(String, Option, Option)> { + nodes.iter().find_map(|n| match n { + MenuNode::Item { id: i, label, shortcut, checked, .. } if i == id => Some((label.clone(), shortcut.clone(), *checked)), + MenuNode::Submenu { children, .. } => find(children, id), + _ => None, + }) + } + let bar = menu_bar(app); + find(&bar.iter().find(|(t, _)| t == "View")?.1, id) + }; + app.ui.left_panel = true; + app.ui.right = RightPanel::Edit; + app.ui.filmstrip = true; + let (label, shortcut, on) = item(&app, "view.rightPanel").expect("View ▸ Right Panel"); + assert_eq!((label.as_str(), shortcut.as_deref(), on), ("Right Panel", Some("F8"), Some(true))); + let (label, shortcut, on) = item(&app, "view.allPanels").expect("View ▸ Hide All Panels"); + assert_eq!((label.as_str(), shortcut.as_deref(), on), ("Hide All Panels", Some("Shift+Tab"), Some(false))); + app.run("view.allPanels", json!({})).unwrap(); + assert_eq!(checked(&app, "view.rightPanel"), Some(false)); + assert_eq!(checked(&app, "view.allPanels"), Some(true)); + assert_eq!(checked(&app, "view.leftPanel"), Some(false)); + app.run("view.rightPanel", json!({})).unwrap(); + assert_eq!(checked(&app, "view.rightPanel"), Some(true)); + assert_eq!(checked(&app, "view.allPanels"), Some(false), "a panel is shown again, so the panels are no longer all hidden"); +} + +#[test] +fn the_keys_are_in_the_keymap_and_f1_opens_help() { + use crate::shortcuts::{parse, shortcut_of}; + let keymap = Default::default(); + assert_eq!(shortcut_of(&keymap, "view.rightPanel"), Some("F8")); + assert_eq!(shortcut_of(&keymap, "view.allPanels"), Some("Shift+Tab")); + assert!(parse("F8").is_some() && parse("Shift+Tab").is_some()); + // F1 (LightCraft Help) goes to the docs + let mut h = loupe(); + let opened = std::sync::Arc::new(std::sync::Mutex::new(Vec::::new())); + let o = opened.clone(); + h.app.services.open_url = Some(Box::new(move |u: &str| { + o.lock().unwrap().push(u.to_string()); + Ok(()) + })); + key(&mut h, "F1", false); + assert_eq!(opened.lock().unwrap().as_slice(), [crate::links::HELP]); +} diff --git a/docs/gaps.md b/docs/gaps.md index 7c4f5622..af7e551d 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps: where LightCraft falls short of Lightroom -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (CR3 entry names the EOS R7 C-RAW fix and the refused CRX variants; CI and Windows / Linux entries note the 0.6.0 riscv64 build; camera colour: tone curve from black-and-white camera JPEGs; Compare and Reference get sharp zoom windows; gap 4 shrinks; gap 14: the HDR switch is read from crs XMP; gaps 2 and 10: EOS R5 Mark II CR3 verified) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (gap 28 shrinks: F8 and ⇧Tab land; CR3 entry names the EOS R7 C-RAW fix and the refused CRX variants; CI and Windows / Linux entries note the 0.6.0 riscv64 build; camera colour: tone curve from black-and-white camera JPEGs; Compare and Reference get sharp zoom windows; gap 4 shrinks; gap 14: the HDR switch is read from crs XMP; gaps 2 and 10: EOS R5 Mark II CR3 verified) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 Every known shortfall, one entry each, ranked by user impact (how many Lightroom users hit it, and how hard it blocks them), then by effort. **Agents pick work from the top** (the first entry nobody is @@ -42,7 +42,7 @@ Hours are Opus 5.5 agent wall-clock hours (see [target-app-parity.md](target-app | 25 | **File writes**: lossy DNG, Render to DNG; AVIF import (PSD export landed with Edit in PhotoCraft, #953) | file-format-parity.md | interchange edge cases | 21–37 | [file-format-parity](file-format-parity.md) | | 26 | **Performance at scale**: 85k-photo library opens in 1.7–4.7 s; 100k grid test pending; colour NR at half resolution | ROADMAP log, gpu-pipeline.md | large-catalog users | 15–30 | [target-app-parity](target-app-parity.md#by-dimension) | | 27 | **Windows / Linux runtime coverage**: Windows installer UI unverified on Windows (#79); GPU path proven on Apple only; the Linux riscv64 tarball (new in 0.6.0, #844) is cross-compiled and never run beyond a QEMU smoke test | ROADMAP, issues, `release.yml` | non-Mac users find bugs first | 15–30 | [hardware-parity](hardware-parity.md) | -| 28 | **Small Classic shortcuts and keys**: rating `[` `]`, flag cycling, filter-bar keys, F1 help, panel keys (Tab, F5–F8) | [ui-parity](ui-parity.md#shortcuts) | Classic muscle memory | 4–8 | [ui-parity](ui-parity.md) | +| 28 | **Small Classic shortcuts and keys**: flag cycling (backtick), the filter-bar `\` key, panel keys Tab and F5 (F6 / F7 are in open PR #1078; F8 and ⇧Tab landed) | [ui-parity](ui-parity.md#shortcuts) | Classic muscle memory | 2–4 | [ui-parity](ui-parity.md) | Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform work they need around them, **~340–600 h** to beta (incl. 40–80 h for the ~92 open core-path user issues) (see [target-app-parity.md](target-app-parity.md#stage)). @@ -72,6 +72,7 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Gap 28 shrinks: F8 and Shift+Tab land; F1 and `[` `]` were already implemented; gap not closed | | 2026-10-11 | minor | Reviewed against main after 0.6.0: CR3 entry names the CRX variants `crx.rs` still refuses and the R7 / R6 Mark II C-RAW state (decoding, not verified, so the measured numbers don't move); CI and Windows / Linux entries note the riscv64 release build (#844); R7 fix added to Closed recently. Panel docking (UI.9, #869) closes no listed gap | | 2026-10-11 | minor | Camera colour entry: raws with a black-and-white camera JPEG take its tone curve (#577); gap not closed | | 2026-10-11 | minor | Gap 4: Compare and Reference views get zoom windows per pane (1:1 was a 2,560 px render magnified); overlays and soft proofing remain, 8–15 h → 4–8 h | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 77ae26e4..01393072 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-11 · **Change:** minor (rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; 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-12 · **Change:** minor (KEYC-PANELS: F8 toggles the right panel, ⇧Tab hides and restores all panels; KEYC-HELP: F1; KEYC-RATING notes; rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; 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 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 @@ -50,12 +50,12 @@ cited here still exists. Formerly docs/parity.md. | V. Preferences (PREF) | 6 | 2 | 2 | 2 | 1/1 (100%) | 4/5 (80%) | | W. Cloud & AI infrastructure (CLOUD / AI) | 1 | 0 | 1 | 2 | — | — | | X. Cross-cutting behaviours (BEHAV) | 23 | 6 | 1 | 1 | 8/8 (100%) | 7/10 (70%) | -| Y. Menus | 84 | 0 | 4 | 8 | 47/47 (100%) | 23/23 (100%) | +| Y. Menus | 86 | 0 | 4 | 8 | 47/47 (100%) | 25/25 (100%) | | Z. Keyboard shortcuts (desktop) | 76 | 3 | 3 | 1 | 49/52 (94%) | 23/24 (96%) | -| Lightroom Classic extras | 34 | 16 | 35 | 8 | — | 24/26 (92%) | -| **Total** | 407 | 50 | 73 | 35 | 192/200 (96%) | 145/157 (92%) | +| Lightroom Classic extras | 35 | 16 | 34 | 8 | — | 24/26 (92%) | +| **Total** | 411 | 51 | 71 | 35 | 193/201 (96%) | 147/159 (92%) | -Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **81.5%** of 530 in-scope rows — P0 98.0% of 200 · P1 95.2% of 157 · P2 49.7% of 172. +Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **81.9%** of 533 in-scope rows — P0 98.0% of 201 · P1 95.3% of 159 · P2 50.6% of 172. ## Top gaps and shortcut notes @@ -528,6 +528,8 @@ visible menu bar yet: the menu model is only exposed through the control channel | MENU-VIEW-ENTERFULLSCREEN | Enter Full Screen | P1 | ✅ | `cmd:view.enterFullScreen` | | | MENU-VIEW-PHOTOSPANEL | Show/Hide photos panel | P0 | ✅ | `cmd:view.leftPanel` | | | MENU-VIEW-FILMSTRIP | Show/Hide filmstrip | P0 | ✅ | `cmd:view.filmstrip` | | +| MENU-VIEW-RIGHTPANEL | Show/Hide right panel — F8 | P1 | ✅ | `cmd:view.rightPanel`, `crates/ui-egui/src/menus.rs` | checked while a tool panel is shown; shows the panel that was hidden (Edit when there was none) | +| MENU-VIEW-HIDEALL | Toggle All Panels (hide / show every panel) — ⇧Tab | P1 | ✅ | `cmd:view.allPanels`, `crates/ui-egui/src/menus.rs` | named Hide All Panels here, checked while they are hidden | | MENU-VIEW-INFO | Show/Hide info | P0 | ✅ | `cmd:panel.info` | | | MENU-VIEW-KEYWORDS | Show/Hide keywords | P0 | ✅ | `cmd:panel.keywords` | | | MENU-VIEW-ACTIVITY | Show/Hide activity (comments) | OOS | 🚫 | | our History panel is `panel.activity` | @@ -762,18 +764,18 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-WEB-IMAGEINFO | Web gallery image info | OOS | 🚫 | | | | LRC-WEB-OUTPUT | Web gallery output | OOS | 🚫 | | | | LRC-WEB-UPLOAD | Web gallery upload | OOS | 🚫 | | | -| KEYC-PANELS | Classic panel keys (Tab, ⇧Tab, T, F5–F8, solo) | P2 | ⬜ | | | +| KEYC-PANELS | Classic panel keys (Tab, ⇧Tab, T, F5–F8, solo) | P2 | 🟡 | `cmd:view.rightPanel`, `cmd:view.allPanels`, `crates/ui-egui/src/menus.rs` | F8 toggles the right panel (the tool that was there comes back); ⇧Tab hides the side panels, Presets and filmstrip and a second press restores exactly what was shown, and does nothing in a text field (`tests_panel_keys.rs`). On a Mac keyboard F8 is Play/Pause unless fn is held or "Use F1, F2, … as standard function keys" is on. Missing: Tab (side panels), T (toolbar), F5, solo mode (F6, F7 in open PR #1078) | | KEYC-MODULES | Classic module switching (⌘⌥1–7) | P2 | 🚫 | | no modules in LightCraft | | KEYC-VIEWS | Classic view keys (E, G, C, N, L, F, I, ⇧R, ⌘⌥0) | P2 | 🟡 | `cmd:view.photoGrid`, `cmd:view.zoom100` | G works; E opens Edit (not loupe); no compare/survey/lights-out/screen modes | | KEYC-SECONDWINDOW | Classic secondary-window keys | P2 | ⬜ | | | | KEYC-CATALOG | Classic photo/catalog keys (⇧⌘I, ⌘', ⌘R, F2, ⌫, ⇧⌘E…) | P2 | ✅ | `cmd:library.import`, `cmd:photo.delete`, `cmd:photo.virtualCopy`, `cmd:app.showInFinder`, `cmd:dialog.rename`, `cmd:photo.editInExternal` | ⇧⌘I add, ⌘' virtual copy, ⌘R show in Finder, F2 rename, ⌫ delete, ⇧⌘E external editor | | KEYC-COMPARE | Classic grid/compare keys (Z, Home/End, =/−, ⌘⇧D, S…) | P2 | 🟡 | `cmd:view.zoomToggle` | Z toggles zoom; no compare, stacks, thumbnail-size keys | -| KEYC-RATING | Classic rating/flag keys (1–5, ⇧1–5, 6–9, P, X, U, ⇧X, ⇧U, `[` `]`, \`) | P2 | 🟡 | `cmd:photo.rate`, `cmd:photo.pick`, `cmd:photo.flag` | 0–5 / Shift+0–5 and 6–9 / Shift+6–9; P/X/U, Shift+X/U and grid Shift+P work; tests cover batch/undo, active Compare candidate and text focus. Missing: Shift+P outside grids (Presets), rating `[` / `]`, flag cycle and filter-bar keys | +| KEYC-RATING | Classic rating/flag keys (1–5, ⇧1–5, 6–9, P, X, U, ⇧X, ⇧U, `[` `]`, \`) | P2 | 🟡 | `cmd:photo.rate`, `cmd:photo.pick`, `cmd:photo.flag` | 0–5 / Shift+0–5 and 6–9 / Shift+6–9; P/X/U, Shift+X/U and grid Shift+P work; tests cover batch/undo, active Compare candidate and text focus. `[` / `]` change the rating in grids (`tests_relative_ratings.rs`). Missing: flag cycle (backtick) and the filter-bar `\` key | | KEYC-COLLECTIONS | Classic collection keys (⌘N, B…) | P2 | 🟡 | `cmd:dialog.newAlbum` | ⌘N new album; no quick collection | | KEYC-METADATA | Classic keyword/metadata keys (⌘K, ⌘S, ⌘⌥⇧C/V…) | P2 | 🟡 | `cmd:photo.saveMetadataToFile` | ⌘S saves metadata; no keyword sets, metadata copy/paste | | KEYC-DEVELOP | Classic develop keys (V, ⌘U, ⇧⌘U, R, Q, K, M, ⇧M, ⇧W, ⇧J, ⇧Q…) | P2 | 🟡 | `cmd:develop.treatment`, `cmd:develop.reset`, `cmd:crop.reset` | V, ⇧⌘R, ⌥⌘R, W, J, Y, ⇧Y, \ match; R/K/M/⇧M differ; no Classic keymap layer | | KEYC-MODULE-OUTPUT | Book / slideshow / print / map / web keys | P2 | ⬜ | | modules not implemented | -| KEYC-HELP | Classic help keys (⌘/, F1) | P2 | 🟡 | `cmd:app.shortcuts` | ⌘/ only | +| KEYC-HELP | Classic help keys (⌘/, F1) | P2 | ✅ | `cmd:app.shortcuts`, `cmd:app.help` | ⌘/ opens Keyboard Shortcuts, F1 LightCraft Help (`tests_panel_keys.rs`) | ### Japanese text watermarks @@ -787,6 +789,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | KEYC-PANELS: F8 toggles the right panel and Shift+Tab hides and restores all panels (🟡, was ⬜); KEYC-HELP: F1 opens help (✅); KEYC-RATING: `[` `]` are implemented, notes corrected; two View menu rows | | 2026-10-11 | minor | KEY-SLIDER-TYPE: honor immediate Return/Escape and keep typed keys in the value editor; add regression references | | 2026-10-11 | minor | LR-PROF-CAMERACOLOR: raws with a black-and-white camera JPEG take its tone curve (issue #577) | | 2026-10-11 | minor | LR-VIEW-ZOOM and LR-VIEW-COMPARE: Compare and Reference are sharp at 1:1 (zoom windows per pane, two originals kept decoded while they are open); statuses unchanged | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 44aa1c70..d28bed14 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (zoom row: Compare and Reference sharp at 1:1) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (shortcuts: F8 and ⇧Tab panel keys, F1 help and `[` `]` were already done, command counts recounted; zoom row: Compare and Reference sharp at 1:1) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 How LightCraft's interaction compares with Lightroom's: views, panels, tools and handles, modifier keys, shortcuts, zoom, context menus and feel. Rows for individual menu items and shortcuts live in @@ -10,8 +10,8 @@ assessment and the notes that don't fit a row. Visual reference for agents: `pla ## Measured counts (2026-10-10) -- 226 engine commands (`crates/engine/src/cmd/`, 30 modules) and 143 UI commands (`UI_COMMANDS` in - `crates/ui-egui/src/menus.rs`, 73 with a shortcut) plus 10 language commands; 23 secondary +- 226 engine commands (`crates/engine/src/cmd/`, 30 modules) and 158 UI commands (`UI_COMMANDS` in + `crates/ui-egui/src/menus.rs`, 77 with a shortcut; recounted 2026-10-12) plus 10 language commands; 23 secondary shortcut bindings (`ALIASES`, `crates/ui-egui/src/shortcuts.rs`); a keymap editor. - 122 develop controls plus 10 indexed templates (`crates/develop/src/controls.rs`). - Checklist: menus 95.3% (86 in-scope rows), desktop shortcuts 94.4% (81 rows), Classic extras @@ -65,11 +65,12 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | Create Version | ⌘⇧S (+ ⇧M) | ⇧M (Windows: Ctrl+⇧S) | — | | Select None | ⌘⇧A (+ ⌘D) | ⌘D | — | -**Library culling (KEYC-RATING):** `0–5`, `6–9` and `P/X/U` reach egui on macOS even when displayed in the native menu (adapted from PR #261; issue #283). `Shift+0–9` applies and advances once; `Shift+P` picks and advances in Photo Grid / Square Grid and retains Presets elsewhere. See [library shortcuts](library-shortcuts.md) for the verified Classic mapping and regression coverage. Remaining Classic gaps: rating `[` / `]`, flag cycling and filter-bar keys. +**Library culling (KEYC-RATING):** `0–5`, `6–9` and `P/X/U` reach egui on macOS even when displayed in the native menu (adapted from PR #261; issue #283). `Shift+0–9` applies and advances once; `Shift+P` picks and advances in Photo Grid / Square Grid and retains Presets elsewhere. See [library shortcuts](library-shortcuts.md) for the verified Classic mapping and regression coverage. Remaining Classic gaps: flag cycling (backtick), the filter-bar `\` key, Tab and F5. **Still missing / broken:** -- No command yet: F1 help (verify the rest of the old list: full screen, settings, stacks, - visualize spots and merges have commands now). +- No Tab (side panels) or F5 yet; F6 and F7 are in PR #1078. F8 (right panel) and ⇧Tab (hide / show all + panels) work: ⇧Tab hides the side panels, Presets and filmstrip and brings back exactly those; on a Mac + keyboard F8 is Play/Pause unless fn is held. - `H` opens Remove; Lightroom also uses it (Classic) to hide pins — pins toggle from View → Show Mask Pins. - ⌘M / ⌘H / ⌘Q / ⌘W rely on the platform window defaults (unverified). @@ -78,5 +79,6 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Shortcuts: F8 and Shift+Tab panel keys; F1 help and `[` `]` rating were already implemented; UI command counts recounted | | 2026-10-11 | minor | Zoom row: Compare and Reference get zoom windows; what remains of LR-VIEW-ZOOM is overlays and soft proofing | | 2026-10-10 | major | Created: measured command and shortcut counts, assessment by area, estimates; moved the shortcut conflicts section here from the checklist |