1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
//! [`Slider`] — a one-row value picker over a numeric range.
//!
//! Like the other interactive widgets, state and view are split:
//! [`SliderState`] owns the value, range, and step and applies edits (via
//! [`SliderState::handle`] or the explicit methods); [`Slider`] borrows it and
//! renders a filled track with a thumb. This is the `StatefulWidget` idiom — the
//! value survives across frames in the host, the view is rebuilt each frame.
use ratatui_core::layout::Rect;
use ratatui_core::style::{Modifier, Style};
use crate::event::{Event, InputOutcome, KeyCode};
use crate::geometry::Size;
use crate::surface::Surface;
use crate::view::{RenderCtx, View};
/// The value, range, and step of a [`Slider`], persisted by the host.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct SliderState {
value: f32,
min: f32,
max: f32,
step: f32,
}
impl Default for SliderState {
/// A `0.0..=1.0` slider stepping by `0.1`, starting at `0.0`.
fn default() -> Self {
Self::new(0.0, 1.0, 0.0)
}
}
impl SliderState {
/// A slider over `min..=max` starting at `value` (clamped into range). The
/// step defaults to 1/100 of the range; override with [`step`](Self::step).
/// If `max < min` the two are swapped so the range is always well-formed.
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,
}
}
/// Set the increment applied by [`increment`](Self::increment) /
/// [`decrement`](Self::decrement) and the arrow keys. Non-positive steps are
/// ignored (the default step is kept).
pub fn step(mut self, step: f32) -> Self {
if step > 0.0 {
self.step = step;
}
self
}
/// The current value.
pub fn value(&self) -> f32 {
self.value
}
/// The range lower bound.
pub fn min(&self) -> f32 {
self.min
}
/// The range upper bound.
pub fn max(&self) -> f32 {
self.max
}
/// Position within the range as `0.0..=1.0` (0 when the range is degenerate).
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)
}
}
/// Set the value directly, clamped into range.
pub fn set_value(&mut self, value: f32) {
self.value = value.clamp(self.min, self.max);
}
/// Set the value from a `0.0..=1.0` position within the range — the seam a
/// host uses to map a click/drag on the track to a value.
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));
}
/// Nudge the value up by one step (clamped to `max`).
pub fn increment(&mut self) {
self.set_value(self.value + self.step);
}
/// Nudge the value down by one step (clamped to `min`).
pub fn decrement(&mut self) {
self.set_value(self.value - self.step);
}
/// Apply a key: Right/Up step up, Left/Down step down, PageUp/PageDown jump
/// ten steps, Home/End snap to the bounds. Reports
/// [`InputOutcome::Changed`] when the value moves and
/// [`InputOutcome::Consumed`] at a bound. Modified keys and everything else
/// are ignored so they can bubble.
pub fn handle(&mut self, event: &Event) -> InputOutcome {
let Event::Key(key) = event else {
return InputOutcome::Ignored;
};
if !key.plain() {
return InputOutcome::Ignored;
}
let before = self.value;
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 InputOutcome::Ignored,
}
if self.value == before {
InputOutcome::Consumed
} else {
InputOutcome::Changed
}
}
}
/// A one-row slider rendered from a [`SliderState`]: a filled track with a thumb,
/// and an optional trailing value label.
///
/// The filled portion and thumb use the theme accent, the remaining track the
/// theme's dim color — the same palette split as [`ProgressBar`](crate::components::ProgressBar).
/// Like a progress bar it fills the width it's given, so place it in a
/// [`fixed`](crate::components::Flex) row or let a flex child grow.
///
/// ```
/// use tuika::prelude::*;
/// let mut state = SliderState::new(0.0, 100.0, 50.0).step(10.0);
/// state.increment();
/// assert_eq!(state.value(), 60.0);
/// let _view = Slider::new(&state).label(&state); // a `View` one row tall
/// ```
pub struct Slider {
ratio: f32,
label: Option<String>,
}
impl Slider {
/// A slider view snapshotting `state` for this frame.
pub fn new(state: &SliderState) -> Self {
Self {
ratio: state.ratio(),
label: None,
}
}
/// Append a right-aligned value label (e.g. ` 0.42`), formatted from `state`.
/// Whole numbers render without a fractional part.
pub fn label(mut self, state: &SliderState) -> Self {
self.label = Some(format_value(state.value()));
self
}
/// Append an explicit trailing label instead of the formatted value.
pub fn label_text(mut self, text: impl Into<String>) -> Self {
self.label = Some(text.into());
self
}
}
/// Format a slider value compactly: an integer when it has no fractional part,
/// otherwise two decimals.
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, _ctx: &RenderCtx) -> 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);
// Reserve a " label" suffix (with a leading space) when present.
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;
}
// Thumb sits at the cell nearest the ratio across the track.
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::tests::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);
// A reversed range is normalized.
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); // clamped
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)), InputOutcome::Changed);
assert_eq!(s.value(), 51.0);
let _ = s.handle(&key(KeyCode::Left));
let _ = s.handle(&key(KeyCode::Left));
assert_eq!(s.value(), 49.0);
let _ = s.handle(&key(KeyCode::PageUp));
assert_eq!(s.value(), 59.0); // ten steps
let _ = s.handle(&key(KeyCode::Home));
assert_eq!(s.value(), 0.0);
let _ = s.handle(&key(KeyCode::End));
assert_eq!(s.value(), 100.0);
// Unrelated keys bubble.
assert_eq!(s.handle(&key(KeyCode::Char('x'))), InputOutcome::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); // full → thumb at the end
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);
// The first cell is filled track in accent; last is the thumb in accent.
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);
}
}
}