use ratatui::prelude::*;
use ratatui::widgets::{Block, Borders};
use super::{Component, EventContext, RenderContext};
use crate::input::{Event, Key};
use crate::scroll::ScrollState;
#[derive(Clone, Debug, PartialEq, Eq)]
#[cfg_attr(
feature = "serialization",
derive(serde::Serialize, serde::Deserialize)
)]
pub enum ScrollViewMessage {
ScrollUp,
ScrollDown,
PageUp,
PageDown,
Home,
End,
SetContentHeight(u16),
}
#[derive(Clone, Debug, PartialEq)]
#[cfg_attr(
feature = "serialization",
derive(serde::Serialize, serde::Deserialize)
)]
pub struct ScrollViewState {
content_height: u16,
scroll: ScrollState,
title: Option<String>,
show_scrollbar: bool,
}
impl Default for ScrollViewState {
fn default() -> Self {
Self {
content_height: 0,
scroll: ScrollState::default(),
title: None,
show_scrollbar: true,
}
}
}
impl ScrollViewState {
pub fn new() -> Self {
Self::default()
}
pub fn with_content_height(mut self, height: u16) -> Self {
self.content_height = height;
self.scroll.set_content_length(height as usize);
self
}
pub fn with_title(mut self, title: impl Into<String>) -> Self {
self.title = Some(title.into());
self
}
pub fn with_show_scrollbar(mut self, show: bool) -> Self {
self.show_scrollbar = show;
self
}
pub fn content_height(&self) -> u16 {
self.content_height
}
pub fn set_content_height(&mut self, height: u16) {
self.content_height = height;
self.scroll.set_content_length(height as usize);
}
pub fn title(&self) -> Option<&str> {
self.title.as_deref()
}
pub fn set_title(&mut self, title: Option<String>) {
self.title = title;
}
pub fn show_scrollbar(&self) -> bool {
self.show_scrollbar
}
pub fn set_show_scrollbar(&mut self, show: bool) {
self.show_scrollbar = show;
}
pub fn scroll_offset(&self) -> usize {
self.scroll.offset()
}
pub fn set_scroll_offset(&mut self, offset: usize) {
self.scroll.set_offset(offset);
}
pub fn scroll_state(&self) -> &ScrollState {
&self.scroll
}
pub fn content_area(&self, area: Rect) -> Rect {
let block = Block::default().borders(Borders::ALL);
let inner = block.inner(area);
if inner.width == 0 || inner.height == 0 {
return Rect::new(area.x, area.y, 0, 0);
}
inner
}
pub fn viewport_height(&self, area: Rect) -> u16 {
let content = self.content_area(area);
content.height
}
pub fn update(&mut self, msg: ScrollViewMessage) -> Option<()> {
ScrollView::update(self, msg)
}
}
pub struct ScrollView;
impl Component for ScrollView {
type State = ScrollViewState;
type Message = ScrollViewMessage;
type Output = ();
fn init() -> Self::State {
ScrollViewState::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()?;
let ctrl = key.modifiers.ctrl();
match key.code {
Key::Up | Key::Char('k') if !ctrl => Some(ScrollViewMessage::ScrollUp),
Key::Down | Key::Char('j') if !ctrl => Some(ScrollViewMessage::ScrollDown),
Key::PageUp => Some(ScrollViewMessage::PageUp),
Key::PageDown => Some(ScrollViewMessage::PageDown),
Key::Char('u') if ctrl => Some(ScrollViewMessage::PageUp),
Key::Char('d') if ctrl => Some(ScrollViewMessage::PageDown),
Key::Char('g') if key.modifiers.shift() => Some(ScrollViewMessage::End),
Key::Home | Key::Char('g') => Some(ScrollViewMessage::Home),
Key::End => Some(ScrollViewMessage::End),
_ => None,
}
}
fn update(state: &mut Self::State, msg: Self::Message) -> Option<Self::Output> {
match msg {
ScrollViewMessage::ScrollUp => {
if state.scroll.scroll_up() {
Some(())
} else {
None
}
}
ScrollViewMessage::ScrollDown => {
if state.scroll.scroll_down() {
Some(())
} else {
None
}
}
ScrollViewMessage::PageUp => {
let page = state.scroll.viewport_height().max(1);
if state.scroll.page_up(page) {
Some(())
} else {
None
}
}
ScrollViewMessage::PageDown => {
let page = state.scroll.viewport_height().max(1);
if state.scroll.page_down(page) {
Some(())
} else {
None
}
}
ScrollViewMessage::Home => {
if state.scroll.scroll_to_start() {
Some(())
} else {
None
}
}
ScrollViewMessage::End => {
if state.scroll.scroll_to_end() {
Some(())
} else {
None
}
}
ScrollViewMessage::SetContentHeight(height) => {
state.content_height = height;
state.scroll.set_content_length(height as usize);
None
}
}
}
fn view(state: &Self::State, ctx: &mut RenderContext<'_, '_>) {
if ctx.area.width == 0 || ctx.area.height == 0 {
return;
}
crate::annotation::with_registry(|reg| {
reg.register(
ctx.area,
crate::annotation::Annotation::new(crate::annotation::WidgetType::Custom(
"ScrollView".to_string(),
))
.with_id("scroll_view")
.with_focus(ctx.focused)
.with_disabled(ctx.disabled),
);
});
let border_style = if ctx.disabled {
ctx.theme.disabled_style()
} else if ctx.focused {
ctx.theme.focused_border_style()
} else {
ctx.theme.border_style()
};
let mut block = Block::default()
.borders(Borders::ALL)
.border_style(border_style);
if let Some(title) = &state.title {
block = block.title(title.as_str());
}
let inner = block.inner(ctx.area);
ctx.frame.render_widget(block, ctx.area);
if inner.height == 0 || inner.width == 0 {
return;
}
let viewport_height = inner.height as usize;
let total = state.content_height as usize;
if state.show_scrollbar && total > viewport_height {
let mut bar_scroll = ScrollState::new(total);
bar_scroll.set_viewport_height(viewport_height);
bar_scroll.set_offset(
state
.scroll
.offset()
.min(total.saturating_sub(viewport_height)),
);
crate::scroll::render_scrollbar_inside_border(
&bar_scroll,
ctx.frame,
ctx.area,
ctx.theme,
);
}
}
}
#[cfg(test)]
mod tests;