use frus_core::{Point, Rect, ResolvedTextStyle, Scene};
use frus_layout::{Dimension, FlexDirection, Style};
use crate::disabled::{disabled_container, disabled_content};
use crate::interaction::Status;
use crate::theme::Theme;
use crate::widget::Widget;
const STAR: f32 = 24.0;
struct Star<Msg> {
filled: bool,
enabled: bool,
message: Msg,
}
impl<Msg: Clone> Widget<Msg> for Star<Msg> {
fn style(&self) -> Style {
Style {
width: Dimension::Length(STAR),
height: Dimension::Length(STAR),
..Default::default()
}
}
fn children(&self) -> &[Box<dyn Widget<Msg>>] {
&[]
}
fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
let o = status.opacity;
let base = if !self.enabled {
if self.filled {
disabled_content(theme)
} else {
disabled_container(theme)
}
} else if self.filled {
theme.primary
} else {
theme.muted.fade(0.45)
};
let hover = if self.enabled {
status.hover_progress
} else {
0.0
};
let color = base.lerp(theme.on_surface, 0.2 * hover);
scene.text(
Point::new(bounds.x, bounds.y - 2.0),
"★".to_string(),
&ResolvedTextStyle::exact(STAR),
color.fade(o),
);
}
fn on_click(&self) -> Option<Msg> {
if !self.enabled {
return None;
}
Some(self.message.clone())
}
fn focusable(&self) -> bool {
self.enabled
}
}
pub struct Rating<Msg = crate::callback::Callback> {
value: u32,
max: u32,
enabled: bool,
on_rate: Box<dyn Fn(u32) -> Msg>,
children: Vec<Box<dyn Widget<Msg>>>,
}
impl<Msg: Clone + 'static> Rating<Msg> {
pub fn new(value: u32, max: u32, on_rate: impl Fn(u32) -> Msg + 'static) -> Self {
let mut rating = Self {
value,
max,
enabled: true,
on_rate: Box::new(on_rate),
children: Vec::new(),
};
rating.rebuild();
rating
}
pub fn enabled(mut self, enabled: bool) -> Self {
self.enabled = enabled;
self.rebuild();
self
}
fn rebuild(&mut self) {
self.children = (0..self.max)
.map(|i| {
Box::new(Star {
filled: i < self.value,
enabled: self.enabled,
message: (self.on_rate)(i + 1),
}) as Box<dyn Widget<Msg>>
})
.collect();
}
}
impl<Msg: Clone> Widget<Msg> for Rating<Msg> {
fn style(&self) -> Style {
Style {
flex_direction: FlexDirection::Row,
gap: 4.0,
..Default::default()
}
}
fn children(&self) -> &[Box<dyn Widget<Msg>>] {
&self.children
}
fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {}
fn on_click(&self) -> Option<Msg> {
None
}
fn semantics(&self) -> Option<frus_core::SemanticsProperties> {
let semantics = frus_core::SemanticsProperties::new(frus_core::Role::Slider)
.value(format!("{} / {}", self.value, self.max))
.range(0.0, self.value as f32, self.max as f32);
Some(if self.enabled {
semantics
} else {
semantics.disabled(true)
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[derive(Clone, Debug, PartialEq)]
enum Msg {
Rate(u32),
}
#[test]
fn has_max_stars_and_click_emits_rank() {
let rating = Rating::new(2, 5, Msg::Rate);
let stars = Widget::<Msg>::children(&rating);
assert_eq!(stars.len(), 5);
assert_eq!(stars[3].on_click(), Some(Msg::Rate(4)));
}
#[test]
fn filled_stars_match_value() {
let rating = Rating::new(3, 5, Msg::Rate);
let count_color = |filled_expected: bool| {
let theme = Theme::default();
let target = if filled_expected {
theme.primary
} else {
theme.muted.fade(0.45)
};
(0..5)
.filter(|&i| {
let mut scene = Scene::new();
Widget::<Msg>::children(&rating)[i].paint(
Rect::new(0.0, 0.0, STAR, STAR),
Status::default(),
&theme,
&mut scene,
);
matches!(scene.primitives()[0], frus_core::Primitive::Text { color, .. } if color == target)
})
.count()
};
assert_eq!(count_color(true), 3); assert_eq!(count_color(false), 2); }
#[test]
fn a_disabled_rating_is_inert_but_still_shows_the_score() {
let dead = Rating::new(3, 5, Msg::Rate).enabled(false);
for (i, star) in Widget::<Msg>::children(&dead).iter().enumerate() {
assert_eq!(star.on_click(), None, "star {i} still answers");
assert!(!star.focusable(), "star {i} is still in the tab order");
}
let semantics = Widget::<Msg>::semantics(&dead).expect("still announced");
assert!(semantics.disabled, "announced as unavailable");
assert_eq!(semantics.value.as_deref(), Some("3 / 5"), "score survives");
}
#[test]
fn a_disabled_rating_flattens_rather_than_fading_the_accent() {
let theme = Theme::default();
let dead = Rating::new(3, 5, Msg::Rate).enabled(false);
let lit = |i: usize| {
let mut scene = Scene::new();
Widget::<Msg>::children(&dead)[i].paint(
Rect::new(0.0, 0.0, STAR, STAR),
Status::default(),
&theme,
&mut scene,
);
match scene.primitives()[0] {
frus_core::Primitive::Text { color, .. } => color,
_ => panic!("a star is text"),
}
};
for i in 0..5 {
assert_ne!(lit(i), theme.primary, "star {i} keeps the accent");
}
for i in 0..3 {
assert_eq!(lit(i), disabled_content(&theme), "star {i} should be lit");
}
for i in 3..5 {
assert_eq!(
lit(i),
disabled_container(&theme),
"star {i} should be dark"
);
}
let from_surface = |c: frus_core::Color| {
(c.r - theme.scheme.surface.r).abs()
+ (c.g - theme.scheme.surface.g).abs()
+ (c.b - theme.scheme.surface.b).abs()
};
assert!(
from_surface(lit(0)) > from_surface(lit(4)),
"and a lit star reads louder than an unlit one"
);
}
}