use crate::draw::command::DrawCommand;
use crate::draw::renderer::Renderer;
use crate::ecs::{Entity, World};
use crate::event::GestureHandler;
use crate::event::focus::{Focusable, KeyHandler};
use crate::event::widget_input::{
CursorBlinkPhase, textinput_gesture_handler, textinput_key_handler,
};
use crate::types::{Color, Fixed, Point, Rect};
use crate::widget::view::{View, ViewCtx};
pub const TEXT_INPUT_CAP: usize = 32;
pub struct TextInput {
pub buffer: [u8; TEXT_INPUT_CAP],
pub len: u8,
pub cursor: u8,
pub focused: bool,
pub text_color: Color,
pub placeholder_color: Color,
pub cursor_color: Color,
pub focus_border_color: Color,
}
impl TextInput {
pub fn new() -> Self {
Self {
buffer: [0u8; TEXT_INPUT_CAP],
len: 0,
cursor: 0,
focused: false,
text_color: Color::rgb(220, 220, 230),
placeholder_color: Color::rgb(120, 120, 140),
cursor_color: Color::rgb(220, 220, 230),
focus_border_color: Color::rgb(88, 166, 255),
}
}
pub fn as_str(&self) -> &str {
core::str::from_utf8(&self.buffer[..self.len as usize]).unwrap_or("")
}
pub fn insert(&mut self, ch: u8) -> bool {
if !(32..=126).contains(&ch) {
return false;
}
if self.len as usize >= TEXT_INPUT_CAP {
return false;
}
let pos = self.cursor as usize;
let end = self.len as usize;
if pos > end {
return false;
}
let mut i = end;
while i > pos {
self.buffer[i] = self.buffer[i - 1];
i -= 1;
}
self.buffer[pos] = ch;
self.len += 1;
self.cursor += 1;
true
}
pub fn backspace(&mut self) -> bool {
if self.cursor == 0 {
return false;
}
let pos = self.cursor as usize - 1;
let end = self.len as usize;
let mut i = pos;
while i + 1 < end {
self.buffer[i] = self.buffer[i + 1];
i += 1;
}
self.len -= 1;
self.cursor -= 1;
true
}
pub fn delete_forward(&mut self) -> bool {
if (self.cursor as usize) >= (self.len as usize) {
return false;
}
let pos = self.cursor as usize;
let end = self.len as usize;
let mut i = pos;
while i + 1 < end {
self.buffer[i] = self.buffer[i + 1];
i += 1;
}
self.len -= 1;
true
}
pub fn move_left(&mut self) {
if self.cursor > 0 {
self.cursor -= 1;
}
}
pub fn move_right(&mut self) {
if (self.cursor as usize) < (self.len as usize) {
self.cursor += 1;
}
}
pub fn move_home(&mut self) {
self.cursor = 0;
}
pub fn move_end(&mut self) {
self.cursor = self.len;
}
}
impl Default for TextInput {
fn default() -> Self {
Self::new()
}
}
pub struct Placeholder(pub &'static str);
fn text_input_render(
renderer: &mut dyn Renderer,
world: &World,
entity: Entity,
rect: &Rect,
ctx: &mut ViewCtx,
) {
let Some(ti) = world.get::<TextInput>(entity) else {
return;
};
if ti.focused {
renderer.draw(
&DrawCommand::Border {
area: *rect,
transform: ctx.transform,
quad: ctx.quad,
color: ti.focus_border_color,
width: Fixed::ONE,
radius: Fixed::ZERO,
opa: 255,
},
ctx.clip,
);
}
let text_x = rect.x + Fixed::from_int(2);
let text_y = rect.y + Fixed::from_int(2);
if ti.len == 0 {
if let Some(ph) = world.get::<Placeholder>(entity) {
renderer.draw(
&DrawCommand::Label {
pos: Point {
x: text_x,
y: text_y,
},
transform: ctx.transform,
text: ph.0.as_bytes(),
color: ti.placeholder_color,
opa: 255,
},
ctx.clip,
);
}
} else {
renderer.draw(
&DrawCommand::Label {
pos: Point {
x: text_x,
y: text_y,
},
transform: ctx.transform,
text: &ti.buffer[..ti.len as usize],
color: ti.text_color,
opa: 255,
},
ctx.clip,
);
}
if ti.focused {
let blink_on = world
.resource::<CursorBlinkPhase>()
.map(|p| p.0)
.unwrap_or(true);
if blink_on {
let cursor_x = text_x + Fixed::from_int(ti.cursor as i32 * 8);
renderer.draw(
&DrawCommand::Fill {
area: Rect {
x: cursor_x,
y: text_y,
w: Fixed::ONE,
h: Fixed::from_int(8),
},
transform: ctx.transform,
quad: ctx.quad,
color: ti.cursor_color,
radius: Fixed::ZERO,
opa: 255,
},
ctx.clip,
);
}
}
}
fn text_input_attach(world: &mut World, entity: Entity) {
if world.get::<TextInput>(entity).is_none() {
return;
}
if world.get::<GestureHandler>(entity).is_some() {
return;
}
world.insert(
entity,
GestureHandler {
on_gesture: textinput_gesture_handler,
},
);
world.insert(entity, Focusable);
world.insert(
entity,
KeyHandler {
on_key: textinput_key_handler,
},
);
}
pub fn view() -> View {
View {
name: "TextInput",
priority: 70,
render: text_input_render,
auto_attach: Some(text_input_attach),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn insert_then_backspace() {
let mut ti = TextInput::new();
for ch in b"hello".iter() {
assert!(ti.insert(*ch));
}
assert_eq!(ti.as_str(), "hello");
assert_eq!(ti.cursor, 5);
assert!(ti.backspace());
assert!(ti.backspace());
assert_eq!(ti.as_str(), "hel");
assert_eq!(ti.cursor, 3);
}
#[test]
fn arrow_keys_navigate() {
let mut ti = TextInput::new();
for ch in b"hello".iter() {
ti.insert(*ch);
}
ti.move_left();
ti.move_left();
assert_eq!(ti.cursor, 3);
ti.insert(b'X');
assert_eq!(ti.as_str(), "helXlo");
assert_eq!(ti.cursor, 4);
}
#[test]
fn home_end() {
let mut ti = TextInput::new();
for ch in b"hi".iter() {
ti.insert(*ch);
}
ti.move_home();
assert_eq!(ti.cursor, 0);
ti.move_end();
assert_eq!(ti.cursor, 2);
}
#[test]
fn delete_forward_removes_at_cursor() {
let mut ti = TextInput::new();
for ch in b"abc".iter() {
ti.insert(*ch);
}
ti.move_home();
assert!(ti.delete_forward());
assert_eq!(ti.as_str(), "bc");
assert_eq!(ti.cursor, 0);
}
#[test]
fn rejects_non_printable_ascii() {
let mut ti = TextInput::new();
assert!(!ti.insert(0x07)); assert!(!ti.insert(0xFF));
assert_eq!(ti.len, 0);
}
#[test]
fn full_buffer_rejects() {
let mut ti = TextInput::new();
for _ in 0..TEXT_INPUT_CAP {
assert!(ti.insert(b'a'));
}
assert!(!ti.insert(b'b'));
assert_eq!(ti.len as usize, TEXT_INPUT_CAP);
}
}