// Copyright 2026 Ling71671
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
///|
/// Standalone Star Rating widget supporting fractional ratings, live hover preview,
/// and keyboard navigation.
pub struct Rating {
id_salt : String
value : Double
max_stars : Int
allow_half : Bool
read_only : Bool
star_size : Double?
spacing : Double?
} derive(Eq, Debug)
///|
/// Creates a new Rating widget with the given identifier and initial score.
pub fn Rating::new(id_salt : String, value : Double) -> Rating {
{
id_salt,
value,
max_stars: 5,
allow_half: true,
read_only: false,
star_size: None,
spacing: None,
}
}
///|
/// Sets maximum number of stars (e.g. 5 or 10).
pub fn Rating::max_stars(self : Rating, max_stars : Int) -> Rating {
{ ..self, max_stars: if max_stars > 0 { max_stars } else { 5 }, }
}
///|
/// Configures whether half-star (0.5) fractional ratings are allowed.
pub fn Rating::allow_half(self : Rating, allow_half : Bool) -> Rating {
{ ..self, allow_half, }
}
///|
/// Configures whether the rating is read-only (display only).
pub fn Rating::read_only(self : Rating, read_only : Bool) -> Rating {
{ ..self, read_only, }
}
///|
/// Sets custom star glyph size.
pub fn Rating::star_size(self : Rating, star_size : Double) -> Rating {
{ ..self, star_size: Some(star_size), }
}
///|
/// Sets horizontal spacing between star glyphs.
pub fn Rating::spacing(self : Rating, spacing : Double) -> Rating {
{ ..self, spacing: Some(spacing), }
}
///|
/// Reference-bound Rating widget for uniform `ui.add(...)` dispatch.
pub struct RatingRef {
rating : Rating
target : Ref[Double]
}
///|
/// Creates a Rating widget bound directly to a `Ref[Double]`.
pub fn Rating::from_ref(id_salt : String, target : Ref[Double]) -> RatingRef {
{ rating: Rating::new(id_salt, target.val), target, }
}
///|
pub fn RatingRef::max_stars(self : RatingRef, max_stars : Int) -> RatingRef {
{ ..self, rating: self.rating.max_stars(max_stars), }
}
///|
pub fn RatingRef::allow_half(self : RatingRef, allow_half : Bool) -> RatingRef {
{ ..self, rating: self.rating.allow_half(allow_half), }
}
///|
pub fn RatingRef::read_only(self : RatingRef, read_only : Bool) -> RatingRef {
{ ..self, rating: self.rating.read_only(read_only), }
}
///|
pub fn RatingRef::star_size(self : RatingRef, star_size : Double) -> RatingRef {
{ ..self, rating: self.rating.star_size(star_size), }
}
///|
pub fn RatingRef::spacing(self : RatingRef, spacing : Double) -> RatingRef {
{ ..self, rating: self.rating.spacing(spacing), }
}
///|
pub impl @core.Widget for RatingRef with fn ui(self, ctx : UIContext) -> Response {
let (new_val, resp) = { ..self.rating, value: self.target.val, }.show(ctx)
self.target.val = new_val
resp
}
///|
/// Renders the rating widget, handles hover preview, mouse clicks, keyboard adjustments,
/// and returns the current score and interaction response.
pub fn Rating::show(self : Rating, ctx : UIContext) -> (Double, Response) {
let scale = ctx.style.scale
let star_sz = match self.star_size {
Some(sz) => sz * scale
None => 18.0 * scale
}
let spacing = match self.spacing {
Some(sp) => sp * scale
None => 4.0 * scale
}
let max_stars = if self.max_stars > 0 { self.max_stars } else { 5 }
let total_w = max_stars.to_double() * star_sz +
(max_stars - 1).to_double() * spacing
let h = star_sz + 4.0 * scale
ctx.push_id(self.id_salt)
let (id, rect, resp) = ctx.allocate_space(@math.Vec2::new(total_w, h))
ctx.pop_id()
if !self.read_only {
ctx.register_focusable(id)
if resp.hovered {
ctx.set_cursor_icon("pointer")
}
}
let is_focused = !self.read_only && (resp.has_focus() || ctx.has_focus(id))
let mut new_val = if self.value.is_nan() { 0.0 } else { self.value }
let mut display_val = new_val
// 1. Mouse Hover and Click Evaluation
if !self.read_only && resp.hovered {
let mouse_x = ctx.input.mouse_pos.x
let rel_x = mouse_x - rect.x
if rel_x < 0.0 {
display_val = 0.0
} else {
let star_idx = (rel_x / (star_sz + spacing)).to_int()
if star_idx >= max_stars {
display_val = max_stars.to_double()
} else {
let star_start_x = rect.x + star_idx.to_double() * (star_sz + spacing)
let inside_x = mouse_x - star_start_x
if inside_x <= 0.0 {
display_val = star_idx.to_double()
} else if inside_x >= star_sz {
display_val = (star_idx + 1).to_double()
} else if self.allow_half && inside_x < star_sz * 0.5 {
display_val = star_idx.to_double() + 0.5
} else {
display_val = (star_idx + 1).to_double()
}
}
}
if resp.clicked {
new_val = display_val
}
}
// 2. Keyboard Navigation when focused
if is_focused {
let step = if self.allow_half { 0.5 } else { 1.0 }
if ctx.input.key_pressed(Key::ArrowLeft) ||
ctx.input.key_pressed(Key::ArrowDown) {
new_val = new_val - step
ctx.input.consume_key(Key::ArrowLeft)
ctx.input.consume_key(Key::ArrowDown)
display_val = new_val
}
if ctx.input.key_pressed(Key::ArrowRight) ||
ctx.input.key_pressed(Key::ArrowUp) {
new_val = new_val + step
ctx.input.consume_key(Key::ArrowRight)
ctx.input.consume_key(Key::ArrowUp)
display_val = new_val
}
if ctx.input.key_pressed(Key::PageDown) {
let coarse_step = if self.allow_half { 2.0 } else { 2.0 }
new_val = new_val - coarse_step
ctx.input.consume_key(Key::PageDown)
display_val = new_val
}
if ctx.input.key_pressed(Key::PageUp) {
let coarse_step = if self.allow_half { 2.0 } else { 2.0 }
new_val = new_val + coarse_step
ctx.input.consume_key(Key::PageUp)
display_val = new_val
}
if ctx.input.key_pressed(Key::Home) {
new_val = 0.0
ctx.input.consume_key(Key::Home)
display_val = new_val
}
if ctx.input.key_pressed(Key::End) {
new_val = max_stars.to_double()
ctx.input.consume_key(Key::End)
display_val = new_val
}
}
// 3. Bounds Clamping & NaN safety
if new_val.is_nan() || new_val < 0.0 {
new_val = 0.0
}
if new_val > max_stars.to_double() {
new_val = max_stars.to_double()
}
if display_val.is_nan() || display_val < 0.0 {
display_val = 0.0
}
if display_val > max_stars.to_double() {
display_val = max_stars.to_double()
}
// 4. Focus Ring
let theme = ctx.theme
if is_focused {
let radius = ctx.style.radius_sm * scale
ctx
.painter()
.add_rect_stroke(
rect.expand(2.0 * scale),
theme.border_focus,
1.5 * scale,
radius + 2.0 * scale,
)
}
// 5. Star Glyphs Drawing
let empty_color = theme.border_default
let active_color = theme.warning
for i = 0; i < max_stars; i = i + 1 {
let star_x = rect.x + i.to_double() * (star_sz + spacing)
let star_y = rect.y + (h - star_sz) * 0.5
let star_pos = @math.Vec2::new(star_x, star_y)
let threshold = (i + 1).to_double()
let half_threshold = i.to_double() + 0.5
if display_val >= threshold {
// Fully active star
ctx.painter().add_text(star_pos, "★", star_sz, active_color)
} else if display_val >= half_threshold {
// Half active star: draw base star, clip left half, draw active star
ctx.painter().add_text(star_pos, "★", star_sz, empty_color)
let half_clip = @math.Rect::new(star_x, star_y, star_sz * 0.5, star_sz)
ctx.painter().push_clip(half_clip)
ctx.painter().add_text(star_pos, "★", star_sz, active_color)
ctx.painter().pop_clip()
} else {
// Inactive star
ctx.painter().add_text(star_pos, "★", star_sz, empty_color)
}
}
(new_val, resp)
}
///|
/// Convenience functional helper to render a rating widget.
pub fn rating(
ctx : UIContext,
id_salt : String,
value : Double,
max_stars? : Int = 5,
allow_half? : Bool = true,
read_only? : Bool = false,
star_size? : Double,
spacing? : Double,
) -> (Double, Response) {
let mut r = Rating::new(id_salt, value)
.max_stars(max_stars)
.allow_half(allow_half)
.read_only(read_only)
if star_size is Some(sz) {
r = r.star_size(sz)
}
if spacing is Some(sp) {
r = r.spacing(sp)
}
r.show(ctx)
}