diff --git a/ROADMAP.md b/ROADMAP.md index 73891acc..5e793e8b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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. diff --git a/crates/catalog/src/model.rs b/crates/catalog/src/model.rs index ec896b4b..ae53b486 100644 --- a/crates/catalog/src/model.rs +++ b/crates/catalog/src/model.rs @@ -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`]). diff --git a/crates/engine/src/camera_preview.rs b/crates/engine/src/camera_preview.rs index 858c1326..55ea2d80 100644 --- a/crates/engine/src/camera_preview.rs +++ b/crates/engine/src/camera_preview.rs @@ -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 { @@ -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] diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index 5d0c5d82..2d5274a3 100644 --- a/crates/engine/src/media.rs +++ b/crates/engine/src/media.rs @@ -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]; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 2b09c586..978e8067 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -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}"); diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index 66f31e99..ad625ccb 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -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", diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index 833ffba3..052eeb96 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -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", diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index 3e9c7556..80c40085 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -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": "コマンドまたはキーを検索", diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index f5b88f4f..529dd4bb 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -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", diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index b80dd73c..95fcfc81 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -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…": "Поиск…", diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index 8e7b1312..3ec174f0 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -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": "搜索命令或按键", diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index 71a2bb5f..7a60b4dd 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -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": "搜尋命令或按鍵", diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 502e31ff..1699d57c 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -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; diff --git a/crates/ui-egui/src/menu_level.rs b/crates/ui-egui/src/menu_level.rs new file mode 100644 index 00000000..df9bdf09 --- /dev/null +++ b/crates/ui-egui/src/menu_level.rs @@ -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 { + 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 { + 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, depth: usize, anchor: Option, rows: Option, 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:` for the control channel and tests. +pub fn level(ui: &mut Ui, depth: usize, bar_bottom: Option, 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 = 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); + } +} diff --git a/crates/ui-egui/src/menubar.rs b/crates/ui-egui/src/menubar.rs index 57e0477f..97c2d634 100644 --- a/crates/ui-egui/src/menubar.rs +++ b/crates/ui-egui/src/menubar.rs @@ -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: