From 7984667269ffb7342d199df7ef999511cb2346a3 Mon Sep 17 00:00:00 2001 From: Lucas Lepski Date: Tue, 13 Oct 2026 13:41:46 +0200 Subject: [PATCH] UI-ARROWS: reconcile chevrons with current upstream UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keep the HDR histogram and SDR/HDR legend, virtualized sidebar rows and current preset grouping while retaining shared chevrons and inset interactive clipping buttons. Apply shared submenu arrows to the new album import menus. Drop the earlier inpainting compatibility change now present upstream. Validation: formatting, strict native Clippy and HEIF checks passed; 811 compiled UI tests passed; native SDR and HDR screenshots checked. Full workspace CI is blocked by all_masks_overlay_shows_every_visible_mask_at_once, reproduced unchanged on clean upstream b3d392b4. Publish this prepared rebase at the contributor’s explicit request with that baseline failure documented. Co-Authored-By: GPT-6.1 Sol --- crates/ui-egui/src/album_picker.rs | 22 +++--- crates/ui-egui/src/credits.rs | 25 ++++--- crates/ui-egui/src/date_picker.rs | 6 +- crates/ui-egui/src/icons.rs | 42 ++++++++---- crates/ui-egui/src/import.rs | 10 +-- crates/ui-egui/src/menu_level.rs | 16 ++--- crates/ui-egui/src/menubar.rs | 2 +- crates/ui-egui/src/panels/bottombar.rs | 6 +- crates/ui-egui/src/panels/denoise.rs | 2 +- crates/ui-egui/src/panels/detail.rs | 2 +- crates/ui-egui/src/panels/dialogs.rs | 9 +-- crates/ui-egui/src/panels/edit.rs | 83 ++++++++++++++++++---- crates/ui-egui/src/panels/filterbar.rs | 2 +- crates/ui-egui/src/panels/grid.rs | 27 +++++--- crates/ui-egui/src/panels/keyword_list.rs | 12 ++-- crates/ui-egui/src/panels/left.rs | 41 +++++------ crates/ui-egui/src/panels/masking.rs | 6 +- crates/ui-egui/src/panels/people.rs | 7 +- crates/ui-egui/src/panels/person.rs | 2 +- crates/ui-egui/src/panels/presets.rs | 6 +- crates/ui-egui/src/panels/profiles.rs | 2 +- crates/ui-egui/src/panels/right.rs | 4 +- crates/ui-egui/src/panels/rules_editor.rs | 84 +++++++++++------------ crates/ui-egui/src/panels/settings.rs | 6 +- crates/ui-egui/src/tests_panels.rs | 27 ++++++++ crates/ui-egui/src/widgets.rs | 44 ++++++++++-- 26 files changed, 318 insertions(+), 177 deletions(-) diff --git a/crates/ui-egui/src/album_picker.rs b/crates/ui-egui/src/album_picker.rs index 72ea3f226..914827e5d 100644 --- a/crates/ui-egui/src/album_picker.rs +++ b/crates/ui-egui/src/album_picker.rs @@ -11,7 +11,7 @@ //! `albumPicker:` (the button), `albumPickerSearch:`, `albumPickerFolder::` //! and `albumPickerItem::`. -use egui::{Response, RichText, Ui, vec2}; +use egui::{Rect, Response, RichText, Ui, vec2}; use crate::icons::Icon; use crate::widgets::register; @@ -146,14 +146,14 @@ pub fn search(entries: &[AlbumEntry], query: &str) -> Vec { first.into_iter().chain(rest).collect() } -/// A small filled triangle centred on `c`: pointing down (`down`), else right. -fn triangle(p: &egui::Painter, c: egui::Pos2, down: bool, color: egui::Color32) { - let pts = if down { - vec![c + vec2(-4.0, -2.0), c + vec2(4.0, -2.0), c + vec2(0.0, 3.0)] - } else { - vec![c + vec2(-2.0, -4.0), c + vec2(3.0, 0.0), c + vec2(-2.0, 4.0)] - }; - p.add(egui::Shape::convex_polygon(pts, color, egui::Stroke::NONE)); +/// A disclosure chevron centred on `c`: pointing down (`down`), else right. +fn disclosure(p: &egui::Painter, c: egui::Pos2, down: bool, color: egui::Color32) { + crate::icons::paint( + p, + Rect::from_center_size(c, vec2(16.0, 16.0)), + if down { crate::icons::Icon::ChevronDown } else { crate::icons::Icon::ChevronRight }, + color, + ); } /// What the open popup remembers between frames. @@ -195,7 +195,7 @@ impl<'a> AlbumPicker<'a> { // the name, and a drop-down arrow painted at the right (the UI font has no arrow glyph) let mut button = ui.add_sized(vec2(width, ui.spacing().interact_size.y), egui::Button::new(text).truncate().right_text(" ")); let arrow_color = crate::theme::Tokens::get(ui.ctx()).icon; - triangle(ui.painter(), egui::pos2(button.rect.right() - 10.0, button.rect.center().y), true, arrow_color); + disclosure(ui.painter(), egui::pos2(button.rect.right() - 10.0, button.rect.center().y), true, arrow_color); register(ui.ctx(), format!("albumPicker:{id}"), button.rect); let key = egui::Id::new(("albumPicker", id.as_str())); if button.clicked() { @@ -255,7 +255,7 @@ impl<'a> AlbumPicker<'a> { ui.painter().rect_filled(rect, 3.0, t.hover); } let x = rect.left() + indent; - triangle(ui.painter(), egui::pos2(x + 7.0, rect.center().y), open, if r.hovered() { t.text } else { t.text_dim }); + disclosure(ui.painter(), egui::pos2(x + 7.0, rect.center().y), open, if r.hovered() { t.text } else { t.text_dim }); let icon = egui::Rect::from_center_size(egui::pos2(x + 23.0, rect.center().y), vec2(14.0, 14.0)); crate::icons::paint(ui.painter(), icon, Icon::Folder, t.icon); ui.painter().text( diff --git a/crates/ui-egui/src/credits.rs b/crates/ui-egui/src/credits.rs index 964396850..d451aa79d 100644 --- a/crates/ui-egui/src/credits.rs +++ b/crates/ui-egui/src/credits.rs @@ -240,7 +240,7 @@ pub fn contributors_ui(app: &mut LightcraftApp, ui: &mut egui::Ui) { } ui.separator(); ui.label(crate::i18n::tr("Sort")); - egui::ComboBox::from_id_salt("credits_sort").selected_text(crate::i18n::tr(v.key.label().0)).show_ui(ui, |ui| { + crate::widgets::combo_box("credits_sort").selected_text(crate::i18n::tr(v.key.label().0)).show_ui(ui, |ui| { for k in SortKey::ALL { if ui.selectable_label(v.key == k, crate::i18n::tr(k.label().0)).clicked() { v.key = k; @@ -248,7 +248,10 @@ pub fn contributors_ui(app: &mut LightcraftApp, ui: &mut egui::Ui) { } } }); - if ui.button(if v.ascending { "▲" } else { "▼" }).on_hover_text(crate::i18n::tr("Reverse the order")).clicked() { + let reverse = + crate::widgets::arrow_button(ui, "", if v.ascending { crate::icons::Icon::ChevronUp } else { crate::icons::Icon::ChevronDown }, false); + reverse.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), crate::i18n::tr("Reverse the order"))); + if reverse.on_hover_text(crate::i18n::tr("Reverse the order")).clicked() { v.ascending = !v.ascending; } ui.separator(); @@ -289,12 +292,18 @@ pub fn contributors_ui(app: &mut LightcraftApp, ui: &mut egui::Ui) { fn table(app: &mut LightcraftApp, ui: &mut egui::Ui, list: &[&Contributor], v: &mut View) { egui::Grid::new("credits_table").striped(true).num_columns(SortKey::ALL.len()).show(ui, |ui| { for k in SortKey::ALL { - let arrow = if v.key == k { if v.ascending { " ▲" } else { " ▼" } } else { "" }; - if ui - .button(RichText::new(format!("{}{arrow}", crate::i18n::tr(k.label().1))).strong()) - .on_hover_text(crate::i18n::tr(k.label().0)) - .clicked() - { + let text = RichText::new(crate::i18n::tr(k.label().1)).strong(); + let header = if v.key == k { + crate::widgets::arrow_button( + ui, + text, + if v.ascending { crate::icons::Icon::ChevronUp } else { crate::icons::Icon::ChevronDown }, + false, + ) + } else { + ui.button(text) + }; + if header.on_hover_text(crate::i18n::tr(k.label().0)).clicked() { if v.key == k { v.ascending = !v.ascending; } else { diff --git a/crates/ui-egui/src/date_picker.rs b/crates/ui-egui/src/date_picker.rs index c04c309af..c84df98c4 100644 --- a/crates/ui-egui/src/date_picker.rs +++ b/crates/ui-egui/src/date_picker.rs @@ -266,7 +266,8 @@ impl<'a> DatePicker<'a> { ui.separator(); } ui.horizontal(|ui| { - let prev = ui.small_button("‹"); + let prev = crate::widgets::arrow_button(ui, "", Icon::ChevronLeft, true); + prev.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), crate::i18n::tr("Previous page"))); register(ui.ctx(), format!("datePickerPrev:{id}"), prev.rect); let title = match view.precision { Precision::Day => crate::i18n::date_group_label(&format!("{:04}-{:02}", view.year, view.month), false), @@ -278,7 +279,8 @@ impl<'a> DatePicker<'a> { }; ui.label(RichText::new(title).strong()); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - let next = ui.small_button("›"); + let next = crate::widgets::arrow_button(ui, "", Icon::ChevronRight, false); + next.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), crate::i18n::tr("Next page"))); register(ui.ctx(), format!("datePickerNext:{id}"), next.rect); if next.clicked() { view = step(view, true); diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index b82194bbb..74d62b164 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -44,7 +44,9 @@ pub enum Icon { ChevronDown, ChevronRight, ChevronLeft, - TriangleLeft, + ChevronUp, + ChevronDoubleLeft, + ChevronDoubleRight, Plus, Minus, Album, @@ -175,6 +177,22 @@ pub fn paint_turned(p: &Painter, rect: Rect, icon: Icon, color: Color32, radians fn paint_with(p: &Painter, rect: Rect, icon: Icon, color: Color32, turn: Option<(f32, f32)>) { let side = rect.width().min(rect.height()); + // All disclosure and navigation chevrons share a 16-point size and stroke. + let side = if matches!( + icon, + Icon::Back + | Icon::Forward + | Icon::ChevronDown + | Icon::ChevronUp + | Icon::ChevronLeft + | Icon::ChevronRight + | Icon::ChevronDoubleLeft + | Icon::ChevronDoubleRight + ) { + side.min(16.0) + } else { + side + }; let r = Rect::from_center_size(rect.center(), vec2(side, side)); let k = side / 20.0; let pen = Pen { p, r, s: Stroke::new((1.5 * k).max(1.0), color), k, turn }; @@ -184,14 +202,6 @@ fn paint_with(p: &Painter, rect: Rect, icon: Icon, color: Color32, turn: Option< pen.rect(3.0, 4.0, 17.0, 16.0, 1.5); pen.line(&[(8.0, 4.0), (8.0, 16.0)]); } - Back => { - pen.line(&[(16.0, 10.0), (4.0, 10.0)]); - pen.line(&[(9.0, 5.0), (4.0, 10.0), (9.0, 15.0)]); - } - Forward => { - pen.line(&[(4.0, 10.0), (16.0, 10.0)]); - pen.line(&[(11.0, 5.0), (16.0, 10.0), (11.0, 15.0)]); - } Monitor => { pen.rect(2.5, 3.5, 17.5, 13.5, 1.5); pen.line(&[(10.0, 13.5), (10.0, 16.5)]); @@ -399,9 +409,17 @@ fn paint_with(p: &Painter, rect: Rect, icon: Icon, color: Color32, turn: Option< pen.rect_fill(10.0, 4.0, 17.0, 16.0, 0.5); } ChevronDown => pen.line(&[(5.0, 7.5), (10.0, 12.5), (15.0, 7.5)]), - ChevronRight => pen.line(&[(7.5, 5.0), (12.5, 10.0), (7.5, 15.0)]), - ChevronLeft => pen.line(&[(12.5, 5.0), (7.5, 10.0), (12.5, 15.0)]), - TriangleLeft => pen.fill(&[(13.0, 6.0), (13.0, 14.0), (7.0, 10.0)], color), + Forward | ChevronRight => pen.line(&[(7.5, 5.0), (12.5, 10.0), (7.5, 15.0)]), + Back | ChevronLeft => pen.line(&[(12.5, 5.0), (7.5, 10.0), (12.5, 15.0)]), + ChevronDoubleLeft => { + pen.line(&[(9.0, 5.0), (4.0, 10.0), (9.0, 15.0)]); + pen.line(&[(16.0, 5.0), (11.0, 10.0), (16.0, 15.0)]); + } + ChevronDoubleRight => { + pen.line(&[(4.0, 5.0), (9.0, 10.0), (4.0, 15.0)]); + pen.line(&[(11.0, 5.0), (16.0, 10.0), (11.0, 15.0)]); + } + ChevronUp => pen.line(&[(5.0, 12.5), (10.0, 7.5), (15.0, 12.5)]), Plus => { pen.line(&[(10.0, 4.0), (10.0, 16.0)]); pen.line(&[(4.0, 10.0), (16.0, 10.0)]); diff --git a/crates/ui-egui/src/import.rs b/crates/ui-egui/src/import.rs index a1a720a8e..5b8147d74 100644 --- a/crates/ui-egui/src/import.rs +++ b/crates/ui-egui/src/import.rs @@ -1102,7 +1102,7 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) { ]; let key = if d.organize.is_empty() { "date".to_string() } else { d.organize.clone() }; let cur = opts.iter().find(|o| o.0 == key).map_or(opts[0].1, |o| o.1); - let combo = egui::ComboBox::from_id_salt("import-organize").selected_text(crate::i18n::tr(cur)).show_ui(ui, |ui| { + let combo = crate::widgets::combo_box("import-organize").selected_text(crate::i18n::tr(cur)).show_ui(ui, |ui| { for (k, label) in opts { let r = ui.selectable_label(key == k, crate::i18n::tr(label)); register(ui.ctx(), format!("button:importOrganize-{k}"), r.rect); @@ -1185,7 +1185,7 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) { _ => "Leave them", }; let mut pick = None; - egui::ComboBox::from_id_salt("import-on-deleted").selected_text(crate::i18n::tr(cur)).show_ui(ui, |ui| { + crate::widgets::combo_box("import-on-deleted").selected_text(crate::i18n::tr(cur)).show_ui(ui, |ui| { for (v, label) in [("restore", "Restore them"), ("fresh", "Import as new"), ("", "Leave them")] { if ui.selectable_label(d.on_deleted == v, crate::i18n::tr(label)).clicked() { pick = Some(v); @@ -1215,7 +1215,7 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) { None if !d.new_album.is_empty() => crate::i18n::tr("New album").into(), None => crate::i18n::tr("None").into(), }; - egui::ComboBox::from_id_salt("import-album").selected_text(cur).show_ui(ui, |ui| { + crate::widgets::combo_box("import-album").selected_text(cur).show_ui(ui, |ui| { if ui.selectable_label(d.album.is_none() && d.new_album.is_empty(), crate::i18n::tr("None")).clicked() { d.album = None; d.new_album.clear(); @@ -1246,7 +1246,7 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) { .find(|p| p.id == d.preset) .map(|p| crate::i18n::builtin_label(&p.name, p.builtin).to_string()) .unwrap_or_else(|| crate::i18n::tr("None").into()); - egui::ComboBox::from_id_salt("import-preset").selected_text(cur).height(300.0).show_ui(ui, |ui| { + crate::widgets::combo_box("import-preset").selected_text(cur).height(300.0).show_ui(ui, |ui| { if ui.selectable_label(d.preset.is_empty(), crate::i18n::tr("None")).clicked() { d.preset.clear(); } @@ -1259,7 +1259,7 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) { if !app.session.metadata_presets.is_empty() { field(ui, "Metadata", |ui| { let cur = if d.metadata_preset.is_empty() { crate::i18n::tr("None").to_string() } else { d.metadata_preset.clone() }; - egui::ComboBox::from_id_salt("import-metadata").selected_text(cur).show_ui(ui, |ui| { + crate::widgets::combo_box("import-metadata").selected_text(cur).show_ui(ui, |ui| { if ui.selectable_label(d.metadata_preset.is_empty(), crate::i18n::tr("None")).clicked() { d.metadata_preset.clear(); } diff --git a/crates/ui-egui/src/menu_level.rs b/crates/ui-egui/src/menu_level.rs index 379a2dd0b..96ff422f4 100644 --- a/crates/ui-egui/src/menu_level.rs +++ b/crates/ui-egui/src/menu_level.rs @@ -6,7 +6,7 @@ //! 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}; +use egui::{Id, Rect, Sense, Ui, vec2}; /// Height of a scroll-arrow strip. pub const ARROW: f32 = 16.0; @@ -109,13 +109,12 @@ pub fn level(ui: &mut Ui, depth: usize, bar_bottom: Option, rows: impl FnOn 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)); + crate::icons::paint( + ui.painter(), + Rect::from_center_size(c, vec2(16.0, 16.0)), + if dir < 0.0 { crate::icons::Icon::ChevronUp } else { crate::icons::Icon::ChevronDown }, + if live { t.text } else { t.text_disabled }, + ); let id = key.with(dir < 0.0); let r = ui.interact(strip, id, Sense::hover()); r.widget_info(|| { @@ -143,6 +142,7 @@ pub fn level(ui: &mut Ui, depth: usize, bar_bottom: Option, rows: impl FnOn #[cfg(test)] mod tests { use super::*; + use egui::pos2; #[test] fn level_room_keeps_every_level_below_the_bar() { diff --git a/crates/ui-egui/src/menubar.rs b/crates/ui-egui/src/menubar.rs index fe6905cad..5d2523281 100644 --- a/crates/ui-egui/src/menubar.rs +++ b/crates/ui-egui/src/menubar.rs @@ -930,7 +930,7 @@ fn switch_open_title(ctx: &egui::Context, titles: &[egui::Response]) -> Option` (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, children: impl FnOnce(&mut egui::Ui)) { - let r = ui.menu_button(text, |ui| crate::menu_level::level(ui, depth + 1, bar_bottom, children)); + let r = crate::widgets::menu_button(ui, 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); diff --git a/crates/ui-egui/src/panels/bottombar.rs b/crates/ui-egui/src/panels/bottombar.rs index b986b8995..d5c825010 100644 --- a/crates/ui-egui/src/panels/bottombar.rs +++ b/crates/ui-egui/src/panels/bottombar.rs @@ -62,9 +62,11 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { } } ui.separator(); - ui.menu_button(crate::i18n::tr("Sort"), |ui| sort_menu(app, ui)); + crate::widgets::menu_button(ui, crate::i18n::tr("Sort"), |ui| sort_menu(app, ui)); if compact_zoom && matches!(app.ui.view, ViewMode::Detail | ViewMode::Compare) { - ui.menu_button(crate::i18n::tr_format!("Click {}:1", app.ui.click_zoom / 100), |ui| click_zoom_menu(app, ui)); + crate::widgets::menu_button(ui, crate::i18n::tr_format!("Click {}:1", app.ui.click_zoom / 100), |ui| { + click_zoom_menu(app, ui) + }); } }); } diff --git a/crates/ui-egui/src/panels/denoise.rs b/crates/ui-egui/src/panels/denoise.rs index 75488f76b..ab4bcca93 100644 --- a/crates/ui-egui/src/panels/denoise.rs +++ b/crates/ui-egui/src/panels/denoise.rs @@ -371,7 +371,7 @@ fn work_section(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, list: &V ui.label(RichText::new(crate::i18n::tr("Limit")).color(t.text_label)); let current = list["cacheGb"].as_u64().unwrap_or(20) as u32; let mut choice = current; - egui::ComboBox::from_id_salt("denoise-cache-limit").selected_text(tr_format!("{size} GB", size = current)).show_ui(ui, |ui| { + crate::widgets::combo_box("denoise-cache-limit").selected_text(tr_format!("{size} GB", size = current)).show_ui(ui, |ui| { for gb in CACHE_LIMITS { ui.selectable_value(&mut choice, gb, tr_format!("{size} GB", size = gb)); } diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 151d4a57a..5703efed6 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -800,7 +800,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { preview_only_pill(ui, canvas, why); } resp.context_menu(|ui| { - ui.menu_button(crate::i18n::tr("Zoom"), |ui| { + crate::widgets::menu_button(ui, crate::i18n::tr("Zoom"), |ui| { for (label, cmd) in [("Fit", "view.zoomFit"), ("100%", "view.zoom100"), ("Zoom In", "view.zoomIn"), ("Zoom Out", "view.zoomOut")] { if ui.button(crate::i18n::tr(label)).clicked() { let _ = app.run(cmd, json!({})); diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index a79261fdc..d79bfeedb 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -558,7 +558,8 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { egui::Grid::new("rename-preview").num_columns(3).spacing([8.0, 2.0]).show(ui, |ui| { for pl in rows { ui.label(egui::RichText::new(&pl.from).color(t.text_dim)); - ui.label(egui::RichText::new("→").color(t.text_dim)); + let (arrow, _) = ui.allocate_exact_size(egui::vec2(16.0, 16.0), egui::Sense::hover()); + crate::icons::paint(ui.painter(), arrow, crate::icons::Icon::ChevronRight, t.text_dim); ui.label(egui::RichText::new(&pl.to).color(t.text)); ui.end_row(); } @@ -646,7 +647,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { let n = app.session.targets(&json!({})).len(); ui.label(format!("{n} selected photos · {} pages", n.div_ceil(options.columns * options.rows))); field(ui, "Paper", |ui| { - egui::ComboBox::from_id_salt("contactSheetPaper").selected_text(&options.paper).show_ui(ui, |ui| { + crate::widgets::combo_box("contactSheetPaper").selected_text(&options.paper).show_ui(ui, |ui| { ui.selectable_value(&mut options.paper, "a4".into(), "A4"); ui.selectable_value(&mut options.paper, "letter".into(), "Letter"); }); @@ -665,7 +666,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { // Preset: load a built-in or saved set of options into the dialog field(ui, "Preset", |ui| { let mut chosen = None; - let c = egui::ComboBox::from_id_salt("exportPreset").width(220.0).selected_text(crate::i18n::tr("Choose…")).show_ui(ui, |ui| { + let c = crate::widgets::combo_box("exportPreset").width(220.0).selected_text(crate::i18n::tr("Choose…")).show_ui(ui, |ui| { let mut builtin = true; for (p, b) in app.session.all_export_presets() { if builtin && !b { @@ -1821,7 +1822,7 @@ fn export_size(ui: &mut egui::Ui, full: &mut bool, r: &mut lightcraft_engine::ex field(ui, "Resize to", |ui| { let cur = MODES.iter().find(|m| m.0 == r.mode).map_or("Long Edge", |m| m.1); let before = r.mode; - let c = egui::ComboBox::from_id_salt("exportResizeMode").width(150.0).selected_text(crate::i18n::tr(cur)).show_ui(ui, |ui| { + let c = crate::widgets::combo_box("exportResizeMode").width(150.0).selected_text(crate::i18n::tr(cur)).show_ui(ui, |ui| { for (m, l) in MODES { ui.selectable_value(&mut r.mode, m, crate::i18n::tr(l)); } diff --git a/crates/ui-egui/src/panels/edit.rs b/crates/ui-egui/src/panels/edit.rs index 533354b6c..cafdc6b8d 100644 --- a/crates/ui-egui/src/panels/edit.rs +++ b/crates/ui-egui/src/panels/edit.rs @@ -455,7 +455,7 @@ fn profile_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings) ui.separator(); } for g in profile_groups() { - let r = ui.menu_button(crate::i18n::tr(g), |ui| { + let r = crate::widgets::menu_button(ui, crate::i18n::tr(g), |ui| { ui.set_min_width(170.0); for p in PROFILES.iter().filter(|p| p.group == g) { item(ui, "group", p.id, p.name, &mut pick); @@ -469,7 +469,7 @@ fn profile_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings) lut_groups.dedup(); for g in &lut_groups { // a folder's name as it is, and its own widget id even when it matches a built-in group - let r = ui.menu_button(g.as_str(), |ui| { + let r = crate::widgets::menu_button(ui, g.as_str(), |ui| { ui.set_min_width(170.0); for p in app.session.lut_profiles.iter().filter(|p| &p.group == g) { item(ui, "group", &p.id, &p.name, &mut pick); @@ -620,6 +620,7 @@ fn histogram(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { "Left: the standard (SDR) range up to SDR white. Right: the HDR range, one band per stop above SDR white (the orange line: the HDR headroom). With Visualize HDR range on, the bands take its colours.", )); } + let clipping = hist.as_ref().map(|h| h.clipping()); if let Some(h) = hist { let smooth = |v: &[u32]| -> Vec { (0..256) @@ -678,15 +679,58 @@ fn histogram(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { p.add(egui::Shape::line(pts, Stroke::new(1.5, *c))); } } - let (lo, hi) = h.clipping(); - for (on, x, active) in [(lo > 0.003, plot.left() + 12.0, app.ui.show_clipping), (hi > 0.003, plot.right() - 12.0, app.ui.show_clipping)] { - let tri = Rect::from_center_size(pos2(x, plot.top() + 10.0), vec2(14.0, 12.0)); - p.rect_filled(tri, 2.0, if active { t.hover } else { Color32::from_gray(60) }); - paint(p, tri.shrink(2.0), Icon::ChevronDown, if on { Color32::WHITE } else { Color32::from_gray(130) }); - } } - if resp.clicked() { - app.ui.show_clipping = !app.ui.show_clipping; + let mut clicked = resp.clicked(); + for (name, x, label, clipped) in [ + ("shadows", plot.left() + 20.0, "Shadow clipping", clipping.map(|(lo, _)| lo > 0.003)), + ("highlights", plot.right() - 20.0, "Highlight clipping", clipping.map(|(_, hi)| hi > 0.003)), + ] { + let rect = Rect::from_center_size(pos2(x, plot.top() + 14.0), vec2(24.0, 22.0)); + let response = ui.interact(rect, ui.id().with(("histogram-clipping", name)), Sense::click()); + register(ui.ctx(), format!("histogram:{name}"), rect); + response.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, ui.is_enabled(), app.ui.show_clipping, crate::i18n::tr(label))); + clicked |= response.clicked(); + let active = app.ui.show_clipping; + // Keep the hit target comfortable, with a smaller box and clear space to the plot edges. + let box_rect = rect.shrink2(vec2(2.0, 2.0)); + p.rect_filled( + box_rect, + 3.0, + if active { + t.pressed + } else if response.hovered() { + t.hover + } else { + Color32::from_gray(60) + }, + ); + if active { + p.rect_stroke(box_rect, 3.0, Stroke::new(1.0, t.text), egui::StrokeKind::Inside); + } + paint( + p, + Rect::from_center_size(rect.center(), vec2(16.0, 16.0)), + Icon::ChevronDown, + if clipped == Some(true) || active { t.text } else { t.text_dim }, + ); + let status = match clipped { + Some(true) => "Clipping detected in this preview.", + Some(false) => "No clipping detected in this preview.", + None => "Histogram not yet available.", + }; + response.on_hover_text(format!( + "{}\n{}\n{}", + crate::i18n::tr(label), + crate::i18n::tr(if active { + "Click to hide clipping warnings for shadows and highlights." + } else { + "Click to show clipping warnings for shadows and highlights." + }), + crate::i18n::tr(status) + )); + } + if clicked { + let _ = app.run("view.clipping", json!({})); } // EXIF strip if let Some(ph) = app.session.catalog.photo(id) { @@ -741,7 +785,7 @@ fn soft_proofing(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { let device = app.ui.proof_profile.as_ref().map(|(_, n)| n.clone()); let shown = device.clone().unwrap_or_else(|| cur.label().to_string()); let mut pick = false; - egui::ComboBox::from_id_salt("proof-space").width(170.0).selected_text(shown).show_ui(ui, |ui| { + crate::widgets::combo_box("proof-space").width(170.0).selected_text(shown).show_ui(ui, |ui| { for s in OutputSpace::ALL { if ui.selectable_label(device.is_none() && cur == s, s.label()).clicked() { let _ = app.run("view.softProof", json!({"space": s})); @@ -1666,8 +1710,21 @@ fn quick_develop(app: &mut LightcraftApp, ui: &mut egui::Ui, n: usize) { egui::Grid::new("quick-develop").num_columns(5).spacing([4.0, 3.0]).show(ui, |ui| { for (label, ctl, small, big) in rows { ui.label(egui::RichText::new(crate::i18n::tr(label)).size(11.5).color(t.text_dim)); - for (txt, d) in [("◀◀", -big), ("◀", -small), ("▶", small), ("▶▶", big)] { - let r = ui.add(egui::Button::new(egui::RichText::new(txt).size(10.0)).min_size(egui::vec2(28.0, 18.0))); + // Keep the existing automation ids while drawing all step arrows as vectors. + for (txt, icon, d) in [ + ("◀◀", Icon::ChevronDoubleLeft, -big), + ("◀", Icon::ChevronLeft, -small), + ("▶", Icon::ChevronRight, small), + ("▶▶", Icon::ChevronDoubleRight, big), + ] { + let r = crate::widgets::arrow_button(ui, "", icon, false); + r.widget_info(|| { + egui::WidgetInfo::labeled( + egui::WidgetType::Button, + ui.is_enabled(), + crate::i18n::tr_format!("{label} {d:+} on every selected photo", d = d, label = crate::i18n::tr(label)), + ) + }); crate::widgets::register(ui.ctx(), format!("button:quick-{ctl}-{txt}"), r.rect); if r.on_hover_text(crate::i18n::tr_format!("{label} {d:+} on every selected photo", d = d, label = crate::i18n::tr(label))) .clicked() diff --git a/crates/ui-egui/src/panels/filterbar.rs b/crates/ui-egui/src/panels/filterbar.rs index c0962b59f..db1056ad8 100644 --- a/crates/ui-egui/src/panels/filterbar.rs +++ b/crates/ui-egui/src/panels/filterbar.rs @@ -280,7 +280,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let _ = app.run("library.clearFilter", json!({})); } // filter presets: apply one, save the current filter, delete - let presets = ui.button(egui::RichText::new(crate::i18n::tr("Presets ▾")).font(t.font(12.5))); + let presets = crate::widgets::dropdown(ui, "filterPresets", crate::i18n::tr("Presets"), t.font(12.5), t.text); register(ui.ctx(), "button:filterPresets", presets.rect); egui::Popup::menu(&presets).show(|ui| { let names: Vec = app.session.filter_presets.iter().map(|f| f.name.clone()).collect(); diff --git a/crates/ui-egui/src/panels/grid.rs b/crates/ui-egui/src/panels/grid.rs index 52edfa460..1cd88f329 100644 --- a/crates/ui-egui/src/panels/grid.rs +++ b/crates/ui-egui/src/panels/grid.rs @@ -827,10 +827,15 @@ fn folder_breadcrumbs(app: &mut LightcraftApp, ui: &mut egui::Ui, path: &str) { // Prefer the current folder, showing more ancestors as space permits. let visible = ((ui.available_width() / 80.0) as usize).clamp(1, 4); let skip = parts.len().saturating_sub(visible); + let separator = |ui: &mut egui::Ui| { + let (r, _) = ui.allocate_exact_size(vec2(16.0, 24.0), Sense::hover()); + paint(ui.painter(), Rect::from_center_size(r.center(), vec2(16.0, 16.0)), Icon::ChevronRight, t.text_dim); + }; if skip > 0 { - ui.label(egui::RichText::new("… ›").color(t.text_dim)).on_hover_text(path); + ui.label(egui::RichText::new("…").color(t.text_dim)).on_hover_text(path); + separator(ui); } - let separator_w = ui.painter().layout_no_wrap("›".into(), t.font(13.0), t.text_dim).size().x + 8.0; + let separator_w = 16.0 + 2.0 * ui.spacing().item_spacing.x; for (i, part) in parts.iter().enumerate().skip(skip) { let remaining = parts.len() - i; let last = remaining == 1; @@ -848,7 +853,7 @@ fn folder_breadcrumbs(app: &mut LightcraftApp, ui: &mut egui::Ui, path: &str) { { let _ = app.run("library.browse", json!({"path": to})); } - ui.label(egui::RichText::new("›").size(13.0).color(t.text_dim)); + separator(ui); } r.on_hover_text(path); } @@ -989,7 +994,7 @@ pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { // Photo ▸ Edit In: a copy with this photo's edits, opened in PhotoCraft or another editor // (desktop only: the web can't start programs) ui.add_enabled_ui(crate::menus::ui_enabled(app, "photo.editInPhotoCraft"), |ui| { - ui.menu_button(crate::i18n::tr("Edit In"), |ui| { + crate::widgets::menu_button(ui, crate::i18n::tr("Edit In"), |ui| { if ui.button(crate::i18n::tr("Edit in PhotoCraft")).clicked() { let _ = app.run("photo.editInPhotoCraft", json!({})); } @@ -1025,27 +1030,27 @@ pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { let _ = app.run("photo.setReference", json!({"id": id.0})); } ui.separator(); - ui.menu_button(crate::i18n::tr("Set Rating"), |ui| { + crate::widgets::menu_button(ui, crate::i18n::tr("Set Rating"), |ui| { for r in 0..=5 { if ui.button(if r == 0 { crate::i18n::tr("No Stars").to_string() } else { "★".repeat(r) }).clicked() { let _ = app.run("photo.rate", json!({"rating": r})); } } }); - ui.menu_button(crate::i18n::tr("Set Flag"), |ui| { + crate::widgets::menu_button(ui, crate::i18n::tr("Set Flag"), |ui| { for (l, f) in [("Pick", "pick"), ("Reject", "reject"), ("Unflagged", "none")] { if ui.button(crate::i18n::tr(l)).clicked() { let _ = app.run("photo.flag", json!({"flag": f})); } } }); - ui.menu_button(crate::i18n::tr("Set Color Label"), |ui| label_menu(app, ui)); + crate::widgets::menu_button(ui, crate::i18n::tr("Set Color Label"), |ui| label_menu(app, ui)); if local_only(app, &app.session.targets(&serde_json::Value::Null)) { // albums hold library photos (issue #684): nothing here to add until they join My Photos let add = ui.add_enabled(false, egui::Button::new(crate::i18n::tr("Add to Album"))).on_disabled_hover_text(albums_hint()); register(ui.ctx(), "button:contextAddToAlbum", add.rect); } else { - let add = ui.menu_button(crate::i18n::tr("Add to Album"), |ui| { + let add = crate::widgets::menu_button(ui, crate::i18n::tr("Add to Album"), |ui| { let albums: Vec<_> = app.session.catalog.albums().filter(|a| !a.folder && !a.is_smart()).map(|a| (a.id.0, a.name.clone())).collect(); let any = !albums.is_empty(); for (aid, name) in albums { @@ -1110,7 +1115,7 @@ pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { if ui.button(crate::i18n::tr("Create Version")).clicked() { let _ = app.run("version.create", json!({})); } - ui.menu_button(crate::i18n::tr("Stack"), |ui| { + crate::widgets::menu_button(ui, crate::i18n::tr("Stack"), |ui| { let stacked = app.session.catalog.stack_of(id).is_some(); let several = app.session.selection.ids.len() > 1; for (label, cmd, on) in [ @@ -1145,7 +1150,7 @@ pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { if ui.button(crate::i18n::tr("Reset Edits")).clicked() { let _ = app.run("develop.reset", json!({})); } - ui.menu_button(crate::i18n::tr("Photo Merge"), |ui| { + crate::widgets::menu_button(ui, crate::i18n::tr("Photo Merge"), |ui| { let n = app.session.targets(&json!({})).len(); for (id, label) in [("dialog.mergeHdr", "HDR…"), ("dialog.mergePanorama", "Panorama…"), ("dialog.mergeHdrPanorama", "HDR Panorama…")] { if ui.add_enabled(n >= 2, egui::Button::new(crate::i18n::tr(label))).clicked() { @@ -1173,7 +1178,7 @@ pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { if ui.button(crate::i18n::tr("Export…")).clicked() { let _ = app.run("dialog.export", json!({})); } - ui.menu_button(crate::i18n::tr("Export with Preset"), |ui| { + crate::widgets::menu_button(ui, crate::i18n::tr("Export with Preset"), |ui| { for (p, _) in app.session.all_export_presets() { if ui.button(&p.name).clicked() { match app.run("app.export", json!({"preset": p.name, "background": true})) { diff --git a/crates/ui-egui/src/panels/keyword_list.rs b/crates/ui-egui/src/panels/keyword_list.rs index 2d691112a..ab8521821 100644 --- a/crates/ui-egui/src/panels/keyword_list.rs +++ b/crates/ui-egui/src/panels/keyword_list.rs @@ -8,6 +8,7 @@ use lightcraft_catalog::{Catalog, PhotoId}; use serde_json::json; use crate::LightcraftApp; +use crate::icons::{Icon, paint}; use crate::state::Dialog; use crate::theme::Tokens; use crate::widgets::register; @@ -178,17 +179,12 @@ fn row(app: &mut LightcraftApp, ui: &mut egui::Ui, r: &Row, selection: &[PhotoId // the triangle if r.has_children { let c = pos2(x + 6.0, cy); - let tri = Rect::from_center_size(c, vec2(14.0, 14.0)); + let tri = Rect::from_center_size(c, vec2(16.0, 16.0)); let tr = ui.interact(tri, egui::Id::new(("keyword-list-tri", r.path.to_lowercase())), Sense::click()); register(ui.ctx(), format!("keywordRowToggle:{}", r.path), tri); - let pts = if r.open { - vec![c + vec2(-4.0, -2.0), c + vec2(4.0, -2.0), c + vec2(0.0, 3.0)] - } else { - vec![c + vec2(-2.0, -4.0), c + vec2(3.0, 0.0), c + vec2(-2.0, 4.0)] - }; // while a filter opens the levels the triangle does nothing: it doesn't light up either let lit = tr.hovered() && can_fold; - ui.painter().add(egui::Shape::convex_polygon(pts, if lit { t.text } else { t.text_dim }, Stroke::NONE)); + paint(ui.painter(), tri, if r.open { Icon::ChevronDown } else { Icon::ChevronRight }, if lit { t.text } else { t.text_dim }); if tr.clicked() && can_fold { let key = r.path.to_lowercase(); if r.open { @@ -243,7 +239,7 @@ fn row(app: &mut LightcraftApp, ui: &mut egui::Ui, r: &Row, selection: &[PhotoId let ar = ui.interact(arrow, egui::Id::new(("keyword-list-show", r.path.to_lowercase())), Sense::click()); register(ui.ctx(), format!("keywordShow:{}", r.path), arrow); if resp.hovered() || ar.hovered() { - ui.painter().text(arrow.center(), Align2::CENTER_CENTER, "→", t.font(13.0), if ar.hovered() { t.text } else { t.text_dim }); + paint(ui.painter(), arrow, Icon::ChevronRight, if ar.hovered() { t.text } else { t.text_dim }); } if ar.on_hover_text(crate::i18n::tr("Show Photos with Keyword")).clicked() { super::left::browse_all_photos(app, true); diff --git a/crates/ui-egui/src/panels/left.rs b/crates/ui-egui/src/panels/left.rs index fcaab2926..c4f90f689 100644 --- a/crates/ui-egui/src/panels/left.rs +++ b/crates/ui-egui/src/panels/left.rs @@ -173,17 +173,17 @@ fn row_need(indent: f32, name_w: f32, mark_w: f32, count_w: Option) -> f32 42.0 + indent + name_w.min(MAX_NAME_NEED) + 18.0 + mark_w + count_w.map_or(0.0, |w| w + 16.0) } -/// The disclosure triangle at the left of a tree row (`indent` is the row's): right-pointing +/// The disclosure chevron at the left of a tree row (`indent` is the row's): right-pointing /// when closed, down when open, registered as widget `widget` for the control channel. Returns /// its response: the caller flips the open state on `clicked()`. -fn disclosure_triangle(ui: &mut egui::Ui, row: &egui::Response, indent: f32, open: bool, id: egui::Id, widget: String) -> egui::Response { +fn disclosure_chevron(ui: &mut egui::Ui, row: &egui::Response, indent: f32, open: bool, id: egui::Id, widget: String) -> egui::Response { let t = Tokens::get(ui.ctx()); let c = pos2(row.rect.left() + 10.0 + indent, row.rect.center().y); - let tri = Rect::from_center_size(c, vec2(14.0, 14.0)); + let tri = Rect::from_center_size(c, vec2(16.0, 16.0)); let tr = ui.interact(tri, id, Sense::click()); register(ui.ctx(), widget, tri); let col = if tr.hovered() { t.text } else { t.text_dim }; - ui.painter().add(egui::Shape::convex_polygon(triangle_points(c, open), col, egui::Stroke::NONE)); + paint(ui.painter(), Rect::from_center_size(c, vec2(16.0, 16.0)), if open { Icon::ChevronDown } else { Icon::ChevronRight }, col); tr } @@ -197,11 +197,11 @@ fn set_tree_open(ui: &egui::Ui, key: egui::Id, open: bool) { ui.data_mut(|d| d.insert_temp(key, open)); } -/// The triangle of a tree row that has rows inside it (Albums' folders, Local, By Date, Folders, -/// Keywords): a click flips `open` and remembers it under `key`. Returns the triangle's response +/// The chevron of a tree row that has rows inside it (Albums' folders, Local, By Date, Folders, +/// Keywords): a click flips `open` and remembers it under `key`. Returns the chevron's response /// (it sits on the row and takes its clicks, so a row's context menu is attached to it too). fn tree_toggle(ui: &mut egui::Ui, row: &egui::Response, indent: f32, key: egui::Id, open: &mut bool, widget: String) -> egui::Response { - let tri = disclosure_triangle(ui, row, indent, *open, key.with("triangle"), widget); + let tri = disclosure_chevron(ui, row, indent, *open, key.with("triangle"), widget); if tri.clicked() { *open = !*open; set_tree_open(ui, key, *open); @@ -211,15 +211,6 @@ fn tree_toggle(ui: &mut egui::Ui, row: &egui::Response, indent: f32, key: egui:: tri } -/// A disclosure triangle around `c`: pointing down when open, right when closed. -fn triangle_points(c: egui::Pos2, open: bool) -> Vec { - if open { - vec![c + vec2(-4.0, -2.0), c + vec2(4.0, -2.0), c + vec2(0.0, 3.0)] - } else { - vec![c + vec2(-2.0, -4.0), c + vec2(3.0, 0.0), c + vec2(-2.0, 4.0)] - } -} - /// Space above a section's header. const SECTION_GAP: f32 = 10.0; /// Height of a section's header. @@ -399,7 +390,7 @@ pub fn section_drag_feedback(app: &mut LightcraftApp, ctx: &egui::Context) { } /// A section's header in `r`: a disclosure chevron in the gutter (where tree rows have their -/// triangle), then the bold title; a click folds or unfolds the section (kept in the UI state, so +/// chevron), then the bold title; a click folds or unfolds the section (kept in the UI state, so /// it survives restarts), and what the section adds to its header (its button, see `header_button`) sits at /// the visible edge. A folded section says how many rows it holds (`rows`, with View ▸ Show Photo /// Counts). `pinned`: this is the copy kept at the top of the sidebar (see `pinned_header`). @@ -433,9 +424,9 @@ fn section_header(app: &mut LightcraftApp, ui: &mut egui::Ui, section: SidebarSe ui.painter().rect_filled(bar, 4.0, t.hover.gamma_multiply(0.6)); } let c = pos2(r.left() + 10.0, r.center().y); - register(ui.ctx(), format!("{widget}Chevron:{id}"), Rect::from_center_size(c, vec2(14.0, 14.0))); + register(ui.ctx(), format!("{widget}Chevron:{id}"), Rect::from_center_size(c, vec2(16.0, 16.0))); let col = if hovered { t.text } else { t.text_dim }; - ui.painter().add(egui::Shape::convex_polygon(triangle_points(c, open), col, egui::Stroke::NONE)); + paint(ui.painter(), Rect::from_center_size(c, vec2(16.0, 16.0)), if open { Icon::ChevronDown } else { Icon::ChevronRight }, col); ui.painter().text(pos2(r.left() + 18.0, r.center().y), Align2::LEFT_CENTER, title, t.semibold(13.5), t.text_label); let button = header_button(section); // (an album being dragged puts "Top Level" in the Albums header) @@ -1549,7 +1540,7 @@ fn folder_menu(app: &mut LightcraftApp, resp: &egui::Response, a: &Album) { resp.context_menu(|ui| { if a.folder { // the same commands as File ▸ New…, aimed at this folder - ui.menu_button(crate::i18n::tr("New"), |ui| { + crate::widgets::menu_button(ui, crate::i18n::tr("New"), |ui| { for (label, command) in [ ("Create Album…", "dialog.newAlbum"), ("Create Smart Album…", "dialog.smartAlbum"), @@ -1582,7 +1573,7 @@ fn folder_menu(app: &mut LightcraftApp, resp: &egui::Response, a: &Album) { } } // File ▸ Import's ways in; the import review opens with this album chosen - let menu = ui.menu_button(crate::i18n::tr("Import"), |ui| { + let menu = crate::widgets::menu_button(ui, crate::i18n::tr("Import"), |ui| { let mut entry = |ui: &mut egui::Ui, label: String, widget: String, command: &str, params: serde_json::Value| { let r = ui.button(label); crate::widgets::register(ui.ctx(), widget, r.rect); @@ -1600,7 +1591,7 @@ fn folder_menu(app: &mut LightcraftApp, resp: &egui::Response, a: &Album) { entry(ui, crate::i18n::tr(label).into(), widget.into(), command, json!({"album": a.id.0})); } // like File ▸ Import from Device: one entry per camera or card found - let device = ui.menu_button(crate::i18n::tr("Import from Device"), |ui| { + let device = crate::widgets::menu_button(ui, crate::i18n::tr("Import from Device"), |ui| { let devices = lightcraft_engine::devices::devices(); if devices.is_empty() { ui.add_enabled(false, egui::Button::new(crate::i18n::tr("No Camera or Card Found"))); @@ -1649,7 +1640,7 @@ fn folder_menu(app: &mut LightcraftApp, resp: &egui::Response, a: &Album) { let _ = app.run("dialog.export", json!({})); } ui.add_enabled_ui(has_photos, |ui| { - ui.menu_button(crate::i18n::tr("Export Album with Preset"), |ui| { + crate::widgets::menu_button(ui, crate::i18n::tr("Export Album with Preset"), |ui| { for (p, _) in app.session.all_export_presets() { if ui.button(&p.name).clicked() { show_all(app); @@ -1666,7 +1657,7 @@ fn folder_menu(app: &mut LightcraftApp, resp: &egui::Response, a: &Album) { let mut folders: Vec<(u64, String)> = app.session.catalog.albums().filter(|f| f.folder && !is_within(app, f.id, a.id)).map(|f| (f.id.0, f.name.clone())).collect(); folders.sort_by_key(|(_, n)| n.to_lowercase()); - ui.menu_button(crate::i18n::tr("Move to"), |ui| { + crate::widgets::menu_button(ui, crate::i18n::tr("Move to"), |ui| { if ui.add_enabled(a.parent.is_some(), egui::Button::new(crate::i18n::tr("Top Level"))).clicked() { let _ = app.run("album.move", json!({"id": a.id.0, "parent": null})); } @@ -1879,7 +1870,7 @@ fn synchronize_item(app: &mut LightcraftApp, ui: &mut egui::Ui, n: &FolderNode, /// Set Color Label ▸ of a Folders row (`folder.label`; the submenu is widget `folderLabelMenu`, /// its colours `folderLabel:`). fn folder_label_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, n: &FolderNode) { - let menu = ui.menu_button(crate::i18n::tr("Set Color Label"), |ui| { + let menu = crate::widgets::menu_button(ui, crate::i18n::tr("Set Color Label"), |ui| { if let Some(l) = crate::panels::grid::label_items(app, ui, n.label, "folderLabel") { if let Err(e) = app.run("folder.label", json!({"path": n.path, "label": crate::panels::grid::label_param(l)})) { app.toast(ui.ctx(), e); diff --git a/crates/ui-egui/src/panels/masking.rs b/crates/ui-egui/src/panels/masking.rs index 26577524a..816f7892a 100644 --- a/crates/ui-egui/src/panels/masking.rs +++ b/crates/ui-egui/src/panels/masking.rs @@ -659,14 +659,14 @@ fn component_row_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, mask: u32, k: run(ui, "Invert", json!({"action": "invert"})); run(ui, &crate::i18n::tr_format!("Duplicate \"{label}\"", label = label), json!({"action": "duplicate"})); if k > 0 { - ui.menu_button(crate::i18n::tr("Mode"), |ui| { + crate::widgets::menu_button(ui, crate::i18n::tr("Mode"), |ui| { for (op, text) in [("add", "Add"), ("subtract", "Subtract"), ("intersect", "Intersect")] { run(ui, text, json!({"action": "op", "op": op})); } }); } - ui.menu_button(crate::i18n::tr("Intersect with"), |ui| component_menu(app, ui, "intersect")); - ui.menu_button(crate::i18n::tr("Subtract"), |ui| component_menu(app, ui, "subtract")); + crate::widgets::menu_button(ui, crate::i18n::tr("Intersect with"), |ui| component_menu(app, ui, "intersect")); + crate::widgets::menu_button(ui, crate::i18n::tr("Subtract"), |ui| component_menu(app, ui, "subtract")); ui.separator(); if ui.button(crate::i18n::tr("Rename…")).clicked() { app.ui.renaming_component = Some((mask, k, label.to_string())); diff --git a/crates/ui-egui/src/panels/people.rs b/crates/ui-egui/src/panels/people.rs index 6fdf032f3..32c0dbc35 100644 --- a/crates/ui-egui/src/panels/people.rs +++ b/crates/ui-egui/src/panels/people.rs @@ -69,10 +69,11 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { ui.painter().text(pos2(head.left() + PAD, head.center().y), Align2::LEFT_CENTER, crate::i18n::tr("Named People"), t.semibold(15.0), t.text); // the person page last left (a face opened from it, or its back button): one click returns to it if let Some(last) = app.ui.last_person.clone().filter(|n| people.iter().any(|p| &p.name == n)) { - let label = fit(ui.painter(), &format!("{last} ›"), t.font(13.0), 240.0); - let width = ui.painter().layout_no_wrap(label.clone(), t.font(13.0), Color32::WHITE).size().x + 24.0; + let label = fit(ui.painter(), &last, t.font(13.0), 224.0); + let width = ui.painter().layout_no_wrap(label.clone(), t.font(13.0), Color32::WHITE).size().x + 44.0; let r = Rect::from_min_size(pos2(title.right() + 14.0, head.center().y - 12.0), vec2(width, 24.0)); - let b = ui.put(r, egui::Button::new(RichText::new(label).font(t.font(13.0))).wrap_mode(egui::TextWrapMode::Extend)); + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(r)); + let b = crate::widgets::arrow_button(&mut child, RichText::new(label).font(t.font(13.0)), crate::icons::Icon::ChevronRight, false); register(ui.ctx(), "people:last", b.rect); if b.on_hover_text(crate::i18n::tr("Back to this person")).clicked() { let _ = app.run("view.person", json!({"name": last})); diff --git a/crates/ui-egui/src/panels/person.rs b/crates/ui-egui/src/panels/person.rs index 9ebf09a64..2d584331b 100644 --- a/crates/ui-egui/src/panels/person.rs +++ b/crates/ui-egui/src/panels/person.rs @@ -93,7 +93,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, name: &str) { ui.add_space(8.0); ui.horizontal(|ui| { ui.add_space(PAD - 4.0); - let back = ui.button("‹ People"); + let back = crate::widgets::arrow_button(ui, crate::i18n::tr("People"), crate::icons::Icon::ChevronLeft, true); register(ui.ctx(), "person:back", back.rect); if back.clicked() { let _ = app.run("view.people", json!({})); diff --git a/crates/ui-egui/src/panels/presets.rs b/crates/ui-egui/src/panels/presets.rs index 3d90c00c3..3f2bc43a7 100644 --- a/crates/ui-egui/src/panels/presets.rs +++ b/crates/ui-egui/src/panels/presets.rs @@ -134,7 +134,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { register(ui.ctx(), format!("presetGroup:{g}"), r); paint( ui.painter(), - Rect::from_center_size(pos2(r.left() + 22.0, r.center().y), vec2(12.0, 12.0)), + Rect::from_center_size(pos2(r.left() + 22.0, r.center().y), vec2(16.0, 16.0)), if open { Icon::ChevronDown } else { Icon::ChevronRight }, t.text_label, ); @@ -185,7 +185,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { } ui.painter().text(pos2(text_x, r.center().y), Align2::LEFT_CENTER, &name, t.font(13.0), t.text_label); if fav { - paint(ui.painter(), Rect::from_center_size(pos2(r.right() - 20.0, r.center().y), vec2(12.0, 12.0)), Icon::StarFilled, t.star); + paint(ui.painter(), Rect::from_center_size(pos2(r.right() - 20.0, r.center().y), vec2(16.0, 16.0)), Icon::StarFilled, t.star); } // resting on a preset previews it in the loupe (no history entry) if resp.hovered() @@ -223,7 +223,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { if ui.button(crate::i18n::tr("Rename…")).clicked() { crate::panels::dialogs::prompt(app, "Rename Preset", "Preset name", &name, "preset.rename", json!({"id": pid}), "name"); } - ui.menu_button(crate::i18n::tr("Move to Group"), |ui| { + crate::widgets::menu_button(ui, crate::i18n::tr("Move to Group"), |ui| { for g in groups.iter().filter(|x| x.user && x.name != group).map(|x| &x.name) { if ui.button(g).clicked() { let _ = app.run("preset.move", json!({"id": pid, "group": g})); diff --git a/crates/ui-egui/src/panels/profiles.rs b/crates/ui-egui/src/panels/profiles.rs index 6294d1e64..6085829e4 100644 --- a/crates/ui-egui/src/panels/profiles.rs +++ b/crates/ui-egui/src/panels/profiles.rs @@ -82,7 +82,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { register(ui.ctx(), if imported { format!("profileGroup:lut:{title}") } else { format!("profileGroup:{title}") }, r); paint( ui.painter(), - Rect::from_center_size(pos2(r.left() + 22.0, r.center().y), vec2(12.0, 12.0)), + Rect::from_center_size(pos2(r.left() + 22.0, r.center().y), vec2(16.0, 16.0)), if open { Icon::ChevronDown } else { Icon::ChevronRight }, t.text_label, ); diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index 8d0cc0d3a..c0b7ad215 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -777,7 +777,7 @@ fn human_size(bytes: u64) -> String { fn copyright_status(app: &mut LightcraftApp, ui: &mut egui::Ui, current: lightcraft_catalog::CopyrightStatus) { let t = Tokens::get(ui.ctx()); ui.label(egui::RichText::new(crate::i18n::tr("Copyright Status")).size(11.5).color(t.text_dim)); - let r = egui::ComboBox::from_id_salt("info-copyright-status").selected_text(crate::i18n::tr(current.label())).show_ui(ui, |ui| { + let r = crate::widgets::combo_box("info-copyright-status").selected_text(crate::i18n::tr(current.label())).show_ui(ui, |ui| { for st in lightcraft_catalog::CopyrightStatus::ALL { if ui.selectable_label(st == current, crate::i18n::tr(st.label())).clicked() && st != current { let _ = app.run("photo.setMeta", json!({"copyrightStatus": st.id()})); @@ -911,7 +911,7 @@ fn keyword_set(app: &mut LightcraftApp, ui: &mut egui::Ui) { let current = sets["current"].as_str().unwrap_or_default().to_string(); ui.horizontal(|ui| { ui.label(egui::RichText::new(crate::i18n::tr("Keyword Set")).color(t.text_dim)); - let combo = egui::ComboBox::from_id_salt("kw-set") + let combo = crate::widgets::combo_box("kw-set") .selected_text(crate::i18n::builtin_label(¤t, current == lightcraft_engine::cmd::keywords::RECENT)) .show_ui(ui, |ui| { for set in sets["sets"].as_array().into_iter().flatten() { diff --git a/crates/ui-egui/src/panels/rules_editor.rs b/crates/ui-egui/src/panels/rules_editor.rs index 006d42667..1ec211d51 100644 --- a/crates/ui-egui/src/panels/rules_editor.rs +++ b/crates/ui-egui/src/panels/rules_editor.rs @@ -51,7 +51,7 @@ fn match_combo(ui: &mut egui::Ui, salt: &str, m: &mut Match) { Match::Any => "any", Match::None => "none", }; - egui::ComboBox::from_id_salt(format!("{salt}-match")).width(64.0).selected_text(crate::i18n::tr(label(*m))).show_ui(ui, |ui| { + crate::widgets::combo_box(format!("{salt}-match")).width(64.0).selected_text(crate::i18n::tr(label(*m))).show_ui(ui, |ui| { for x in [Match::All, Match::Any, Match::None] { ui.selectable_value(m, x, crate::i18n::tr(label(x))); } @@ -149,7 +149,7 @@ fn value_editor(ui: &mut egui::Ui, field: &str, op: &str, v: &mut Value, salt: & let mut unit = stored_unit.to_lowercase(); let mut changed = ui.add(egui::DragValue::new(&mut n).range(1.0..=10_000.0).clamp_existing_to_range(false).speed(0.2).fixed_decimals(0)).changed(); - egui::ComboBox::from_id_salt(format!("{salt}-unit")).width(70.0).selected_text(crate::i18n::tr(&unit)).show_ui(ui, |ui| { + crate::widgets::combo_box(format!("{salt}-unit")).width(70.0).selected_text(crate::i18n::tr(&unit)).show_ui(ui, |ui| { for u in ["hours", "days", "weeks", "months", "years"] { if ui.selectable_label(unit.trim_end_matches('s') == u.trim_end_matches('s'), crate::i18n::tr(u)).clicked() { unit = u.to_string(); @@ -198,23 +198,20 @@ fn value_editor(ui: &mut egui::Ui, field: &str, op: &str, v: &mut Value, salt: & (Some(Kind::Number), _) => number_value(ui, v, field), (Some(Kind::Choice(c)), _) => { let cur = v.as_str().unwrap_or("").to_string(); - egui::ComboBox::from_id_salt(format!("{salt}-choice")).width(120.0).selected_text(crate::i18n::choice_text(field, &cur)).show_ui( - ui, - |ui| { - for (id, _) in c.iter() { - if ui.selectable_label(cur == *id, crate::i18n::choice_text(field, id)).clicked() { - *v = json!(id); - } + crate::widgets::combo_box(format!("{salt}-choice")).width(120.0).selected_text(crate::i18n::choice_text(field, &cur)).show_ui(ui, |ui| { + for (id, _) in c.iter() { + if ui.selectable_label(cur == *id, crate::i18n::choice_text(field, id)).clicked() { + *v = json!(id); } - }, - ); + } + }); } (Some(Kind::Bool), _) => { // the catalog's reading of the value ("false" is no); one that is neither yes nor no is // shown as written and kept until a choice replaces it, so the dialog reports it let cur = bool_value(v); let text = cur.map_or_else(|| v.to_string(), |b| crate::i18n::bool_text(b).to_string()); - egui::ComboBox::from_id_salt(format!("{salt}-bool")).width(60.0).selected_text(text).show_ui(ui, |ui| { + crate::widgets::combo_box(format!("{salt}-bool")).width(60.0).selected_text(text).show_ui(ui, |ui| { for b in [true, false] { if ui.selectable_label(cur == Some(b), crate::i18n::bool_text(b)).clicked() { *v = json!(b); @@ -235,38 +232,37 @@ fn value_editor(ui: &mut egui::Ui, field: &str, op: &str, v: &mut Value, salt: & fn field_menu(ui: &mut egui::Ui, salt: &str, field: &mut String, op: &mut String, value: &mut Value) { let label = field_label(field).unwrap_or(field.as_str()).to_string(); let current = field.clone(); - let r = - egui::ComboBox::from_id_salt(format!("{salt}-field")).width(150.0).height(400.0).selected_text(crate::i18n::tr(&label)).show_ui(ui, |ui| { - let mut pick = None; - let mut item = |ui: &mut egui::Ui, id: &'static str| { - let r = ui.selectable_label(current == id, crate::i18n::tr(field_label(id).unwrap_or(id))); - register(ui.ctx(), format!("ruleFieldItem:{id}:{salt}"), r.rect); - if r.clicked() { - pick = Some(id); - ui.close(); + let r = crate::widgets::combo_box(format!("{salt}-field")).width(150.0).height(400.0).selected_text(crate::i18n::tr(&label)).show_ui(ui, |ui| { + let mut pick = None; + let mut item = |ui: &mut egui::Ui, id: &'static str| { + let r = ui.selectable_label(current == id, crate::i18n::tr(field_label(id).unwrap_or(id))); + register(ui.ctx(), format!("ruleFieldItem:{id}:{salt}"), r.rect); + if r.clicked() { + pick = Some(id); + ui.close(); + } + }; + for id in TOP_LEVEL_FIELDS { + item(ui, id); + } + ui.separator(); + // submenu rows look like the items above them; the one holding the current field stands out + // (a transparent frame, not none: a frameless button drops its padding and hover fill) + let inactive = &mut ui.visuals_mut().widgets.inactive; + inactive.weak_bg_fill = egui::Color32::TRANSPARENT; + inactive.bg_stroke = egui::Stroke::NONE; + for (group, ids) in FIELD_GROUPS { + let text = RichText::new(crate::i18n::tr(group)); + let text = if ids.contains(¤t.as_str()) { text.strong() } else { text }; + let r = crate::widgets::menu_button(ui, text, |ui| { + for id in *ids { + item(ui, id); } - }; - for id in TOP_LEVEL_FIELDS { - item(ui, id); - } - ui.separator(); - // submenu rows look like the items above them; the one holding the current field stands out - // (a transparent frame, not none: a frameless button drops its padding and hover fill) - let inactive = &mut ui.visuals_mut().widgets.inactive; - inactive.weak_bg_fill = egui::Color32::TRANSPARENT; - inactive.bg_stroke = egui::Stroke::NONE; - for (group, ids) in FIELD_GROUPS { - let text = RichText::new(crate::i18n::tr(group)); - let text = if ids.contains(¤t.as_str()) { text.strong() } else { text }; - let r = ui.menu_button(text, |ui| { - for id in *ids { - item(ui, id); - } - }); - register(ui.ctx(), format!("ruleFieldGroup:{group}:{salt}"), r.response.rect); - } - pick - }); + }); + register(ui.ctx(), format!("ruleFieldGroup:{group}:{salt}"), r.response.rect); + } + pick + }); register(ui.ctx(), format!("ruleField:{salt}"), r.response.rect); let Some(Some(id)) = r.inner else { return }; if field == id { @@ -321,7 +317,7 @@ pub fn edit(ui: &mut egui::Ui, rs: &mut RuleSet, salt: &str, depth: usize, path: field_menu(ui, &rsalt, field, op, value); let kind = field_kind(field).unwrap_or(Kind::Text); let op_label = ops_for(kind).iter().find(|o| o.0 == op.as_str()).map_or(op.as_str(), |o| o.1).to_string(); - egui::ComboBox::from_id_salt(format!("{rsalt}-op")).width(110.0).selected_text(crate::i18n::tr(&op_label)).show_ui(ui, |ui| { + crate::widgets::combo_box(format!("{rsalt}-op")).width(110.0).selected_text(crate::i18n::tr(&op_label)).show_ui(ui, |ui| { for (id, l) in ops_for(kind) { if ui.selectable_label(op == id, crate::i18n::tr(l)).clicked() && op != id { let was_special = matches!(op.as_str(), "between" | "inLast" | "notInLast" | "isEmpty" | "isNotEmpty"); diff --git a/crates/ui-egui/src/panels/settings.rs b/crates/ui-egui/src/panels/settings.rs index b95b55b19..0c7e9b04a 100644 --- a/crates/ui-egui/src/panels/settings.rs +++ b/crates/ui-egui/src/panels/settings.rs @@ -206,7 +206,7 @@ fn preset_combo(app: &LightcraftApp, ui: &mut egui::Ui, id: &str, current: Optio }; let text = current.map(name).unwrap_or_else(|| crate::i18n::tr(none_label).to_string()); let mut out = None; - let r = egui::ComboBox::from_id_salt(id).width(240.0).selected_text(text).show_ui(ui, |ui| { + let r = crate::widgets::combo_box(id).width(240.0).selected_text(text).show_ui(ui, |ui| { if ui.selectable_label(current.is_none(), crate::i18n::tr(none_label)).clicked() { out = Some(None); } @@ -276,7 +276,7 @@ fn import_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { .unwrap_or_else(|| pid.to_string()), }; let id = format!("settingsCamera-{i}"); - let r = egui::ComboBox::from_id_salt(&id).width(240.0).selected_text(label).show_ui(ui, |ui| { + let r = crate::widgets::combo_box(&id).width(240.0).selected_text(label).show_ui(ui, |ui| { if ui.selectable_label(current == Some(RAW_DEFAULT), crate::i18n::tr("Same as raw default")).clicked() { pick = Some(json!({"camera": cam, "remove": true})); } @@ -325,7 +325,7 @@ fn import_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { row(ui, t, crate::i18n::tr("Metadata preset"), |ui| { let cur = d.metadata_preset.clone(); let mut pick = None; - let r = egui::ComboBox::from_id_salt("settingsMetaPreset") + let r = crate::widgets::combo_box("settingsMetaPreset") .width(240.0) .selected_text(cur.clone().unwrap_or_else(|| crate::i18n::tr("None").into())) .show_ui(ui, |ui| { diff --git a/crates/ui-egui/src/tests_panels.rs b/crates/ui-egui/src/tests_panels.rs index b18c4e968..5b979ccdc 100644 --- a/crates/ui-egui/src/tests_panels.rs +++ b/crates/ui-egui/src/tests_panels.rs @@ -2365,3 +2365,30 @@ fn new_album_can_leave_the_selected_photos_out() { confirm(&mut h); assert!(photos(&h, "Nothing").is_empty()); } + +/// Histogram corner controls stay usable even before a histogram is available, and each +/// click invokes the same clipping-warning command as the J shortcut exactly once. +#[test] +fn histogram_corners_toggle_clipping_warnings() { + let mut h = demo([1400.0, 1000.0], json!({"view": "detail", "right": "edit"})); + for texture in h.app.renderer.textures.values_mut() { + texture.histogram = None; + } + h.step(); + h.app.ui.show_clipping = false; + let histogram = h.app.widgets.iter().find(|(id, _)| id == "histogram").unwrap().1; + for id in ["histogram:shadows", "histogram:highlights"] { + let rect = h.app.widgets.iter().find(|(widget, _)| widget == id).unwrap().1; + assert!(histogram.shrink(8.0).contains_rect(rect), "{id} keeps clear space to the histogram edges: {rect:?} in {histogram:?}"); + } + for (id, enabled) in [("histogram:shadows", true), ("histogram:highlights", false), ("histogram", true)] { + let r = h.request("ui.clickWidget", json!({"id": id}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + assert_eq!(h.app.ui.show_clipping, enabled, "{id} must toggle exactly once"); + } + let r = h.request("ui.key", json!({"key": "J"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + assert!(!h.app.ui.show_clipping); +} diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index a6af3dc7a..56298609d 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -8,6 +8,42 @@ use lightcraft_develop::{ControlSpec, Track}; use crate::icons::{Icon, paint}; use crate::theme::Tokens; +/// Standard dropdown chevrons, including settings, import/export and rule editors. +pub fn combo_box(id: impl egui::AsIdSalt) -> egui::ComboBox { + egui::ComboBox::from_id_salt(id).icon(|ui, rect, visuals, _| { + paint(ui.painter(), Rect::from_center_size(rect.center(), vec2(16.0, 16.0)), Icon::ChevronDown, visuals.fg_stroke.color); + }) +} + +/// A native button with a vector arrow beside its text; egui reserves the icon's space. +pub fn arrow_button<'a>(ui: &mut Ui, text: impl egui::IntoAtoms<'a>, icon: Icon, leading: bool) -> Response { + let id = ui.next_auto_id().with("arrow"); + let mut atoms = text.into_atoms(); + let arrow = egui::Atom::custom(id, vec2(16.0, 16.0)); + if leading { + atoms.push_left(arrow); + } else { + atoms.push_right(arrow); + } + let r = egui::Button::new(atoms).atom_ui(ui); + if let Some(rect) = r.rect(id) { + paint(ui.painter(), rect, icon, ui.style().interact(&r.response).fg_stroke.color); + } + r.response +} + +/// Keep egui's submenu interaction and layout, using our vector chevron instead of a font glyph. +pub fn menu_button<'a, R>(ui: &mut Ui, atoms: impl egui::IntoAtoms<'a>, contents: impl FnOnce(&mut Ui) -> R) -> egui::InnerResponse> { + if !egui::containers::menu::is_in_menu(ui) { + return ui.menu_button(atoms, contents); + } + let button = egui::Button::new(atoms.into_atoms()).right_text(" "); + let (response, inner) = egui::containers::menu::SubMenuButton::from_button(button).ui(ui, contents); + let center = pos2(response.rect.right() - ui.spacing().button_padding.x - 8.0, response.rect.center().y); + paint(ui.painter(), Rect::from_center_size(center, vec2(16.0, 16.0)), Icon::ChevronRight, ui.style().interact(&response).fg_stroke.color); + egui::InnerResponse::new(inner.map(|r| r.inner), response) +} + // ------------------------------------------------------------------ automation registry #[derive(Clone, Default)] @@ -441,7 +477,7 @@ pub fn section_header(ui: &mut Ui, id: &str, title: &str, open: bool, enabled: O if ui.rect_contains_pointer(r) { p.rect_filled(r, 0.0, t.chrome.gamma_multiply(1.06)); } - let chev = Rect::from_center_size(pos2(r.left() + 30.0, r.center().y), vec2(14.0, 14.0)); + let chev = Rect::from_center_size(pos2(r.left() + 30.0, r.center().y), vec2(16.0, 16.0)); paint(p, chev, if open { Icon::ChevronDown } else { Icon::ChevronRight }, t.text_label); p.text(pos2(r.left() + 44.0, r.center().y), Align2::LEFT_CENTER, title, t.semibold(14.0), t.text); let mut toggled = None; @@ -491,8 +527,8 @@ pub fn flyout_row(ui: &mut Ui, id: &str, title: &str, icon: Icon, open: bool) -> p.text(pos2(tile.right() + 10.0, r.center().y), Align2::LEFT_CENTER, title, t.semibold(13.0), t.text_label); paint( p, - Rect::from_center_size(pos2(r.right() - 14.0, r.center().y), vec2(12.0, 12.0)), - if open { Icon::ChevronDown } else { Icon::TriangleLeft }, + Rect::from_center_size(pos2(r.right() - 14.0, r.center().y), vec2(16.0, 16.0)), + if open { Icon::ChevronDown } else { Icon::ChevronLeft }, t.text_label, ); resp @@ -629,7 +665,7 @@ pub fn dropdown(ui: &mut Ui, id: &str, text: &str, font: egui::FontId, color: Co register(ui.ctx(), format!("dropdown:{id}"), r); let c = if resp.hovered() { t.text } else { color }; ui.painter().galley(pos2(r.left(), r.center().y - galley.size().y / 2.0), galley, c); - paint(ui.painter(), Rect::from_center_size(pos2(r.right() - 7.0, r.center().y + 1.0), vec2(12.0, 12.0)), Icon::ChevronDown, c); + paint(ui.painter(), Rect::from_center_size(pos2(r.right() - 10.0, r.center().y), vec2(16.0, 16.0)), Icon::ChevronDown, c); resp }