use crate::core::{Color, Font, HorizontalAlignment, Point, Rect, Size};
use crate::event::{Event, EventHandler};
use crate::render::RenderContext;
use crate::signal::Signal1;
use crate::widget::capability::coercion::{expect_bool, expect_usize};
use crate::widget::capability::properties_trait::{base_property_get, base_property_set};
use crate::widget::capability::types::{CapabilityAccessError, CapabilityValue};
use crate::widget::capability::WidgetProperties;
use crate::widget::{BaseWidget, Draw, Widget, WidgetKind};
use crate::{impl_widget_property_hooks, property_names_of};
const ELLIPSIS: char = '\u{2026}';
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Cell {
Page(usize),
Gap { forward: bool },
Previous,
Next,
}
pub struct Pagination {
base: BaseWidget,
total: usize,
page_size: usize,
page: usize,
sibling_count: usize,
show_nav_buttons: bool,
pub page_changed: Signal1<usize>,
}
impl Pagination {
pub fn new(geometry: Rect) -> Self {
Self {
base: BaseWidget::new(WidgetKind::Pagination, geometry, "Pagination"),
total: 0,
page_size: 10,
page: 0,
sibling_count: 1,
show_nav_buttons: true,
page_changed: Signal1::new(),
}
}
pub fn total(&self) -> usize {
self.total
}
pub fn set_total(&mut self, total: usize) {
self.total = total;
let last = self.last_page();
if self.page > last {
self.page = last;
self.page_changed.emit(last);
}
self.base.request_redraw();
}
pub fn page_size(&self) -> usize {
self.page_size
}
pub fn set_page_size(&mut self, page_size: usize) {
self.page_size = page_size.max(1);
let last = self.last_page();
if self.page > last {
self.page = last;
self.page_changed.emit(last);
}
self.base.request_redraw();
}
pub fn page(&self) -> usize {
self.page
}
pub fn set_page(&mut self, page: usize) {
let clamped = page.min(self.last_page());
if clamped == self.page {
return;
}
self.page = clamped;
self.page_changed.emit(clamped);
self.base.request_redraw();
}
pub fn page_count(&self) -> usize {
if self.total == 0 {
return 1;
}
self.total.div_ceil(self.page_size)
}
pub fn last_page(&self) -> usize {
self.page_count() - 1
}
pub fn has_previous(&self) -> bool {
self.page > 0
}
pub fn has_next(&self) -> bool {
self.page < self.last_page()
}
pub fn next_page(&mut self) -> bool {
if !self.has_next() {
return false;
}
self.set_page(self.page + 1);
true
}
pub fn previous_page(&mut self) -> bool {
if !self.has_previous() {
return false;
}
self.set_page(self.page - 1);
true
}
pub fn first_item_index(&self) -> usize {
self.page.saturating_mul(self.page_size)
}
pub fn item_count_on_page(&self) -> usize {
self.total.saturating_sub(self.first_item_index()).min(self.page_size)
}
pub fn sibling_count(&self) -> usize {
self.sibling_count
}
pub fn set_sibling_count(&mut self, sibling_count: usize) {
self.sibling_count = sibling_count.min(3);
self.base.request_layout();
self.base.request_redraw();
}
pub fn show_nav_buttons(&self) -> bool {
self.show_nav_buttons
}
pub fn set_show_nav_buttons(&mut self, show: bool) {
self.show_nav_buttons = show;
self.base.request_layout();
self.base.request_redraw();
}
pub fn cells(&self) -> Vec<String> {
self.cell_plan()
.iter()
.map(|cell| match cell {
Cell::Page(index) => (index + 1).to_string(),
Cell::Gap { .. } => ELLIPSIS.to_string(),
Cell::Previous => "\u{2039}".to_string(),
Cell::Next => "\u{203a}".to_string(),
})
.collect()
}
fn cell_plan(&self) -> Vec<Cell> {
let last = self.last_page();
let mut plan = Vec::new();
if self.show_nav_buttons {
plan.push(Cell::Previous);
}
let window = self.sibling_count * 2 + 1;
let start =
self.page.saturating_sub(self.sibling_count).min(last.saturating_sub(window - 1));
let end = (start + window - 1).min(last);
if start > 0 {
plan.push(Cell::Page(0));
if start > 1 {
plan.push(Cell::Gap { forward: false });
}
}
for index in start..=end {
plan.push(Cell::Page(index));
}
if end < last {
if end + 1 < last {
plan.push(Cell::Gap { forward: true });
}
plan.push(Cell::Page(last));
}
if self.show_nav_buttons {
plan.push(Cell::Next);
}
plan
}
fn cell_width(&self) -> u32 {
let cells = self.cell_plan().len().max(1) as u32;
(self.geometry().width / cells).max(1)
}
fn cell_at(&self, pos: Point) -> Option<Cell> {
let rect = self.geometry();
if pos.x < rect.x
|| pos.x >= rect.x + rect.width as i32
|| pos.y < rect.y
|| pos.y >= rect.y + rect.height as i32
{
return None;
}
let index = ((pos.x - rect.x) as u32 / self.cell_width()) as usize;
self.cell_plan().get(index).copied()
}
fn activate(&mut self, cell: Cell) {
match cell {
Cell::Page(index) => self.set_page(index),
Cell::Previous => {
self.previous_page();
}
Cell::Next => {
self.next_page();
}
Cell::Gap { forward } => {
let window = self.sibling_count * 2 + 1;
if forward {
self.set_page(self.page.saturating_add(window));
} else {
self.set_page(self.page.saturating_sub(window));
}
}
}
}
}
impl Widget for Pagination {
fn base(&self) -> &BaseWidget {
&self.base
}
fn base_mut(&mut self) -> &mut BaseWidget {
&mut self.base
}
fn size_hint(&self) -> Size {
Size::new(self.cell_width() * self.cell_plan().len().max(1) as u32, 32)
}
impl_draw_bridge!();
impl_widget_property_hooks!();
}
impl WidgetProperties for Pagination {
fn get(&self, name: &str) -> Result<CapabilityValue, CapabilityAccessError> {
match name {
"total" => Ok(CapabilityValue::UInt(self.total() as u64)),
"page_size" => Ok(CapabilityValue::UInt(self.page_size() as u64)),
"page" => Ok(CapabilityValue::UInt(self.page() as u64)),
"page_count" => Ok(CapabilityValue::UInt(self.page_count() as u64)),
"last_page" => Ok(CapabilityValue::UInt(self.last_page() as u64)),
"sibling_count" => Ok(CapabilityValue::UInt(self.sibling_count() as u64)),
"show_nav_buttons" => Ok(CapabilityValue::Bool(self.show_nav_buttons())),
_ => base_property_get(self, name),
}
}
fn set(&mut self, name: &str, value: CapabilityValue) -> Result<(), CapabilityAccessError> {
match name {
"total" => {
self.set_total(expect_usize(value)?);
Ok(())
}
"page_size" => {
self.set_page_size(expect_usize(value)?);
Ok(())
}
"page" => {
self.set_page(expect_usize(value)?);
Ok(())
}
"sibling_count" => {
self.set_sibling_count(expect_usize(value)?);
Ok(())
}
"show_nav_buttons" => {
self.set_show_nav_buttons(expect_bool(value)?);
Ok(())
}
"page_count" | "last_page" => Err(CapabilityAccessError::ReadOnlyProperty),
_ => base_property_set(self, name, value),
}
}
fn property_names(&self) -> &'static [&'static str] {
property_names_of![
"total",
"page_size",
"page",
"page_count",
"last_page",
"sibling_count",
"show_nav_buttons",
BASE_PROPERTY_NAMES
]
}
}
impl EventHandler for Pagination {
fn handle_event(&mut self, event: &Event) {
self.base.handle_event(event);
if !self.base.is_enabled() {
return;
}
match event {
Event::MousePress { pos, button: 1 } => {
if let Some(cell) = self.cell_at(*pos) {
self.activate(cell);
}
}
Event::KeyPress { key, modifiers: _ } => match *key {
37 | 38 => {
self.previous_page();
}
39 | 40 => {
self.next_page();
}
36 => self.set_page(0),
35 => self.set_page(self.last_page()),
_ => {}
},
_ => {}
}
}
}
impl Draw for Pagination {
fn draw(&mut self, context: &mut RenderContext) {
let rect = self.base.geometry();
if rect.width == 0 || rect.height == 0 {
return;
}
let style = self.base.style().clone();
let background = style.background_color.unwrap_or(Color::WHITE);
let text_color = style.text_color.unwrap_or(Color::BLACK);
let selected_background = style.border_color.unwrap_or(Color::rgb(60, 90, 160));
let font = Font::simple("Sans", (rect.height as f32 * 0.4).max(8.0));
context.fill_rect(rect, background);
let cell_w = self.cell_width();
for (index, cell) in self.cell_plan().iter().enumerate() {
let x = rect.x + (index as u32 * cell_w) as i32;
let cell_rect = Rect::new(x, rect.y, cell_w, rect.height);
let is_current = matches!(cell, Cell::Page(page) if *page == self.page);
let is_affordance = matches!(cell, Cell::Previous | Cell::Next | Cell::Gap { .. });
if is_current {
context.fill_rect(cell_rect, selected_background);
}
let label = match cell {
Cell::Page(page) => (page + 1).to_string(),
Cell::Gap { .. } => ELLIPSIS.to_string(),
Cell::Previous => "\u{2039}".to_string(),
Cell::Next => "\u{203a}".to_string(),
};
let color = if is_current {
background
} else if is_affordance {
background.blend(&text_color, 0.6)
} else {
text_color
};
context.draw_text(
Point::new(
cell_rect.x + (cell_rect.width as i32) / 2,
cell_rect.y + (cell_rect.height as i32) / 2,
),
&label,
&font,
color,
HorizontalAlignment::Center,
);
}
context.draw_rect(rect, style.border_color.unwrap_or(Color::rgb(210, 210, 210)));
}
fn uses_custom_drawing(&self) -> bool {
true
}
}
#[cfg(test)]
mod tests {
use super::*;
fn bar() -> Pagination {
Pagination::new(Rect::new(0, 0, 300, 32))
}
#[test]
fn starts_on_the_first_page() {
let bar = bar();
assert_eq!(bar.page(), 0);
assert_eq!(bar.page_count(), 1, "an empty set still shows one page");
assert!(!bar.has_previous());
assert!(!bar.has_next());
}
#[test]
fn page_count_from_total_and_size() {
let mut bar = bar();
bar.set_total(95);
bar.set_page_size(10);
assert_eq!(bar.page_count(), 10, "95 items at 10 per page is 10 pages");
assert_eq!(bar.last_page(), 9);
}
#[test]
fn a_partial_last_page_reports_only_its_own_items() {
let mut bar = bar();
bar.set_total(95);
bar.set_page_size(10);
bar.set_page(9);
assert_eq!(bar.first_item_index(), 90);
assert_eq!(bar.item_count_on_page(), 5, "the last page holds the remainder");
}
#[test]
fn set_page_clamps_to_the_range() {
let mut bar = bar();
bar.set_total(30);
bar.set_page_size(10);
bar.set_page(99);
assert_eq!(bar.page(), 2);
bar.set_page(0);
assert_eq!(bar.page(), 0);
}
#[test]
fn shrinking_the_total_pulls_the_page_back() {
let mut bar = bar();
bar.set_total(100);
bar.set_page_size(10);
bar.set_page(9);
bar.set_total(20);
assert_eq!(bar.page(), 1, "the page is clamped into the new range");
assert_eq!(bar.page_count(), 2);
}
#[test]
fn a_zero_page_size_is_treated_as_one() {
let mut bar = bar();
bar.set_page_size(0);
assert_eq!(bar.page_size(), 1);
}
#[test]
fn next_and_previous_report_whether_they_moved() {
let mut bar = bar();
bar.set_total(30);
bar.set_page_size(10);
assert!(!bar.previous_page(), "already on the first page");
assert!(bar.next_page());
assert_eq!(bar.page(), 1);
assert!(bar.previous_page());
assert_eq!(bar.page(), 0);
bar.set_page(2);
assert!(!bar.next_page(), "already on the last page");
}
#[test]
fn a_long_run_collapses_into_gaps() {
let mut bar = bar();
bar.set_total(1000);
bar.set_page_size(10);
bar.set_page(49);
let cells = bar.cells();
assert!(cells.contains(&ELLIPSIS.to_string()), "the run must be shortened: {cells:?}");
assert!(
cells.len() < 12,
"the bar must stay a single row, got {} cells: {cells:?}",
cells.len()
);
assert_eq!(cells.get(1), Some(&"1".to_string()));
assert_eq!(cells.last().map(String::as_str), Some("\u{203a}"));
}
#[test]
fn a_short_run_shows_every_page() {
let mut bar = bar();
bar.set_total(30);
bar.set_page_size(10);
let cells = bar.cells();
assert_eq!(cells, vec!["\u{2039}", "1", "2", "3", "\u{203a}"]);
}
#[test]
fn the_current_page_is_always_drawn() {
let mut bar = bar();
bar.set_total(1000);
bar.set_page_size(10);
for page in [0usize, 1, 5, 49, 98, 99] {
bar.set_page(page);
let expected = (bar.page() + 1).to_string();
assert!(
bar.cells().contains(&expected),
"page {} must be visible in {:?}",
bar.page() + 1,
bar.cells()
);
}
}
#[test]
fn clicking_a_page_number_navigates() {
let mut bar = bar();
bar.set_total(30);
bar.set_page_size(10);
let cell_w = bar.cell_width();
let target_x = bar.geometry().x + (2 * cell_w) as i32 + 1;
bar.handle_event(&Event::mouse_press(target_x, 10, 1));
assert_eq!(bar.page(), 1, "clicking the second number selects page 2");
}
#[test]
fn clicking_a_gap_jumps_a_window() {
let mut bar = bar();
bar.set_total(1000);
bar.set_page_size(10);
bar.set_page(50);
let plan = bar.cell_plan();
let gap_index = plan
.iter()
.position(|cell| matches!(cell, Cell::Gap { forward: true }))
.expect("a forward gap must exist mid-run");
let cell_w = bar.cell_width();
let x = bar.geometry().x + (gap_index as u32 * cell_w) as i32 + 1;
bar.handle_event(&Event::mouse_press(x, 10, 1));
assert_eq!(bar.page(), 53, "a gap jumps past the current window");
}
#[test]
fn page_changed_fires_only_on_a_real_change() {
use std::sync::{Arc, Mutex};
let mut bar = bar();
bar.set_total(30);
bar.set_page_size(10);
let seen = Arc::new(Mutex::new(Vec::<usize>::new()));
let sink = Arc::clone(&seen);
bar.page_changed.connect(move |page| {
sink.lock().expect("signal sink poisoned").push(*page);
});
bar.set_page(1);
bar.set_page(1);
bar.set_page(2);
let recorded = seen.lock().expect("signal sink poisoned").clone();
assert_eq!(recorded, vec![1, 2], "a no-op write must not emit");
}
#[test]
fn arrow_keys_navigate() {
let mut bar = bar();
bar.set_total(30);
bar.set_page_size(10);
bar.handle_event(&Event::key_press(39, 0));
assert_eq!(bar.page(), 1, "Right advances");
bar.handle_event(&Event::key_press(37, 0));
assert_eq!(bar.page(), 0, "Left retreats");
bar.handle_event(&Event::key_press(35, 0));
assert_eq!(bar.page(), 2, "End jumps to the last page");
bar.handle_event(&Event::key_press(36, 0));
assert_eq!(bar.page(), 0, "Home jumps to the first page");
}
#[test]
fn nav_buttons_can_be_hidden() {
let mut bar = bar();
bar.set_total(30);
bar.set_page_size(10);
bar.set_show_nav_buttons(false);
let cells = bar.cells();
assert_eq!(cells, vec!["1", "2", "3"], "no nav ends when they are hidden");
}
#[test]
fn sibling_count_is_clamped() {
let mut bar = bar();
bar.set_sibling_count(99);
assert_eq!(bar.sibling_count(), 3, "the bar must stay one row");
bar.set_sibling_count(0);
assert_eq!(bar.sibling_count(), 0);
}
#[test]
fn a_click_outside_the_bar_is_ignored() {
let mut bar = bar();
bar.set_total(30);
bar.set_page_size(10);
bar.handle_event(&Event::mouse_press(500, 500, 1));
assert_eq!(bar.page(), 0);
bar.handle_event(&Event::mouse_press(1, 5, 2));
assert_eq!(bar.page(), 0, "a non-primary button must not page");
}
#[test]
fn properties_round_trip() {
use crate::widget::capability::properties_trait::{
widget_property_get, widget_property_set,
};
let mut bar = bar();
widget_property_set(&mut bar, "total", CapabilityValue::UInt(95)).expect("total");
widget_property_set(&mut bar, "page_size", CapabilityValue::UInt(10)).expect("page_size");
widget_property_set(&mut bar, "page", CapabilityValue::UInt(3)).expect("page");
assert_eq!(widget_property_get(&bar, "total"), Ok(CapabilityValue::UInt(95)));
assert_eq!(widget_property_get(&bar, "page"), Ok(CapabilityValue::UInt(3)));
assert_eq!(widget_property_get(&bar, "page_count"), Ok(CapabilityValue::UInt(10)));
assert_eq!(widget_property_get(&bar, "last_page"), Ok(CapabilityValue::UInt(9)));
assert_eq!(
widget_property_set(&mut bar, "page_count", CapabilityValue::UInt(1)),
Err(CapabilityAccessError::ReadOnlyProperty)
);
}
}