mod input;
mod render;
use ratatui::layout::Rect;
use ratatui::style::Color;
pub use input::NumberInputEvent;
pub use render::{render_number_input, render_number_input_aligned};
use super::FocusState;
use crate::view::ui::text_edit::TextEdit;
#[derive(Debug, Clone)]
pub struct NumberInputState {
pub value: i64,
pub min: Option<i64>,
pub max: Option<i64>,
pub step: i64,
pub label: String,
pub focus: FocusState,
pub editor: Option<TextEdit>,
pub is_percentage: bool,
}
impl NumberInputState {
pub fn new(value: i64, label: impl Into<String>) -> Self {
Self {
value,
min: None,
max: None,
step: 1,
label: label.into(),
focus: FocusState::Normal,
editor: None,
is_percentage: false,
}
}
pub fn editing(&self) -> bool {
self.editor.is_some()
}
pub fn with_min(mut self, min: i64) -> Self {
self.min = Some(min);
self
}
pub fn with_max(mut self, max: i64) -> Self {
self.max = Some(max);
self
}
pub fn with_step(mut self, step: i64) -> Self {
self.step = step;
self
}
pub fn with_focus(mut self, focus: FocusState) -> Self {
self.focus = focus;
self
}
pub fn with_percentage(mut self) -> Self {
self.is_percentage = true;
self
}
pub fn is_enabled(&self) -> bool {
self.focus != FocusState::Disabled
}
pub fn increment(&mut self) {
if !self.is_enabled() {
return;
}
let new_value = self.value.saturating_add(self.step);
self.value = match self.max {
Some(max) => new_value.min(max),
None => new_value,
};
}
pub fn decrement(&mut self) {
if !self.is_enabled() {
return;
}
let new_value = self.value.saturating_sub(self.step);
self.value = match self.min {
Some(min) => new_value.max(min),
None => new_value,
};
}
pub fn set_value(&mut self, value: i64) {
if !self.is_enabled() {
return;
}
let mut v = value;
if let Some(min) = self.min {
v = v.max(min);
}
if let Some(max) = self.max {
v = v.min(max);
}
self.value = v;
}
pub fn start_editing(&mut self) {
if !self.is_enabled() {
return;
}
let mut editor = TextEdit::single_line();
editor.set_value(&self.value.to_string());
editor.select_all();
self.editor = Some(editor);
}
pub fn cancel_editing(&mut self) {
self.editor = None;
}
pub fn confirm_editing(&mut self) {
if let Some(editor) = self.editor.take() {
if let Ok(new_value) = editor.value().parse::<i64>() {
self.set_value(new_value);
}
}
}
pub fn insert_char(&mut self, c: char) {
if let Some(editor) = &mut self.editor {
if c.is_ascii_digit() || c == '-' || c == '.' {
editor.insert_char(c);
}
}
}
pub fn backspace(&mut self) {
if let Some(editor) = &mut self.editor {
editor.backspace();
}
}
pub fn delete(&mut self) {
if let Some(editor) = &mut self.editor {
editor.delete();
}
}
pub fn move_left(&mut self) {
if let Some(editor) = &mut self.editor {
editor.move_left();
}
}
pub fn move_right(&mut self) {
if let Some(editor) = &mut self.editor {
editor.move_right();
}
}
pub fn move_home(&mut self) {
if let Some(editor) = &mut self.editor {
editor.move_home();
}
}
pub fn move_end(&mut self) {
if let Some(editor) = &mut self.editor {
editor.move_end();
}
}
pub fn move_word_left(&mut self) {
if let Some(editor) = &mut self.editor {
editor.move_word_left();
}
}
pub fn move_word_right(&mut self) {
if let Some(editor) = &mut self.editor {
editor.move_word_right();
}
}
pub fn move_left_selecting(&mut self) {
if let Some(editor) = &mut self.editor {
editor.move_left_selecting();
}
}
pub fn move_right_selecting(&mut self) {
if let Some(editor) = &mut self.editor {
editor.move_right_selecting();
}
}
pub fn move_home_selecting(&mut self) {
if let Some(editor) = &mut self.editor {
editor.move_home_selecting();
}
}
pub fn move_end_selecting(&mut self) {
if let Some(editor) = &mut self.editor {
editor.move_end_selecting();
}
}
pub fn move_word_left_selecting(&mut self) {
if let Some(editor) = &mut self.editor {
editor.move_word_left_selecting();
}
}
pub fn move_word_right_selecting(&mut self) {
if let Some(editor) = &mut self.editor {
editor.move_word_right_selecting();
}
}
pub fn select_all(&mut self) {
if let Some(editor) = &mut self.editor {
editor.select_all();
}
}
pub fn delete_word_forward(&mut self) {
if let Some(editor) = &mut self.editor {
editor.delete_word_forward();
}
}
pub fn delete_word_backward(&mut self) {
if let Some(editor) = &mut self.editor {
editor.delete_word_backward();
}
}
pub fn selected_text(&self) -> Option<String> {
self.editor.as_ref().and_then(|e| e.selected_text())
}
pub fn delete_selection(&mut self) -> Option<String> {
self.editor.as_mut().and_then(|e| e.delete_selection())
}
pub fn insert_str(&mut self, text: &str) {
if let Some(editor) = &mut self.editor {
let filtered: String = text
.chars()
.filter(|c| c.is_ascii_digit() || *c == '-' || *c == '.')
.collect();
editor.insert_str(&filtered);
}
}
pub fn display_text(&self) -> String {
if let Some(editor) = &self.editor {
editor.value()
} else {
self.value.to_string()
}
}
pub fn cursor_col(&self) -> usize {
self.editor.as_ref().map(|e| e.cursor_col).unwrap_or(0)
}
pub fn has_selection(&self) -> bool {
self.editor
.as_ref()
.map(|e| e.has_selection())
.unwrap_or(false)
}
pub fn selection_range(&self) -> Option<(usize, usize)> {
self.editor.as_ref().and_then(|e| {
e.selection_range()
.map(|((_, start_col), (_, end_col))| (start_col, end_col))
})
}
}
#[derive(Debug, Clone, Copy)]
pub struct NumberInputColors {
pub label: Color,
pub value: Color,
pub border: Color,
pub button: Color,
pub focused: Color,
pub focused_fg: Color,
pub disabled: Color,
}
impl Default for NumberInputColors {
fn default() -> Self {
Self {
label: Color::White,
value: Color::Yellow,
border: Color::Gray,
button: Color::Cyan,
focused: Color::Cyan,
focused_fg: Color::Black,
disabled: Color::DarkGray,
}
}
}
impl NumberInputColors {
pub fn from_theme(theme: &crate::view::theme::Theme) -> Self {
Self {
label: theme.editor_fg,
value: theme.help_key_fg,
border: theme.line_number_fg,
button: theme.menu_active_fg,
focused: theme.settings_selected_bg,
focused_fg: theme.settings_selected_fg,
disabled: theme.line_number_fg,
}
}
}
#[derive(Debug, Clone, Copy, Default)]
pub struct NumberInputLayout {
pub value_area: Rect,
pub decrement_area: Rect,
pub increment_area: Rect,
pub full_area: Rect,
}
impl NumberInputLayout {
pub fn is_decrement(&self, x: u16, y: u16) -> bool {
x >= self.decrement_area.x
&& x < self.decrement_area.x + self.decrement_area.width
&& y >= self.decrement_area.y
&& y < self.decrement_area.y + self.decrement_area.height
}
pub fn is_increment(&self, x: u16, y: u16) -> bool {
x >= self.increment_area.x
&& x < self.increment_area.x + self.increment_area.width
&& y >= self.increment_area.y
&& y < self.increment_area.y + self.increment_area.height
}
pub fn is_value(&self, x: u16, y: u16) -> bool {
x >= self.value_area.x
&& x < self.value_area.x + self.value_area.width
&& y >= self.value_area.y
&& y < self.value_area.y + self.value_area.height
}
pub fn contains(&self, x: u16, y: u16) -> bool {
x >= self.full_area.x
&& x < self.full_area.x + self.full_area.width
&& y >= self.full_area.y
&& y < self.full_area.y + self.full_area.height
}
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::backend::TestBackend;
use ratatui::Terminal;
fn test_frame<F>(width: u16, height: u16, f: F)
where
F: FnOnce(&mut ratatui::Frame, Rect),
{
let backend = TestBackend::new(width, height);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|frame| {
let area = Rect::new(0, 0, width, height);
f(frame, area);
})
.unwrap();
}
#[test]
fn test_number_input_renders() {
test_frame(40, 1, |frame, area| {
let state = NumberInputState::new(42, "Count");
let colors = NumberInputColors::default();
let layout = render_number_input(frame, area, &state, &colors);
assert!(layout.value_area.width > 0);
assert!(layout.decrement_area.width > 0);
assert!(layout.increment_area.width > 0);
});
}
#[test]
fn test_number_input_increment() {
let mut state = NumberInputState::new(5, "Value");
state.increment();
assert_eq!(state.value, 6);
}
#[test]
fn test_number_input_decrement() {
let mut state = NumberInputState::new(5, "Value");
state.decrement();
assert_eq!(state.value, 4);
}
#[test]
fn test_number_input_min_max() {
let mut state = NumberInputState::new(5, "Value").with_min(0).with_max(10);
state.set_value(-5);
assert_eq!(state.value, 0);
state.set_value(20);
assert_eq!(state.value, 10);
}
#[test]
fn test_number_input_step() {
let mut state = NumberInputState::new(0, "Value").with_step(5);
state.increment();
assert_eq!(state.value, 5);
state.increment();
assert_eq!(state.value, 10);
}
#[test]
fn test_number_input_disabled() {
let mut state = NumberInputState::new(5, "Value").with_focus(FocusState::Disabled);
state.increment();
assert_eq!(state.value, 5);
}
#[test]
fn test_number_input_hit_detection() {
test_frame(40, 1, |frame, area| {
let state = NumberInputState::new(42, "Count");
let colors = NumberInputColors::default();
let layout = render_number_input(frame, area, &state, &colors);
let dec_x = layout.decrement_area.x;
assert!(layout.is_decrement(dec_x, 0));
assert!(!layout.is_increment(dec_x, 0));
let inc_x = layout.increment_area.x;
assert!(layout.is_increment(inc_x, 0));
assert!(!layout.is_decrement(inc_x, 0));
});
}
#[test]
fn test_number_input_start_editing() {
let mut state = NumberInputState::new(42, "Value");
assert!(!state.editing());
assert_eq!(state.display_text(), "42");
state.start_editing();
assert!(state.editing());
assert_eq!(state.display_text(), "42");
}
#[test]
fn test_number_input_cancel_editing() {
let mut state = NumberInputState::new(42, "Value");
state.start_editing();
state.insert_char('1');
state.insert_char('0');
state.insert_char('0');
assert_eq!(state.display_text(), "100");
state.cancel_editing();
assert!(!state.editing());
assert_eq!(state.display_text(), "42");
assert_eq!(state.value, 42);
}
#[test]
fn test_number_input_confirm_editing() {
let mut state = NumberInputState::new(42, "Value");
state.start_editing();
state.select_all();
state.insert_str("100");
state.confirm_editing();
assert!(!state.editing());
assert_eq!(state.value, 100);
}
#[test]
fn test_number_input_confirm_invalid_resets() {
let mut state = NumberInputState::new(42, "Value");
state.start_editing();
state.select_all();
state.insert_str("abc");
state.confirm_editing();
assert!(!state.editing());
assert_eq!(state.value, 42);
}
#[test]
fn test_number_input_insert_char() {
let mut state = NumberInputState::new(0, "Value");
state.start_editing();
state.select_all();
state.insert_char('1');
state.insert_char('2');
state.insert_char('3');
assert_eq!(state.display_text(), "123");
let mut state2 = NumberInputState::new(0, "Value");
state2.start_editing();
state2.select_all();
state2.insert_char('-');
assert_eq!(state2.display_text(), "-");
state2.insert_char('-'); state2.insert_char('5');
assert_eq!(state2.display_text(), "--5");
}
#[test]
fn test_number_input_backspace() {
let mut state = NumberInputState::new(123, "Value");
state.start_editing();
assert_eq!(state.display_text(), "123");
state.move_end();
state.backspace();
assert_eq!(state.display_text(), "12");
state.backspace();
assert_eq!(state.display_text(), "1");
state.backspace();
assert_eq!(state.display_text(), "");
state.backspace();
assert_eq!(state.display_text(), "");
}
#[test]
fn test_number_input_display_text() {
let mut state = NumberInputState::new(42, "Value");
assert_eq!(state.display_text(), "42");
state.start_editing();
assert_eq!(state.display_text(), "42");
state.move_end();
state.insert_char('0');
assert_eq!(state.display_text(), "420");
}
#[test]
fn test_number_input_editing_respects_minmax() {
let mut state = NumberInputState::new(50, "Value").with_min(0).with_max(100);
state.start_editing();
state.select_all();
state.insert_str("200");
state.confirm_editing();
assert_eq!(state.value, 100);
}
#[test]
fn test_number_input_disabled_no_editing() {
let mut state = NumberInputState::new(42, "Value").with_focus(FocusState::Disabled);
state.start_editing();
assert!(!state.editing());
}
#[test]
fn test_number_input_decimal_point() {
let mut state = NumberInputState::new(0, "Value");
state.start_editing();
state.select_all();
state.insert_str("0.25");
assert_eq!(state.display_text(), "0.25");
state.confirm_editing();
assert_eq!(state.value, 0);
}
#[test]
fn test_number_input_selection() {
let mut state = NumberInputState::new(12345, "Value");
state.start_editing();
assert_eq!(state.display_text(), "12345");
state.select_all();
assert!(state.has_selection());
state.insert_char('9');
assert_eq!(state.display_text(), "9");
}
#[test]
fn test_number_input_cursor_navigation() {
let mut state = NumberInputState::new(123, "Value");
state.start_editing();
assert_eq!(state.cursor_col(), 3);
state.move_left();
assert_eq!(state.cursor_col(), 2);
state.move_home();
assert_eq!(state.cursor_col(), 0);
state.move_end();
assert_eq!(state.cursor_col(), 3);
}
}