use std::cell::RefCell;
use std::rc::Rc;
use crate::layout::{Dimension, Style as LayoutStyle};
use crate::reactive::{Scope, Signal};
use crate::render::Style;
use crate::theme::TokenSet;
use crate::ui::{dyn_view, Element, EventCtx, Key, Mods, Phase, UiEvent};
pub(crate) type BoxedTextFn = Box<dyn FnMut(&str)>;
pub(crate) type TextCallback = Rc<RefCell<Option<BoxedTextFn>>>;
pub struct TextInput {
value: Option<Signal<String>>,
placeholder: String,
masked: bool,
layout: Option<LayoutStyle>,
on_change: Option<BoxedTextFn>,
on_submit: Option<BoxedTextFn>,
}
#[derive(Copy, Clone)]
struct Caret {
cursor: usize,
anchor: Option<usize>,
scroll: i32,
}
pub(crate) struct ClusterMap {
bounds: Vec<usize>,
widths: Vec<i32>,
}
impl ClusterMap {
pub(crate) fn of(text: &str) -> ClusterMap {
let mut bounds = Vec::new();
let mut widths = Vec::new();
for seg in crate::text::segments(text) {
bounds.push(seg.offset);
widths.push(seg.width);
}
bounds.push(text.len());
ClusterMap { bounds, widths }
}
pub(crate) fn len(&self) -> usize {
self.widths.len()
}
pub(crate) fn byte(&self, idx: usize) -> usize {
self.bounds[idx.min(self.len())]
}
pub(crate) fn col(&self, idx: usize) -> i32 {
self.widths[..idx.min(self.len())].iter().sum()
}
pub(crate) fn cluster_after(&self, byte_end: usize) -> usize {
self.bounds.partition_point(|&b| b < byte_end)
}
}
impl TextInput {
pub fn new() -> TextInput {
TextInput {
value: None,
placeholder: String::new(),
masked: false,
layout: None,
on_change: None,
on_submit: None,
}
}
pub fn value(mut self, value: Signal<String>) -> TextInput {
self.value = Some(value);
self
}
pub fn placeholder(mut self, text: impl Into<String>) -> TextInput {
self.placeholder = text.into();
self
}
pub fn masked(mut self, masked: bool) -> TextInput {
self.masked = masked;
self
}
pub fn layout(mut self, layout: LayoutStyle) -> TextInput {
self.layout = Some(layout);
self
}
pub fn on_change(mut self, f: impl FnMut(&str) + 'static) -> TextInput {
self.on_change = Some(Box::new(f));
self
}
pub fn on_submit(mut self, f: impl FnMut(&str) + 'static) -> TextInput {
self.on_submit = Some(Box::new(f));
self
}
pub fn view(self, cx: Scope) -> crate::ui::View {
let t = crate::widgets::theme_tokens(cx);
self.element(cx, &t).build()
}
pub fn element(self, cx: Scope, t: &TokenSet) -> Element {
let text_fg = t.text;
let ground = t.surface;
let stroke = t.border;
let stroke_focus = t.border_focus;
let placeholder_fg = t.text_faint;
let sel_bg = t.selection_bg;
let sel_fg = t.selection_fg;
let cursor_bg = t.cursor;
let value = self.value.unwrap_or_else(|| cx.signal(String::new()));
let caret = cx.signal(Caret {
cursor: 0,
anchor: None,
scroll: 0,
});
let focused = cx.signal(false);
let placeholder = self.placeholder;
let masked = self.masked;
let on_change: TextCallback = Rc::new(RefCell::new(self.on_change));
let on_submit: TextCallback = Rc::new(RefCell::new(self.on_submit));
let layout = self.layout.unwrap_or_else(|| {
LayoutStyle::default()
.height(Dimension::Cells(1))
.grow(1.0)
.shrink(0.0)
});
let handler = {
let on_change = on_change.clone();
move |ctx: &mut EventCtx, ev: &UiEvent| {
let width = (ctx.current_rect().w - 2).max(1);
match ev {
UiEvent::Key(k) => {
if edit_key(
k.key, k.mods, value, caret, width, masked, &on_change, &on_submit,
) {
ctx.stop_propagation();
}
}
UiEvent::Paste(s) => {
let clean: String = s
.chars()
.map(|c| if c == '\n' || c == '\r' { ' ' } else { c })
.collect();
insert_text(&clean, value, caret, width);
notify(&on_change, value);
ctx.stop_propagation();
}
_ => {}
}
}
};
Element::new()
.style(layout)
.role(crate::ui::Role::Input)
.access_label(placeholder.clone())
.access_value(move || {
if masked {
value.with_untracked(|v| "•".repeat(crate::text::segments(v).count()))
} else {
value.get_untracked()
}
})
.focusable()
.focus_signal(focused)
.on(Phase::Bubble, handler)
.child(dyn_view(
LayoutStyle::default()
.width(Dimension::Percent(1.0))
.height(Dimension::Cells(1)),
move || {
let text = value.get();
let caret_now = caret.get();
let focused = focused.get();
let placeholder = placeholder.clone();
Element::new()
.style(LayoutStyle::default().width(Dimension::Percent(1.0)))
.draw(move |canvas, rect| {
if rect.is_empty() || rect.w < 3 {
return;
}
let bg = ground;
canvas.fill_styled(rect, ' ', &Style::new().fg(text_fg).bg(bg));
let stroke_style = Style::new()
.fg(if focused { stroke_focus } else { stroke })
.bg(bg);
canvas.print_styled(rect.origin(), "▐", &stroke_style);
canvas.print_styled(
crate::base::Point::new(rect.right() - 1, rect.y),
"▌",
&stroke_style,
);
let tx = rect.x + 1; let tw = rect.w - 2;
if text.is_empty() && !focused {
canvas.print_styled(
crate::base::Point::new(tx, rect.y),
&placeholder,
&Style::new().fg(placeholder_fg).bg(bg),
);
return;
}
let (sel_lo, sel_hi) = selection_range(&caret_now);
let mut col = -caret_now.scroll;
let mut count = 0usize;
for (i, seg) in crate::text::segments(&text).enumerate() {
count = i + 1;
let w = seg.width;
if w > 0 && col + w > 0 && col + w <= tw {
let selected = i >= sel_lo && i < sel_hi;
let at_cursor = focused && i == caret_now.cursor;
let style = if at_cursor {
Style::new().fg(bg).bg(cursor_bg)
} else if selected {
Style::new().fg(sel_fg).bg(sel_bg)
} else {
Style::new().fg(text_fg).bg(bg)
};
if masked {
canvas.print_styled(
crate::base::Point::new(tx + col, rect.y),
"•",
&style,
);
for pad in 1..w {
canvas.print_styled(
crate::base::Point::new(tx + col + pad, rect.y),
" ",
&style,
);
}
} else {
canvas.print_styled(
crate::base::Point::new(tx + col, rect.y),
seg.cluster,
&style,
);
}
}
col += w;
}
if focused && caret_now.cursor >= count && col < tw && col >= 0 {
canvas.print_styled(
crate::base::Point::new(tx + col, rect.y),
" ",
&Style::new().fg(bg).bg(cursor_bg),
);
}
})
.build()
},
))
}
}
impl Default for TextInput {
fn default() -> Self {
TextInput::new()
}
}
pub(crate) fn notify(cb: &TextCallback, value: Signal<String>) {
let snapshot = value.get_untracked();
if let Some(f) = cb.borrow_mut().as_mut() {
f(&snapshot);
}
}
fn selection_range(c: &Caret) -> (usize, usize) {
match c.anchor {
Some(a) if a != c.cursor => (a.min(c.cursor), a.max(c.cursor)),
_ => (usize::MAX, usize::MAX), }
}
fn adjust_scroll(caret: &mut Caret, map: &ClusterMap, width: i32) {
let col = map.col(caret.cursor);
if col < caret.scroll {
caret.scroll = col;
}
if col >= caret.scroll + width {
caret.scroll = col - width + 1;
}
caret.scroll = caret.scroll.max(0);
}
fn delete_selection(text: &mut String, caret: &mut Caret) -> bool {
let (lo, hi) = selection_range(caret);
if lo == usize::MAX {
return false;
}
let map = ClusterMap::of(text);
text.replace_range(map.byte(lo)..map.byte(hi), "");
caret.cursor = lo;
caret.anchor = None;
true
}
fn insert_text(s: &str, value: Signal<String>, caret: Signal<Caret>, width: i32) {
let mut c = caret.get_untracked();
value.update(|text| {
delete_selection(text, &mut c);
let map = ClusterMap::of(text);
let insert_at = c.cursor.min(map.len());
let insert_byte = map.byte(insert_at);
text.insert_str(insert_byte, s);
let map = ClusterMap::of(text);
c.cursor = map.cluster_after(insert_byte + s.len());
adjust_scroll(&mut c, &map, width);
});
caret.set(c);
}
pub(crate) fn cluster_is_word(text: &str, at: usize) -> bool {
text[at..]
.chars()
.next()
.is_some_and(|c| c.is_alphanumeric() || c == '_')
}
pub(crate) fn word_step(text: &str, map: &ClusterMap, from: usize, dir: i32) -> usize {
let n = map.len();
let is_word = |i: usize| cluster_is_word(text, map.byte(i));
if dir > 0 {
let mut i = from;
while i < n && !is_word(i) {
i += 1;
}
while i < n && is_word(i) {
i += 1;
}
i
} else {
let mut i = from;
while i > 0 && !is_word(i - 1) {
i -= 1;
}
while i > 0 && is_word(i - 1) {
i -= 1;
}
i
}
}
#[allow(clippy::too_many_arguments)]
fn edit_key(
key: Key,
mods: Mods,
value: Signal<String>,
caret: Signal<Caret>,
width: i32,
masked: bool,
on_change: &TextCallback,
on_submit: &TextCallback,
) -> bool {
let shift = mods.contains(Mods::SHIFT);
let alt = mods.contains(Mods::ALT);
let ctrl = mods.contains(Mods::CTRL);
let mut c = caret.get_untracked();
let (map, text_snapshot) = value.with_untracked(|v| (ClusterMap::of(v), v.clone()));
let len = map.len();
c.cursor = c.cursor.min(len);
if let Some(a) = c.anchor {
c.anchor = Some(a.min(len));
}
let move_to = |c: &mut Caret, target: usize| {
if shift {
if c.anchor.is_none() {
c.anchor = Some(c.cursor);
}
} else {
c.anchor = None;
}
c.cursor = target.min(len);
};
match key {
Key::Left => {
let target = if alt && masked {
0 } else if alt {
word_step(&text_snapshot, &map, c.cursor, -1)
} else {
c.cursor.saturating_sub(1)
};
move_to(&mut c, target);
adjust_scroll(&mut c, &map, width);
caret.set(c);
return true;
}
Key::Right => {
let target = if alt && masked {
len } else if alt {
word_step(&text_snapshot, &map, c.cursor, 1)
} else {
c.cursor + 1
};
move_to(&mut c, target);
adjust_scroll(&mut c, &map, width);
caret.set(c);
return true;
}
Key::Home => {
move_to(&mut c, 0);
adjust_scroll(&mut c, &map, width);
caret.set(c);
return true;
}
Key::End => {
move_to(&mut c, len);
adjust_scroll(&mut c, &map, width);
caret.set(c);
return true;
}
_ => {}
}
match key {
Key::Char(ch) if !ctrl && !alt => {
let mut buf = [0u8; 4];
insert_text(ch.encode_utf8(&mut buf), value, caret, width);
notify(on_change, value);
true
}
Key::Backspace => {
value.update(|text| {
if !delete_selection(text, &mut c) && c.cursor > 0 {
let map = ClusterMap::of(text);
text.replace_range(map.byte(c.cursor - 1)..map.byte(c.cursor), "");
c.cursor -= 1;
}
let map = ClusterMap::of(text);
adjust_scroll(&mut c, &map, width);
});
caret.set(c);
notify(on_change, value);
true
}
Key::Delete => {
value.update(|text| {
if !delete_selection(text, &mut c) {
let map = ClusterMap::of(text);
if c.cursor < map.len() {
text.replace_range(map.byte(c.cursor)..map.byte(c.cursor + 1), "");
}
}
});
caret.set(c);
notify(on_change, value);
true
}
Key::Enter => {
notify(on_submit, value);
true
}
_ => false,
}
}
#[cfg(test)]
#[path = "input_tests.rs"]
mod tests;