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 SliderOrientation {
#[default]
Horizontal,
Vertical,
}
#[derive(Clone, Debug, PartialEq)]
pub enum SliderMessage {
Increment,
Decrement,
IncrementPage,
DecrementPage,
SetValue(f64),
SetMin,
SetMax,
}
#[derive(Clone, Debug, PartialEq)]
pub enum SliderOutput {
ValueChanged(f64),
}
#[derive(Clone, Debug, PartialEq)]
#[cfg_attr(
feature = "serialization",
derive(serde::Serialize, serde::Deserialize)
)]
pub struct SliderState {
value: f64,
min: f64,
max: f64,
step: f64,
orientation: SliderOrientation,
label: Option<String>,
show_value: bool,
}
impl Default for SliderState {
fn default() -> Self {
Self {
value: 0.0,
min: 0.0,
max: 100.0,
step: 1.0,
orientation: SliderOrientation::default(),
label: None,
show_value: true,
}
}
}
impl SliderState {
pub fn new(min: f64, max: f64) -> Self {
Self {
value: min,
min,
max,
..Self::default()
}
}
pub fn with_value(mut self, value: f64) -> Self {
self.value = value.clamp(self.min, self.max);
self
}
pub fn with_step(mut self, step: f64) -> Self {
self.step = step;
self
}
pub fn with_orientation(mut self, orientation: SliderOrientation) -> Self {
self.orientation = orientation;
self
}
pub fn with_label(mut self, label: impl Into<String>) -> Self {
self.label = Some(label.into());
self
}
pub fn with_show_value(mut self, show_value: bool) -> Self {
self.show_value = show_value;
self
}
pub fn value(&self) -> f64 {
self.value
}
pub fn set_value(&mut self, value: f64) {
self.value = value.clamp(self.min, self.max);
}
pub fn min(&self) -> f64 {
self.min
}
pub fn max(&self) -> f64 {
self.max
}
pub fn step(&self) -> f64 {
self.step
}
pub fn percentage(&self) -> f64 {
let range = self.max - self.min;
if range == 0.0 {
0.0
} else {
(self.value - self.min) / range
}
}
pub fn label(&self) -> Option<&str> {
self.label.as_deref()
}
pub fn show_value(&self) -> bool {
self.show_value
}
pub fn set_show_value(&mut self, show: bool) {
self.show_value = show;
}
pub fn orientation(&self) -> &SliderOrientation {
&self.orientation
}
pub fn set_orientation(&mut self, orientation: SliderOrientation) {
self.orientation = orientation;
}
pub fn update(&mut self, msg: SliderMessage) -> Option<SliderOutput> {
Slider::update(self, msg)
}
}
pub struct Slider;
impl Component for Slider {
type State = SliderState;
type Message = SliderMessage;
type Output = SliderOutput;
fn init() -> Self::State {
SliderState::default()
}
fn update(state: &mut Self::State, msg: Self::Message) -> Option<Self::Output> {
let old_value = state.value;
match msg {
SliderMessage::Increment => {
state.value = (state.value + state.step).min(state.max);
}
SliderMessage::Decrement => {
state.value = (state.value - state.step).max(state.min);
}
SliderMessage::IncrementPage => {
state.value = (state.value + state.step * 10.0).min(state.max);
}
SliderMessage::DecrementPage => {
state.value = (state.value - state.step * 10.0).max(state.min);
}
SliderMessage::SetValue(v) => {
state.value = v.clamp(state.min, state.max);
}
SliderMessage::SetMin => {
state.value = state.min;
}
SliderMessage::SetMax => {
state.value = state.max;
}
}
if (state.value - old_value).abs() > f64::EPSILON {
Some(SliderOutput::ValueChanged(state.value))
} else {
None
}
}
fn handle_event(
state: &Self::State,
event: &Event,
ctx: &EventContext,
) -> Option<Self::Message> {
if !ctx.focused || ctx.disabled {
return None;
}
if let Some(key) = event.as_key() {
match state.orientation {
SliderOrientation::Horizontal => match key.code {
Key::Right | Key::Char('l') => Some(SliderMessage::Increment),
Key::Left | Key::Char('h') => Some(SliderMessage::Decrement),
Key::PageUp => Some(SliderMessage::IncrementPage),
Key::PageDown => Some(SliderMessage::DecrementPage),
Key::Home => Some(SliderMessage::SetMin),
Key::End => Some(SliderMessage::SetMax),
_ => None,
},
SliderOrientation::Vertical => match key.code {
Key::Up | Key::Char('k') => Some(SliderMessage::Increment),
Key::Down | Key::Char('j') => Some(SliderMessage::Decrement),
Key::PageUp => Some(SliderMessage::IncrementPage),
Key::PageDown => Some(SliderMessage::DecrementPage),
Key::Home => Some(SliderMessage::SetMin),
Key::End => Some(SliderMessage::SetMax),
_ => None,
},
}
} else {
None
}
}
fn view(state: &Self::State, ctx: &mut RenderContext<'_, '_>) {
match state.orientation {
SliderOrientation::Horizontal => view_horizontal(state, ctx),
SliderOrientation::Vertical => view_vertical(state, ctx),
}
}
}
fn view_horizontal(state: &SliderState, ctx: &mut RenderContext<'_, '_>) {
if ctx.area.height == 0 || ctx.area.width == 0 {
return;
}
let (label_style, filled_style, empty_style) = compute_styles(ctx.theme, &ctx.event_context());
let mut lines = Vec::new();
if state.label.is_some() || state.show_value {
let label_text = build_label_text(state);
lines.push(Line::from(Span::styled(label_text, label_style)));
}
let track_width = ctx.area.width as usize;
let pct = state.percentage();
let filled = (pct * track_width as f64).round() as usize;
let empty = track_width.saturating_sub(filled);
let mut spans = Vec::new();
if filled > 0 {
spans.push(Span::styled("\u{2588}".repeat(filled), filled_style));
}
if empty > 0 {
spans.push(Span::styled("\u{2591}".repeat(empty), empty_style));
}
lines.push(Line::from(spans));
let paragraph = Paragraph::new(Text::from(lines));
let value_str = format_value(state.value);
let annotation = crate::annotation::Annotation::new(crate::annotation::WidgetType::Custom(
"Slider".to_string(),
))
.with_id("slider")
.with_value(value_str);
let annotation = if let Some(label) = &state.label {
annotation.with_label(label.as_str())
} else {
annotation
};
let annotated = crate::annotation::Annotate::new(paragraph, annotation)
.focused(ctx.focused)
.disabled(ctx.disabled);
ctx.frame.render_widget(annotated, ctx.area);
}
fn view_vertical(state: &SliderState, ctx: &mut RenderContext<'_, '_>) {
if ctx.area.height == 0 || ctx.area.width == 0 {
return;
}
let (label_style, filled_style, empty_style) = compute_styles(ctx.theme, &ctx.event_context());
let mut lines = Vec::new();
let label_lines = if state.label.is_some() || state.show_value {
1
} else {
0
};
let track_height = (ctx.area.height as usize).saturating_sub(label_lines);
if state.label.is_some() || state.show_value {
let label_text = build_label_text(state);
lines.push(Line::from(Span::styled(label_text, label_style)));
}
let pct = state.percentage();
let filled = (pct * track_height as f64).round() as usize;
let empty = track_height.saturating_sub(filled);
for _ in 0..empty {
lines.push(Line::from(Span::styled("\u{2591}", empty_style)));
}
for _ in 0..filled {
lines.push(Line::from(Span::styled("\u{2588}", filled_style)));
}
let paragraph = Paragraph::new(Text::from(lines));
let value_str = format_value(state.value);
let annotation = crate::annotation::Annotation::new(crate::annotation::WidgetType::Custom(
"Slider".to_string(),
))
.with_id("slider")
.with_value(value_str);
let annotation = if let Some(label) = &state.label {
annotation.with_label(label.as_str())
} else {
annotation
};
let annotated = crate::annotation::Annotate::new(paragraph, annotation)
.focused(ctx.focused)
.disabled(ctx.disabled);
ctx.frame.render_widget(annotated, ctx.area);
}
fn compute_styles(theme: &Theme, ctx: &EventContext) -> (Style, Style, Style) {
if ctx.disabled {
let disabled = theme.disabled_style();
(disabled, disabled, disabled)
} else if ctx.focused {
let label_style = theme.focused_style();
let filled_style = theme.focused_style();
let empty_style = theme.normal_style();
(label_style, filled_style, empty_style)
} else {
let label_style = theme.normal_style();
let filled_style = theme.normal_style();
let empty_style = theme.normal_style();
(label_style, filled_style, empty_style)
}
}
fn build_label_text(state: &SliderState) -> String {
let mut parts = Vec::new();
if let Some(label) = &state.label {
parts.push(label.clone());
}
if state.show_value {
parts.push(format_value(state.value));
}
parts.join(": ")
}
fn format_value(value: f64) -> String {
if value.fract() == 0.0 {
format!("{}", value as i64)
} else {
format!("{value}")
}
}
#[cfg(test)]
mod tests;