use crate::{
AnyElement, ComponentTheme, EventResult, Handler, Hooks, Palette, PointerKind, PointerOptions,
UsePointer, UsePreviousSize, UseTheme,
components::theme::resolve_style,
hooks::use_pointer::pointer_enabled,
prelude::{Fragment, Positioned, Text},
};
use ratatui::{style::Style, widgets::Block};
use ratatui_kit_macros::{Props, component, element};
use unicode_width::UnicodeWidthChar;
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct InputTheme {
pub cursor_style: Style,
pub placeholder_style: Style,
pub style: Style,
}
impl ComponentTheme for InputTheme {
fn from_palette(palette: &Palette) -> Self {
Self {
cursor_style: Style::new().bg(palette.accent).fg(palette.on_accent),
placeholder_style: Style::new().fg(palette.placeholder),
style: Style::new().fg(palette.fg),
}
}
}
impl Default for InputTheme {
fn default() -> Self {
Self::from_palette(&Palette::default())
}
}
#[derive(Props, Default)]
pub struct InputProps {
pub input: tui_input::Input,
pub cursor_style: Option<Style>,
pub placeholder: String,
pub placeholder_style: Option<Style>,
pub style: Option<Style>,
pub hide_cursor: bool,
pub on_seek: Handler<'static, usize>,
}
pub(crate) fn cursor_char_index(value: &str, target_cells: usize) -> usize {
let mut acc = 0usize;
let mut index = 0usize;
for ch in value.chars() {
let width = ch.width().unwrap_or(0);
if target_cells < acc + width {
return index;
}
acc += width;
index += 1;
}
index
}
#[component]
pub fn Input(props: &mut InputProps, mut hooks: Hooks) -> impl Into<AnyElement<'static>> {
let theme = hooks.use_component_theme::<InputTheme>();
let cursor_style = resolve_style(theme.cursor_style, props.cursor_style);
let placeholder_style = resolve_style(theme.placeholder_style, props.placeholder_style);
let style = resolve_style(theme.style, props.style);
let size = hooks.use_previous_size();
let input = &props.input;
let input_width = size.width.saturating_sub(1) as usize;
let scroll = if props.hide_cursor || input_width == 0 {
0
} else {
input.visual_scroll(input_width)
};
let text = if input.value().is_empty() {
props.placeholder.clone()
} else {
input.value().to_string()
};
let x = input.visual_cursor().max(scroll) - scroll;
let position = (size.x + x as u16, size.y);
if pointer_enabled(&hooks) {
let seek_enabled = !props.on_seek.is_default();
let mut on_seek = props.on_seek.take();
let input = props.input.clone();
hooks.use_pointer(PointerOptions::default(), move |ev| {
if seek_enabled && let PointerKind::Click { .. } = ev.kind {
let input_width = size.width.saturating_sub(1) as usize;
let scroll = input.visual_scroll(input_width);
let cell = ev.position.x.saturating_sub(size.x) as usize;
on_seek(cursor_char_index(input.value(), scroll + cell));
EventResult::Consumed
} else {
EventResult::Ignored
}
});
}
element!(Fragment {
Text(
text:text,
style: if input.value().is_empty() {
placeholder_style
}else{
style
},
scroll:(0, scroll as u16),
)
if !props.hide_cursor {
Positioned(
x: position.0.min(size.x + size.width.saturating_sub(1)),
y: position.1.min(size.y + size.height),
width: 1u16,
height: 1u16,
){
widget(Block::new().style(cursor_style))
}
}
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cursor_index_ascii() {
assert_eq!(cursor_char_index("abcd", 0), 0);
assert_eq!(cursor_char_index("abcd", 2), 2);
assert_eq!(cursor_char_index("abcd", 99), 4);
assert_eq!(cursor_char_index("", 3), 0);
}
#[derive(Default, Props)]
struct DynamicSeekProps {
log: std::sync::Arc<std::sync::Mutex<Vec<usize>>>,
}
#[component]
fn DynamicSeek(props: &DynamicSeekProps, mut hooks: Hooks) -> impl Into<AnyElement<'static>> {
use crate::{EventPriority, EventScope, UseEventHandler, UseState};
use crossterm::event::{Event, KeyCode};
let mut enabled = hooks.use_state(|| false);
hooks.use_event_handler(EventScope::Current, EventPriority::Normal, move |event| {
if matches!(event, Event::Key(key) if key.code == KeyCode::Char('s')) {
enabled.set(!enabled.get());
EventResult::Consumed
} else {
EventResult::Ignored
}
});
let log = props.log.clone();
let on_seek: Handler<'static, usize> = if enabled.get() {
Handler::from(move |index| log.lock().unwrap().push(index))
} else {
Handler::default()
};
element!(Input(input: tui_input::Input::new("abc".into()), on_seek: on_seek))
}
#[test]
fn click_seeking_can_be_enabled_and_disabled_after_mount() {
use crossterm::event::{KeyCode, MouseButton, MouseEventKind};
let log = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
let mut mounted =
crate::mouse_test::Mounted::mount(element!(DynamicSeek(log: log.clone())), true);
mounted.dispatch_key(KeyCode::Char('s'));
mounted.dispatch(MouseEventKind::Down(MouseButton::Left), 1, 0);
mounted.dispatch(MouseEventKind::Up(MouseButton::Left), 1, 0);
assert_eq!(log.lock().unwrap().as_slice(), &[1]);
mounted.dispatch_key(KeyCode::Char('s'));
mounted.dispatch(MouseEventKind::Down(MouseButton::Left), 2, 0);
mounted.dispatch(MouseEventKind::Up(MouseButton::Left), 2, 0);
assert_eq!(log.lock().unwrap().as_slice(), &[1]);
}
#[test]
fn cursor_index_wide_chars() {
assert_eq!(cursor_char_index("中文", 0), 0);
assert_eq!(cursor_char_index("中文", 1), 0);
assert_eq!(cursor_char_index("中文", 2), 1);
assert_eq!(cursor_char_index("a中b", 3), 2);
}
}