fix(ui): wrap the Edit header buttons instead of letting Reset cover HDR

Reset was laid out right-to-left in whatever width Auto, B&W and HDR left, so a long label drew over HDR:
German Zurücksetzen in the default panel, and 16 of the 18 languages at some panel width. A button that
doesn't fit now starts the next row; Reset stays right-aligned.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Julian Zahnow
2026-10-24 15:50:00 +02:00
co-authored by Claude Opus 5.5
parent c414ef06ec
commit d0670de8ce
2 changed files with 141 additions and 27 deletions
+128 -26
View File
@@ -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<usize>> = Vec::new();
let mut row: Vec<usize> = 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();
+13 -1
View File
@@ -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<usize>, per_row: usize) -> Option<usize> {