use denise::Pen;
use denise::{ElementState, InputEvent, KeyCode, Point, Rect, Role};
use crate::widget::{
Event, EventCtx, Handled, MeasureCtx, Measured, Offer, PaintCtx, VisualState, Widget,
};
use crate::widgets::describe::{
Describe, DynDescribe, Group, Mismatch, Payload, Property, PropertyKind, ROLES, Value,
};
use crate::widgets::style::{focus_ring, interactive_pair};
const VALLEY_PERCENT: i32 = 38;
const GAP_DIVISOR: i32 = 8;
#[derive(Clone, Debug)]
pub struct Rating<M> {
value: f32,
max: u32,
role: Role,
message: Option<fn(f32) -> M>,
clearable: bool,
}
impl<M> Rating<M> {
pub fn new(value: f32, message: fn(f32) -> M) -> Self {
Self {
value: clamp(value, 5),
max: 5,
role: Role::Warning,
message: Some(message),
clearable: false,
}
}
pub fn display(value: f32) -> Self {
Self {
value: clamp(value, 5),
max: 5,
role: Role::Warning,
message: None,
clearable: false,
}
}
pub fn with_max(mut self, max: u32) -> Self {
self.max = max.max(1);
self.value = clamp(self.value, self.max);
self
}
pub fn with_role(mut self, role: Role) -> Self {
self.role = role;
self
}
pub fn clearable(mut self) -> Self {
self.clearable = true;
self
}
#[inline]
pub const fn value(&self) -> f32 {
self.value
}
#[inline]
pub const fn max(&self) -> u32 {
self.max
}
pub fn set_value(&mut self, value: f32) {
self.value = clamp(value, self.max);
}
pub fn update(&mut self, value: f32) -> bool {
let value = clamp(value, self.max);
let changed = value != self.value;
self.value = value;
changed
}
pub fn preferred_width(&self, height: i32) -> i32 {
width_of(height.max(0), self.max as i32)
}
fn commit(&mut self, value: f32, ctx: &mut EventCtx<'_, M>) -> Handled {
let value = clamp(value, self.max);
if value == self.value {
return Handled::Yes;
}
self.value = value;
if let Some(message) = self.message {
ctx.emit(message(value));
}
Handled::Yes
}
fn step_up(&self) -> f32 {
(self.value as i32 + 1) as f32
}
fn step_down(&self) -> f32 {
let whole = self.value as i32;
if self.value > whole as f32 {
whole as f32
} else {
(whole - 1) as f32
}
}
fn star_at(&self, bounds: Rect, x: i32) -> Option<u32> {
let (side, step) = geometry(bounds, self.max);
if side <= 0 {
return None;
}
let offset = x - bounds.x;
if offset < 0 {
return None;
}
let index = (offset / step.max(1)).min(self.max as i32 - 1);
Some(index as u32 + 1)
}
}
impl<M> Default for Rating<M> {
fn default() -> Self {
Self::display(0.0)
}
}
#[inline]
fn clamp(value: f32, max: u32) -> f32 {
if value.is_nan() {
0.0
} else {
value.clamp(0.0, max as f32)
}
}
#[inline]
fn width_of(side: i32, max: i32) -> i32 {
side * max + (side / GAP_DIVISOR) * (max - 1)
}
fn geometry(bounds: Rect, max: u32) -> (i32, i32) {
let max = max.max(1) as i32;
if bounds.width <= 0 || bounds.height <= 0 {
return (0, 0);
}
let guess = (bounds.width * GAP_DIVISOR) / (max * (GAP_DIVISOR + 1) - 1).max(1);
let mut side = guess.min(bounds.height).max(0);
while side > 0 && width_of(side, max) > bounds.width {
side -= 1;
}
while side < bounds.height && width_of(side + 1, max) <= bounds.width {
side += 1;
}
(side, side + side / GAP_DIVISOR)
}
impl<M: 'static> Widget<M> for Rating<M> {
fn describe(&self) -> Option<&dyn DynDescribe> {
Some(self)
}
fn describe_mut(&mut self) -> Option<&mut dyn DynDescribe> {
Some(self)
}
fn measure(&self, _ctx: &mut MeasureCtx<'_>, offered: Offer) -> Measured {
Measured {
width: offered.height.map(|h| self.preferred_width(h)),
height: None,
}
}
fn paint(&self, ctx: &mut PaintCtx<'_>, canvas: &mut Pen<'_>) {
let bounds = ctx.bounds;
let (side, step) = geometry(bounds, self.max);
if side <= 0 {
return;
}
let radius = side / 2;
let valley = (radius * VALLEY_PERCENT / 100).max(1);
let top = bounds.y + (bounds.height - side) / 2;
let (empty, fill) = star_colors(ctx.theme, ctx.state, self.role);
for i in 0..self.max as i32 {
let x = bounds.x + i * step;
let centre = Point::new(x + radius, top + radius);
canvas.fill_star(centre, radius, valley, 5, 0, empty);
let filled = (self.value - i as f32).clamp(0.0, 1.0);
if filled <= 0.0 {
continue;
}
if filled >= 1.0 {
canvas.fill_star(centre, radius, valley, 5, 0, fill);
continue;
}
let width = ((side as f32 * filled) as i32 + 1).clamp(1, side);
let mut c = canvas.with_clip(Rect::new(x, top, width, side));
c.fill_star(centre, radius, valley, 5, 0, fill);
}
if ctx.state.contains(VisualState::FOCUSED) {
focus_ring(
ctx.theme,
bounds,
ctx.theme.radius(denise::Radius::Field),
canvas,
);
}
}
fn on_event(&mut self, event: &Event<'_>, ctx: &mut EventCtx<'_, M>) -> Handled {
if self.message.is_none() {
return Handled::No;
}
let bounds = ctx.bounds;
match event {
Event::Input(InputEvent::PointerButton {
state: ElementState::Down,
position,
..
})
| Event::Input(InputEvent::TouchDown { position, .. }) => {
if !bounds.contains(*position) {
return Handled::No;
}
let Some(star) = self.star_at(bounds, position.x) else {
return Handled::No;
};
ctx.request_focus();
let target = star as f32;
if self.clearable && self.value == target {
return self.commit(0.0, ctx);
}
self.commit(target, ctx)
}
Event::Input(InputEvent::Key {
code,
state: ElementState::Down,
..
}) if ctx.state.contains(VisualState::FOCUSED) => {
let value = match code {
KeyCode::ArrowLeft | KeyCode::ArrowDown => self.step_down(),
KeyCode::ArrowRight | KeyCode::ArrowUp => self.step_up(),
KeyCode::Home => 0.0,
KeyCode::End => self.max as f32,
_ => return Handled::No,
};
self.commit(value, ctx)
}
_ => Handled::No,
}
}
fn accepts_pointer(&self) -> bool {
self.message.is_some()
}
fn focusable(&self) -> bool {
self.message.is_some()
}
}
pub(crate) fn star_colors(
theme: &denise::Theme,
state: VisualState,
role: Role,
) -> (denise::Color, denise::Color) {
if state.contains(VisualState::DISABLED) {
let empty = theme.color(Role::Base300);
let fill = crate::widgets::style::muted(empty, theme.color(Role::BaseContent));
(empty, fill)
} else {
let (empty, _) = interactive_pair(theme, Role::Base300, state);
(empty, interactive_pair(theme, role, state).0)
}
}
impl<M> Describe for Rating<M> {
const KIND: &'static str = "rating";
const DOC: &'static str = "Stars, filled to a value and set by pressing one.";
const GROUP: Group = Group::Input;
const ICON: &'static denise::icon::Icon = &super::icons::RATING;
const PROPERTIES: &'static [Property] = &[
Property::new(
"value",
PropertyKind::Float { min: 0.0, max: 5.0 },
"How many stars are filled; fractional, so an average of `4.3` draws four stars and a bit.",
),
Property::new(
"max",
PropertyKind::Int { min: 1, max: 10 },
"How many symbols there are.",
),
Property::new(
"on-change",
PropertyKind::Message(Payload::Number),
"Emitted with the new value when a person rates. Omitted, the rating is display-only.",
),
Property::new(
"clearable",
PropertyKind::Bool,
"Whether pressing the current value clears it to zero — the only route to zero without a keyboard.",
),
Property::new(
"role",
PropertyKind::Enum(ROLES),
"Colour of the filled stars.",
),
];
fn get(&self, name: &str) -> Option<Value> {
Some(match name {
"value" => Value::Float(self.value),
"max" => Value::Int(self.max as i32),
"clearable" => Value::Bool(self.clearable),
"role" => Value::role(self.role),
_ => return None,
})
}
fn apply(&mut self, name: &str, value: Value) -> Result<(), Mismatch> {
match name {
"value" => self.set_value(value.as_float()?),
"max" => {
self.max = value.as_count()?.max(1);
self.set_value(self.value);
}
"clearable" => self.clearable = value.as_bool()?,
"role" => self.role = value.as_role()?,
"on-change" => return Err(Mismatch::Supplied),
_ => return Err(Mismatch::Unknown),
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[derive(Clone, Debug, PartialEq)]
enum Msg {
Rated(f32),
}
fn rating() -> Rating<Msg> {
Rating::new(0.0, Msg::Rated)
}
#[test]
fn the_value_clamps_to_the_star_count() {
assert_eq!(Rating::<Msg>::display(9.0).value(), 5.0);
assert_eq!(Rating::<Msg>::display(-9.0).value(), 0.0);
assert_eq!(Rating::<Msg>::display(3.0).with_max(2).value(), 2.0);
assert_eq!(rating().with_max(0).max(), 1, "zero stars is one star");
}
#[test]
fn a_value_that_is_not_a_number_is_zero_stars() {
let zero_over_zero = core::hint::black_box(0.0f32) / core::hint::black_box(0.0f32);
assert!(zero_over_zero.is_nan(), "the premise");
assert_eq!(clamp(zero_over_zero, 5), 0.0);
let mut r = rating();
r.set_value(3.0);
r.set_value(zero_over_zero);
assert_eq!(r.value(), 0.0, "and it does not keep the old value");
}
#[test]
fn infinities_clamp_to_the_end_they_point_at() {
assert_eq!(clamp(f32::INFINITY, 5), 5.0);
assert_eq!(clamp(f32::NEG_INFINITY, 5), 0.0);
}
#[test]
fn the_row_fits_inside_any_rectangle() {
for bounds in [
Rect::new(0, 0, 200, 40),
Rect::new(10, 10, 40, 200),
Rect::new(0, 0, 3, 3),
Rect::new(0, 0, 1, 100),
] {
for max in [1u32, 3, 5, 10] {
let (side, step) = geometry(bounds, max);
assert!(
side <= bounds.height,
"{bounds:?} {max}: taller than its box"
);
if side == 0 {
continue;
}
let used = step * (max as i32 - 1) + side;
assert!(
used <= bounds.width,
"{bounds:?} {max}: {used} wide in {}",
bounds.width
);
}
}
}
#[test]
fn the_preferred_width_is_wide_enough_for_the_stars_it_asked_for() {
for height in [8, 16, 24, 40, 100] {
for max in [1u32, 3, 5, 10] {
let r = Rating::<Msg>::display(0.0).with_max(max);
let width = r.preferred_width(height);
let bounds = Rect::new(0, 0, width, height);
let (side, _) = geometry(bounds, max);
assert_eq!(
side, height,
"height {height} max {max}: asked {width}, got stars of {side}"
);
}
}
}
#[test]
fn every_x_across_the_row_picks_a_star() {
let bounds = Rect::new(20, 10, 200, 40);
let r = rating();
let mut seen = [false; 5];
for x in bounds.x..bounds.right() {
let star = r.star_at(bounds, x).expect("inside the row");
assert!((1..=5).contains(&star), "x={x} gave star {star}");
seen[star as usize - 1] = true;
}
assert!(
seen.iter().all(|&s| s),
"some star was unreachable: {seen:?}"
);
}
#[test]
fn the_star_under_the_pointer_never_goes_backwards() {
let bounds = Rect::new(0, 0, 173, 33);
let r = rating();
let mut previous = 0;
for x in bounds.x..bounds.right() {
let star = r.star_at(bounds, x).expect("inside");
assert!(
star >= previous,
"x={x} went back to {star} from {previous}"
);
previous = star;
}
assert_eq!(previous, 5, "the last star was never reached");
}
#[test]
fn stepping_lands_on_whole_stars_from_anywhere() {
let at = |v: f32| {
let mut r = Rating::<Msg>::display(0.0);
r.set_value(v);
(r.step_down(), r.step_up())
};
assert_eq!(
at(4.3),
(4.0, 5.0),
"an average steps to the stars either side"
);
assert_eq!(at(3.0), (2.0, 4.0), "a whole value steps past itself");
assert_eq!(at(0.0), (-1.0, 1.0), "and the clamp catches the low end");
assert_eq!(at(0.4), (0.0, 1.0));
assert_eq!(at(5.0), (4.0, 6.0), "the clamp catches the high end too");
}
#[test]
fn a_press_in_the_gap_between_stars_still_picks_one() {
let bounds = Rect::new(0, 0, 225, 40);
let r = rating();
let (side, step) = geometry(bounds, 5);
assert!(step > side, "the premise: there is a gap to press in");
for i in 0..4 {
for x in (bounds.x + i * step + side)..(bounds.x + (i + 1) * step) {
assert_eq!(
r.star_at(bounds, x),
Some(i as u32 + 1),
"x={x} in the gap after star {}",
i + 1
);
}
}
}
#[test]
fn writing_the_same_value_reports_no_change() {
let mut r = rating();
assert!(r.update(3.0));
assert!(!r.update(3.0));
assert!(r.update(4.0));
}
#[test]
fn a_display_rating_takes_no_input() {
let r = Rating::<Msg>::display(3.0);
assert!(!Widget::<Msg>::focusable(&r));
assert!(!Widget::<Msg>::accepts_pointer(&r));
let live = rating();
assert!(Widget::<Msg>::focusable(&live));
assert!(Widget::<Msg>::accepts_pointer(&live));
}
fn visible_step(theme: &denise::Theme) -> u32 {
denise::theme::contrast_x100(theme.color(Role::Base200), theme.color(Role::Base300))
}
#[test]
fn the_empty_stars_are_visible_against_the_surfaces_they_sit_on() {
use denise::Theme;
use denise::theme::contrast_x100;
for theme in Theme::BUILT_IN {
let floor = visible_step(&theme);
for state in [VisualState::NONE, VisualState::DISABLED] {
let (empty, _) = star_colors(&theme, state, Role::Warning);
for behind in [Role::Base100, Role::Base200] {
let ratio = contrast_x100(theme.color(behind), empty);
assert!(
ratio >= floor,
"{} {state:?}: empty stars on {behind:?} are {ratio}, floor is {floor}",
theme.name
);
}
}
}
}
#[test]
fn a_disabled_rating_still_shows_its_value() {
use denise::Theme;
use denise::theme::contrast_x100;
for theme in Theme::BUILT_IN {
for role in [Role::Warning, Role::Primary, Role::Error] {
for state in [VisualState::DISABLED, VisualState::NONE] {
let (empty, fill) = star_colors(&theme, state, role);
let ratio = contrast_x100(empty, fill);
let floor = visible_step(&theme);
assert!(
ratio >= floor,
"{} {role:?} {state:?}: filled against empty is {ratio}, floor is {floor}",
theme.name
);
}
}
}
}
}