Merge pull request #405 from storytold/claude/integrate-batch-10

Fix #310 (CR2/PEF starting look) and integrate #402 (menus stay below the menu bar)
This commit is contained in:
BFlat 2026-10-09 06:39:08 +07:00 committed by GitHub
commit 32cedb3153
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 338 additions and 20 deletions

View File

@ -24,7 +24,7 @@ duplicate Local entries #22, black GPU exports on an Intel iGPU #78).
|---|---:|---|---|
| **Feature checklist** | 79.7% | P0 core and P1 nearly complete: import (Add / Copy / Move, templates, devices), library, grid/loupe/compare/survey, every Edit slider, curves, colour grading, masking tools, crop/Upright, heal/clone, presets/profiles, versions/history, sync, export, menus, shortcuts | P0: full-resolution zoom detail; P1: lens-profile database, content-aware fill (patch synthesis), video playback/trim |
| **RAW coverage** (formats people shoot) | ~55% | DNG (all kinds), CR2, CR3 lossless Bayer and version 0x100/0x200 C-RAW (M50/R100/R8 pixel-exact corpus), ARW, NEF (uncompressed + Huffman lossless/lossy), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans), uncompressed ORF, RW2 / RWL / Panasonic RAW (every raw format, checked on 178 files from 118 Panasonic and Leica bodies), PEF; every container's embedded preview (incl. CR3) | **CR3** (every Canon since ~2018), compressed ORF, Nikon lossy-after-split, Canon sRAW, HEIC/AVIF. NEFs labelled compressed but stored uncompressed (Z 6 packed 14-bit, D850 12-bit uncompressed). Per-model verification is thin (~55 corpus files vs >1,000 models) |
| **Colour & image quality** | ~55–65% | Pipeline is complete and fast; GPU path CPU-exact within 1/255 | **No measured camera calibration database**: ARW, NEF, RW2, RAF and CR3 have guarded camera-JPEG colour estimates, with bundled ILCE-7M4, X-H2S and X-T4 profiles (docs/camera-preview-colour.md); other non-DNG raws and rejected estimates use a neutral matrix. Colour fidelity remains incomplete. No lens-profile database. No measured fidelity against Lightroom (tone, highlights, texture/clarity, NR, sharpening are tuned by eye) |
| **Colour & image quality** | ~55–65% | Pipeline is complete and fast; GPU path CPU-exact within 1/255 | **No measured camera calibration database**: ARW, NEF, RW2, RAF, CR3, CR2 and PEF have guarded camera-JPEG colour estimates, with bundled ILCE-7M4, X-H2S and X-T4 profiles (docs/camera-preview-colour.md); other non-DNG raws and rejected estimates use a neutral matrix. Colour fidelity remains incomplete. No lens-profile database. No measured fidelity against Lightroom (tone, highlights, texture/clarity, NR, sharpening are tuned by eye) |
| **AI & computational** | ~15–20% | Assisted culling (focus, bursts), auto tone, HDR/panorama merge; subject/sky/background masks as classical heuristics | Real segmentation masks (subject, sky, people, objects, landscape, depth), AI denoise, super resolution, lens blur, generative remove, faces/people, natural-language search. **Blocked on a model strategy** (licensable weights or our own training; pure-Rust inference is feasible) |
| **Workflow & library** | ~85% (single machine) | Robust catalog (journal + snapshots, background compaction, crash-tested), 85k-photo libraries stay responsive, Local browsing with automatic cleanup, XMP interop, keywords, smart albums, Move import | Opening an 85k library takes 1.7–4.7 s; no cloud sync (out of scope), no tablet companion (#74, roadmap), shared albums, publish services, tethering |
| **Classic modules** | ~30% | Geotagging from GPX track logs, soft proofing (partial), slideshow (basic) | **Map view, Book, Print, Slideshow module, Web, publish services**: ~40 tracker rows ⬜ |
@ -47,8 +47,9 @@ duplicate Local entries #22, black GPU exports on an Intel iGPU #78).
Priorities, in order. Each points at tracker rows in [`docs/parity.md`](docs/parity.md) → *Top gaps*.
1. **Camera colour calibration of our own** (LR-PROF-CAMERACOLOR, P0): fit each camera to its own embedded JPEG, use
matrices the files carry themselves, then chart shots. Sony ARW, Nikon NEF, Panasonic RW2 and Fujifilm RAF have the file-local fit
(matrix + tone curve from their own JPEG); generalise it to the other makes' raws (PEF, ORF…), then validate fidelity.
matrices the files carry themselves, then chart shots. Sony ARW, Nikon NEF, Panasonic RW2, Fujifilm RAF, Canon CR3/CR2
and Pentax PEF have the file-local fit (matrix + tone curve from their own JPEG; CR2 and PEF since issue #310, not yet
checked on the corpus); then ORF, then validate fidelity.
2. **Raw formats, clean-room** (LR-IMP-FORMATS, P0): Remaining **unverified CR3 CRX** variants, compressed ORF, NEF
lossy-after-split, sRAW. Decided 2026-10-05: write our own decoders from prose descriptions (never decoder source,
no LGPL dependency); compressed NEF (#86) is the template.
@ -186,7 +187,7 @@ Not decoded yet — preview only (no permissively licensed description; black-bo
alignment or reset predictors at the split row). The other Nikon Huffman variants are decoded (`crates/raw/src/vendor/nefc.rs` documents the analysis).
- **Olympus compressed ORF**, **Canon CR3 unverified CRX variants** (M11.1), **Canon sRAW/mRAW**, lossy DNG.
**Camera colour matrices:** ARW, NEF, RW2, RAF and CR3 files can use guarded, separate chromaticity and tone estimates from their own embedded JPEG (see `docs/camera-preview-colour.md`); this is a per-file camera-look estimate with relative WB, not measured calibration or absolute-Kelvin WB. Other non-DNG raws and rejected fits use the documented neutral fallback (camera RGB ≈ linear sRGB, flagged
**Camera colour matrices:** ARW, NEF, RW2, RAF, CR3, CR2 and PEF files can use guarded, separate chromaticity and tone estimates from their own embedded JPEG (see `docs/camera-preview-colour.md`); this is a per-file camera-look estimate with relative WB, not measured calibration or absolute-Kelvin WB. Other non-DNG raws and rejected fits use the documented neutral fallback (camera RGB ≈ linear sRGB, flagged
`matrix_is_fallback`) with the file's as-shot white-balance multipliers. Clean sources to evaluate next: manufacturer
matrices stored in the files themselves (Olympus ImageProcessing `ColorMatrix`, Pentax equivalents; Panasonic RW2 files carry none) and our
own chart-based calibration (M11.4). Adobe matrices are never used.

View File

@ -336,12 +336,13 @@ impl Photo {
pub fn develops_raw(&self) -> bool {
self.kind == MediaKind::Raw && self.preview_only.is_none()
}
/// The current ARW, NEF, RW2, RAF and CR3 readers have vendor WB multipliers but no measured camera
/// The current ARW, NEF, RW2, RAF, CR3, CR2 and PEF readers have vendor WB multipliers but no measured camera
/// illuminant. Use adjustments relative to the camera's as-shot look, as for rendered
/// photographs (the engine's `camera_preview::file_local_look` covers the same formats; RWL and
/// RAW are Leica's and the oldest Panasonic bodies' names for RW2 files).
pub fn relative_wb(&self) -> bool {
self.develops_raw() && ["ARW", "NEF", "NRW", "RW2", "RWL", "RAW", "RAF", "CR3"].iter().any(|f| self.format.eq_ignore_ascii_case(f))
self.develops_raw()
&& ["ARW", "NEF", "NRW", "RW2", "RWL", "RAW", "RAF", "CR3", "CR2", "PEF"].iter().any(|f| self.format.eq_ignore_ascii_case(f))
}
/// The develop settings import gave this photo: [`Photo::camera_defaults`], or the user's
/// default preset applied on top of them ([`Photo::import_look`]).

View File

@ -31,7 +31,10 @@ pub(crate) const PROFILE_PROXY: usize = 192;
/// as-shot look (`docs/camera-preview-colour.md`). The catalog's `Photo::relative_wb` matches the
/// same formats by file extension.
pub(crate) fn file_local_look(format: RawFormat) -> bool {
matches!(format, RawFormat::Arw | RawFormat::Nef | RawFormat::Nrw | RawFormat::Rw2 | RawFormat::Raf | RawFormat::Cr3)
matches!(
format,
RawFormat::Arw | RawFormat::Nef | RawFormat::Nrw | RawFormat::Rw2 | RawFormat::Raf | RawFormat::Cr3 | RawFormat::Cr2 | RawFormat::Pef
)
}
pub(crate) fn fit_preview(raw: &RawImage, bytes: &[u8], transform: &CameraTransform) -> Option<CameraLook> {
@ -1017,8 +1020,11 @@ mod tests {
#[test]
fn supported_raws_get_a_file_local_look() {
assert!([RawFormat::Arw, RawFormat::Nef, RawFormat::Nrw, RawFormat::Rw2, RawFormat::Raf, RawFormat::Cr3].into_iter().all(file_local_look));
assert!(![RawFormat::Dng, RawFormat::Cr2].into_iter().any(file_local_look));
// every decoded raw without a colour matrix of its own (issue #310: CR2 rendered flat with the fallback)
let local = [RawFormat::Arw, RawFormat::Nef, RawFormat::Nrw, RawFormat::Rw2, RawFormat::Raf, RawFormat::Cr3, RawFormat::Cr2, RawFormat::Pef];
assert!(local.into_iter().all(file_local_look));
// DNG carries its own colour model
assert!(!file_local_look(RawFormat::Dng));
}
#[test]

View File

@ -62,7 +62,7 @@ impl SettingsHashes {
}
/// Bump when the pipeline's output changes, to invalidate cached thumbnails.
pub const RENDER_CACHE_VERSION: u64 = 14;
pub const RENDER_CACHE_VERSION: u64 = 15;
/// Thumbnails render at one of these long edges (so window/cell size changes reuse the cache).
pub const THUMB_SIZES: [usize; 4] = [128, 256, 384, 512];

View File

@ -14,7 +14,7 @@ fn active_dev(s: &Session) -> lightcraft_develop::DevelopSettings {
fn changing_one_wb_control_resolves_as_shot_without_stale_tint() {
use lightcraft_catalog::{Photo, PhotoId, Source};
use lightcraft_develop::{DevelopSettings, WbMode};
for format in ["ARW", "NEF", "NRW", "RAF"] {
for format in ["ARW", "NEF", "NRW", "RAF", "CR2", "PEF"] {
let mut s = demo();
let id = PhotoId(100);
let name = format!("synthetic.{format}");

View File

@ -1287,6 +1287,8 @@
"Scanning": "Wird durchsucht",
"Scores stay on the photos: filter or make smart albums with Focus and Best of Similar Shots.": "Die Werte bleiben an den Fotos gespeichert: Filtere danach oder erstelle Smart-Alben mit Schärfe und „Beste ähnlicher Aufnahmen“.",
"Screen": "Bildschirm",
"Scroll menu down": "Menü nach unten scrollen",
"Scroll menu up": "Menü nach oben scrollen",
"Search": "Suche",
"Search Photos": "Fotos suchen",
"Search commands or keys": "Befehle oder Tasten suchen",

View File

@ -1235,6 +1235,8 @@
"Scanning": "Explorando",
"Scores stay on the photos: filter or make smart albums with Focus and Best of Similar Shots.": "Las puntuaciones se quedan en las fotos: se pueden filtrar o crear álbumes inteligentes con Enfoque y La mejor de las tomas similares.",
"Screen": "Pantalla",
"Scroll menu down": "Desplazar el menú hacia abajo",
"Scroll menu up": "Desplazar el menú hacia arriba",
"Search": "Buscar",
"Search Photos": "Buscar fotos",
"Search commands or keys": "Buscar comandos o teclas",

View File

@ -1172,6 +1172,8 @@
"Scanning": "検索中",
"Scores stay on the photos: filter or make smart albums with Focus and Best of Similar Shots.": "評価結果は写真に保存されます。ピント評価や類似写真内のベストショットで絞り込み・スマートアルバム作成ができます",
"Screen": "画面用",
"Scroll menu down": "メニューを下にスクロール",
"Scroll menu up": "メニューを上にスクロール",
"Search": "検索",
"Search Photos": "写真を検索",
"Search commands or keys": "コマンドまたはキーを検索",

View File

@ -1245,6 +1245,8 @@
"Scanning": "Examinando",
"Scores stay on the photos: filter or make smart albums with Focus and Best of Similar Shots.": "As pontuações ficam nas fotos: filtre ou crie álbuns inteligentes com Foco e Melhor de fotos semelhantes.",
"Screen": "Tela",
"Scroll menu down": "Rolar o menu para baixo",
"Scroll menu up": "Rolar o menu para cima",
"Search": "Pesquisar",
"Search Photos": "Pesquisar fotos",
"Search commands or keys": "Pesquisar comandos ou teclas",

View File

@ -1027,6 +1027,8 @@
"Scanning": "Сканирование",
"Scores stay on the photos: filter or make smart albums with Focus and Best of Similar Shots.": "Оценки остаются на фото: фильтруйте или создавайте смарт-альбомы по Focus и Best of Similar Shots.",
"Screen": "Экран",
"Scroll menu down": "Прокрутить меню вниз",
"Scroll menu up": "Прокрутить меню вверх",
"Search Photos": "Поиск фото",
"Search commands or keys": "Поиск команд или клавиш",
"Search…": "Поиск…",

View File

@ -1233,6 +1233,8 @@
"Scanning": "正在扫描",
"Scores stay on the photos: filter or make smart albums with Focus and Best of Similar Shots.": "评分会保留在照片上:可使用“对焦”和“相似照片中的最佳”进行筛选或创建智能相册。",
"Screen": "屏幕",
"Scroll menu down": "向下滚动菜单",
"Scroll menu up": "向上滚动菜单",
"Search": "搜索",
"Search Photos": "搜索照片",
"Search commands or keys": "搜索命令或按键",

View File

@ -1233,6 +1233,8 @@
"Scanning": "正在掃描",
"Scores stay on the photos: filter or make smart albums with Focus and Best of Similar Shots.": "分數會保留在照片上:可依焦點與相似照片中的最佳照片篩選,或建立智慧型相簿。",
"Screen": "螢幕",
"Scroll menu down": "向下捲動選單",
"Scroll menu up": "向上捲動選單",
"Search": "搜尋",
"Search Photos": "搜尋照片",
"Search commands or keys": "搜尋命令或按鍵",

View File

@ -15,6 +15,7 @@ pub mod icons;
pub mod import;
pub mod lightroom_import;
pub mod links;
pub mod menu_level;
pub mod menubar;
pub mod menus;
pub mod merge;
@ -44,6 +45,8 @@ mod tests_masking;
#[cfg(test)]
mod tests_masking_layout;
#[cfg(test)]
mod tests_menubar;
#[cfg(test)]
mod tests_offline;
#[cfg(test)]
mod tests_panels;

View File

@ -0,0 +1,179 @@
//! Menus bounded by the window (#189). egui keeps a popup inside the window: a menu or submenu
//! taller than the room below the menu bar was slid up over the bar, which hid the menu titles,
//! and the pointer that had rested on the title then rested on the menu's first row and opened
//! its submenu by itself. Here every level of a menu gets only the room it has below the bar (a
//! submenu: from its row down, or up to the bar when egui opens it upward) and scrolls inside
//! it: wheel, dragging, and ▲ / ▼ strips that scroll while the pointer rests on them, like
//! native menus on a small display. The same approach as PhotoCraft's menu bar (its #319).
use egui::{Id, Rect, Sense, Ui, pos2, vec2};
/// Height of a scroll-arrow strip.
pub const ARROW: f32 = 16.0;
/// Gap kept between a menu and the window edge.
const EDGE: f32 = 6.0;
/// How fast resting on a scroll arrow scrolls, in points per second.
const SPEED: f32 = 600.0;
fn anchor_id(depth: usize) -> Id {
Id::new(("lc-menu-anchor", depth))
}
/// Remember the row the submenu at `depth` (2 = a top-level menu's submenu) hangs from. It is
/// read on the next frame: a submenu is drawn while its row is, before the row's rect is known.
pub fn set_anchor(ctx: &egui::Context, depth: usize, row: Rect) {
let pass = ctx.cumulative_pass_nr();
ctx.data_mut(|d| d.insert_temp(anchor_id(depth), (row, pass)));
}
/// The row the submenu at `depth` hung from on the previous frame (none when it has just opened,
/// or when a stored row belongs to a submenu that has closed since).
fn anchor(ctx: &egui::Context, depth: usize) -> Option<Rect> {
anchor_at(ctx.data(|d| d.get_temp(anchor_id(depth))), ctx.cumulative_pass_nr())
}
/// The anchor `stored` as `(row, pass it was set on)`, if that was the pass before `pass`.
fn anchor_at(stored: Option<(Rect, u64)>, pass: u64) -> Option<Rect> {
stored.filter(|(_, at)| at.saturating_add(1) == pass).map(|(row, _)| row)
}
/// The height a menu level's rows may take. Every level stays below the menu bar: a top-level
/// menu hangs from it, and a submenu that is too tall for the window between its row and the
/// bottom, or (opening upward, as egui does when that fits) between the bar and its row, scrolls
/// instead of egui sliding it up over the bar. `anchor` is the submenu's row (unknown on its
/// first frame), `rows` the level's rows' height when known (the popup shrinks to it), and
/// `frame` the popup's margins.
pub fn level_room(screen: Rect, bar_bottom: Option<f32>, depth: usize, anchor: Option<Rect>, rows: Option<f32>, frame: f32) -> f32 {
let top = bar_bottom.unwrap_or(screen.top()).max(screen.top()) + EDGE;
let bottom = screen.bottom() - EDGE;
let below_bar = bottom - top;
let room = match anchor.filter(|_| depth > 1) {
Some(row) => {
// Downward from the row, or upward from it, whichever leaves more room. egui opens a
// submenu upward only when the downward one doesn't fit the window (it hangs from the
// row's top less half the frame margin).
let (down, up) = (bottom - row.top(), row.bottom() - top);
let opens_up = |h: f32| row.top() - (frame - 2.0) / 2.0 + h + frame > screen.bottom();
let want = rows.unwrap_or(f32::INFINITY);
if want > down && up > down && opens_up(want.min(up)) { up } else { down }.min(below_bar)
}
None => below_bar,
} - frame;
if room.is_finite() { room.max(4.0 * ARROW) } else { 4.0 * ARROW }
}
/// Is the pointer over `rect` on this ui's layer (not under a submenu drawn on top)?
fn pointer_on(ui: &Ui, rect: Rect) -> bool {
ui.ctx().pointer_hover_pos().is_some_and(|p| rect.contains(p) && ui.ctx().layer_id_at(p) == Some(ui.layer_id()))
}
/// Draw one level of a menu, bounded by the window: when its rows don't fit the room below the
/// menu bar they scroll, with scroll arrows at the top and bottom. `depth` is 1 for a top-level
/// menu; `bar_bottom` is where the menu bar ends. The level's rect is registered as
/// `menu-level:<depth>` for the control channel and tests.
pub fn level(ui: &mut Ui, depth: usize, bar_bottom: Option<f32>, rows: impl FnOnce(&mut Ui)) {
let ctx = ui.ctx().clone();
let screen = ctx.content_rect();
// The menu frame's margin and stroke around the rows.
let frame = ui.spacing().menu_margin.sum().y + 2.0;
let key = ui.id().with(("lc-menu-level", depth));
// Rows' height from the last frame: does this level overflow?
let content: Option<f32> = ctx.data(|d| d.get_temp(key));
let room = level_room(screen, bar_bottom, depth, anchor(&ctx, depth), content, frame);
let over = content.is_some_and(|h| h > room + 0.5);
let up = over.then(|| ui.allocate_exact_size(vec2(0.0, ARROW), Sense::hover()).0);
let height = if over { room - 2.0 * ARROW } else { room };
// The popup's `Ui` is only as tall as the popup was last frame (egui's default area size on
// the first), and a scroll area never grows past that. Ask for the room the window has;
// auto-shrink then fits the area to its rows, so a menu scrolls only when they don't fit.
let out = egui::ScrollArea::vertical()
.id_salt(("menu-level", depth))
.max_height(height)
.min_scrolled_height(height)
.scroll_source(egui::containers::scroll_area::ScrollSource::ALL)
.show(ui, rows);
let down = over.then(|| ui.allocate_exact_size(vec2(0.0, ARROW), Sense::hover()).0);
ctx.data_mut(|d| d.insert_temp(key, out.content_size.y));
crate::widgets::register(&ctx, format!("menu-level:{depth}"), ui.min_rect());
let (Some(up), Some(down)) = (up, down) else { return };
let t = crate::theme::Tokens::get(&ctx);
let x = ui.min_rect().x_range();
let max = (out.content_size.y - out.inner_rect.height()).max(0.0);
let offset = out.state.offset.y;
let dt = ctx.input(|i| i.stable_dt).clamp(0.0, 0.1);
let mut target = offset;
for (strip, dir, live) in [(up, -1.0, offset > 0.5), (down, 1.0, offset < max - 0.5)] {
let strip = Rect::from_x_y_ranges(x, strip.y_range());
let c = strip.center();
let (h, w) = (4.5, 6.0);
let tri = if dir < 0.0 {
vec![pos2(c.x - w, c.y + h * 0.5), pos2(c.x + w, c.y + h * 0.5), pos2(c.x, c.y - h * 0.5)]
} else {
vec![pos2(c.x - w, c.y - h * 0.5), pos2(c.x + w, c.y - h * 0.5), pos2(c.x, c.y + h * 0.5)]
};
ui.painter().add(egui::Shape::convex_polygon(tri, if live { t.text } else { t.text_disabled }, egui::Stroke::NONE));
let id = key.with(dir < 0.0);
let r = ui.interact(strip, id, Sense::hover());
r.widget_info(|| {
egui::WidgetInfo::labeled(egui::WidgetType::Button, live, crate::i18n::tr(if dir < 0.0 { "Scroll menu up" } else { "Scroll menu down" }))
});
if live && pointer_on(ui, strip) {
target = (offset + dir * SPEED * dt.max(1.0 / 120.0)).clamp(0.0, max);
ctx.request_repaint();
}
// The wheel scrolls over the arrows too.
if pointer_on(ui, strip) {
let wheel = ctx.input(|i| i.smooth_scroll_delta.y);
if wheel != 0.0 {
target = (target - wheel).clamp(0.0, max);
}
}
}
if target != offset {
let mut st = out.state;
st.offset.y = target;
st.store(&ctx, out.id);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn level_room_keeps_every_level_below_the_bar() {
let screen = Rect::from_min_size(egui::Pos2::ZERO, vec2(1280.0, 703.0));
let (bar, frame) = (Some(42.0), 14.0);
let below = 703.0 - EDGE - (42.0 + EDGE) - frame;
assert_eq!(level_room(screen, bar, 1, None, None, frame), below);
// A submenu from a row near the top opens downward with the room under it…
let row = Rect::from_min_size(pos2(100.0, 80.0), vec2(200.0, 30.0));
assert_eq!(level_room(screen, bar, 2, Some(row), None, frame), 703.0 - EDGE - 80.0 - frame);
// …and from a row near the bottom, upward to the bar.
let low = Rect::from_min_size(pos2(100.0, 600.0), vec2(200.0, 30.0));
assert_eq!(level_room(screen, bar, 2, Some(low), None, frame), 630.0 - (42.0 + EDGE) - frame);
// A short submenu from a low row still fits downward: it keeps that room.
assert_eq!(level_room(screen, bar, 2, Some(low), Some(50.0), frame), 703.0 - EDGE - 600.0 - frame);
// Never more than the space under the bar, never less than the scroll arrows need.
let odd = Rect::from_min_size(pos2(100.0, -50.0), vec2(200.0, 30.0));
assert!(level_room(screen, bar, 2, Some(odd), None, frame) <= below);
assert_eq!(level_room(Rect::from_min_size(egui::Pos2::ZERO, vec2(100.0, 40.0)), bar, 1, None, None, frame), 4.0 * ARROW);
assert_eq!(level_room(screen, Some(f32::NAN), 1, None, None, frame), level_room(screen, None, 1, None, None, frame));
assert_eq!(level_room(Rect::NOTHING, bar, 2, Some(row), None, frame), 4.0 * ARROW);
// A top-level menu ignores a stale anchor.
assert_eq!(level_room(screen, bar, 1, Some(low), None, frame), below);
}
#[test]
fn an_anchor_is_read_on_the_next_frame_only() {
let row = Rect::from_min_size(pos2(10.0, 20.0), vec2(200.0, 24.0));
// set on pass 7: the submenu's room is measured from it on pass 8…
assert_eq!(anchor_at(Some((row, 7)), 8), Some(row));
// …not on the pass it was set (the row is drawn after its submenu), nor once the
// submenu has closed and stopped refreshing it
assert_eq!(anchor_at(Some((row, 7)), 7), None);
assert_eq!(anchor_at(Some((row, 7)), 9), None);
assert_eq!(anchor_at(None, 8), None);
assert_eq!(anchor_at(Some((row, u64::MAX)), 0), None);
}
}

View File

@ -746,22 +746,27 @@ pub fn show_in_window(app: &mut LightcraftApp, ui: &mut egui::Ui, max_width: f32
let mut clicked: Option<(String, Value)> = None;
let start = ui.cursor().left();
let mac = ui.ctx().os() == egui::os::OperatingSystem::Mac;
// Menus hang below the whole top bar and never grow past the window (#189): a tall menu
// scrolls instead of egui sliding it up over the titles.
let bar_bottom = Some(ui.max_rect().bottom());
if total <= max_width {
let saved = ui.spacing().item_spacing.x;
ui.spacing_mut().item_spacing.x = TITLE_GAP;
for (title, items) in &bar {
let r = ui.add(egui::Button::new(egui::RichText::new(crate::i18n::tr(title)).font(font.clone()).color(t.text_label)).frame(false));
crate::widgets::register(ui.ctx(), format!("menu:{title}"), r.rect);
egui::Popup::menu(&r).show(|ui| nodes_ui(ui, items, mac, &mut clicked));
egui::Popup::menu(&r).show(|ui| crate::menu_level::level(ui, 1, bar_bottom, |ui| nodes_ui(ui, items, mac, &mut clicked, 1, bar_bottom)));
}
ui.spacing_mut().item_spacing.x = saved;
} else {
let r = ui.add(egui::Button::new(egui::RichText::new(crate::i18n::tr("Menu")).font(font.clone()).color(t.text_label)).frame(false));
crate::widgets::register(ui.ctx(), "menu:all", r.rect);
egui::Popup::menu(&r).show(|ui| {
for (title, items) in &bar {
ui.menu_button(crate::i18n::tr(title), |ui| nodes_ui(ui, items, mac, &mut clicked));
}
crate::menu_level::level(ui, 1, bar_bottom, |ui| {
for (title, items) in &bar {
submenu(ui, title, crate::i18n::tr(title).to_string(), 1, bar_bottom, |ui| nodes_ui(ui, items, mac, &mut clicked, 2, bar_bottom));
}
});
});
}
if let Some((id, params)) = clicked {
@ -776,7 +781,19 @@ pub fn show_in_window(app: &mut LightcraftApp, ui: &mut egui::Ui, max_width: f32
ui.cursor().left() - start
}
fn nodes_ui(ui: &mut egui::Ui, nodes: &[MenuNode], mac: bool, clicked: &mut Option<(String, Value)>) {
/// A submenu row showing `text` at `depth`, whose rows `children` draws one level deeper,
/// bounded like every level. The row is registered as `menusub:<label>` (the untranslated
/// label), and while its submenu is open it is the anchor the next frame's room is measured from.
fn submenu(ui: &mut egui::Ui, label: &str, text: String, depth: usize, bar_bottom: Option<f32>, children: impl FnOnce(&mut egui::Ui)) {
let r = ui.menu_button(text, |ui| crate::menu_level::level(ui, depth + 1, bar_bottom, children));
crate::widgets::register(ui.ctx(), format!("menusub:{label}"), r.response.rect);
if r.inner.is_some() {
crate::menu_level::set_anchor(ui.ctx(), depth + 1, r.response.rect);
}
}
/// `depth` is 1 for a top-level menu's rows; `bar_bottom` is where the menu bar ends.
fn nodes_ui(ui: &mut egui::Ui, nodes: &[MenuNode], mac: bool, clicked: &mut Option<(String, Value)>, depth: usize, bar_bottom: Option<f32>) {
ui.set_min_width(220.0);
for n in nodes {
match n {
@ -784,7 +801,9 @@ fn nodes_ui(ui: &mut egui::Ui, nodes: &[MenuNode], mac: bool, clicked: &mut Opti
ui.separator();
}
MenuNode::Submenu { label, children } => {
ui.menu_button(format!(" {}", crate::i18n::tr(label)), |ui| nodes_ui(ui, children, mac, clicked));
submenu(ui, label, format!(" {}", crate::i18n::tr(label)), depth, bar_bottom, |ui| {
nodes_ui(ui, children, mac, clicked, depth + 1, bar_bottom)
});
}
MenuNode::Item { id, params, label, shortcut, enabled, checked } => {
// a gutter for check marks, like native menus

View File

@ -0,0 +1,86 @@
//! #189: the in-window menu bar's menus and submenus stay below the bar. On a short window a
//! tall menu (Photo) used to be slid up over the bar by egui, hiding the menu titles, and the
//! pointer left on the title then opened the first row's submenu by itself.
use crate::headless::Headless;
use crate::{LightcraftApp, Services};
use serde_json::json;
use std::time::Duration;
const T: Duration = Duration::from_secs(5);
const SETTLE: Duration = Duration::from_secs(10);
fn app(size: [f32; 2]) -> Headless {
let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() });
let mut h = Headless::new(app, size, 1.0);
h.settle(SETTLE);
h
}
fn widget(h: &Headless, id: &str) -> Option<egui::Rect> {
h.app.widgets.iter().find(|(w, _)| w == id).map(|(_, r)| *r)
}
fn widgets_with(h: &Headless, prefix: &str) -> Vec<(String, egui::Rect)> {
h.app.widgets.iter().filter(|(w, _)| w.starts_with(prefix)).cloned().collect()
}
/// The open level `id` hangs below every menu title and ends inside the window.
fn check_bar_clear(h: &Headless, id: &str, what: &str) {
let level = widget(h, id).unwrap_or_else(|| panic!("{what}: no {id} on screen"));
let titles = widgets_with(h, "menu:");
assert!(!titles.is_empty(), "{what}: no menu titles");
let bar_bottom = titles.iter().map(|(_, r)| r.bottom()).fold(f32::MIN, f32::max);
assert!(level.top() >= bar_bottom - 0.5, "{what}: {id} {level:?} starts above the bar's bottom {bar_bottom}");
assert!(level.bottom() <= h.size.y + 0.5, "{what}: {id} {level:?} runs past the window's bottom {}", h.size.y);
for (title, r) in &titles {
assert!(!level.intersects(*r), "{what}: {id} {level:?} covers {title} {r:?}");
}
}
#[test]
fn no_menu_or_submenu_covers_the_menu_bar() {
// The reporter's 1280 × 720 display less the title bar, and a shorter window where even a
// scrolled menu's submenus must open upward.
let sizes = [[1280.0, 703.0], [1160.0, 560.0]];
let mut submenus = 0;
for size in sizes {
for top in ["Photo", "File", "View"] {
let what = format!("{size:?} {top}");
let mut h = app(size);
assert!(widget(&h, "menu-level:1").is_none(), "{what}: a menu is open before any click");
let r = h.request("ui.clickWidget", json!({"id": format!("menu:{top}")}), T);
assert_eq!(r["ok"], true, "{what}: {r}");
h.settle(SETTLE);
check_bar_clear(&h, "menu-level:1", &what);
// Hover each submenu row in view, top to bottom: rows low in the menu open upward.
let level = widget(&h, "menu-level:1").unwrap_or_else(|| panic!("{what}: menu"));
let rows: Vec<String> = widgets_with(&h, "menusub:").into_iter().filter(|(_, r)| level.contains_rect(*r)).map(|(id, _)| id).collect();
for row in rows {
let r = h.request("ui.hoverWidget", json!({"id": row}), T);
assert_eq!(r["ok"], true, "{what}: {r}");
h.settle(SETTLE);
if widget(&h, "menu-level:2").is_none() {
continue;
}
submenus += 1;
check_bar_clear(&h, "menu-level:2", &format!("{what} › {row}"));
}
}
}
assert!(submenus >= 6, "opened {submenus} submenus");
}
#[test]
fn a_tall_menu_scrolls_instead_of_growing_past_the_window() {
let mut h = app([1160.0, 420.0]);
let r = h.request("ui.clickWidget", json!({"id": "menu:Photo"}), T);
assert_eq!(r["ok"], true, "{r}");
h.settle(SETTLE);
check_bar_clear(&h, "menu-level:1", "420 pt window");
let level = widget(&h, "menu-level:1").expect("Photo menu");
// Not every row fits: some are scrolled out of view, none drawn outside the menu.
let rows = widgets_with(&h, "menusub:");
assert!(!rows.is_empty(), "no submenu rows registered");
assert!(rows.iter().any(|(_, r)| !level.contains_rect(*r)), "every row fits a 420 pt window: {rows:?}");
}

View File

@ -1,6 +1,6 @@
# Sony ARW, Nikon NEF, Panasonic RW2, Fujifilm RAF and Canon CR3 starting look
# Sony ARW, Nikon NEF, Panasonic RW2, Fujifilm RAF, Canon CR3/CR2 and Pentax PEF starting look
ARW, NEF, RW2, RAF and CR3 decoding supply sensor data and camera white-balance multipliers, but currently no measured camera colour matrix. Treating camera RGB as linear sRGB produces a dark, muted default rendering. The loader now estimates a file-local starting look from that file's embedded camera JPEG, only when a usable camera matrix is absent. All output pixels still come from the RAW sensor data; there is no JPEG replacement or uniform saturation boost.
ARW, NEF, RW2, RAF, CR3, CR2 and PEF decoding supply sensor data and camera white-balance multipliers, but currently no measured camera colour matrix. Treating camera RGB as linear sRGB produces a dark, muted default rendering. The loader now estimates a file-local starting look from that file's embedded camera JPEG, only when a usable camera matrix is absent. All output pixels still come from the RAW sensor data; there is no JPEG replacement or uniform saturation boost.
## Colour and tone are separate
@ -84,3 +84,10 @@ Nikon maker-note `CropArea` (0x0045) supplies the default `[left, top, width, he
An embedded JPEG with neither ICC nor EXIF metadata uses Nikon maker-note `ColorSpace` (0x001e: 1 = sRGB, 2 = Adobe RGB). This fallback is applied before transfer decoding and resizing, and used by quick previews, preview-only RAW and the existing camera-look reference decode. A JPEG's own metadata takes precedence. No fitting algorithm, training split, highlight reconstruction or white-balance control changes are included. Render cache version 12 invalidates older previews.
Synthetic tests cover crop bounds/CFA preservation, colour-space tags, fallback transfer decoding and metadata precedence. A private Nikon Z 8 sample reports an 8256 × 5504 default crop from an 8280 × 5520 sensor. No private sample or Adobe asset is included.
### Canon CR2 and Pentax PEF
CR2 and PEF decode without a camera colour matrix too, but were left out of the fit until issue #310 (CR2 files opening
flat and desaturated: the camera's preview first, then the neutral fallback). They now use the same fit, gates and
relative white balance. This change was not measured on the corpus: the gates keep the fallback for any file whose fit
doesn't pass, so a file can only stay as before or move closer to its camera JPEG. Per-model numbers are still to come.

View File

@ -287,7 +287,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
| LR-PROF-ADOBE | Standard raw looks (own equivalents) | P0 | ✅ | `crates/engine/src/presets.rs` (`PROFILES`), `crates/pipeline/src/profiles.rs` | six own looks: Color, Neutral, Vivid, Landscape, Portrait, Monochrome |
| LR-PROF-ADAPTIVE | Adaptive profiles | P2 | ⬜ | | |
| LR-PROF-CAMERA | Camera-matching looks | P2 | ⬜ | | |
| LR-PROF-CAMERACOLOR | Camera colour calibration (own) | P0 | 🟡 | `crates/raw/src/color.rs`, `crates/raw/src/profile.rs`, `crates/engine/src/camera_preview.rs` | DNG files use the colour matrices and the camera-profile look they carry (`ProfileHueSatMap`, `ProfileLookTable`, `ProfileToneCurve`, read from the file at run time per DNG spec ch. 6 — Lightroom-converted DNGs rendered flat and muted without them, issue #138); Sony ARW, Nikon NEF, Panasonic RW2 and Fujifilm RAF and Canon CR3 get a guarded file-local fit to their own embedded JPEG — chromaticity matrix + hue/saturation/value table + tone and chroma curves, relative WB — or, with a bundled or local per-model profile pooled from many photos (`lightcraft-cli calibrate`, ARW, NEF and RAF so far, `crates/engine/src/camera_profiles.rs`), the profile's colour and only their own tone/chroma curves (on 29 held-out ILCE-7M4 photos mean ΔE vs the camera JPEG 3.54 → 3.10), kept with smart previews (see docs/camera-preview-colour.md); ARW: on 10 public raw.pixls.us samples from 8 bodies 7 fits were accepted, mean ΔE vs the camera JPEG 17–26 → 3–10; NEF: 13 of 13 decodable samples (D750, D780, D850, D7500, Z 50 and local D7500 shots) accepted, ΔE 13–46 → 3–8 (issue #150); RW2 / RWL: 140 of 174 public samples accepted, from 93 of the 114 bodies with a preview, median ΔE 5.0 with matching lightness (13 of the 14 corpus files with a preview: ΔE 9.6–27 → 2.8–8.9; most rejections are compacts and kit zooms whose camera JPEG is distortion-corrected); a fit that fails the gates on all pixels is retried away from edges, where a lens-corrected camera JPEG still lines up (issue #232: the six distortion-corrected public ILCE-7RM2 samples ΔE 16.6–16.9 → 3.7–3.8, the corpus GX80 19.1 → 7.9); RAF: bundled X-H2S/X-T4 profiles from 201/545 photos; on 150 withheld files mean ΔE improves slightly over per-file fitting (X-H2S 1.70 → 1.62, X-T4 2.43 → 2.34); 23 of 24 references from 13 bodies accepted, including the dark X-H2S samples (8.74 → 2.25) (`docs/camera-preview-colour.md`); this is not measured camera calibration; other raws and rejected fits use a neutral fallback (camera RGB ≈ linear sRGB, `matrix_is_fallback`) with as-shot white balance, so colours are muted and not accurate. Needs our own per-camera calibration: matrices the files carry themselves (e.g. Olympus `ColorMatrix`), fitting each camera to its own embedded JPEG, then chart shots. Adobe matrices / DCPs are never used. Biggest image-quality gap today |
| LR-PROF-CAMERACOLOR | Camera colour calibration (own) | P0 | 🟡 | `crates/raw/src/color.rs`, `crates/raw/src/profile.rs`, `crates/engine/src/camera_preview.rs` | DNG files use the colour matrices and the camera-profile look they carry (`ProfileHueSatMap`, `ProfileLookTable`, `ProfileToneCurve`, read from the file at run time per DNG spec ch. 6 — Lightroom-converted DNGs rendered flat and muted without them, issue #138); Sony ARW, Nikon NEF, Panasonic RW2, Fujifilm RAF, Canon CR3 and CR2 and Pentax PEF get a guarded file-local fit to their own embedded JPEG — chromaticity matrix + hue/saturation/value table + tone and chroma curves, relative WB — or, with a bundled or local per-model profile pooled from many photos (`lightcraft-cli calibrate`, ARW, NEF and RAF so far, `crates/engine/src/camera_profiles.rs`), the profile's colour and only their own tone/chroma curves (on 29 held-out ILCE-7M4 photos mean ΔE vs the camera JPEG 3.54 → 3.10), kept with smart previews (see docs/camera-preview-colour.md); ARW: on 10 public raw.pixls.us samples from 8 bodies 7 fits were accepted, mean ΔE vs the camera JPEG 17–26 → 3–10; NEF: 13 of 13 decodable samples (D750, D780, D850, D7500, Z 50 and local D7500 shots) accepted, ΔE 13–46 → 3–8 (issue #150); RW2 / RWL: 140 of 174 public samples accepted, from 93 of the 114 bodies with a preview, median ΔE 5.0 with matching lightness (13 of the 14 corpus files with a preview: ΔE 9.6–27 → 2.8–8.9; most rejections are compacts and kit zooms whose camera JPEG is distortion-corrected); a fit that fails the gates on all pixels is retried away from edges, where a lens-corrected camera JPEG still lines up (issue #232: the six distortion-corrected public ILCE-7RM2 samples ΔE 16.6–16.9 → 3.7–3.8, the corpus GX80 19.1 → 7.9); RAF: bundled X-H2S/X-T4 profiles from 201/545 photos; on 150 withheld files mean ΔE improves slightly over per-file fitting (X-H2S 1.70 → 1.62, X-T4 2.43 → 2.34); 23 of 24 references from 13 bodies accepted, including the dark X-H2S samples (8.74 → 2.25) (`docs/camera-preview-colour.md`); this is not measured camera calibration; other raws and rejected fits use a neutral fallback (camera RGB ≈ linear sRGB, `matrix_is_fallback`) with as-shot white balance, so colours are muted and not accurate. Needs our own per-camera calibration: matrices the files carry themselves (e.g. Olympus `ColorMatrix`), fitting each camera to its own embedded JPEG, then chart shots. Adobe matrices / DCPs are never used. Biggest image-quality gap today |
| LR-PROF-CREATIVE | Creative profiles (own) | P2 | ✅ | `cmd:develop.profile`, `crates/pipeline/src/profiles.rs` | 16 own looks in Film / Cinematic / Muted / B&W (tone + point-curve fades, colour grading, mixer / B&W mix); scale with `ctl:profile.amount`; sliders untouched |
| LR-PROF-LEGACY | Legacy profiles | P2 | ⬜ | | |
| LR-PROF-NONRAW | Profiles for non-raw files | P0 | ✅ | `cmd:develop.profile` | same looks apply to JPEG/TIFF |

View File

@ -15,6 +15,8 @@
belonged to another command moves to the new one. Menus show the new keys; agents use `app.setShortcut`.
### RAW decoding
- Canon CR2 and Pentax PEF raws get the same starting look fitted to the camera's own JPEG as ARW, NEF, RW2, RAF
and CR3, instead of opening flat and desaturated (issue #310). Photos already imported pick it up when re-rendered.
- Panasonic and Leica raws (RW2, RWL) are now corrected for lens distortion the way the camera corrects its own JPEG
(issue #256): the correction the camera records in the file is applied under Lens Corrections ▸ Enable Profile
Corrections, on by default for newly imported photos, with the same framing as the camera's JPEG. At 12 mm the