extern crate alloc;
use alloc::string::String;
use alloc::vec::Vec;
use rlvgl_core::draw::draw_widget_bg;
use rlvgl_core::event::Event;
use rlvgl_core::font::{FontMetrics, WidgetFont, shape_text_ltr};
use rlvgl_core::renderer::{ClipRenderer, Renderer};
use rlvgl_core::scroll::{DEFAULT_SNAP_ATTRACTION_RADIUS, snap_offset_to_points};
use rlvgl_core::style::Style;
use rlvgl_core::widget::{Color, Rect, Widget};
const ROW_HEIGHT: i32 = 16;
const DEFAULT_VISIBLE_ROWS: u8 = 3;
const ROW_PAD_X: i32 = 4;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum RollerMode {
#[default]
Normal,
Infinite,
}
pub struct Roller {
bounds: Rect,
options: Vec<String>,
selected: usize,
mode: RollerMode,
visible_rows: u8,
offset_px: i32,
pub style: Style,
pub items_color: Color,
pub selected_color: Color,
pub items_text_color: Color,
pub selected_text_color: Color,
font: WidgetFont,
}
impl Roller {
pub fn new(bounds: Rect) -> Self {
Self {
bounds,
options: Vec::new(),
selected: 0,
mode: RollerMode::default(),
visible_rows: DEFAULT_VISIBLE_ROWS,
offset_px: 0,
style: Style::default(),
items_color: Color(180, 180, 180, 255),
selected_color: Color(60, 120, 200, 255),
items_text_color: Color(100, 100, 100, 255),
selected_text_color: Color(255, 255, 255, 255),
font: WidgetFont::new(),
}
}
pub fn set_options(&mut self, options: &[impl AsRef<str>], mode: RollerMode) {
self.options = options.iter().map(|s| String::from(s.as_ref())).collect();
self.mode = mode;
self.selected = 0;
self.offset_px = 0;
}
pub fn options(&self) -> &[String] {
&self.options
}
pub fn option_count(&self) -> usize {
self.options.len()
}
pub fn set_selected(&mut self, index: usize, _animated: bool) {
if self.options.is_empty() {
self.selected = 0;
self.offset_px = 0;
return;
}
self.selected = match self.mode {
RollerMode::Normal => index.min(self.options.len().saturating_sub(1)),
RollerMode::Infinite => index % self.options.len(),
};
self.offset_px = self.offset_for_index(self.selected);
self.snap_to_nearest();
}
pub fn selected(&self) -> usize {
self.selected
}
pub fn mode(&self) -> RollerMode {
self.mode
}
pub fn set_visible_row_count(&mut self, count: u8) {
self.visible_rows = count.max(1);
}
pub fn visible_row_count(&self) -> u8 {
self.visible_rows
}
pub fn navigate_up(&mut self) {
if self.options.is_empty() {
return;
}
self.selected = match self.mode {
RollerMode::Normal => self.selected.saturating_sub(1),
RollerMode::Infinite => {
if self.selected == 0 {
self.options.len() - 1
} else {
self.selected - 1
}
}
};
self.offset_px = self.offset_for_index(self.selected);
self.snap_to_nearest();
}
pub fn navigate_down(&mut self) {
if self.options.is_empty() {
return;
}
self.selected = match self.mode {
RollerMode::Normal => (self.selected + 1).min(self.options.len() - 1),
RollerMode::Infinite => (self.selected + 1) % self.options.len(),
};
self.offset_px = self.offset_for_index(self.selected);
self.snap_to_nearest();
}
pub fn navigate_page_up(&mut self) {
let steps = usize::from(self.visible_rows);
for _ in 0..steps {
self.navigate_up();
}
}
pub fn navigate_page_down(&mut self) {
let steps = usize::from(self.visible_rows);
for _ in 0..steps {
self.navigate_down();
}
}
pub fn set_font(&mut self, font: &'static dyn FontMetrics) {
self.font.set(font);
}
fn row_height(&self) -> i32 {
ROW_HEIGHT
}
fn offset_for_index(&self, index: usize) -> i32 {
let rh = self.row_height();
let viewport_h = self.bounds.height;
let center_top = index as i32 * rh;
(center_top - (viewport_h / 2 - rh / 2)).max(0)
}
fn snap_points(&self) -> Vec<i32> {
let n = if self.options.is_empty() {
0
} else {
self.options.len()
};
(0..n).map(|i| self.offset_for_index(i)).collect()
}
fn snap_to_nearest(&mut self) {
let points = self.snap_points();
self.offset_px =
snap_offset_to_points(self.offset_px, &points, DEFAULT_SNAP_ATTRACTION_RADIUS);
}
fn visible_rect(&self) -> Rect {
self.bounds
}
fn draw_row(&self, renderer: &mut dyn Renderer, text: &str, screen_y: i32, is_selected: bool) {
let rh = self.row_height();
let row_rect = Rect {
x: self.bounds.x,
y: screen_y,
width: self.bounds.width,
height: rh,
};
let bg_color = if is_selected {
self.selected_color
} else {
self.items_color
};
if bg_color.3 > 0 {
renderer.fill_rect(row_rect, bg_color);
}
let text_color = if is_selected {
self.selected_text_color
} else {
self.items_text_color
};
if text_color.3 > 0 {
let font = self.font.resolve();
let metrics = rlvgl_core::font::FontMetrics::line_metrics(font);
let baseline = screen_y + metrics.ascent as i32 + (rh - metrics.line_height as i32) / 2;
let shaped = shape_text_ltr(font, text, (self.bounds.x + ROW_PAD_X, baseline), 0);
renderer.draw_text_shaped(&shaped, (0, 0), text_color);
}
}
}
impl Widget for Roller {
fn bounds(&self) -> Rect {
self.bounds
}
fn widget_font_mut(&mut self) -> Option<&mut WidgetFont> {
Some(&mut self.font)
}
fn set_bounds(&mut self, bounds: Rect) {
self.bounds = bounds;
if !self.options.is_empty() {
self.offset_px = self.offset_for_index(self.selected);
}
}
fn draw(&self, renderer: &mut dyn Renderer) {
if self.bounds.width <= 0 || self.bounds.height <= 0 {
return;
}
draw_widget_bg(renderer, self.bounds, &self.style);
if self.options.is_empty() {
return;
}
let rh = self.row_height();
let n = self.options.len();
let first_content_y = self.offset_px;
let first_row = (first_content_y / rh).max(0) as usize;
let rows_needed = (self.bounds.height / rh + 2) as usize;
let mut clipped = ClipRenderer::new(renderer, self.visible_rect());
let center_screen_y = self.bounds.y + (self.bounds.height / 2) - (rh / 2);
for r in 0..rows_needed {
let content_row = first_row + r;
let opt_idx = match self.mode {
RollerMode::Normal => {
if content_row >= n {
break;
}
content_row
}
RollerMode::Infinite => content_row % n,
};
let content_top = content_row as i32 * rh;
let screen_y = self.bounds.y + (content_top - self.offset_px);
if screen_y + rh <= self.bounds.y {
continue;
}
if screen_y >= self.bounds.y + self.bounds.height {
break;
}
let is_center = (screen_y - center_screen_y).abs() < rh;
let is_selected = is_center && opt_idx == self.selected;
self.draw_row(&mut clipped, &self.options[opt_idx], screen_y, is_selected);
}
}
fn handle_event(&mut self, _event: &Event) -> bool {
false
}
}
#[cfg(test)]
mod tests {
extern crate alloc;
use alloc::vec;
use alloc::vec::Vec;
use super::*;
struct NullRenderer;
impl rlvgl_core::renderer::Renderer for NullRenderer {
fn fill_rect(&mut self, _r: Rect, _c: Color) {}
fn draw_text(&mut self, _pos: (i32, i32), _t: &str, _c: Color) {}
}
fn make_options() -> Vec<&'static str> {
vec!["Alpha", "Beta", "Gamma", "Delta", "Epsilon"]
}
fn rect(x: i32, y: i32, w: i32, h: i32) -> Rect {
Rect {
x,
y,
width: w,
height: h,
}
}
#[test]
fn initial_state_is_empty_and_selected_zero() {
let r = Roller::new(rect(0, 0, 80, 48));
assert_eq!(r.option_count(), 0);
assert_eq!(r.selected(), 0);
assert_eq!(r.mode(), RollerMode::Normal);
}
#[test]
fn set_options_resets_selection_and_offset() {
let mut r = Roller::new(rect(0, 0, 80, 48));
let opts = make_options();
r.set_options(&opts, RollerMode::Normal);
assert_eq!(r.option_count(), 5);
assert_eq!(r.selected(), 0);
assert_eq!(r.options()[2], "Gamma");
}
#[test]
fn set_selected_clamps_in_normal_mode() {
let mut r = Roller::new(rect(0, 0, 80, 48));
r.set_options(&make_options(), RollerMode::Normal);
r.set_selected(100, false);
assert_eq!(r.selected(), 4); }
#[test]
fn set_selected_wraps_in_infinite_mode() {
let mut r = Roller::new(rect(0, 0, 80, 48));
r.set_options(&make_options(), RollerMode::Infinite);
r.set_selected(7, false); assert_eq!(r.selected(), 2);
}
#[test]
fn navigate_down_wraps_in_infinite_mode() {
let mut r = Roller::new(rect(0, 0, 80, 48));
r.set_options(&make_options(), RollerMode::Infinite);
r.set_selected(4, false); r.navigate_down();
assert_eq!(r.selected(), 0); }
#[test]
fn navigate_up_stops_at_zero_in_normal_mode() {
let mut r = Roller::new(rect(0, 0, 80, 48));
r.set_options(&make_options(), RollerMode::Normal);
r.navigate_up(); assert_eq!(r.selected(), 0);
}
#[test]
fn navigate_up_wraps_in_infinite_mode() {
let mut r = Roller::new(rect(0, 0, 80, 48));
r.set_options(&make_options(), RollerMode::Infinite);
r.navigate_up(); assert_eq!(r.selected(), 4);
}
#[test]
fn snap_to_nearest_item_boundary_after_select() {
let mut r = Roller::new(rect(0, 0, 80, 48));
r.set_options(&make_options(), RollerMode::Normal);
r.set_selected(2, false);
let expected = r.offset_for_index(2);
assert_eq!(r.offset_px, expected);
}
#[test]
fn set_bounds_reanchors_offset() {
let mut r = Roller::new(rect(0, 0, 80, 48));
r.set_options(&make_options(), RollerMode::Normal);
r.set_selected(3, false);
let offset_before = r.offset_px;
r.set_bounds(rect(10, 10, 100, 64));
let expected = r.offset_for_index(3);
assert_eq!(r.offset_px, expected);
let _ = offset_before; }
#[test]
fn draw_does_not_panic_with_empty_options() {
let r = Roller::new(rect(0, 0, 80, 48));
let mut renderer = NullRenderer;
r.draw(&mut renderer); }
#[test]
fn visible_row_count_is_clamped_to_min_one() {
let mut r = Roller::new(rect(0, 0, 80, 48));
r.set_visible_row_count(0);
assert_eq!(r.visible_row_count(), 1);
}
}