Merge PR #2076: LR-LIB-RATING: one shared star rating for every rating on sc

# Conflicts:
#	docs/whats-new.md
This commit is contained in:
francoisgn
2026-10-22 22:27:34 +03:00
16 changed files with 525 additions and 87 deletions
+3
View File
@@ -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;
+2 -2
View File
@@ -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);
+1 -1
View File
@@ -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;
+1 -4
View File
@@ -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),
+2 -16
View File
@@ -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);
+1 -4
View File
@@ -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),
+7 -5
View File
@@ -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));
}
+6 -1
View File
@@ -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()
+1 -1
View File
@@ -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 =
+1 -1
View File
@@ -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);
+1 -6
View File
@@ -1091,12 +1091,7 @@ fn theme_card<V: PartialEq + Copy>(
});
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 {
+256
View File
@@ -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<u8>) -> 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<u8> {
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<String>,
}
impl Rating {
/// A rating of `rating` (0 to 5) whose stars are reachable as `star:{id}:{n}`.
pub fn new(id: impl Into<String>, 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<String>) -> 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<u8> {
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<String> = 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:?}");
}
}
+238
View File
@@ -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<u8>, keys: &[egui::Key], build: impl Fn(&mut egui::Ui) -> Option<u8>) -> (Vec<u8>, 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<u8>) -> 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::<usize>() / 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<u8> {
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:?}");
}
-45
View File
@@ -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<u8> {
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<u8>) -> 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 {
+1 -1
View File
@@ -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) |
+4
View File
@@ -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