This commit is contained in:
Claude committed 2026-10-11 11:43:08 +00:00
commit 38f16ff1b4
15 files changed
+166

No files matched your search

+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -737,6 +737,7 @@
"Free": "自由",
"Full": "全体",
"Full Screen Preview": "全画面プレビュー",
"Lights Out": "消灯モード",
"Full size": "元のサイズ",
"GPS Track Log": "GPS軌跡ファイル",
"General": "一般",
+1
View File
@@ -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",
+1
View File
@@ -455,6 +455,7 @@
"Friday": "Пятница",
"Full": "Полный",
"Full Screen Preview": "Полноэкранный просмотр",
"Lights Out": "Выключить свет",
"Full screen mode": "Полноэкранный режим",
"Full size": "Полный размер",
"GPS Track Log": "GPS-трек",
+1
View File
@@ -933,6 +933,7 @@
"Forget unchanged photos": "Забути незмінені фото",
"Forward": "Уперед",
"Full Screen Preview": "Повноекранний перегляд",
"Lights Out": "Вимкнути світло",
"Full size": "Повний розмір",
"GPS Track Log": "Журнал маршруту GPS",
"Geometry": "Геометрія",
+1
View File
@@ -691,6 +691,7 @@
"Friday": "星期五",
"Full": "完整",
"Full Screen Preview": "全屏预览",
"Lights Out": "关灯模式",
"Full screen mode": "全屏模式",
"Full size": "完整大小",
"GPS Track Log": "GPS 轨迹日志",
+1
View File
@@ -691,6 +691,7 @@
"Friday": "星期五",
"Full": "完整",
"Full Screen Preview": "全螢幕預覽",
"Lights Out": "關燈模式",
"Full screen mode": "全螢幕模式",
"Full size": "完整尺寸",
"GPS Track Log": "GPS 軌跡記錄",
+27
View File
@@ -79,6 +79,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;
@@ -1035,6 +1037,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 {
@@ -1082,6 +1108,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);
+2
View File
@@ -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<bool> {
"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),
+14
View File
@@ -106,6 +106,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"),
@@ -404,6 +405,8 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
"view.back" => {
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 {
@@ -463,6 +466,17 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
}
Ok(json!({"fullscreen": app.ui.fullscreen}))
}
"view.lightsOut" => {
// {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);
+36
View File
@@ -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.
@@ -873,6 +908,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,
+76
View File
@@ -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");
}
+2
View File
@@ -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` | |