use ratatui::prelude::*;
use ratatui::widgets::{Block, Borders, Paragraph};
use super::{Component, EventContext, RenderContext};
use crate::input::{Event, Key};
#[derive(Clone, Debug, PartialEq)]
pub enum NumberInputMessage {
Increment,
Decrement,
SetValue(f64),
StartEdit,
ConfirmEdit,
CancelEdit,
EditChar(char),
EditBackspace,
}
#[derive(Clone, Debug, PartialEq)]
pub enum NumberInputOutput {
ValueChanged(f64),
EditStarted,
EditConfirmed(f64),
EditCancelled,
}
#[derive(Clone, Debug, PartialEq)]
#[cfg_attr(
feature = "serialization",
derive(serde::Serialize, serde::Deserialize)
)]
pub struct NumberInputState {
value: f64,
min: Option<f64>,
max: Option<f64>,
step: f64,
precision: usize,
label: Option<String>,
placeholder: Option<String>,
editing: bool,
edit_buffer: String,
}
impl Default for NumberInputState {
fn default() -> Self {
Self {
value: 0.0,
min: None,
max: None,
step: 1.0,
precision: 0,
label: None,
placeholder: None,
editing: false,
edit_buffer: String::new(),
}
}
}
impl NumberInputState {
pub fn new(value: f64) -> Self {
Self {
value,
..Self::default()
}
}
pub fn integer(value: i64) -> Self {
Self {
value: value as f64,
step: 1.0,
precision: 0,
..Self::default()
}
}
pub fn with_min(mut self, min: f64) -> Self {
self.min = Some(min);
self.value = self.clamp(self.value);
self
}
pub fn with_max(mut self, max: f64) -> Self {
self.max = Some(max);
self.value = self.clamp(self.value);
self
}
pub fn with_range(mut self, min: f64, max: f64) -> Self {
self.min = Some(min);
self.max = Some(max);
self.value = self.clamp(self.value);
self
}
pub fn with_step(mut self, step: f64) -> Self {
self.step = step;
self
}
pub fn with_precision(mut self, precision: usize) -> Self {
self.precision = precision;
self
}
pub fn with_label(mut self, label: impl Into<String>) -> Self {
self.label = Some(label.into());
self
}
pub fn with_placeholder(mut self, placeholder: impl Into<String>) -> Self {
self.placeholder = Some(placeholder.into());
self
}
pub fn value(&self) -> f64 {
self.value
}
pub fn set_value(&mut self, value: f64) {
self.value = self.clamp(value);
}
pub fn is_editing(&self) -> bool {
self.editing
}
pub fn edit_buffer(&self) -> &str {
&self.edit_buffer
}
pub fn label(&self) -> Option<&str> {
self.label.as_deref()
}
pub fn placeholder(&self) -> Option<&str> {
self.placeholder.as_deref()
}
pub fn set_placeholder(&mut self, placeholder: impl Into<String>) {
self.placeholder = Some(placeholder.into());
}
pub fn step(&self) -> f64 {
self.step
}
pub fn precision(&self) -> usize {
self.precision
}
pub fn min(&self) -> Option<f64> {
self.min
}
pub fn max(&self) -> Option<f64> {
self.max
}
pub fn format_value(&self) -> String {
format!("{:.prec$}", self.value, prec = self.precision)
}
pub fn update(&mut self, msg: NumberInputMessage) -> Option<NumberInputOutput> {
NumberInput::update(self, msg)
}
fn clamp(&self, value: f64) -> f64 {
let mut v = value;
if let Some(min) = self.min {
if v < min {
v = min;
}
}
if let Some(max) = self.max {
if v > max {
v = max;
}
}
v
}
}
pub struct NumberInput;
impl Component for NumberInput {
type State = NumberInputState;
type Message = NumberInputMessage;
type Output = NumberInputOutput;
fn init() -> Self::State {
NumberInputState::default()
}
fn update(state: &mut Self::State, msg: Self::Message) -> Option<Self::Output> {
match msg {
NumberInputMessage::Increment => {
let old = state.value;
let new = state.clamp(state.value + state.step);
if (new - old).abs() > f64::EPSILON {
state.value = new;
Some(NumberInputOutput::ValueChanged(state.value))
} else {
None
}
}
NumberInputMessage::Decrement => {
let old = state.value;
let new = state.clamp(state.value - state.step);
if (new - old).abs() > f64::EPSILON {
state.value = new;
Some(NumberInputOutput::ValueChanged(state.value))
} else {
None
}
}
NumberInputMessage::SetValue(v) => {
let old = state.value;
let new = state.clamp(v);
if (new - old).abs() > f64::EPSILON {
state.value = new;
Some(NumberInputOutput::ValueChanged(state.value))
} else {
None
}
}
NumberInputMessage::StartEdit => {
state.editing = true;
state.edit_buffer = state.format_value();
Some(NumberInputOutput::EditStarted)
}
NumberInputMessage::ConfirmEdit => {
state.editing = false;
if let Ok(parsed) = state.edit_buffer.parse::<f64>() {
let new = state.clamp(parsed);
state.value = new;
state.edit_buffer.clear();
Some(NumberInputOutput::EditConfirmed(state.value))
} else {
state.edit_buffer.clear();
Some(NumberInputOutput::EditCancelled)
}
}
NumberInputMessage::CancelEdit => {
state.editing = false;
state.edit_buffer.clear();
Some(NumberInputOutput::EditCancelled)
}
NumberInputMessage::EditChar(c) => {
if is_valid_numeric_char(c, &state.edit_buffer) {
state.edit_buffer.push(c);
}
None
}
NumberInputMessage::EditBackspace => {
state.edit_buffer.pop();
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() {
if state.editing {
match key.code {
Key::Enter => Some(NumberInputMessage::ConfirmEdit),
Key::Esc => Some(NumberInputMessage::CancelEdit),
Key::Backspace => Some(NumberInputMessage::EditBackspace),
Key::Char(_) => key
.raw_char
.filter(|c| is_valid_numeric_char(*c, &state.edit_buffer))
.map(NumberInputMessage::EditChar),
_ => None,
}
} else {
match key.code {
Key::Up | Key::Char('k') => Some(NumberInputMessage::Increment),
Key::Down | Key::Char('j') => Some(NumberInputMessage::Decrement),
Key::Enter => Some(NumberInputMessage::StartEdit),
_ => None,
}
}
} else {
None
}
}
fn view(state: &Self::State, ctx: &mut RenderContext<'_, '_>) {
if ctx.area.width == 0 || ctx.area.height == 0 {
return;
}
let border_style = if ctx.focused {
ctx.theme.focused_border_style()
} else {
ctx.theme.border_style()
};
let block = Block::default()
.borders(Borders::ALL)
.border_style(border_style);
let content_style = if ctx.disabled {
ctx.theme.disabled_style()
} else if ctx.focused {
ctx.theme.focused_style()
} else {
ctx.theme.normal_style()
};
let display_text = if state.editing {
let cursor = "_";
if state.edit_buffer.is_empty() {
if let Some(placeholder) = &state.placeholder {
placeholder.clone()
} else {
cursor.to_string()
}
} else {
format!("{}{cursor}", state.edit_buffer)
}
} else {
state.format_value()
};
let full_text = if let Some(label) = &state.label {
format!("{label}: {display_text}")
} else {
display_text
};
let paragraph = Paragraph::new(full_text)
.style(content_style)
.block(block)
.alignment(Alignment::Right);
let value_str = state.format_value();
let annotation = crate::annotation::Annotation::new(crate::annotation::WidgetType::Custom(
"NumberInput".to_string(),
))
.with_id("number_input")
.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 is_valid_numeric_char(c: char, buffer: &str) -> bool {
match c {
'0'..='9' => true,
'.' => !buffer.contains('.'),
'-' => buffer.is_empty(),
_ => false,
}
}
#[cfg(test)]
mod tests;
#[cfg(test)]
mod view_tests;