diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 5226e8419..2ad2b0dbd 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -31,6 +31,7 @@ mod model_setup; pub mod panels; pub mod pick; pub mod print_view; +pub mod rating; pub mod recheck; pub mod region; pub mod reload; @@ -151,6 +152,8 @@ mod tests_profiles_layout; #[cfg(test)] mod tests_quit_unsaved; #[cfg(test)] +mod tests_rating; +#[cfg(test)] mod tests_recheck; #[cfg(test)] mod tests_rename_person; diff --git a/crates/ui-egui/src/panels/bottombar.rs b/crates/ui-egui/src/panels/bottombar.rs index 69b76bcb8..415e23d06 100644 --- a/crates/ui-egui/src/panels/bottombar.rs +++ b/crates/ui-egui/src/panels/bottombar.rs @@ -8,7 +8,7 @@ use crate::LightcraftApp; use crate::icons::{Icon, paint}; use crate::state::{ViewMode, Zoom}; use crate::theme::Tokens; -use crate::widgets::{icon_button, register, stars}; +use crate::widgets::{icon_button, register}; const VIEW_MODES: &[(&str, Icon, ViewMode, &str)] = &[ ("photoGrid", Icon::GridPhoto, ViewMode::PhotoGrid, "Photo Grid (G)"), @@ -105,7 +105,7 @@ fn centre(app: &mut LightcraftApp, ui: &mut egui::Ui, full: Rect, from: f32, to: ui.new_child(egui::UiBuilder::new().max_rect(pill.shrink2(vec2(10.0, 4.0))).layout(egui::Layout::left_to_right(egui::Align::Center))); pill_ui.spacing_mut().item_spacing.x = 3.0; let rating = active.as_ref().map(|p| p.rating).unwrap_or(0); - if let Some(r) = stars(&mut pill_ui, "bottom", rating, 19.0) { + if let Some(r) = crate::rating::Rating::new("bottom", rating).show(&mut pill_ui) { let mut params = json!({"rating": r}); crate::panels::compare::target_active(app, &mut params); let _ = app.run("photo.rate", params); diff --git a/crates/ui-egui/src/panels/compare.rs b/crates/ui-egui/src/panels/compare.rs index bb7937640..8d0f6eaec 100644 --- a/crates/ui-egui/src/panels/compare.rs +++ b/crates/ui-egui/src/panels/compare.rs @@ -343,7 +343,7 @@ fn photo_tile(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, slot: Slo let filled = i < photo.rating; let r = Rect::from_min_size(pos2(x + i as f32 * 13.0, cap.center().y - 6.0), vec2(12.0, 12.0)); if r.right() < cap.right() { - paint(pt, r, if filled { Icon::StarFilled } else { Icon::Star }, if filled { t.star } else { t.icon.gamma_multiply(0.6) }); + crate::rating::paint_star(pt, r, filled, if filled { t.star } else { t.icon.gamma_multiply(0.6) }); } } x += 5.0 * 13.0 + 4.0; diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index b95d7b056..c61a81383 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -2939,10 +2939,7 @@ fn film_badges(p: &egui::Painter, t: &Tokens, fr: Rect, ph: &lightcraft_catalog: p.rect_filled(bar, 0.0, Color32::from_black_alpha(130)); let y = bar.center().y; let mut x = bar.left() + 3.0; - for i in 0..ph.rating { - paint(p, Rect::from_min_size(pos2(x + i as f32 * 9.0, y - 4.0), vec2(8.0, 8.0)), Icon::StarFilled, t.star); - } - x += ph.rating as f32 * 9.0 + 2.0; + x += crate::rating::paint_rating(p, pos2(x, y), ph.rating, crate::rating::RatingSize::Small, t.star) + 2.0; match ph.flag { Flag::Pick => paint(p, Rect::from_min_size(pos2(x, y - 5.0), vec2(10.0, 10.0)), Icon::FlagPickFilled, t.pick), Flag::Reject => paint(p, Rect::from_min_size(pos2(x, y - 5.0), vec2(10.0, 10.0)), Icon::FlagReject, t.reject), diff --git a/crates/ui-egui/src/panels/filterbar.rs b/crates/ui-egui/src/panels/filterbar.rs index 8365a1ff8..8b524df11 100644 --- a/crates/ui-egui/src/panels/filterbar.rs +++ b/crates/ui-egui/src/panels/filterbar.rs @@ -130,22 +130,8 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { .map(|(l, k, op)| (l.to_string(), json!({"ratingOp": k}), f.rating_op == op)) .collect(), ); - for i in 1..=5u8 { - let (r, resp) = ui.allocate_exact_size(vec2(17.0, 26.0), Sense::click()); - crate::widgets::announce(&resp, format!("{i} ★")); - register(ui.ctx(), format!("filter:star{i}"), r); - let filled = i <= f.rating; - let c = if filled { - t.star - } else if resp.hovered() { - t.text_label - } else { - t.icon - }; - paint(ui.painter(), Rect::from_center_size(r.center(), vec2(14.0, 14.0)), if filled { Icon::StarFilled } else { Icon::Star }, c); - if resp.clicked() { - filter(app, json!({"rating": if f.rating == i { 0 } else { i }})); - } + if let Some(r) = crate::rating::Rating::new("filter", f.rating).automation_ids("filter:star").show(ui) { + filter(app, json!({"rating": r})); } ui.add_space(14.0); diff --git a/crates/ui-egui/src/panels/grid.rs b/crates/ui-egui/src/panels/grid.rs index a1184892c..2d5b05e76 100644 --- a/crates/ui-egui/src/panels/grid.rs +++ b/crates/ui-egui/src/panels/grid.rs @@ -857,10 +857,7 @@ fn cell(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, r: Rect, square p.rect_filled(bar, 0.0, Color32::from_black_alpha(120)); } let mut x = bar.left() + 6.0; - for i in 0..photo.rating { - paint(p, Rect::from_min_size(pos2(x + i as f32 * 13.0, bar.center().y - 6.0), vec2(12.0, 12.0)), Icon::StarFilled, t.star); - } - x += photo.rating as f32 * 13.0 + 4.0; + x += crate::rating::paint_rating(p, pos2(x, bar.center().y), photo.rating, crate::rating::RatingSize::Medium, t.star) + 4.0; match photo.flag { Flag::Pick => paint(p, Rect::from_min_size(pos2(x, bar.center().y - 7.0), vec2(14.0, 14.0)), Icon::FlagPickFilled, t.pick), Flag::Reject => paint(p, Rect::from_min_size(pos2(x, bar.center().y - 7.0), vec2(14.0, 14.0)), Icon::FlagReject, t.reject), diff --git a/crates/ui-egui/src/panels/mask_effects.rs b/crates/ui-egui/src/panels/mask_effects.rs index 6062d241c..ef17bfba0 100644 --- a/crates/ui-egui/src/panels/mask_effects.rs +++ b/crates/ui-egui/src/panels/mask_effects.rs @@ -237,7 +237,12 @@ fn menu(app: &mut LightcraftApp, ui: &mut egui::Ui, mask: &Mask, current: Option let elided = g.elided; ui.painter().galley(rect.left_center() + vec2(18.0, -g.size().y / 2.0), g, t.text); if fav { - ui.painter().text(rect.right_center() - vec2(4.0, 0.0), egui::Align2::RIGHT_CENTER, "★", t.font(11.0), t.text_dim); + crate::rating::paint_star( + ui.painter(), + egui::Rect::from_center_size(rect.right_center() - vec2(10.0, 0.0), vec2(11.0, 11.0)), + true, + t.text_dim, + ); } if moved && st.highlight == Some(i) { resp.scroll_to_me(None); @@ -522,10 +527,7 @@ pub fn manage_body( for e in list { let sel = selected.contains(&e.name); ui.horizontal(|ui| { - let star = ui.add(egui::Button::new(if e.favorite { "★" } else { "☆" }).frame(false)).on_hover_text(crate::i18n::tr("Favorite")); - if ui.is_rect_visible(star.rect) { - register(ui.ctx(), format!("effectStar:{}", e.name), star.rect); - } + let star = crate::rating::favorite_button(ui, format!("effectStar:{}", e.name), e.favorite, crate::i18n::tr("Favorite")); if star.clicked() { toggle_star = Some((e.name.clone(), !e.favorite)); } diff --git a/crates/ui-egui/src/panels/presets.rs b/crates/ui-egui/src/panels/presets.rs index a8cf21424..439a15a1a 100644 --- a/crates/ui-egui/src/panels/presets.rs +++ b/crates/ui-egui/src/panels/presets.rs @@ -227,7 +227,12 @@ 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(16.0, 16.0)), Icon::StarFilled, t.star); + crate::rating::paint_star( + ui.painter(), + Rect::from_center_size(pos2(r.right() - 20.0, r.center().y), vec2(16.0, 16.0)), + true, + t.star, + ); } // resting on a preset previews it in the loupe (no history entry) if resp.hovered() diff --git a/crates/ui-egui/src/panels/profiles.rs b/crates/ui-egui/src/panels/profiles.rs index 8a4b08345..000ebdbcb 100644 --- a/crates/ui-egui/src/panels/profiles.rs +++ b/crates/ui-egui/src/panels/profiles.rs @@ -182,7 +182,7 @@ fn cell_ui( let over_star = resp.hover_pos().is_some_and(|q| star.contains(q)); if fav || resp.hovered() { painter.circle_filled(star.center(), 10.0, Color32::from_black_alpha(110)); - paint(painter, star.shrink(4.0), if fav { Icon::StarFilled } else { Icon::Star }, if fav || over_star { t.star } else { Color32::WHITE }); + crate::rating::paint_star(painter, star.shrink(4.0), fav, if fav || over_star { t.star } else { Color32::WHITE }); } if resp.hovered() && !applied && !over_star { app.hover_preview = diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index 76ecc746a..26bb0ec3c 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -642,7 +642,7 @@ fn info(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { ui.label(egui::RichText::new(crate::i18n::tr_format!("Virtual copy “{name}” of {of}", name = name, of = of)).color(t.text_label)); } ui.add_space(8.0); - if let Some(r) = ui.horizontal(|ui| crate::widgets::stars(ui, "info", p.rating, 20.0)).inner { + if let Some(r) = ui.horizontal(|ui| crate::rating::Rating::new("info", p.rating).show(ui)).inner { let mut params = json!({"rating": r}); crate::panels::compare::target_active(app, &mut params); let _ = app.run("photo.rate", params); diff --git a/crates/ui-egui/src/panels/settings.rs b/crates/ui-egui/src/panels/settings.rs index 1f1dd4eec..d37b9943e 100644 --- a/crates/ui-egui/src/panels/settings.rs +++ b/crates/ui-egui/src/panels/settings.rs @@ -1091,12 +1091,7 @@ fn theme_card( }); register(ui.ctx(), format!("button:themeFavorite-{}", kind.id()), r.rect); if favorite || hovered { - crate::icons::paint( - ui.painter(), - r.rect.shrink(4.0), - if favorite { crate::icons::Icon::StarFilled } else { crate::icons::Icon::Star }, - if favorite { t.accent } else { t.text_dim }, - ); + crate::rating::paint_star(ui.painter(), r.rect.shrink(4.0), favorite, if favorite { t.accent } else { t.text_dim }); } if r.clicked() { if favorite { diff --git a/crates/ui-egui/src/rating.rs b/crates/ui-egui/src/rating.rs new file mode 100644 index 000000000..16cd611d2 --- /dev/null +++ b/crates/ui-egui/src/rating.rs @@ -0,0 +1,256 @@ +//! One star rating for the whole interface: the clickable row of five stars ([`Rating`]) and the +//! painter-only helpers for stars drawn onto photos and captions ([`paint_rating`], [`paint_star`]). +//! It replaces `widgets::stars` and the private star-painting loops of the grid badge, the film +//! badges, the compare caption and the filter bar. +//! +//! ```ignore +//! if let Some(r) = Rating::new("bottom", photo.rating).show(ui) { … } +//! paint_rating(ui.painter(), pos, 3, RatingSize::Small, color); +//! ``` +//! +//! Hovering a star previews the rating a click would set; clicking the current rating clears it. +//! The row is one Tab stop (the star of the current rating, or the first when unrated); inside it, ← and → +//! lower and raise the rating (0 to 5) and the focus follows. + +use egui::{Color32, Key, Modifiers, Painter, Pos2, Rect, Sense, Ui, pos2, vec2}; + +use crate::icons::{Icon, paint}; +use crate::theme::Tokens; +use crate::widgets::{announce_selected, register}; + +/// Number of stars in a rating. +const STARS: u8 = 5; + +/// How big a rating is drawn. Each size matches one of the renderers it replaced. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RatingSize { + /// Film badges: 8 px stars every 9 px. + Small, + /// Grid badges and compare captions: 12 px stars every 13 px. + Medium, + /// The clickable row (bottom bar, Info panel, filter bar): 14 px stars in 19 px cells. + Large, +} + +impl RatingSize { + /// Side of one painted star. + pub fn icon(self) -> f32 { + match self { + Self::Small => 8.0, + Self::Medium => 12.0, + Self::Large => 14.0, + } + } + + /// Distance from one painted star to the next in [`paint_rating`]. + pub fn step(self) -> f32 { + match self { + Self::Small => 9.0, + Self::Medium => 13.0, + Self::Large => 17.0, + } + } + + /// Side of one clickable cell in a [`Rating`] row. + fn cell(self) -> f32 { + self.step() + 2.0 + } +} + +/// Paint one star (outline or filled) centred in `rect`. +pub fn paint_star(painter: &Painter, rect: Rect, filled: bool, color: Color32) { + paint(painter, rect, if filled { Icon::StarFilled } else { Icon::Star }, color); +} + +/// Paint `rating` filled stars in a row, the first one's left edge at `pos.x` and its vertical centre +/// at `pos.y`. Returns the width the row takes (0 for no stars), so the caller can lay out what follows. +pub fn paint_rating(painter: &Painter, pos: Pos2, rating: u8, size: RatingSize, color: Color32) -> f32 { + let n = rating.min(STARS); + let side = size.icon(); + for i in 0..n { + let left = pos.x + f32::from(i) * size.step(); + paint_star(painter, Rect::from_min_size(pos2(left, pos.y - side / 2.0), vec2(side, side)), true, color); + } + f32::from(n) * size.step() +} + +/// What the stars show once hovering is taken into account: the hovered star's rating previews over +/// the current one. +fn star_lit(i: u8, rating: u8, hover: Option) -> bool { + i < hover.unwrap_or(rating) +} + +/// The rating a click on star `n` (1 to 5) sets: clicking the current rating clears it. +fn clicked_rating(current: u8, n: u8) -> u8 { + if current == n { 0 } else { n } +} + +/// The rating the arrow keys move to from `current`, if any. +fn nudged_rating(current: u8, left: bool, right: bool) -> Option { + match (left, right) { + (true, false) if current > 0 => Some(current - 1), + (false, true) if current < STARS => Some(current + 1), + _ => None, + } +} + +/// A row of five stars. Build it, then [`Rating::show`]. +pub struct Rating { + id: String, + rating: u8, + ids_prefix: Option, +} + +impl Rating { + /// A rating of `rating` (0 to 5) whose stars are reachable as `star:{id}:{n}`. + pub fn new(id: impl Into, rating: u8) -> Self { + Self { id: id.into(), rating: rating.min(STARS), ids_prefix: None } + } + + /// Register the stars under `{prefix}{n}` instead of `star:{id}:{n}`, for rows that already have + /// published automation ids (the filter bar's `filter:star3`). + pub fn automation_ids(mut self, prefix: impl Into) -> Self { + self.ids_prefix = Some(prefix.into()); + self + } + + fn widget_id(&self, n: u8) -> String { + match &self.ids_prefix { + Some(prefix) => format!("{prefix}{n}"), + None => format!("star:{}:{n}", self.id), + } + } + + fn star_id(&self, ui: &Ui, i: u8) -> egui::Id { + ui.id().with(("rating-star", &self.id, i)) + } + + /// Draw the row. Returns the new rating when a click or an arrow key changed it. + pub fn show(self, ui: &mut Ui) -> Option { + let t = Tokens::get(ui.ctx()); + let cell = RatingSize::Large.cell(); + let mut out = None; + let mut hover_n = None; + let mut focused_at = None; + let mut cells = [Rect::NOTHING; STARS as usize]; + // One Tab stop for the row, like a radio group: the star of the current rating (the first when + // unrated), plus whichever star already has the focus so it keeps it + let stop = self.rating.max(1) - 1; + for (i, slot) in (0..STARS).zip(cells.iter_mut()) { + let id = self.star_id(ui, i); + let (r, _) = ui.allocate_exact_size(vec2(cell, cell), Sense::hover()); + let sense = if i == stop || ui.memory(|m| m.has_focus(id)) { Sense::click() } else { Sense::CLICK }; + let resp = ui.interact(r, id, sense); + announce_selected(&resp, format!("{} ★", i + 1), i < self.rating); + register(ui.ctx(), self.widget_id(i + 1), r); + if resp.hovered() { + hover_n = Some(i + 1); + } + if resp.clicked() { + out = Some(clicked_rating(self.rating, i + 1)); + } + if resp.has_focus() { + focused_at = Some(i); + // ← and → step the rating instead of moving the focus to the neighbouring widget + ui.memory_mut(|m| { + m.set_focus_lock_filter(resp.id, egui::EventFilter { horizontal_arrows: true, ..Default::default() }); + }); + ui.painter().rect_stroke(r.expand(1.0), 6.0, egui::Stroke::new(2.0, t.accent), egui::StrokeKind::Outside); + } + *slot = r; + } + let focused = focused_at.is_some(); + if focused && out.is_none() { + let (left, right) = ui.input_mut(|i| (i.consume_key(Modifiers::NONE, Key::ArrowLeft), i.consume_key(Modifiers::NONE, Key::ArrowRight))); + if left || right { + // egui picked the neighbour to focus from this same key before the lock above could apply (the + // first arrow after Tab): cancel that move + ui.memory_mut(|m| m.move_focus(egui::FocusDirection::None)); + } + out = nudged_rating(self.rating, left, right); + if let Some(n) = out { + // keep the focus on the star that is now the row's Tab stop + let to = self.star_id(ui, n.max(1) - 1); + ui.memory_mut(|m| m.request_focus(to)); + } else if focused_at != Some(stop) { + // the rating changed from outside (undo, a shortcut, another photo): the focus follows the Tab stop + let to = self.star_id(ui, stop); + ui.memory_mut(|m| m.request_focus(to)); + } + } + // The hovered star also gets the icon-button hover square, so hovering the current rating (where + // the preview looks the same) still shows feedback. + let side = RatingSize::Large.icon(); + for (i, r) in (0..STARS).zip(cells) { + if hover_n == Some(i + 1) { + ui.painter().rect_filled(r, 4.0, t.hover.gamma_multiply(0.7)); + } + let filled = star_lit(i, self.rating, hover_n); + paint_star(ui.painter(), Rect::from_center_size(r.center(), vec2(side, side)), filled, if filled { t.star } else { t.icon }); + } + out + } +} + +/// A frameless star toggle for favourites: filled when `favorite`, named `name` for screen readers and as +/// its tooltip, reachable as `widget` (a full automation id). A square one line tall. +pub fn favorite_button(ui: &mut Ui, widget: String, favorite: bool, name: &str) -> egui::Response { + let t = Tokens::get(ui.ctx()); + let side = ui.spacing().interact_size.y; + let (r, resp) = ui.allocate_exact_size(vec2(side, side), Sense::click()); + announce_selected(&resp, name, favorite); + // (rows scrolled out of view can't be clicked) + if ui.is_rect_visible(r) { + register(ui.ctx(), widget, r); + } + if resp.hovered() { + ui.painter().rect_filled(r, 4.0, t.hover.gamma_multiply(0.7)); + } + if resp.has_focus() { + ui.painter().rect_stroke(r.expand(1.0), 6.0, egui::Stroke::new(2.0, t.accent), egui::StrokeKind::Outside); + } + let icon = RatingSize::Medium.icon(); + paint_star(ui.painter(), Rect::from_center_size(r.center(), vec2(icon, icon)), favorite, if favorite { t.star } else { t.icon }); + resp.on_hover_text(name) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_hovered_star_previews_its_rating() { + let lit = |rating, hover| (0..STARS).filter(|&i| star_lit(i, rating, hover)).count(); + assert_eq!(lit(2, None), 2, "no hover shows the rating"); + assert_eq!(lit(2, Some(4)), 4); + assert_eq!(lit(5, Some(1)), 1); + assert_eq!(lit(0, Some(3)), 3); + } + + #[test] + fn arrows_stay_between_zero_and_five() { + assert_eq!(nudged_rating(0, true, false), None); + assert_eq!(nudged_rating(5, false, true), None); + assert_eq!(nudged_rating(3, true, false), Some(2)); + assert_eq!(nudged_rating(3, false, true), Some(4)); + assert_eq!(nudged_rating(3, true, true), None); + } + + #[test] + fn rows_scrolled_out_of_view_are_not_registered() { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let mut out = + ctx.run_ui(egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(300.0, 100.0))), ..Default::default() }, |ui| { + egui::ScrollArea::vertical().max_height(60.0).show(ui, |ui| { + for i in 0..30 { + favorite_button(ui, format!("effectStar:{i}"), false, "Favorite"); + } + }); + }); + out.textures_delta.clear(); + let ids: Vec = crate::widgets::take_registry(&ctx).into_iter().map(|(id, _)| id).collect(); + assert!(ids.contains(&"effectStar:0".to_string()), "{ids:?}"); + assert!(!ids.contains(&"effectStar:29".to_string()), "{ids:?}"); + } +} diff --git a/crates/ui-egui/src/tests_rating.rs b/crates/ui-egui/src/tests_rating.rs new file mode 100644 index 000000000..f89d1267b --- /dev/null +++ b/crates/ui-egui/src/tests_rating.rs @@ -0,0 +1,238 @@ +//! The star rating: clicking sets the rating, clicking the current one clears it, the row is one +//! Tab stop, the arrow keys step it, and screen readers hear every star. + +use egui::{Pos2, Rect, vec2}; + +use crate::rating::Rating; + +/// Draw `build` for a few frames. After the first, click the centre of star `click` (1 to 5) if given, +/// then press `keys`. Returns every value `build` reported and the AccessKit +/// (role, name) pairs. +fn drive(click: Option, keys: &[egui::Key], build: impl Fn(&mut egui::Ui) -> Option) -> (Vec, Vec<(String, String)>) { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + ctx.enable_accesskit(); + let mut reported = Vec::new(); + let mut nodes = Vec::new(); + let mut rects: Vec<(String, Rect)> = Vec::new(); + for frame in 0..6 { + let mut events = Vec::new(); + if frame == 1 + && let Some(n) = click + { + let want = format!("star:t:{n}"); + let at = rects.iter().find(|(id, _)| *id == want).map(|(_, r)| r.center()).unwrap_or(Pos2::ZERO); + events.push(egui::Event::PointerMoved(at)); + events.push(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); + events.push(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }); + } + // one key per frame from the third on, so Tab has moved the focus before an arrow arrives + if frame >= 2 + && let Some(k) = keys.get(frame - 2) + { + events.push(egui::Event::Key { key: *k, physical_key: Some(*k), pressed: true, repeat: false, modifiers: Default::default() }); + } + let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(400.0, 200.0))), events, ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| { + if let Some(v) = build(ui) { + reported.push(v); + } + }); + out.textures_delta.clear(); + let seen = crate::widgets::take_registry(&ctx); + if rects.is_empty() { + rects = seen; + } + if let Some(update) = out.platform_output.accesskit_update.take() { + nodes = update.nodes.iter().map(|(_, n)| (format!("{:?}", n.role()), n.label().unwrap_or_default().to_string())).collect(); + } + } + (reported, nodes) +} + +fn row(rating: u8) -> Rating { + Rating::new("t", rating) +} + +#[test] +fn clicking_a_star_sets_that_rating() { + let (got, _) = drive(Some(4), &[], |ui| row(2).show(ui)); + assert_eq!(got, vec![4]); +} + +#[test] +fn clicking_the_current_rating_clears_it() { + let (got, _) = drive(Some(3), &[], |ui| row(3).show(ui)); + assert_eq!(got, vec![0]); +} + +#[test] +fn hovering_alone_changes_nothing() { + // the preview is only painted; the rating is reported on click + let (got, _) = drive(None, &[], |ui| row(2).show(ui)); + assert!(got.is_empty()); +} + +/// How many stars `Rating::show` paints filled (in the star colour) after the pointer has moved to +/// the centre of star `hover` (1 to 5), if given. A filled star is ten triangles of one colour. +fn filled_stars(rating: u8, hover: Option) -> usize { + fn count(shape: &egui::Shape, colour: egui::Color32) -> usize { + match shape { + egui::Shape::Path(path) => usize::from(path.fill == colour), + egui::Shape::Vec(shapes) => shapes.iter().map(|shape| count(shape, colour)).sum(), + _ => 0, + } + } + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let colour = crate::theme::Tokens::get(&ctx).star; + let mut at = None; + let mut filled = 0; + for frame in 0..4 { + let mut events = Vec::new(); + if frame >= 1 + && let Some(at) = at + { + events.push(egui::Event::PointerMoved(at)); + } + let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(400.0, 200.0))), events, ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| { + let _ = row(rating).show(ui); + }); + out.textures_delta.clear(); + let seen = crate::widgets::take_registry(&ctx); + if let (Some(n), true) = (hover, frame == 0) { + let want = format!("star:t:{n}"); + at = seen.iter().find(|(id, _)| *id == want).map(|(_, r)| r.center()); + assert!(at.is_some(), "star {n} is registered"); + } + filled = out.shapes.iter().map(|clipped| count(&clipped.shape, colour)).sum::() / 10; + } + filled +} + +#[test] +fn hovering_a_star_paints_the_rating_a_click_would_set() { + // the row shows 2 filled stars; the pointer over star 4 shows 4, and over star 1 shows 1 + assert_eq!(filled_stars(2, None), 2); + assert_eq!(filled_stars(2, Some(4)), 4); + assert_eq!(filled_stars(2, Some(1)), 1, "hovering a lower star previews the lower rating"); +} + +#[test] +fn a_screen_reader_hears_every_star_with_its_state() { + let (_, nodes) = drive(None, &[], |ui| row(2).show(ui)); + for n in 1..=5 { + let name = format!("{n} ★"); + assert!(nodes.iter().any(|(role, label)| role == "Button" && *label == name), "{name}: {nodes:?}"); + } +} + +#[test] +fn the_arrow_keys_step_the_rating_of_a_focused_star() { + // Tab focuses the first star; the row shows 3, so → asks for 4 and ← for 2 + let (got, _) = drive(None, &[egui::Key::Tab, egui::Key::ArrowRight], |ui| row(3).show(ui)); + assert_eq!(got, vec![4], "{got:?}"); + let (got, _) = drive(None, &[egui::Key::Tab, egui::Key::ArrowLeft], |ui| row(3).show(ui)); + assert_eq!(got, vec![2], "{got:?}"); +} + +#[test] +fn arrow_keys_do_nothing_when_no_star_is_focused() { + let (got, _) = drive(None, &[egui::Key::ArrowRight], |ui| row(3).show(ui)); + assert!(got.is_empty(), "{got:?}"); +} + +/// Feed the shown rating back each frame, with other focusable widgets on both sides. +fn step_with_neighbours(start: u8, keys: &[egui::Key]) -> Vec { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let rating = std::cell::Cell::new(start); + let mut seen = Vec::new(); + // a key every frame, Tab then an arrow straight away + for frame in 0..keys.len() + 3 { + let mut events = Vec::new(); + if let Some(k) = frame.checked_sub(1).and_then(|f| keys.get(f)) { + events.push(egui::Event::Key { key: *k, physical_key: Some(*k), pressed: true, repeat: false, modifiers: Default::default() }); + } + let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(600.0, 200.0))), events, ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| { + ui.horizontal(|ui| { + let _ = ui.button("left neighbour"); + if let Some(r) = row(rating.get()).show(ui) { + rating.set(r); + } + let _ = ui.button("right neighbour"); + }); + }); + out.textures_delta.clear(); + seen.push(rating.get()); + } + seen +} + +#[test] +fn arrows_keep_stepping_between_other_focusable_widgets() { + use egui::Key::*; + // Tab, Tab: the left neighbour, then star 1 + let seen = step_with_neighbours(5, &[Tab, Tab, ArrowLeft, ArrowLeft, ArrowLeft, ArrowLeft, ArrowLeft]); + assert_eq!(seen.last(), Some(&0), "{seen:?}"); + let seen = step_with_neighbours(0, &[Tab, Tab, ArrowRight, ArrowRight, ArrowRight, ArrowRight, ArrowRight, ArrowLeft]); + assert_eq!(seen.last(), Some(&4), "{seen:?}"); +} + +#[test] +fn a_rating_row_is_one_tab_stop() { + use egui::Key::Tab; + // left neighbour, one stop for the five stars, right neighbour: the third Tab is already past the row + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let mut focused = Vec::new(); + for frame in 0..5 { + let events = if frame >= 1 { + vec![egui::Event::Key { key: Tab, physical_key: Some(Tab), pressed: true, repeat: false, modifiers: Default::default() }] + } else { + Vec::new() + }; + let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(600.0, 200.0))), events, ..Default::default() }; + let mut right = None; + let mut out = ctx.run_ui(raw, |ui| { + ui.horizontal(|ui| { + let _ = ui.button("left neighbour"); + let _ = row(3).show(ui); + right = Some(ui.button("right neighbour").id); + }); + }); + out.textures_delta.clear(); + focused.push(right.is_some_and(|id| ctx.memory(|m| m.has_focus(id)))); + } + // frames 1 to 3 press Tab: left neighbour, the row, right neighbour + assert_eq!(focused, vec![false, false, false, true, false], "{focused:?}"); +} + +#[test] +fn a_rating_changed_from_outside_moves_the_focus_to_the_tab_stop() { + use egui::Key::Tab; + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let rating = std::cell::Cell::new(2u8); + let mut stars_focused = Vec::new(); + for frame in 0..5 { + // frame 1: Tab focuses the row's stop (star 2); frame 2: the rating changes elsewhere to 5 + if frame == 2 { + rating.set(5); + } + let events = if frame == 1 { + vec![egui::Event::Key { key: Tab, physical_key: Some(Tab), pressed: true, repeat: false, modifiers: Default::default() }] + } else { + Vec::new() + }; + let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(600.0, 200.0))), events, ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| { + let _ = row(rating.get()).show(ui); + stars_focused = (0..5).map(|i| ctx.memory(|m| m.has_focus(ui.id().with(("rating-star", "t", i as u8))))).collect(); + }); + out.textures_delta.clear(); + } + assert_eq!(stars_focused, vec![false, false, false, false, true], "{stars_focused:?}"); +} diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 2db3f885e..01d5b65f8 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -776,41 +776,6 @@ pub fn icon_button(ui: &mut Ui, id: &str, icon: Icon, size: egui::Vec2, active: if !tooltip.is_empty() { resp.on_hover_text(tooltip) } else { resp } } -/// Star rating row (5 stars); returns a new rating when clicked (clicking the current rating clears). -pub fn stars(ui: &mut Ui, id: &str, rating: u8, size: f32) -> Option { - let t = Tokens::get(ui.ctx()); - let mut out = None; - let mut hover_n = None; - let mut rects = [Rect::NOTHING; 5]; - for (i, slot) in (0..5u8).zip(rects.iter_mut()) { - let (r, resp) = ui.allocate_exact_size(vec2(size, size), Sense::click()); - crate::widgets::announce_selected(&resp, format!("{} ★", i + 1), i < rating); - register(ui.ctx(), format!("star:{id}:{}", i + 1), r); - if resp.hovered() { - hover_n = Some(i + 1); - } - if resp.clicked() { - out = Some(if rating == i + 1 { 0 } else { i + 1 }); - } - *slot = r; - } - // Hover previews the rating a click would set: stars 1..=hovered light up. The hovered star also gets the - // icon_button hover square, so hovering the current rating (where the preview looks the same) still shows feedback. - for (i, r) in (0..5u8).zip(rects) { - if hover_n == Some(i + 1) { - ui.painter().rect_filled(r, 4.0, t.hover.gamma_multiply(0.7)); - } - let filled = star_lit(i, rating, hover_n); - paint(ui.painter(), r.shrink(size * 0.12), if filled { Icon::StarFilled } else { Icon::Star }, if filled { t.star } else { t.icon }); - } - out -} - -/// Whether the star at zero-based index `i` is drawn filled: the hovered star's rating previews over the current one. -fn star_lit(i: u8, rating: u8, hover: Option) -> bool { - i < hover.unwrap_or(rating) -} - /// A borderless dropdown button: `text` followed by a painted chevron (no font glyph needed). pub fn dropdown(ui: &mut Ui, id: &str, text: &str, font: egui::FontId, color: Color32) -> Response { dropdown_within(ui, id, text, font, color, f32::INFINITY) @@ -849,16 +814,6 @@ mod tests { assert!(preview_only_explanation("unsupported CR3").contains("unsupported CR3")); } - /// Issue #647: hovering a star previews the rating a click would set. - #[test] - fn hovered_star_previews_its_rating() { - let lit = |rating, hover| (0..5u8).filter(|&i| star_lit(i, rating, hover)).count(); - assert_eq!(lit(2, None), 2, "no hover shows the rating"); - assert_eq!(lit(2, Some(4)), 4); - assert_eq!(lit(5, Some(1)), 1); - assert_eq!(lit(0, Some(3)), 3); - } - #[test] fn nudges_are_a_sensible_step() { let spec = |id, min, max, step| ControlSpec { diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 99f17561e..ead0265ca 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -100,7 +100,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-LIB-SHARED-ALBUM | Shared albums | P2 | ⬜ | | needs a sharing service | | LR-LIB-OFFLINE | Keep album offline | P2 | 🚫 | | not applicable: local-first library | | LR-LIB-TARGET | Target album | P2 | ✅ | `cmd:album.setTarget`, `cmd:album.toggleTarget` | see LRC-LIB-COLLECTIONS | -| LR-LIB-RATING | Star ratings | P0 | ✅ | `cmd:photo.rate` (`advance`), `cmd:photo.decreaseRating`, `cmd:photo.increaseRating`, `crates/ui-egui/src/shortcuts.rs` | 0 clears; 1–5 set; Shift+0–5 applies and advances once, including with Auto Advance. Default grid brackets adjust each photo by one, clamped to 0–5; one batch undo and AutoWrite. Batch grid and undo/redo regressions; macOS native ownership corrected (PR #261) | +| LR-LIB-RATING | Star ratings | P0 | ✅ | `cmd:photo.rate` (`advance`), `cmd:photo.decreaseRating`, `cmd:photo.increaseRating`, `crates/ui-egui/src/shortcuts.rs`, `crates/ui-egui/src/rating.rs` | one shared star row in the bottom bar, Info panel and filter bar (hover previews, ←/→ on a focused star) and one painter for badges (grid cells, filmstrip, Compare caption); menu rows with star text (Photo ▸ Set Rating, Select By Rating, the grid context menu) are separate menu items, unchanged; 0 clears; 1–5 set; Shift+0–5 applies and advances once, including with Auto Advance. Default grid brackets adjust each photo by one, clamped to 0–5; one batch undo and AutoWrite. Batch grid and undo/redo regressions; macOS native ownership corrected (PR #261) | | LR-LIB-FLAG | Pick / reject flags | P0 | ✅ | `cmd:photo.pick`, `cmd:photo.reject`, `cmd:photo.unflag`, `cmd:photo.flag` | P pick, X reject, U clear; Shift+P picks and advances in both grids (Presets elsewhere); Shift+X/U and legacy Shift+Z advance; no flag cycle; rejection does not delete the file; a pick shows as a filled flag on thumbnails, in Compare, in the loupe and on the Pick button, a reject outlined (#1013) | | LR-LIB-LABEL | Colour labels | P1 | ✅ | `cmd:photo.label`, `cmd:label.setNames`, `cmd:label.names`, `cmd:dialog.labelNames`, keys 6–9 in `crates/ui-egui/src/shortcuts.rs` | Photo menu and grid context menu (coloured, named), Info-panel swatches, label dot plus translucent colour on square-grid/filmstrip surrounds and photo-grid footers (photo pixels unchanged); colour-label toasts have pale matching backgrounds; setting/clearing through keys (including Shift), menus or Info swatches shows the same bottom toast as ratings, with the colour/custom name; editable label names (undoable, journaled); no purple key (as in Lightroom), no custom extra labels | | LR-LIB-KEYWORD | Keywords | P0 | ✅ | `cmd:panel.keywords`, `cmd:photo.setMeta` (`addKeywords`/`removeKeywords`), `cmd:keyword.list`, `cmd:keyword.rename`, `cmd:keyword.delete`, `cmd:keyword.merge`, `cmd:keyword.suggest`, `crates/catalog/src/keywords.rs`, `crates/ui-egui/src/panels/left.rs` | left-panel keyword tree with counts (click filters, children included; context menu rename / merge / delete / add / remove); library-wide ops are one undo step and replay from the op log; suggestions (co-occurring / most used / completions) in the Keywords panel; keywords reorganize by drag and drop in the Keyword List (LRC-LIB-KEYWORDS) | diff --git a/docs/whats-new.md b/docs/whats-new.md index 4bbbac16d..ae6eaeb45 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -18,6 +18,10 @@ - A long folder (General ▸ Library ▸ Location, the folder Auto Import watches, the smart previews folder, a monitor profile's file) is shortened to its last folders ("…/Pictures/Library") on one line; rest the pointer on it for the whole path. +### One star rating control +- The stars in the bottom bar, the Info panel and the filter bar are now one shared control, and the badges on grid + cells, filmstrip thumbnails and the Compare caption use the same drawing. Hovering a star in the filter bar now + previews the rating like the others do, and with a star focused (Tab), ← and → lower and raise the rating. ### Sharper resampling in HDR and panorama merges - Merged photos (HDR and panorama) are resampled with a sharper filter (Lanczos-3 instead of bilinear). Moving a