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 8f71a3ea8..15434237a 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, @@ -179,6 +181,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 }; @@ -188,14 +206,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)]); @@ -403,9 +413,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 c49caa0bf..648e87250 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -574,7 +574,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(); } @@ -663,7 +664,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"); }); @@ -682,7 +683,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 { @@ -1858,7 +1859,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 4afef0436..6a69a94a0 100644 --- a/crates/ui-egui/src/panels/settings.rs +++ b/crates/ui-egui/src/panels/settings.rs @@ -210,7 +210,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); } @@ -280,7 +280,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})); } @@ -329,7 +329,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 }