diff --git a/crates/ui-egui/src/panels/edit.rs b/crates/ui-egui/src/panels/edit.rs index 1a770ebd8..dd8fa7db5 100644 --- a/crates/ui-egui/src/panels/edit.rs +++ b/crates/ui-egui/src/panels/edit.rs @@ -107,32 +107,19 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { let (hr, _) = ui.allocate_exact_size(vec2(ui.available_width(), 40.0), Sense::hover()); ui.painter().text(pos2(hr.left() + 24.0, hr.bottom() - 10.0), Align2::LEFT_CENTER, crate::i18n::tr("Edit"), t.semibold(15.0), t.text); egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 8, bottom: 14 }).show(ui, |ui| { - ui.horizontal(|ui| { - ui.spacing_mut().item_spacing.x = 8.0; - if text_button(ui, "auto", crate::i18n::tr("Auto"), false).clicked() { - let _ = app.run("develop.auto", json!({})); - app.toast(ui.ctx(), crate::i18n::tr("Auto settings applied")); - } - let bw = crate::is_bw(&d); - if text_button(ui, "bw", crate::i18n::tr("B&W"), bw).clicked() { - let _ = app.run("develop.treatment", json!({})); - } - // HDR editing, next to B&W as Lightroom has it - if text_button(ui, "hdr", crate::i18n::tr("HDR"), d.hdr.enabled) - .on_hover_text(crate::i18n::tr("Edit in HDR: highlights above SDR white, for HDR exports and displays")) - .clicked() - { - let _ = app.run("develop.hdr", json!({"enabled": !d.hdr.enabled})); - } - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if text_button(ui, "reset", crate::i18n::tr("Reset"), false) - .on_hover_text(reset_tip(&app.ui.settings.keymap, ui.ctx().os() == egui::os::OperatingSystem::Mac)) - .clicked() - { - reset_clicked = true; - } - }); - }); + let tip = reset_tip(&app.ui.settings.keymap, ui.ctx().os() == egui::os::OperatingSystem::Mac); + let clicks = header_buttons(ui, crate::is_bw(&d), d.hdr.enabled, &tip); + if clicks.auto { + let _ = app.run("develop.auto", json!({})); + app.toast(ui.ctx(), crate::i18n::tr("Auto settings applied")); + } + if clicks.bw { + let _ = app.run("develop.treatment", json!({})); + } + if clicks.hdr { + let _ = app.run("develop.hdr", json!({"enabled": !d.hdr.enabled})); + } + reset_clicked = clicks.reset; }); if let Some(why) = &preview_only { let reload = egui::Frame::NONE @@ -1932,6 +1919,75 @@ pub(crate) fn reload_preview_only(app: &mut LightcraftApp, ctx: &egui::Context, } /// The Reset button's tooltip with the reset shortcut as this platform writes it (issue #1751). +/// Which of the Edit header's buttons were clicked. +#[derive(Default)] +struct HeaderClicks { + auto: bool, + bw: bool, + hdr: bool, + reset: bool, +} + +/// The Edit header's buttons: Auto, B&W and HDR on the left, Reset on the right. A button that doesn't fit on the +/// row starts the next one (long translations in a narrow panel, such as German *Zurücksetzen* beside HDR), so none +/// covers another; Reset stays right-aligned on whichever row it lands. +fn header_buttons(ui: &mut egui::Ui, bw: bool, hdr: bool, reset_tip: &str) -> HeaderClicks { + const GAP: f32 = 8.0; + let mut clicks = HeaderClicks::default(); + let avail = ui.available_width(); + let left = [("auto", "Auto", false), ("bw", "B&W", bw), ("hdr", "HDR", hdr)]; + // rows of `left` indices, filled greedily; `used` is the width taken on the current (last) row + let mut rows: Vec> = Vec::new(); + let mut row: Vec = Vec::new(); + let mut used = 0.0; + for (i, (_, label, _)) in left.iter().enumerate() { + let w = crate::widgets::text_button_size(ui, label); + if row.is_empty() { + used = w; + } else if used + GAP + w > avail { + rows.push(std::mem::take(&mut row)); + used = w; + } else { + used += GAP + w; + } + row.push(i); + } + rows.push(row); + let reset_on_last = used + GAP + crate::widgets::text_button_size(ui, "Reset") <= avail; + let reset = |ui: &mut egui::Ui, clicks: &mut HeaderClicks| { + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + clicks.reset = text_button(ui, "reset", crate::i18n::tr("Reset"), false).on_hover_text(reset_tip).clicked(); + }); + }; + let last = rows.len() - 1; + for (r, row) in rows.iter().enumerate() { + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = GAP; + for &i in row { + let (id, label, active) = left[i]; + let mut resp = text_button(ui, id, crate::i18n::tr(label), active); + if id == "hdr" { + // HDR editing, next to B&W as Lightroom has it + resp = resp.on_hover_text(crate::i18n::tr("Edit in HDR: highlights above SDR white, for HDR exports and displays")); + } + let clicked = resp.clicked(); + match id { + "auto" => clicks.auto = clicked, + "bw" => clicks.bw = clicked, + _ => clicks.hdr = clicked, + } + } + if r == last && reset_on_last { + reset(ui, &mut clicks); + } + }); + } + if !reset_on_last { + ui.horizontal(|ui| reset(ui, &mut clicks)); + } + clicks +} + fn reset_tip(keymap: &crate::shortcuts::Keymap, mac: bool) -> String { let base = crate::i18n::tr("Reset all edits"); match crate::shortcuts::shortcut_of(keymap, "develop.reset") { @@ -1969,6 +2025,52 @@ mod curve_histogram_tests { mod tests { use super::*; + /// The Edit header's buttons never cover each other or run past the panel, in any language: a button that doesn't + /// fit starts the next row, and Reset stays right-aligned. Before, Reset covered HDR in 11 of the 18 languages at + /// the default 222 pt (German *Zurücksetzen* among them) and in all but the two Chinese ones at some width. 200 pt + /// is about the narrowest panel (`RIGHT_WIDTH.min` less the margins). + #[test] + fn edit_header_buttons_do_not_overlap_in_any_language() { + use crate::i18n::Locale; + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + crate::theme::apply(&ctx); + let mut wrapped = Vec::new(); + for &locale in Locale::ALL { + crate::i18n::set_language(locale); + for width in [200.0, 222.0, 260.0, 400.0] { + let mut rects = Vec::new(); + for frame in 0..2 { + let raw = crate::headless::HeadlessView::raw_input(vec2(width, 200.0), 1.0, frame as f64 / 60.0, vec![]); + let mut out = ctx.run_ui(raw, |ui| { + egui::CentralPanel::default().frame(egui::Frame::NONE).show(ui, |ui| { + header_buttons(ui, false, false, ""); + }); + }); + out.textures_delta.clear(); + rects = crate::widgets::take_registry(&ctx); + } + let get = |id: &str| rects.iter().find(|(i, _)| i == id).map(|(_, r)| *r).unwrap(); + let buttons = ["button:auto", "button:bw", "button:hdr", "button:reset"].map(get); + for (i, a) in buttons.iter().enumerate() { + assert!(a.right() <= width + 0.5, "{locale:?} {width}: button {i} past the edge: {a:?}"); + for b in &buttons[i + 1..] { + assert!(!a.intersects(b.shrink(0.5)), "{locale:?} {width}: {a:?} overlaps {b:?}"); + } + } + let [_, _, hdr, reset] = buttons; + assert!((reset.right() - width).abs() < 0.5, "{locale:?} {width}: Reset stays right-aligned: {reset:?}"); + if reset.top() > hdr.top() { + wrapped.push((locale, width)); + } + } + } + crate::i18n::set_language(Locale::En); + assert!(wrapped.contains(&(Locale::De, 222.0)), "German wraps in the default panel: {wrapped:?}"); + // English keeps its single row from the default width up + assert!(!wrapped.iter().any(|&(l, w)| l == Locale::En && w >= 222.0), "{wrapped:?}"); + } + #[test] fn an_hdr_edit_keeps_its_hdr_histogram_through_8_bit_renders() { let ctx = egui::Context::default(); diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 01d5b65f8..1f1987d3b 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -694,7 +694,7 @@ pub fn text_button(ui: &mut Ui, id: &str, label: &str, active: bool) -> Response let t = Tokens::get(ui.ctx()); let font = t.semibold(11.5); let galley = ui.painter().layout_no_wrap(label.to_string(), font, t.text); - let size = vec2((galley.size().x + 18.0).max(37.0), 24.0); + let size = vec2(text_button_width(galley.size().x), 24.0); let (r, resp) = ui.allocate_exact_size(size, Sense::click()); resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, true, active, label)); register(ui.ctx(), format!("button:{id}"), r); @@ -711,6 +711,18 @@ pub fn text_button(ui: &mut Ui, id: &str, label: &str, active: bool) -> Response resp } +/// The width of a [`text_button`] whose label is `text_width` wide. +fn text_button_width(text_width: f32) -> f32 { + (text_width + 18.0).max(37.0) +} + +/// The width [`text_button`] takes for `label` (translated as it would be), to plan a row before drawing it. +pub fn text_button_size(ui: &Ui, label: &str) -> f32 { + let t = Tokens::get(ui.ctx()); + let galley = ui.painter().layout_no_wrap(crate::i18n::tr(label).to_string(), t.semibold(11.5), t.text); + text_button_width(galley.size().x) +} + /// A segmented control: `labels` share the available width equally, `per_row` per row. Each segment is /// addressable as `button:{id}-{key}` (keys parallel to labels). Returns the clicked index. pub fn segmented(ui: &mut Ui, id: &str, items: &[(&str, &str)], active: Option, per_row: usize) -> Option {