UI-ARROWS: reconcile chevrons with current upstream UI

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 <noreply@openai.com>
This commit is contained in:
Lucas Lepski
2026-10-14 15:27:56 +02:00
co-authored by GPT-6.1 Sol
parent 73c61aae44
commit 7984667269
26 changed files with 318 additions and 177 deletions
+11 -11
View File
@@ -11,7 +11,7 @@
//! `albumPicker:<id>` (the button), `albumPickerSearch:<id>`, `albumPickerFolder:<folder>:<id>`
//! and `albumPickerItem:<album>:<id>`.
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<usize> {
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(
+17 -8
View File
@@ -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 {
+4 -2
View File
@@ -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);
+30 -12
View File
@@ -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)]);
+5 -5
View File
@@ -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();
}
+8 -8
View File
@@ -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<f32>, 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<f32>, rows: impl FnOn
#[cfg(test)]
mod tests {
use super::*;
use egui::pos2;
#[test]
fn level_room_keeps_every_level_below_the_bar() {
+1 -1
View File
@@ -930,7 +930,7 @@ fn switch_open_title(ctx: &egui::Context, titles: &[egui::Response]) -> Option<e
/// bounded like every level. The row is registered as `menusub:<label>` (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<f32>, 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);
+4 -2
View File
@@ -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)
});
}
});
}
+1 -1
View File
@@ -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));
}
+1 -1
View File
@@ -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!({}));
+5 -4
View File
@@ -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));
}
+70 -13
View File
@@ -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<f32> {
(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()
+1 -1
View File
@@ -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<String> = app.session.filter_presets.iter().map(|f| f.name.clone()).collect();
+16 -11
View File
@@ -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})) {
+4 -8
View File
@@ -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);
+16 -25
View File
@@ -173,17 +173,17 @@ fn row_need(indent: f32, name_w: f32, mark_w: f32, count_w: Option<f32>) -> 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<egui::Pos2> {
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:<colour>`).
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);
+3 -3
View File
@@ -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()));
+4 -3
View File
@@ -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}));
+1 -1
View File
@@ -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!({}));
+3 -3
View File
@@ -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}));
+1 -1
View File
@@ -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,
);
+2 -2
View File
@@ -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(&current, current == lightcraft_engine::cmd::keywords::RECENT))
.show_ui(ui, |ui| {
for set in sets["sets"].as_array().into_iter().flatten() {
+40 -44
View File
@@ -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(&current.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(&current.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");
+3 -3
View File
@@ -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| {
+27
View File
@@ -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);
}
+40 -4
View File
@@ -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<Option<R>> {
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
}