use ratatui_core::layout::Rect;
use ratatui_core::style::{Modifier, Style};
use crate::event::{Event, EventFlow, KeyCode};
use crate::geometry::Size;
use crate::surface::Surface;
use crate::view::{RenderCtx, View};
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct SliderState {
value: f32,
min: f32,
max: f32,
step: f32,
}
impl Default for SliderState {
fn default() -> Self {
Self::new(0.0, 1.0, 0.0)
}
}
impl SliderState {
pub fn new(min: f32, max: f32, value: f32) -> Self {
let (min, max) = if max < min { (max, min) } else { (min, max) };
let step = ((max - min) / 100.0).max(f32::MIN_POSITIVE);
Self {
value: value.clamp(min, max),
min,
max,
step,
}
}
pub fn step(mut self, step: f32) -> Self {
if step > 0.0 {
self.step = step;
}
self
}
pub fn value(&self) -> f32 {
self.value
}
pub fn min(&self) -> f32 {
self.min
}
pub fn max(&self) -> f32 {
self.max
}
pub fn ratio(&self) -> f32 {
let span = self.max - self.min;
if span <= 0.0 {
0.0
} else {
((self.value - self.min) / span).clamp(0.0, 1.0)
}
}
pub fn set_value(&mut self, value: f32) {
self.value = value.clamp(self.min, self.max);
}
pub fn set_ratio(&mut self, ratio: f32) {
let r = ratio.clamp(0.0, 1.0);
self.set_value(self.min + r * (self.max - self.min));
}
pub fn increment(&mut self) {
self.set_value(self.value + self.step);
}
pub fn decrement(&mut self) {
self.set_value(self.value - self.step);
}
pub fn handle(&mut self, event: &Event) -> EventFlow {
let Event::Key(key) = event else {
return EventFlow::Ignored;
};
if !key.plain() {
return EventFlow::Ignored;
}
match key.code {
KeyCode::Right | KeyCode::Up => self.increment(),
KeyCode::Left | KeyCode::Down => self.decrement(),
KeyCode::PageUp => self.set_value(self.value + self.step * 10.0),
KeyCode::PageDown => self.set_value(self.value - self.step * 10.0),
KeyCode::Home => self.value = self.min,
KeyCode::End => self.value = self.max,
_ => return EventFlow::Ignored,
}
EventFlow::Consumed
}
}
pub struct Slider {
ratio: f32,
label: Option<String>,
}
impl Slider {
pub fn new(state: &SliderState) -> Self {
Self {
ratio: state.ratio(),
label: None,
}
}
pub fn label(mut self, state: &SliderState) -> Self {
self.label = Some(format_value(state.value()));
self
}
pub fn label_text(mut self, text: impl Into<String>) -> Self {
self.label = Some(text.into());
self
}
}
fn format_value(value: f32) -> String {
if value.fract() == 0.0 {
format!("{}", value as i64)
} else {
format!("{value:.2}")
}
}
impl View for Slider {
fn measure(&self, available: Size) -> Size {
Size::new(available.width, 1)
}
fn render(&self, area: Rect, surface: &mut Surface, ctx: &RenderCtx) {
if area.width == 0 || area.height == 0 {
return;
}
let accent = Style::default().fg(ctx.theme.accent);
let track = Style::default().fg(ctx.theme.dim);
let suffix = self
.label
.as_ref()
.map(|l| format!(" {l}"))
.filter(|_| area.width > 4);
let suffix_w = suffix
.as_ref()
.map(|s| crate::width::str_cols(s))
.unwrap_or(0);
let bar_w = area.width.saturating_sub(suffix_w);
if bar_w == 0 {
return;
}
let last = bar_w.saturating_sub(1);
let thumb = (self.ratio * last as f32).round() as u16;
for i in 0..bar_w {
let x = area.x + i;
if i == thumb {
surface.set(x, area.y, '●', accent.add_modifier(Modifier::BOLD));
} else if i < thumb {
surface.set(x, area.y, '━', accent);
} else {
surface.set(x, area.y, '─', track);
}
}
if let Some(suffix) = suffix {
surface.set_string(
area.x + bar_w,
area.y,
&suffix,
Style::default().fg(ctx.theme.muted),
);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::event::{Event, Key, KeyCode};
use crate::style::Theme;
use crate::test_support::{buffer, rainbow_theme, row};
use crate::view::{RenderCtx, View};
fn key(code: KeyCode) -> Event {
Event::Key(Key::new(code))
}
#[test]
fn state_clamps_and_reports_ratio() {
let mut s = SliderState::new(0.0, 10.0, 5.0).step(1.0);
assert!((s.ratio() - 0.5).abs() < 1e-6);
s.set_value(100.0);
assert_eq!(s.value(), 10.0);
s.set_value(-100.0);
assert_eq!(s.value(), 0.0);
let r = SliderState::new(10.0, 0.0, 3.0);
assert_eq!(r.min(), 0.0);
assert_eq!(r.max(), 10.0);
}
#[test]
fn set_ratio_maps_into_range() {
let mut s = SliderState::new(0.0, 200.0, 0.0);
s.set_ratio(0.25);
assert_eq!(s.value(), 50.0);
s.set_ratio(2.0); assert_eq!(s.value(), 200.0);
}
#[test]
fn keys_step_page_and_snap() {
let mut s = SliderState::new(0.0, 100.0, 50.0).step(1.0);
assert_eq!(s.handle(&key(KeyCode::Right)), EventFlow::Consumed);
assert_eq!(s.value(), 51.0);
s.handle(&key(KeyCode::Left));
s.handle(&key(KeyCode::Left));
assert_eq!(s.value(), 49.0);
s.handle(&key(KeyCode::PageUp));
assert_eq!(s.value(), 59.0); s.handle(&key(KeyCode::Home));
assert_eq!(s.value(), 0.0);
s.handle(&key(KeyCode::End));
assert_eq!(s.value(), 100.0);
assert_eq!(s.handle(&key(KeyCode::Char('x'))), EventFlow::Ignored);
}
#[test]
fn renders_filled_track_thumb_and_label() {
let theme = Theme::default();
let state = SliderState::new(0.0, 1.0, 0.5);
let view = Slider::new(&state).label(&state);
let buf = crate::testing::render(&view, 20, 1, &theme);
let line = row(&buf, 0);
assert!(line.contains('●'), "thumb present: {line:?}");
assert!(line.contains('━'), "filled portion present: {line:?}");
assert!(line.contains('─'), "empty track present: {line:?}");
assert!(line.contains("0.5"), "value label present: {line:?}");
}
#[test]
fn thumb_and_fill_use_theme_accent() {
let t = rainbow_theme();
let ctx = RenderCtx::new(&t);
let state = SliderState::new(0.0, 1.0, 1.0); let mut buf = buffer(10, 1);
let area = buf.area;
let mut surface = Surface::new(&mut buf, area);
Slider::new(&state).render(area, &mut surface, &ctx);
assert_eq!(buf[(0, 0)].fg, t.accent);
assert_eq!(buf[(9, 0)].symbol(), "●");
assert_eq!(buf[(9, 0)].fg, t.accent);
}
#[test]
fn degenerate_widths_do_not_panic() {
let theme = Theme::default();
let state = SliderState::new(0.0, 1.0, 0.5);
for w in [0u16, 1, 2] {
let _ = crate::testing::render(&Slider::new(&state).label(&state), w, 1, &theme);
}
}
}