From 48edfb8d3a4f74973ccaffcc8fb45a37dca7b3dc Mon Sep 17 00:00:00 2001 From: Andrew Frahn Date: Sun, 11 Oct 2026 20:47:54 +1100 Subject: [PATCH] View: Lights Out (Shift+L) dims or blacks out everything but the photo View > Lights Out (view.lightsOut) cycles On -> Dim (80 %) -> Off; Esc turns the lights back on; {mode: on|dim|off} sets a state directly. The panels stay where they are: a black veil is painted over everything but the loupe's photo (or the grid / Compare / Survey area). Shift+L, since L is Linear Gradient. Fixes #611. Sources: own design. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/ui-egui/locales/de.json | 1 + crates/ui-egui/locales/es.json | 1 + crates/ui-egui/locales/fr.json | 1 + crates/ui-egui/locales/ja.json | 1 + crates/ui-egui/locales/pt-br.json | 1 + crates/ui-egui/locales/ru.json | 1 + crates/ui-egui/locales/uk.json | 1 + crates/ui-egui/locales/zh-hans.json | 1 + crates/ui-egui/locales/zh-hant.json | 1 + crates/ui-egui/src/lib.rs | 27 +++++++++ crates/ui-egui/src/menubar.rs | 2 + crates/ui-egui/src/menus.rs | 14 +++++ crates/ui-egui/src/state.rs | 36 ++++++++++++ crates/ui-egui/src/tests_lights_out.rs | 76 ++++++++++++++++++++++++++ docs/parity-checklist.md | 2 + 15 files changed, 166 insertions(+) create mode 100644 crates/ui-egui/src/tests_lights_out.rs diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index 6520fb3e..fb258c30 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -581,6 +581,7 @@ "Friday": "Freitag", "Full": "Vollständig", "Full Screen Preview": "Vollbildvorschau", + "Lights Out": "Licht aus", "Full screen mode": "Vollbildmodus", "Full size": "Volle Größe", "GPS Track Log": "GPS-Track", diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index fc53b639..74e49812 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -548,6 +548,7 @@ "Friday": "Viernes", "Full": "Completa", "Full Screen Preview": "Vista previa a pantalla completa", + "Lights Out": "Luces apagadas", "Full screen mode": "Modo de pantalla completa", "Full size": "Tamaño completo", "GPS Track Log": "Registro de ruta GPS", diff --git a/crates/ui-egui/locales/fr.json b/crates/ui-egui/locales/fr.json index 53509a50..2b256ef2 100644 --- a/crates/ui-egui/locales/fr.json +++ b/crates/ui-egui/locales/fr.json @@ -545,6 +545,7 @@ "Friday": "Vendredi", "Full": "Complet", "Full Screen Preview": "Aperçu en plein écran", + "Lights Out": "Lumières éteintes", "Full screen mode": "Mode plein écran", "Full size": "Taille complète", "GPS Track Log": "Trace GPS", diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index 2babd30b..6d26246b 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -736,6 +736,7 @@ "Free": "自由", "Full": "全体", "Full Screen Preview": "全画面プレビュー", + "Lights Out": "消灯モード", "Full size": "元のサイズ", "GPS Track Log": "GPS軌跡ファイル", "General": "一般", diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index dfe47281..5b5540dc 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -554,6 +554,7 @@ "Friday": "Sexta-feira", "Full": "Completa", "Full Screen Preview": "Pré-visualização em tela cheia", + "Lights Out": "Luzes apagadas", "Full screen mode": "Modo tela cheia", "Full size": "Tamanho completo", "GPS Track Log": "Registro de trajeto GPS", diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index fafb65a1..4a7a1f32 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -455,6 +455,7 @@ "Friday": "Пятница", "Full": "Полный", "Full Screen Preview": "Полноэкранный просмотр", + "Lights Out": "Выключить свет", "Full screen mode": "Полноэкранный режим", "Full size": "Полный размер", "GPS Track Log": "GPS-трек", diff --git a/crates/ui-egui/locales/uk.json b/crates/ui-egui/locales/uk.json index 7ebad5b1..bbe1fb7b 100644 --- a/crates/ui-egui/locales/uk.json +++ b/crates/ui-egui/locales/uk.json @@ -931,6 +931,7 @@ "Forget unchanged photos": "Забути незмінені фото", "Forward": "Уперед", "Full Screen Preview": "Повноекранний перегляд", + "Lights Out": "Вимкнути світло", "Full size": "Повний розмір", "GPS Track Log": "Журнал маршруту GPS", "Geometry": "Геометрія", diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index 8a0e58b8..053b01aa 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -690,6 +690,7 @@ "Friday": "星期五", "Full": "完整", "Full Screen Preview": "全屏预览", + "Lights Out": "关灯模式", "Full screen mode": "全屏模式", "Full size": "完整大小", "GPS Track Log": "GPS 轨迹日志", diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index f1cbec3a..8f36652b 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -690,6 +690,7 @@ "Friday": "星期五", "Full": "完整", "Full Screen Preview": "全螢幕預覽", + "Lights Out": "關燈模式", "Full screen mode": "全螢幕模式", "Full size": "完整尺寸", "GPS Track Log": "GPS 軌跡記錄", diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index f853fe16..e09ed21d 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -66,6 +66,8 @@ mod tests_labels; #[cfg(test)] mod tests_library_problem; #[cfg(test)] +mod tests_lights_out; +#[cfg(test)] mod tests_masking; #[cfg(test)] mod tests_masking_layout; @@ -996,6 +998,30 @@ impl LightcraftApp { } /// Frame timings once layout is done (`t0`: when layout started). + /// Lights Out: veil everything but the photo (the loupe's image, or the grid / Compare / + /// Survey area) in black. Painted above the panels, below dialogs and notices. + fn lights_out(&self, ctx: &egui::Context) { + let a = self.ui.lights_out.veil(); + if a <= 0.0 { + return; + } + let screen = ctx.content_rect(); + let keep = if self.ui.view == state::ViewMode::Detail { self.image_rect.or(self.canvas_rect) } else { self.canvas_rect }; + let veil = egui::Color32::from_black_alpha((a.clamp(0.0, 1.0) * 255.0).round() as u8); + let p = ctx.layer_painter(egui::LayerId::new(egui::Order::Middle, egui::Id::new("lights-out"))); + let k = keep.unwrap_or(egui::Rect::NOTHING).intersect(screen); + if !k.is_positive() { + p.rect_filled(screen, 0.0, veil); + return; + } + // four bands around the photo + use egui::{Rect, pos2}; + p.rect_filled(Rect::from_min_max(screen.min, pos2(screen.right(), k.top())), 0.0, veil); + p.rect_filled(Rect::from_min_max(pos2(screen.left(), k.bottom()), screen.max), 0.0, veil); + p.rect_filled(Rect::from_min_max(pos2(screen.left(), k.top()), pos2(k.left(), k.bottom())), 0.0, veil); + p.rect_filled(Rect::from_min_max(pos2(k.right(), k.top()), pos2(screen.right(), k.bottom())), 0.0, veil); + } + fn end_frame(&mut self, ctx: &egui::Context, t0: f64) { ctx.output(|o| { for c in &o.commands { @@ -1042,6 +1068,7 @@ impl LightcraftApp { panels::library_problem::banner(self, ui); panels::strip::show(self, ui); egui::CentralPanel::default().frame(egui::Frame::NONE).show(ui, |ui| docking::show(self, ui)); + self.lights_out(&ctx); panels::second::show(self, &ctx); panels::notices::show(self, &ctx); panels::dialogs::show(self, &ctx); diff --git a/crates/ui-egui/src/menubar.rs b/crates/ui-egui/src/menubar.rs index b919aa2e..ed4a9812 100644 --- a/crates/ui-egui/src/menubar.rs +++ b/crates/ui-egui/src/menubar.rs @@ -132,6 +132,7 @@ const LAYOUT: &[(&str, &[&str])] = &[ "view.infoOverlay", "---", "view.fullScreenPreview", + "view.lightsOut", "view.enterFullScreen", "---", "view.showOriginal", @@ -320,6 +321,7 @@ pub fn checked(app: &LightcraftApp, id: &str) -> Option { "view.filterBar" => Some(u.filter_bar), "view.navigator" => Some(u.navigator), "view.fullScreenPreview" => Some(u.fullscreen), + "view.lightsOut" => Some(u.lights_out != crate::state::LightsOut::On), "view.enterFullScreen" => Some(app.window_is_fullscreen), "panel.edit" => panel(RightPanel::Edit), "panel.profiles" => panel(RightPanel::Profiles), diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 81a46eb9..394f6460 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -105,6 +105,7 @@ pub const UI_COMMANDS: &[UiCommand] = &[ ("local.hide", "Remove from Local", None, ""), ("local.restoreHidden", "Show Hidden Local Locations", None, ""), ("view.fullScreenPreview", "Full Screen Preview", Some("F"), "View"), + ("view.lightsOut", "Lights Out", Some("Shift+L"), "View"), ("view.enterFullScreen", "Enter Full Screen", Some("Cmd+Shift+F"), "View"), ("view.infoOverlay", "Cycle Info Overlay", Some("Cmd+I"), "View"), ("view.navigator", "Navigator", None, "View"), @@ -399,6 +400,8 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { if app.ui.dialog.is_some() { app.ui.dialog = None; + } else if app.ui.lights_out != crate::state::LightsOut::On { + app.ui.lights_out = crate::state::LightsOut::On; } else if app.ui.keyword_painter.is_some() { app.ui.keyword_painter = None; } else if app.ui.fullscreen { @@ -455,6 +458,17 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { + // {mode?: on | dim | off} (cycles when omitted) + match p.get("mode").and_then(Value::as_str) { + Some(m) => serde_json::from_value(json!(m)).map_err(|_| format!("unknown lights-out mode: {m} (on|dim|off)")), + None => Ok(app.ui.lights_out.next()), + } + .map(|mode| { + app.ui.lights_out = mode; + json!({"lightsOut": mode}) + }) + } "view.enterFullScreen" => { // applied by the host's frame logic (it knows the window's current state) let on = p.get("on").and_then(Value::as_bool); diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 6a50e2ba..e4c593a0 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -127,6 +127,38 @@ impl InfoOverlay { } } +/// Lights Out (View ▸ Lights Out, Shift+L cycles On → Dim → Off → On; Esc turns the lights back +/// on): everything but the photo is veiled in black, the panels stay where they are. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum LightsOut { + #[default] + On, + /// The interface at 20 % brightness. + Dim, + /// The interface black. + Off, +} + +impl LightsOut { + pub fn next(self) -> LightsOut { + match self { + LightsOut::On => LightsOut::Dim, + LightsOut::Dim => LightsOut::Off, + LightsOut::Off => LightsOut::On, + } + } + + /// Opacity of the black veil over everything but the photo. + pub fn veil(self) -> f32 { + match self { + LightsOut::On => 0.0, + LightsOut::Dim => 0.8, + LightsOut::Off => 1.0, + } + } +} + /// App-level preferences (Settings dialog). They belong to the app, not a library, and are /// saved with the UI state in the app's config folder (`ui.json`, key `settings`); library /// preferences (import defaults, XMP, cache size) live in the library's `prefs.json`. @@ -463,6 +495,9 @@ pub struct UiState { pub auto_advance: bool, /// Full-screen preview (F): the photo alone on black, no chrome. pub fullscreen: bool, + /// Lights Out (Shift+L): not saved, every launch starts with the lights on. + #[serde(skip)] + pub lights_out: LightsOut, /// Window ▸ Second Window. pub second_window: bool, /// The keyword painter: clicking a photo in the grid toggles this keyword on it. @@ -866,6 +901,7 @@ impl Default for UiState { spots_threshold: 50.0, auto_advance: false, fullscreen: false, + lights_out: LightsOut::On, slideshow: None, second_window: false, keyword_painter: None, diff --git a/crates/ui-egui/src/tests_lights_out.rs b/crates/ui-egui/src/tests_lights_out.rs new file mode 100644 index 00000000..1720c7ad --- /dev/null +++ b/crates/ui-egui/src/tests_lights_out.rs @@ -0,0 +1,76 @@ +//! Headless tests of View ▸ Lights Out (Shift+L): On → Dim → Off → On, Esc turns the lights back +//! on, and everything but the photo is veiled while the photo keeps its pixels. + +use std::time::Duration; + +use serde_json::json; + +use crate::headless::Headless; +use crate::state::LightsOut; +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 luma(img: &egui::ColorImage, p: egui::Pos2) -> u32 { + let [w, _] = img.size; + let c = img.pixels[(p.y as usize) * w + p.x as usize]; + c.r() as u32 + c.g() as u32 + c.b() as u32 +} + +#[test] +fn shift_l_cycles_lights_out_and_esc_turns_the_lights_back_on() { + let mut h = loupe(); + assert_eq!(h.app.ui.lights_out, LightsOut::On); + key(&mut h, "L", true); + assert_eq!(h.app.ui.lights_out, LightsOut::Dim); + key(&mut h, "L", true); + assert_eq!(h.app.ui.lights_out, LightsOut::Off); + key(&mut h, "L", true); + assert_eq!(h.app.ui.lights_out, LightsOut::On); + // plain L stays the Linear Gradient tool + key(&mut h, "L", false); + assert_eq!(h.app.ui.lights_out, LightsOut::On); + key(&mut h, "Escape", false); + key(&mut h, "L", true); + key(&mut h, "Escape", false); + assert_eq!(h.app.ui.lights_out, LightsOut::On); + let r = h.request("engine.execute", json!({"command": "view.lightsOut", "params": {"mode": "dim"}}), T); + assert_eq!(h.app.ui.lights_out, LightsOut::Dim, "{r}"); + let r = h.request("engine.execute", json!({"command": "view.lightsOut", "params": {"mode": "bogus"}}), T); + assert_eq!(h.app.ui.lights_out, LightsOut::Dim, "{r}"); +} + +#[test] +fn lights_off_blacks_out_the_interface_but_not_the_photo() { + let mut h = loupe(); + let before = h.snapshot(SETTLE); + let img = h.app.image_rect.expect("the loupe shows the photo"); + let inside = img.center(); + let outside = egui::pos2(5.0, 5.0); + assert!(!img.contains(outside)); + assert!(luma(&before, outside) > 0, "the title/top bar is not black to begin with"); + let r = h.request("engine.execute", json!({"command": "view.lightsOut", "params": {"mode": "off"}}), T); + assert_eq!(h.app.ui.lights_out, LightsOut::Off, "{r}"); + let after = h.snapshot(SETTLE); + assert_eq!(luma(&after, outside), 0, "the interface goes black"); + assert_eq!(luma(&after, inside), luma(&before, inside), "the photo keeps its pixels"); +} diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 09941108..09139b75 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -517,6 +517,7 @@ visible menu bar yet: the menu model is only exposed through the control channel | MENU-VIEW-DETAIL | Detail | P0 | ✅ | `cmd:view.detail` | | | MENU-VIEW-EDIT | Edit | P0 | ✅ | `cmd:panel.edit` | | | MENU-VIEW-FULLSCREENPREVIEW | Full Screen Preview | P1 | ✅ | `cmd:view.fullScreenPreview` | | +| MENU-VIEW-LIGHTSOUT | Lights Out | P2 | ✅ | `cmd:view.lightsOut` | everything but the photo veiled; panels stay put | | 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` | | @@ -586,6 +587,7 @@ From `06-shortcuts.md` part 1. Evidence is our binding; conflicts are explained | KEY-DETAIL | Detail — D | P0 | ✅ | `cmd:view.detail` | | | KEY-EDIT | Edit — E | P0 | ✅ | `cmd:panel.edit` | | | KEY-FULLSCREEN | Full-screen preview — F | P1 | ✅ | `cmd:view.fullScreenPreview` | | +| KEY-LIGHTSOUT | Lights Out — Shift+L (Classic's L is Linear Gradient here) | P2 | ✅ | `cmd:view.lightsOut` | On → Dim (80 %) → Off; Esc turns the lights on | | KEY-GRID | Grid — G | P0 | ✅ | `cmd:view.gridToggle`, `cmd:view.photoGrid` | G toggles Photo Grid ↔ Square Grid | | KEY-INFO | Info — I | P0 | ✅ | `cmd:panel.info` | | | KEY-KEYWORDS | Keywords — K | P0 | ✅ | `cmd:panel.keywords` | |