LR-VIEW-SECONDWINDOW: Window ▸ Second Window (⌘F11)

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 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-02 23:35:18 -04:00
co-authored by Claude Opus 5.5
parent 59090387aa
commit 9adcfe1a2c
9 changed files with 83 additions and 4 deletions
+2
View File
@@ -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);
+1
View File
@@ -228,6 +228,7 @@ pub fn checked(app: &LightcraftApp, id: &str) -> Option<bool> {
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),
+5
View File
@@ -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<Re
app.ui.view = ViewMode::SquareGrid;
Ok(Value::Null)
}
"view.secondWindow" => {
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}))
+1
View File
@@ -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;
+49
View File
@@ -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);
}
}
+2
View File
@@ -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 {
+3
View File
@@ -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(),
+16
View File
@@ -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);
}
+4 -4
View File
@@ -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.
<!-- /parity:summary -->
## 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 | ⬜ | | |