From 9adcfe1a2cdd208a6a2e42cc0a88bd6a05ba8450 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 2 Oct 2026 23:35:18 -0400 Subject: [PATCH] =?UTF-8?q?LR-VIEW-SECONDWINDOW:=20Window=20=E2=96=B8=20Se?= =?UTF-8?q?cond=20Window=20(=E2=8C=98F11)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The active photo, fitted, in its own native window (an egui immediate viewport) with its own render slot sized for that window; closing the window turns it off. Without native windows (web, headless) egui shows it as a floating panel. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/lib.rs | 2 ++ crates/ui-egui/src/menubar.rs | 1 + crates/ui-egui/src/menus.rs | 5 +++ crates/ui-egui/src/panels/mod.rs | 1 + crates/ui-egui/src/panels/second.rs | 49 +++++++++++++++++++++++++++++ crates/ui-egui/src/render.rs | 2 ++ crates/ui-egui/src/state.rs | 3 ++ crates/ui-egui/src/tests_masking.rs | 16 ++++++++++ docs/parity.md | 8 ++--- 9 files changed, 83 insertions(+), 4 deletions(-) create mode 100644 crates/ui-egui/src/panels/second.rs diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index adc98bdf9..5de32bc30 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -523,6 +523,7 @@ impl LightcraftApp { if self.ui.fullscreen { // full-screen preview: the photo alone on black egui::CentralPanel::default().frame(egui::Frame::NONE.fill(egui::Color32::BLACK)).show(ui, |ui| panels::detail::show(self, ui)); + panels::second::show(self, &ctx); panels::dialogs::show(self, &ctx); panels::toast(self, &ctx); self.widgets = widgets::take_registry(&ctx); @@ -552,6 +553,7 @@ impl LightcraftApp { state::ViewMode::Compare => panels::compare::show_compare(self, ui), state::ViewMode::Survey => panels::compare::show_survey(self, ui), }); + panels::second::show(self, &ctx); panels::dialogs::show(self, &ctx); import::progress(self, &ctx); export_task::poll(self, &ctx); diff --git a/crates/ui-egui/src/menubar.rs b/crates/ui-egui/src/menubar.rs index d771b9094..ccffe590b 100644 --- a/crates/ui-egui/src/menubar.rs +++ b/crates/ui-egui/src/menubar.rs @@ -228,6 +228,7 @@ pub fn checked(app: &LightcraftApp, id: &str) -> Option { match id { "develop.autoSync" => Some(app.session.auto_sync), "view.photoCounts" => Some(u.show_counts), + "view.secondWindow" => Some(u.second_window), "view.photoGrid" => Some(u.view == ViewMode::PhotoGrid), "view.squareGrid" => Some(u.view == ViewMode::SquareGrid), "view.detail" => Some(u.view == ViewMode::Detail), diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 483fbfb96..cfec4c1b7 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -83,6 +83,7 @@ pub const UI_COMMANDS: &[UiCommand] = &[ ("dialog.smartAlbum", "New Smart Album…", None, "File"), ("view.photoCounts", "Show Photo Counts", None, "View"), ("view.slideshow", "Slideshow", Some("Cmd+Alt+Enter"), "View"), + ("view.secondWindow", "Second Window", Some("Cmd+F11"), "Window"), ("dialog.allMetadata", "All Metadata…", None, "Photo"), ("dialog.newSmartAlbum", "New Smart Album from Filter…", Some("Cmd+Alt+N"), "File"), ("dialog.createPreset", "Create Preset…", Some("Cmd+Shift+P"), "Photo"), @@ -193,6 +194,10 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { + app.ui.second_window = p.get("show").and_then(Value::as_bool).unwrap_or(!app.ui.second_window); + Ok(json!({"show": app.ui.second_window})) + } "view.photoCounts" => { app.ui.show_counts = p.get("show").and_then(Value::as_bool).unwrap_or(!app.ui.show_counts); Ok(json!({"show": app.ui.show_counts})) diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index b140f710c..21833936a 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -14,6 +14,7 @@ pub mod presets; pub mod profiles; pub mod right; pub mod rules_editor; +pub mod second; pub mod settings; pub mod strip; pub mod topbar; diff --git a/crates/ui-egui/src/panels/second.rs b/crates/ui-egui/src/panels/second.rs new file mode 100644 index 000000000..1a07bfb0b --- /dev/null +++ b/crates/ui-egui/src/panels/second.rs @@ -0,0 +1,49 @@ +//! The second window (Window ▸ Second Window): the active photo, fitted, in its own window — +//! for a second display while the main window shows the grid or the tools. + +use egui::{Color32, Rect, pos2, vec2}; + +use crate::LightcraftApp; +use crate::render::Slot; + +pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { + if !app.ui.second_window { + return; + } + let vid = egui::ViewportId::from_hash_of("lightcraft-second-window"); + let builder = egui::ViewportBuilder::default().with_title("LightCraft — Second Window").with_inner_size([960.0, 640.0]); + ctx.show_viewport_immediate(vid, builder, |ctx, class| { + // (without native windows — web, headless — egui wraps this in a floating window) + egui::CentralPanel::default().frame(egui::Frame::NONE.fill(Color32::BLACK)).show(ctx, |ui| body(app, ui)); + if class != egui::ViewportClass::EmbeddedWindow && ctx.input(|i| i.viewport().close_requested()) { + app.ui.second_window = false; + } + }); +} + +fn body(app: &mut LightcraftApp, ui: &mut egui::Ui) { + let area = ui.available_rect_before_wrap(); + crate::widgets::register(ui.ctx(), "view:secondWindow", area); + ui.allocate_rect(area, egui::Sense::hover()); + let Some(id) = app.session.active() else { + ui.painter().text(area.center(), egui::Align2::CENTER_CENTER, "No photo selected", egui::FontId::proportional(14.0), Color32::GRAY); + return; + }; + // a render sized for this window + let ppp = ui.ctx().pixels_per_point(); + let (w, h) = (((area.width() * ppp) as usize).clamp(64, 4096), ((area.height() * ppp) as usize).clamp(64, 4096)); + if let Some(job) = app.session.loupe_job(id, w, h, true) + && app.renderer.textures.get(&Slot::Second).is_none_or(|t| t.key != job.key) + && !app.renderer.is_pending(Slot::Second) + { + app.renderer.request(Slot::Second, job, 95); + } + let mine = |s: Slot| app.renderer.textures.get(&s).filter(|t| t.photo == id); + let tex = mine(Slot::Second).or_else(|| mine(Slot::Main)).or_else(|| app.renderer.thumb(id)); + if let Some(t) = tex { + let [tw, th] = t.tex.size(); + let s = (area.width() / tw as f32).min(area.height() / th as f32); + let r = Rect::from_center_size(area.center(), vec2(tw as f32 * s, th as f32 * s)); + ui.painter().image(t.tex.id(), r, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); + } +} diff --git a/crates/ui-egui/src/render.rs b/crates/ui-egui/src/render.rs index 842b96976..24d275dde 100644 --- a/crates/ui-egui/src/render.rs +++ b/crates/ui-egui/src/render.rs @@ -45,6 +45,8 @@ pub enum Slot { Hover, /// An import candidate's thumbnail in the import review dialog (by index). Import(u32), + /// The second window's view of the active photo. + Second, } pub struct Tex { diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index a6031716d..5855974f9 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -272,6 +272,8 @@ pub struct UiState { pub auto_advance: bool, /// Full-screen preview (F): the photo alone on black, no chrome. pub fullscreen: bool, + /// Window ▸ Second Window. + pub second_window: bool, /// A running slideshow (full screen): seconds per photo, when the next one is due (egui /// time), paused. #[serde(skip)] @@ -490,6 +492,7 @@ impl Default for UiState { auto_advance: false, fullscreen: false, slideshow: None, + second_window: false, info_overlay: InfoOverlay::Off, navigator: true, settings: AppSettings::default(), diff --git a/crates/ui-egui/src/tests_masking.rs b/crates/ui-egui/src/tests_masking.rs index 0bad2a637..94434405f 100644 --- a/crates/ui-egui/src/tests_masking.rs +++ b/crates/ui-egui/src/tests_masking.rs @@ -446,3 +446,19 @@ fn edit_in_external_editor_opens_the_copy() { let _ = std::fs::remove_dir_all(&dir); h.settle(SETTLE); } + +#[test] +fn second_window_shows_the_active_photo() { + let mut h = detail("panel.edit"); + exec(&mut h, "view.photoGrid", json!({})); + exec(&mut h, "view.secondWindow", json!({"show": true})); + h.settle(SETTLE); + // headless has no native windows: it's embedded, and renders the photo for its own slot + let tex = h.app.renderer.textures.get(&crate::render::Slot::Second).map(|t| t.photo); + assert_eq!(tex, h.app.session.active(), "the second window has its own render of the active photo"); + let r = h.request("ui.clickWidget", json!({"id": "view:secondWindow"}), T); + assert_eq!(r["ok"], true, "on screen: {r}"); + exec(&mut h, "view.secondWindow", json!({})); + assert!(!h.app.ui.second_window); + h.settle(SETTLE); +} diff --git a/docs/parity.md b/docs/parity.md index e115490d1..956d3fffc 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -23,7 +23,7 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table |---|---:|---:|---:|---:|---:|---:| | A. Import (IMP) | 9 | 2 | 2 | 1 | 3/4 (75%) | 3/4 (75%) | | B. Library management (LIB) | 22 | 0 | 3 | 2 | 9/9 (100%) | 9/9 (100%) | -| C. Views & navigation (VIEW) | 15 | 0 | 2 | 0 | 9/9 (100%) | 4/4 (100%) | +| C. Views & navigation (VIEW) | 16 | 0 | 1 | 0 | 9/9 (100%) | 4/4 (100%) | | D. Search & filter (FILT) | 10 | 0 | 3 | 0 | 4/4 (100%) | 4/4 (100%) | | E. Metadata (META) | 5 | 1 | 0 | 0 | 2/2 (100%) | 2/2 (100%) | | F. Edit panel — global adjustments (EDIT) | 42 | 1 | 5 | 1 | 28/28 (100%) | 13/14 (93%) | @@ -48,9 +48,9 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table | Y. Menus | 77 | 2 | 6 | 8 | 46/47 (98%) | 22/23 (96%) | | Z. Keyboard shortcuts (desktop) | 74 | 3 | 3 | 1 | 49/52 (94%) | 22/23 (96%) | | Lightroom Classic extras | 22 | 10 | 47 | 9 | — | 18/21 (86%) | -| **Total** | 366 | 24 | 110 | 37 | 193/198 (97%) | 132/144 (92%) | +| **Total** | 367 | 24 | 109 | 37 | 193/198 (97%) | 132/144 (92%) | -Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **75.6%** of 500 in-scope rows — P0 98.7% of 198 · P1 94.4% of 144 · P2 29.4% of 158. +Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **75.8%** of 500 in-scope rows — P0 98.7% of 198 · P1 94.4% of 144 · P2 30.1% of 158. ## Top gaps @@ -162,7 +162,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-VIEW-SURVEY | Survey view [Classic] | P2 | ✅ | `cmd:view.survey`, `crates/ui-egui/src/panels/compare.rs` | selection tiled (≤ 48), keys act on the active photo, hover × removes | | LR-VIEW-INFOOVERLAY | Info overlay on the photo | P1 | ✅ | `cmd:view.infoOverlay` | off / file + date + size / exposure + camera; ⌘I cycles (I in full screen; elsewhere I stays the Info panel) | | LR-VIEW-SLIDESHOW | Slideshow | P2 | ✅ | `cmd:view.slideshow` | View ▸ Slideshow (⌥⌘↩): the photos in view full screen, every 4 s (`interval`), wrapping; Space pauses, ←/→ step, Esc ends | -| LR-VIEW-SECONDWINDOW | Second display window [Classic] | P2 | ⬜ | | | +| LR-VIEW-SECONDWINDOW | Second display window [Classic] | P2 | ✅ | `cmd:view.secondWindow`, `crates/ui-egui/src/panels/second.rs` | Window ▸ Second Window (⌘F11): the active photo fitted in its own native window with its own render (a floating panel where there are no native windows); loupe view only (no grid / compare / survey there) | | LR-VIEW-CLIPPING | Clipping indicators | P0 | ✅ | `cmd:view.clipping` | | | LR-VIEW-HISTOGRAM | Histogram | P0 | ✅ | `cmd:view.histogram`, `crates/ui-egui/src/panels/edit.rs` | no drag-to-adjust on the histogram | | LR-VIEW-HDR-DISPLAY | HDR display output | P2 | ⬜ | | |