use alloc::string::String;
use denise::{ElementState, InputEvent, KeyCode, Point, Radius, Rect, Role};
use denise_render::Canvas;
use denise_text::{TextEngine, TextStyle};
use crate::widget::{Animation, Event, EventCtx, Handled, PaintCtx, VisualState, Widget};
use crate::widgets::style::{Align, focus_ring, interactive_pair};
const BLINK_MS: u64 = 500;
#[derive(Clone, Debug)]
pub struct TextInput<M> {
text: String,
placeholder: String,
caret: usize,
first_visible: usize,
max_chars: usize,
style: TextStyle,
radius: Radius,
submit: Option<M>,
password: bool,
blink_epoch: u64,
caret_on: bool,
has_focus: bool,
}
impl<M> TextInput<M> {
pub fn new() -> Self {
Self {
text: String::new(),
placeholder: String::new(),
caret: 0,
first_visible: 0,
max_chars: 256,
style: TextStyle::built_in(16),
radius: Radius::Field,
submit: None,
password: false,
blink_epoch: 0,
caret_on: true,
has_focus: false,
}
}
pub fn with_placeholder(mut self, placeholder: impl Into<String>) -> Self {
self.placeholder = placeholder.into();
self
}
pub fn with_submit(mut self, message: M) -> Self {
self.submit = Some(message);
self
}
pub fn with_max_chars(mut self, max: usize) -> Self {
self.max_chars = max;
self
}
pub fn with_style(mut self, style: TextStyle) -> Self {
self.style = style;
self
}
pub fn with_size(mut self, size_px: u16) -> Self {
self.style.size_px = size_px;
self
}
#[inline]
pub const fn style(&self) -> TextStyle {
self.style
}
pub fn with_password(mut self, password: bool) -> Self {
self.password = password;
self
}
#[inline]
pub fn text(&self) -> &str {
&self.text
}
pub fn set_text(&mut self, text: impl Into<String>) {
self.text = text.into();
self.caret = self.len_chars();
self.first_visible = 0;
}
pub fn set_style(&mut self, style: TextStyle) {
self.style = style;
}
pub fn clear(&mut self) {
self.set_text(String::new());
}
#[inline]
pub const fn caret(&self) -> usize {
self.caret
}
#[inline]
fn len_chars(&self) -> usize {
self.text.chars().count()
}
fn byte_of(&self, index: usize) -> usize {
self.text
.char_indices()
.nth(index)
.map_or(self.text.len(), |(offset, _)| offset)
}
#[inline]
const fn pad(&self) -> i32 {
self.style.size_px as i32 / 3
}
fn inner(&self, bounds: Rect) -> Rect {
Rect::from_edges(
bounds.x + self.pad(),
bounds.y,
bounds.right() - self.pad(),
bounds.bottom(),
)
}
fn run_width(&self, engine: &mut TextEngine, from: usize, to: usize) -> i32 {
if from >= to {
return 0;
}
if self.password {
return engine.measure_line(self.style, "*") * (to - from) as i32;
}
let (start, end) = (self.byte_of(from), self.byte_of(to));
engine.measure_line(self.style, &self.text[start..end])
}
fn window_start(&self, engine: &mut TextEngine, bounds: Rect) -> usize {
let available = self.inner(bounds).width;
let mut first = self.first_visible.min(self.caret);
while first < self.caret && self.run_width(engine, first, self.caret) > available {
first += 1;
}
first
}
pub fn caret_x(&self, engine: &mut TextEngine, bounds: Rect) -> i32 {
let first = self.window_start(engine, bounds);
self.pad() + self.run_width(engine, first, self.caret)
}
fn scroll_to_caret(&mut self, engine: &mut TextEngine, bounds: Rect) {
self.first_visible = self.window_start(engine, bounds);
}
fn wake_caret(&mut self, now_ms: u64) {
self.blink_epoch = now_ms;
self.caret_on = true;
}
fn insert(&mut self, ch: char) -> bool {
if self.len_chars() >= self.max_chars {
return false;
}
let at = self.byte_of(self.caret);
self.text.insert(at, ch);
self.caret += 1;
true
}
fn delete_before(&mut self) -> bool {
if self.caret == 0 {
return false;
}
let at = self.byte_of(self.caret - 1);
self.text.remove(at);
self.caret -= 1;
true
}
fn delete_after(&mut self) -> bool {
if self.caret >= self.len_chars() {
return false;
}
let at = self.byte_of(self.caret);
self.text.remove(at);
true
}
}
impl<M> Default for TextInput<M> {
fn default() -> Self {
Self::new()
}
}
impl<M: Clone + 'static> Widget<M> for TextInput<M> {
fn paint(&self, ctx: &mut PaintCtx<'_>, canvas: &mut Canvas<'_>) {
let radius = ctx.theme.radius(self.radius);
let disabled = ctx.state.contains(VisualState::DISABLED);
let focused = ctx.state.contains(VisualState::FOCUSED);
let (background, _) = interactive_pair(ctx.theme, Role::Base100, ctx.state);
canvas.fill_rounded_rect(ctx.bounds, radius, background);
canvas.stroke_rounded_rect(ctx.bounds, radius, 1, ctx.theme.color(Role::Base300));
if focused {
focus_ring(ctx.theme, ctx.bounds, radius, canvas);
}
let inner = self.inner(ctx.bounds);
let line_height = ctx.text.line_height(self.style);
let top = inner.y + Align::Center.offset(inner.height, line_height);
let mut clipped = canvas.with_clip(inner);
if self.text.is_empty() {
if !self.placeholder.is_empty() {
let hint = ctx
.theme
.color(Role::Base300)
.mix(ctx.theme.color(Role::BaseContent), 128);
ctx.text.draw(
&mut clipped,
self.style,
Point::new(inner.x, top),
&self.placeholder,
hint,
);
}
} else {
let content = if disabled {
ctx.theme.color(Role::Base300)
} else {
ctx.theme.color(Role::BaseContent)
};
let first = self.window_start(ctx.text, ctx.bounds);
if self.password {
let advance = ctx.text.measure_line(self.style, "*");
let count = self.len_chars().saturating_sub(first);
for i in 0..count {
let x = inner.x + advance * i as i32;
if x > inner.right() {
break;
}
ctx.text
.draw(&mut clipped, self.style, Point::new(x, top), "*", content);
}
} else {
let start = self.byte_of(first);
ctx.text.draw(
&mut clipped,
self.style,
Point::new(inner.x, top),
&self.text[start..],
content,
);
}
}
if focused && self.caret_on && !disabled {
let first = self.window_start(ctx.text, ctx.bounds);
let x = inner.x + self.run_width(ctx.text, first, self.caret);
let width = (i32::from(self.style.size_px) / 10).max(1);
clipped.fill_rect(
Rect::new(x, top, width, line_height),
ctx.theme.color(Role::Accent),
);
}
}
fn on_event(&mut self, event: &Event<'_>, ctx: &mut EventCtx<'_, M>) -> Handled {
match event {
Event::FocusGained => {
self.has_focus = true;
self.wake_caret(ctx.now_ms);
ctx.request_animation();
Handled::No
}
Event::FocusLost => {
self.has_focus = false;
self.wake_caret(ctx.now_ms);
Handled::No
}
Event::Input(InputEvent::Text { ch }) if !ch.is_control() => {
if self.insert(*ch) {
self.wake_caret(ctx.now_ms);
let bounds = ctx.bounds;
self.scroll_to_caret(ctx.text, bounds);
Handled::Yes
} else {
Handled::No
}
}
Event::Input(InputEvent::Key {
code,
state: ElementState::Down,
..
}) => {
let changed = match code {
KeyCode::Backspace => self.delete_before(),
KeyCode::Delete => self.delete_after(),
KeyCode::ArrowLeft => {
let moved = self.caret > 0;
self.caret = self.caret.saturating_sub(1);
moved
}
KeyCode::ArrowRight => {
let moved = self.caret < self.len_chars();
self.caret = (self.caret + 1).min(self.len_chars());
moved
}
KeyCode::Home => {
let moved = self.caret != 0;
self.caret = 0;
moved
}
KeyCode::End => {
let moved = self.caret != self.len_chars();
self.caret = self.len_chars();
moved
}
KeyCode::Enter | KeyCode::NumpadEnter => {
if let Some(message) = self.submit.clone() {
ctx.emit(message);
}
return Handled::Yes;
}
_ => return Handled::No,
};
self.wake_caret(ctx.now_ms);
let bounds = ctx.bounds;
self.scroll_to_caret(ctx.text, bounds);
let _ = changed;
Handled::Yes
}
_ => Handled::No,
}
}
fn accepts_pointer(&self) -> bool {
true
}
fn focusable(&self) -> bool {
true
}
fn animate(&mut self, now_ms: u64) -> Animation {
if !self.has_focus {
return Animation::NONE;
}
let elapsed = now_ms.saturating_sub(self.blink_epoch);
let on = (elapsed / BLINK_MS).is_multiple_of(2);
let repaint = on != self.caret_on;
self.caret_on = on;
Animation {
repaint,
next_ms: Some(self.blink_epoch + (elapsed / BLINK_MS + 1) * BLINK_MS),
}
}
}