use ratatui::prelude::*;
use ratatui::widgets::Paragraph;
use super::{Component, EventContext, RenderContext};
use crate::input::{Event, Key};
use crate::theme::Theme;
#[derive(Clone, Debug, Default, PartialEq, Eq)]
#[cfg_attr(
feature = "serialization",
derive(serde::Serialize, serde::Deserialize)
)]
pub enum PaginatorStyle {
#[default]
PageOfTotal,
RangeOfTotal,
Dots,
Compact,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum PaginatorMessage {
NextPage,
PrevPage,
FirstPage,
LastPage,
GoToPage(usize),
SetTotalPages(usize),
SetTotalItems(usize),
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum PaginatorOutput {
PageChanged(usize),
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[cfg_attr(
feature = "serialization",
derive(serde::Serialize, serde::Deserialize)
)]
pub struct PaginatorState {
current_page: usize,
total_pages: usize,
page_size: usize,
total_items: usize,
style: PaginatorStyle,
}
impl Default for PaginatorState {
fn default() -> Self {
Self {
current_page: 0,
total_pages: 1,
page_size: 10,
total_items: 10,
style: PaginatorStyle::default(),
}
}
}
impl PaginatorState {
pub fn new(total_pages: usize) -> Self {
let total_pages = total_pages.max(1);
Self {
total_pages,
total_items: total_pages * 10,
..Self::default()
}
}
pub fn from_items(total_items: usize, page_size: usize) -> Self {
let page_size = page_size.max(1);
let total_pages = calculate_total_pages(total_items, page_size);
Self {
total_pages,
page_size,
total_items,
..Self::default()
}
}
pub fn with_style(mut self, style: PaginatorStyle) -> Self {
self.style = style;
self
}
pub fn with_page_size(mut self, page_size: usize) -> Self {
self.page_size = page_size.max(1);
self.total_pages = calculate_total_pages(self.total_items, self.page_size);
self.current_page = self.current_page.min(self.total_pages.saturating_sub(1));
self
}
pub fn with_current_page(mut self, page: usize) -> Self {
self.current_page = page.min(self.total_pages.saturating_sub(1));
self
}
pub fn current_page(&self) -> usize {
self.current_page
}
pub fn display_page(&self) -> usize {
self.current_page + 1
}
pub fn total_pages(&self) -> usize {
self.total_pages
}
pub fn total_items(&self) -> usize {
self.total_items
}
pub fn page_size(&self) -> usize {
self.page_size
}
pub fn style(&self) -> &PaginatorStyle {
&self.style
}
pub fn is_first_page(&self) -> bool {
self.current_page == 0
}
pub fn is_last_page(&self) -> bool {
self.current_page >= self.total_pages.saturating_sub(1)
}
pub fn range_start(&self) -> usize {
self.current_page * self.page_size
}
pub fn range_end(&self) -> usize {
let end = (self.current_page + 1) * self.page_size;
end.min(self.total_items).saturating_sub(1)
}
pub fn set_current_page(&mut self, page: usize) {
self.current_page = page.min(self.total_pages.saturating_sub(1));
}
pub fn set_total_pages(&mut self, total: usize) {
self.total_pages = total.max(1);
self.total_items = self.total_pages * self.page_size;
self.current_page = self.current_page.min(self.total_pages.saturating_sub(1));
}
pub fn set_total_items(&mut self, total: usize) {
self.total_items = total;
self.total_pages = calculate_total_pages(total, self.page_size);
self.current_page = self.current_page.min(self.total_pages.saturating_sub(1));
}
pub fn update(&mut self, msg: PaginatorMessage) -> Option<PaginatorOutput> {
Paginator::update(self, msg)
}
}
pub struct Paginator;
impl Component for Paginator {
type State = PaginatorState;
type Message = PaginatorMessage;
type Output = PaginatorOutput;
fn init() -> Self::State {
PaginatorState::default()
}
fn handle_event(
_state: &Self::State,
event: &Event,
ctx: &EventContext,
) -> Option<Self::Message> {
if !ctx.focused || ctx.disabled {
return None;
}
let key = event.as_key()?;
match key.code {
Key::Left | Key::Char('h') => Some(PaginatorMessage::PrevPage),
Key::Right | Key::Char('l') => Some(PaginatorMessage::NextPage),
Key::Home => Some(PaginatorMessage::FirstPage),
Key::End => Some(PaginatorMessage::LastPage),
_ => None,
}
}
fn update(state: &mut Self::State, msg: Self::Message) -> Option<Self::Output> {
match msg {
PaginatorMessage::NextPage => {
if state.current_page < state.total_pages.saturating_sub(1) {
state.current_page += 1;
Some(PaginatorOutput::PageChanged(state.current_page))
} else {
None
}
}
PaginatorMessage::PrevPage => {
if state.current_page > 0 {
state.current_page -= 1;
Some(PaginatorOutput::PageChanged(state.current_page))
} else {
None
}
}
PaginatorMessage::FirstPage => {
if state.current_page != 0 {
state.current_page = 0;
Some(PaginatorOutput::PageChanged(0))
} else {
None
}
}
PaginatorMessage::LastPage => {
let last = state.total_pages.saturating_sub(1);
if state.current_page != last {
state.current_page = last;
Some(PaginatorOutput::PageChanged(last))
} else {
None
}
}
PaginatorMessage::GoToPage(page) => {
let clamped = page.min(state.total_pages.saturating_sub(1));
if state.current_page != clamped {
state.current_page = clamped;
Some(PaginatorOutput::PageChanged(clamped))
} else {
None
}
}
PaginatorMessage::SetTotalPages(total) => {
state.set_total_pages(total);
None
}
PaginatorMessage::SetTotalItems(total) => {
state.set_total_items(total);
None
}
}
}
fn view(state: &Self::State, ctx: &mut RenderContext<'_, '_>) {
crate::annotation::with_registry(|reg| {
reg.register(
ctx.area,
crate::annotation::Annotation::paginator("paginator")
.with_focus(ctx.focused)
.with_disabled(ctx.disabled)
.with_value(format!("{}/{}", state.display_page(), state.total_pages)),
);
});
let text_style = if ctx.disabled {
ctx.theme.disabled_style()
} else if ctx.focused {
ctx.theme.focused_style()
} else {
ctx.theme.normal_style()
};
let content = match &state.style {
PaginatorStyle::PageOfTotal => {
format!("Page {} of {}", state.display_page(), state.total_pages)
}
PaginatorStyle::RangeOfTotal => {
if state.total_items == 0 {
"Showing 0 of 0".to_string()
} else {
let start = state.range_start() + 1; let end = state.range_end() + 1; format!(
"Showing {}-{} of {}",
format_number(start),
format_number(end),
format_number(state.total_items),
)
}
}
PaginatorStyle::Dots => render_dots(state),
PaginatorStyle::Compact => render_compact(state, ctx.theme),
};
if state.style == PaginatorStyle::Compact {
let spans = render_compact_spans(state, ctx.theme, &ctx.event_context());
let line = Line::from(spans);
let paragraph = Paragraph::new(line).alignment(Alignment::Center);
ctx.frame.render_widget(paragraph, ctx.area);
} else {
let paragraph = Paragraph::new(content)
.style(text_style)
.alignment(Alignment::Center);
ctx.frame.render_widget(paragraph, ctx.area);
}
}
}
fn render_dots(state: &PaginatorState) -> String {
const FILLED: char = '●';
const EMPTY: char = '○';
const MAX_DOTS: usize = 10;
if state.total_pages <= MAX_DOTS {
(0..state.total_pages)
.map(|i| {
if i == state.current_page {
FILLED
} else {
EMPTY
}
})
.collect::<Vec<_>>()
.iter()
.map(|c| c.to_string())
.collect::<Vec<_>>()
.join(" ")
} else {
let mut parts: Vec<String> = Vec::new();
parts.push(if state.current_page == 0 {
FILLED.to_string()
} else {
EMPTY.to_string()
});
let window_size = MAX_DOTS - 2;
if state.current_page <= window_size / 2 + 1 {
for i in 1..window_size {
parts.push(if i == state.current_page {
FILLED.to_string()
} else {
EMPTY.to_string()
});
}
parts.push("…".to_string());
} else if state.current_page >= state.total_pages - 1 - window_size / 2 {
parts.push("…".to_string());
let start = state.total_pages - window_size;
for i in start..state.total_pages - 1 {
parts.push(if i == state.current_page {
FILLED.to_string()
} else {
EMPTY.to_string()
});
}
} else {
parts.push("…".to_string());
let half = (window_size - 3) / 2; let start = state.current_page - half;
let end = state.current_page + half;
for i in start..=end {
parts.push(if i == state.current_page {
FILLED.to_string()
} else {
EMPTY.to_string()
});
}
parts.push("…".to_string());
}
parts.push(if state.current_page == state.total_pages - 1 {
FILLED.to_string()
} else {
EMPTY.to_string()
});
parts.join(" ")
}
}
fn render_compact(state: &PaginatorState, _theme: &Theme) -> String {
let left = if state.is_first_page() { " " } else { "◀" };
let right = if state.is_last_page() { " " } else { "▶" };
format!(
"{} {} / {} {}",
left,
state.display_page(),
state.total_pages,
right
)
}
fn render_compact_spans<'a>(
state: &PaginatorState,
theme: &Theme,
ctx: &EventContext,
) -> Vec<Span<'a>> {
let text_style = if ctx.disabled {
theme.disabled_style()
} else if ctx.focused {
theme.focused_style()
} else {
theme.normal_style()
};
let dim_style = if ctx.disabled {
theme.disabled_style()
} else {
theme.border_style()
};
let left_arrow = if state.is_first_page() {
Span::styled(" ", dim_style)
} else {
Span::styled("◀", text_style)
};
let right_arrow = if state.is_last_page() {
Span::styled(" ", dim_style)
} else {
Span::styled("▶", text_style)
};
let page_text = format!(" {} / {} ", state.display_page(), state.total_pages);
vec![left_arrow, Span::styled(page_text, text_style), right_arrow]
}
fn format_number(n: usize) -> String {
let s = n.to_string();
if s.len() <= 3 {
return s;
}
let mut result = String::with_capacity(s.len() + s.len() / 3);
let chars: Vec<char> = s.chars().collect();
for (i, ch) in chars.iter().enumerate() {
if i > 0 && (chars.len() - i) % 3 == 0 {
result.push(',');
}
result.push(*ch);
}
result
}
fn calculate_total_pages(total_items: usize, page_size: usize) -> usize {
if total_items == 0 {
return 1;
}
let page_size = page_size.max(1);
total_items.div_ceil(page_size)
}
#[cfg(test)]
mod tests;