use std::cell::{Cell, RefCell};
use std::rc::Rc;
use std::time::Duration;
use parley::Generation;
use smudge_core::clipboard;
use smudge_core::element::{Element, Value};
use smudge_core::geometry::{Color, Corners, Point, Rect, Size};
use smudge_core::input::{Handled, Key, KeyEvent, Modifiers};
use smudge_core::paint::GlyphRun;
use smudge_core::paint::PaintOp;
use smudge_core::reactive::{RwSignal, prelude::*};
use smudge_core::semantics::{Role, Semantics};
use smudge_core::theme::ColorToken;
use smudge_core::timer::{self, TimerHandle};
use smudge_core::tree::Dirty;
use smudge_core::widget::{AvailableSpace, MeasureInput, PaintCx, Widget};
use web_time::Instant;
use crate::input::Editor;
use crate::layout::TextLayout;
use crate::style::{TextStyle, current_text_style};
const BLINK_PERIOD: Duration = Duration::from_millis(530);
const DOUBLE_CLICK_TIME: Duration = Duration::from_millis(400);
const DOUBLE_CLICK_DISTANCE: f32 = 4.0;
const CARET_WIDTH: f32 = 1.0;
const SELECTION_ALPHA: u8 = 96;
#[derive(Clone, Copy, Debug, PartialEq)]
struct CaretPaint {
rect: Rect,
color: Color,
}
struct InputChrome {
editor: Editor,
edits: RwSignal<u64>,
caret: RwSignal<Option<CaretPaint>>,
caret_on: RwSignal<bool>,
caret_shown: RwSignal<bool>,
painted: Cell<Option<(Rect, Color, Point)>>,
scroll_x: Cell<f32>,
dragging: Cell<bool>,
last_click: Cell<Option<(Instant, Point)>>,
blink: RefCell<Option<TimerHandle>>,
}
impl InputChrome {
fn touched(&self) {
self.caret_on.set(true);
let mut blink = self.blink.borrow_mut();
if blink.is_some() {
let on = self.caret_on.clone();
*blink = Some(blink_timer(on));
}
drop(blink);
self.edits.update(|edits| *edits += 1);
}
fn start_blink(&self) {
let mut blink = self.blink.borrow_mut();
if blink.is_some() {
return;
}
*blink = Some(blink_timer(self.caret_on.clone()));
}
fn stop_blink(&self) {
*self.blink.borrow_mut() = None;
}
fn place(&self, content: Rect, color: Color) -> f32 {
let caret = self.editor.cursor_geometry(CARET_WIDTH);
let layout_width = self.editor.layout_size().width;
let max_scroll = (layout_width + CARET_WIDTH - content.width()).max(0.0);
let mut scroll = self.scroll_x.get();
if let Some(caret) = caret {
if caret.x0 + CARET_WIDTH > scroll + content.width() {
scroll = caret.x0 + CARET_WIDTH - content.width();
}
if caret.x0 < scroll {
scroll = caret.x0;
}
}
let scroll = scroll.clamp(0.0, max_scroll);
self.scroll_x.set(scroll);
let paint = caret.map(|caret| CaretPaint {
rect: Rect::new(
content.x0 + caret.x0 - scroll,
content.y0 + caret.y0,
content.x0 + caret.x0 - scroll + CARET_WIDTH,
content.y0 + caret.y1,
),
color,
});
if self.caret.read(|current| *current) != paint {
self.caret.set(paint);
}
scroll
}
fn place_before_paint(&self) {
if let Some((content, color, _)) = self.painted.get() {
self.place(content, color);
}
}
}
fn blink_timer(on: RwSignal<bool>) -> TimerHandle {
timer::every(BLINK_PERIOD, move || {
on.update(|shown| *shown = !*shown);
})
}
struct Caret {
paint: Option<CaretPaint>,
shown: bool,
}
impl Widget for Caret {
fn paint(&self, _cx: &PaintCx<'_>, out: &mut Vec<PaintOp>) {
if let (true, Some(paint)) = (self.shown, self.paint) {
out.push(PaintOp::FillRect {
rect: paint.rect,
radius: Corners::ZERO,
color: paint.color,
});
}
}
}
fn apply_caret_paint(caret: &mut Caret, paint: Option<CaretPaint>) -> Dirty {
if caret.paint == paint {
return Dirty::Clean;
}
caret.paint = paint;
Dirty::Paint
}
fn apply_caret_shown(caret: &mut Caret, shown: bool) -> Dirty {
if caret.shown == shown {
return Dirty::Clean;
}
caret.shown = shown;
Dirty::Paint
}
fn caret_node(chrome: &Rc<InputChrome>) -> Element<Caret> {
let paint_chrome = Rc::clone(chrome);
let shown_chrome = Rc::clone(chrome);
Element::new(Caret {
paint: None,
shown: false,
})
.absolute()
.bind(move || paint_chrome.caret.get(), apply_caret_paint)
.bind(
move || shown_chrome.caret_on.get() && shown_chrome.caret_shown.get(),
apply_caret_shown,
)
}
pub struct TextInput {
chrome: Rc<InputChrome>,
placeholder: String,
style: TextStyle,
placeholder_layout: RefCell<Option<TextLayout>>,
placeholder_runs: RefCell<Option<CachedRuns>>,
runs: RefCell<Option<TextRuns>>,
}
struct TextRuns {
origin: Point,
color: Color,
scroll_x: f32,
generation: Generation,
runs: Vec<Rc<GlyphRun>>,
}
struct CachedRuns {
origin: Point,
color: Color,
runs: Vec<Rc<GlyphRun>>,
}
impl TextInput {
fn new(chrome: Rc<InputChrome>) -> TextInput {
TextInput {
chrome,
placeholder: String::new(),
style: TextStyle::default(),
placeholder_layout: RefCell::new(None),
placeholder_runs: RefCell::new(None),
runs: RefCell::new(None),
}
}
fn invalidate_style(&mut self) {
*self.placeholder_layout.borrow_mut() = None;
*self.placeholder_runs.borrow_mut() = None;
*self.runs.borrow_mut() = None;
}
fn text_color(&self, cx: &PaintCx<'_>) -> Color {
match &cx.style.paint.text_color {
Some(color) => color.resolve(cx.theme),
None => self.style.color.resolve(cx.theme),
}
}
}
impl Widget for TextInput {
fn semantics(&self) -> Option<Semantics> {
let label = (!self.placeholder.is_empty()).then(|| self.placeholder.clone());
Some(Semantics {
role: Role::TextInput,
label,
value: Some(self.chrome.editor.text()),
})
}
fn measure(&mut self, input: MeasureInput) -> Option<Size> {
let width = match input.available_width {
AvailableSpace::Definite(width) => width.max(0.0),
AvailableSpace::MinContent | AvailableSpace::MaxContent => 0.0,
};
Some(Size {
width,
height: self.style.size * self.style.line_height,
})
}
fn paint(&self, cx: &PaintCx<'_>, out: &mut Vec<PaintOp>) {
let color = self.text_color(cx);
let inset = Point {
x: cx.content.x0 - cx.rect.x0,
y: cx.content.y0 - cx.rect.y0,
};
self.chrome.painted.set(Some((cx.content, color, inset)));
let scroll = self.chrome.place(cx.content, color);
let text_origin = Point {
x: cx.content.x0 - scroll,
y: cx.content.y0,
};
let selection_color = Color {
a: SELECTION_ALPHA,
..cx.theme.color(ColorToken::ACCENT)
};
for area in self.chrome.editor.selection_geometry() {
out.push(PaintOp::FillRect {
rect: Rect::new(
text_origin.x + area.x0,
text_origin.y + area.y0,
text_origin.x + area.x1,
text_origin.y + area.y1,
),
radius: Corners::ZERO,
color: selection_color,
});
}
for area in self.chrome.editor.compose_geometry() {
out.push(PaintOp::FillRect {
rect: Rect::new(
text_origin.x + area.x0,
text_origin.y + area.y0,
text_origin.x + area.x1,
text_origin.y + area.y1,
),
radius: Corners::ZERO,
color,
});
}
if self.chrome.editor.is_empty()
&& !self.chrome.editor.is_composing()
&& !self.placeholder.is_empty()
{
let muted = cx.theme.color(ColorToken::TEXT_MUTED);
self.paint_placeholder(text_origin, muted, out);
} else {
self.paint_text(text_origin, color, scroll, out);
}
}
fn ime_area(&self) -> Option<Rect> {
let (content, _, _) = self.chrome.painted.get()?;
let area = self.chrome.editor.ime_area();
let scroll = self.chrome.scroll_x.get();
Some(Rect::new(
content.x0 + area.x0 - scroll,
content.y0 + area.y0,
content.x0 + area.x1 - scroll,
content.y0 + area.y1,
))
}
}
impl TextInput {
fn paint_text(&self, origin: Point, color: Color, scroll_x: f32, out: &mut Vec<PaintOp>) {
let generation = self.chrome.editor.generation();
let mut cache = self.runs.borrow_mut();
let stale = match &*cache {
Some(cached) => {
cached.origin != origin
|| cached.color != color
|| cached.scroll_x != scroll_x
|| cached.generation != generation
}
None => true,
};
if stale {
let runs = self
.chrome
.editor
.glyph_runs_at(origin, color)
.into_iter()
.map(Rc::new)
.collect();
*cache = Some(TextRuns {
origin,
color,
scroll_x,
generation,
runs,
});
}
if let Some(cached) = cache.as_ref() {
out.extend(cached.runs.iter().cloned().map(PaintOp::Glyphs));
}
}
fn paint_placeholder(&self, origin: Point, color: Color, out: &mut Vec<PaintOp>) {
if self.placeholder_layout.borrow().is_none() {
*self.placeholder_layout.borrow_mut() =
Some(TextLayout::new(&self.placeholder, &self.style, None));
}
let mut cache = self.placeholder_runs.borrow_mut();
let stale = match &*cache {
Some(cached) => cached.origin != origin || cached.color != color,
None => true,
};
if stale {
let runs = self
.placeholder_layout
.borrow()
.as_ref()
.expect("the layout was just built or is cached")
.glyph_runs_at(origin, color)
.into_iter()
.map(Rc::new)
.collect();
*cache = Some(CachedRuns {
origin,
color,
runs,
});
}
if let Some(cached) = cache.as_ref() {
out.extend(cached.runs.iter().cloned().map(PaintOp::Glyphs));
}
}
}
pub fn text_input(value: RwSignal<String>) -> Element<TextInput> {
let chrome = Rc::new(InputChrome {
editor: Editor::new(value.clone()),
edits: RwSignal::new(0),
caret: RwSignal::new(None),
caret_on: RwSignal::new(true),
caret_shown: RwSignal::new(false),
painted: Cell::new(None),
scroll_x: Cell::new(0.0),
dragging: Cell::new(false),
last_click: Cell::new(None),
blink: RefCell::new(None),
});
let key_chrome = Rc::clone(&chrome);
let press_chrome = Rc::clone(&chrome);
let move_chrome = Rc::clone(&chrome);
let up_chrome = Rc::clone(&chrome);
let focus_chrome = Rc::clone(&chrome);
let commit_chrome = Rc::clone(&chrome);
let preedit_chrome = Rc::clone(&chrome);
let edits_chrome = Rc::clone(&chrome);
let caret_chrome = Rc::clone(&chrome);
Element::new(TextInput::new(Rc::clone(&chrome)))
.clip(true)
.bind(current_text_style, apply_ambient_style)
.bind(move || value.get(), apply_value)
.bind(move || edits_chrome.edits.get(), apply_edits)
.on_key(move |event| handle_key(&key_chrome, event))
.on_pointer_down(move |position| handle_press(&press_chrome, position))
.on_pointer_move(move |position| handle_move(&move_chrome, position))
.on_pointer_up(move |position| handle_release(&up_chrome, position))
.on_focus(move |focused| handle_focus(&focus_chrome, focused))
.on_ime_commit(move |text| handle_commit(&commit_chrome, text))
.on_ime_preedit(move |text, cursor| handle_preedit(&preedit_chrome, text, cursor))
.child(caret_node(&caret_chrome))
}
pub trait TextInputExt: Sized {
fn placeholder(self, text: impl Into<Value<String>>) -> Self;
}
impl TextInputExt for Element<TextInput> {
fn placeholder(self, text: impl Into<Value<String>>) -> Self {
self.bind(text, set_placeholder)
}
}
fn apply_ambient_style(input: &mut TextInput, ambient: TextStyle) -> Dirty {
if input.style == ambient {
return Dirty::Clean;
}
input.style = ambient;
input.chrome.editor.set_style(&input.style);
input.invalidate_style();
Dirty::Layout
}
fn apply_value(input: &mut TextInput, value: String) -> Dirty {
if input.chrome.editor.has_text(&value) {
return Dirty::Clean;
}
input.chrome.editor.set_text(&value);
input.chrome.place_before_paint();
Dirty::Paint
}
fn apply_edits(input: &mut TextInput, _edits: u64) -> Dirty {
input.chrome.place_before_paint();
Dirty::Paint
}
fn set_placeholder(input: &mut TextInput, text: String) -> Dirty {
if input.placeholder == text {
return Dirty::Clean;
}
input.placeholder = text;
*input.placeholder_layout.borrow_mut() = None;
*input.placeholder_runs.borrow_mut() = None;
Dirty::Paint
}
fn handle_press(chrome: &InputChrome, position: Point) {
let now = timer::now();
let double_click = chrome
.last_click
.get()
.is_some_and(|(last_at, last_at_point)| {
now.duration_since(last_at) <= DOUBLE_CLICK_TIME
&& distance_squared(position, last_at_point)
<= DOUBLE_CLICK_DISTANCE * DOUBLE_CLICK_DISTANCE
});
chrome.last_click.set(Some((now, position)));
chrome.dragging.set(true);
let (x, y) = layout_position(chrome, position);
if double_click {
chrome.editor.select_word_at_point(x, y);
} else {
chrome.editor.move_to_point(x, y);
}
chrome.touched();
}
fn handle_move(chrome: &InputChrome, position: Point) {
if !chrome.dragging.get() {
return;
}
if chrome.last_click.get().is_some_and(|(_, pressed_at)| {
distance_squared(position, pressed_at) > DOUBLE_CLICK_DISTANCE * DOUBLE_CLICK_DISTANCE
}) {
chrome.last_click.set(None);
}
let (x, y) = layout_position(chrome, position);
chrome.editor.extend_to_point(x, y);
chrome.touched();
}
fn handle_release(chrome: &InputChrome, _position: Point) {
chrome.dragging.set(false);
}
fn layout_position(chrome: &InputChrome, position: Point) -> (f32, f32) {
let origin = chrome
.painted
.get()
.map(|(_, _, inset)| inset)
.unwrap_or_default();
(
position.x - origin.x + chrome.scroll_x.get(),
position.y - origin.y,
)
}
fn handle_key(chrome: &InputChrome, event: &KeyEvent) -> Handled {
if !event.pressed {
return Handled::No;
}
let modifiers = event.modifiers;
let by_word = modifiers.control || modifiers.alt;
match &event.key {
Key::Character(text) if is_shortcut(modifiers) && text.eq_ignore_ascii_case("a") => {
chrome.editor.select_all();
chrome.touched();
Handled::Yes
}
Key::Character(text) if is_shortcut(modifiers) && text.eq_ignore_ascii_case("c") => {
copy_selection(chrome);
Handled::Yes
}
Key::Character(text) if is_shortcut(modifiers) && text.eq_ignore_ascii_case("x") => {
if cut_selection(chrome) {
chrome.touched();
}
Handled::Yes
}
Key::Character(text) if is_shortcut(modifiers) && text.eq_ignore_ascii_case("v") => {
paste_from_clipboard(chrome);
Handled::Yes
}
Key::Character(text) if is_shortcut(modifiers) && text.eq_ignore_ascii_case("z") => {
if modifiers.shift {
chrome.editor.redo();
} else {
chrome.editor.undo();
}
chrome.touched();
Handled::Yes
}
Key::Character(text)
if cfg!(not(target_os = "macos"))
&& is_shortcut(modifiers)
&& text.eq_ignore_ascii_case("y") =>
{
chrome.editor.redo();
chrome.touched();
Handled::Yes
}
Key::Backspace => {
if by_word {
chrome.editor.backspace_word();
} else {
chrome.editor.backspace();
}
chrome.touched();
Handled::Yes
}
Key::Delete => {
if by_word {
chrome.editor.delete_word();
} else {
chrome.editor.delete();
}
chrome.touched();
Handled::Yes
}
Key::ArrowLeft => {
apply_move(chrome, modifiers.shift, by_word, Direction::Left);
Handled::Yes
}
Key::ArrowRight => {
apply_move(chrome, modifiers.shift, by_word, Direction::Right);
Handled::Yes
}
Key::Home => {
if modifiers.shift {
chrome.editor.extend_to_start();
} else {
chrome.editor.move_to_start();
}
chrome.touched();
Handled::Yes
}
Key::End => {
if modifiers.shift {
chrome.editor.extend_to_end();
} else {
chrome.editor.move_to_end();
}
chrome.touched();
Handled::Yes
}
Key::Character(text) => {
if !types_text(modifiers) {
return Handled::No;
}
chrome.editor.insert(text);
chrome.touched();
Handled::Yes
}
Key::Space => {
if !types_text(modifiers) {
return Handled::No;
}
chrome.editor.insert(" ");
chrome.touched();
Handled::Yes
}
_ => Handled::No,
}
}
fn is_shortcut(modifiers: Modifiers) -> bool {
modifiers.command() && !types_text(modifiers)
}
fn types_text(modifiers: Modifiers) -> bool {
#[cfg(target_os = "macos")]
{
!modifiers.meta && !modifiers.control
}
#[cfg(not(target_os = "macos"))]
{
let alt_gr = modifiers.control && modifiers.alt;
!modifiers.meta && (alt_gr || (!modifiers.control && !modifiers.alt))
}
}
#[derive(Copy, Clone)]
enum Direction {
Left,
Right,
}
fn apply_move(chrome: &InputChrome, extend: bool, by_word: bool, direction: Direction) {
let editor = &chrome.editor;
match (extend, by_word, direction) {
(false, false, Direction::Left) => editor.move_left(),
(false, false, Direction::Right) => editor.move_right(),
(false, true, Direction::Left) => editor.move_word_left(),
(false, true, Direction::Right) => editor.move_word_right(),
(true, false, Direction::Left) => editor.extend_left(),
(true, false, Direction::Right) => editor.extend_right(),
(true, true, Direction::Left) => editor.extend_word_left(),
(true, true, Direction::Right) => editor.extend_word_right(),
}
chrome.touched();
}
fn copy_selection(chrome: &InputChrome) {
if let Some(text) = chrome.editor.selected_text() {
clipboard::with(|slot| slot.set(text));
}
}
fn cut_selection(chrome: &InputChrome) -> bool {
let Some(text) = chrome.editor.selected_text() else {
return false;
};
let mut copied = false;
clipboard::with(|slot| {
slot.set(text);
copied = true;
});
if copied {
chrome.editor.delete_selection();
}
copied
}
fn paste_from_clipboard(chrome: &InputChrome) {
if let Some(text) = clipboard::with(|slot| slot.get()).flatten() {
chrome.editor.paste(&text);
chrome.touched();
}
}
fn handle_focus(chrome: &InputChrome, focused: bool) {
if !focused {
chrome.editor.compose("", None);
}
chrome.caret_shown.set(focused);
if focused {
chrome.caret_on.set(true);
chrome.start_blink();
} else {
chrome.stop_blink();
}
chrome.edits.update(|edits| *edits += 1);
}
fn handle_commit(chrome: &InputChrome, text: &str) {
if text.is_empty() {
return;
}
chrome.editor.commit(text);
chrome.touched();
}
fn handle_preedit(chrome: &InputChrome, text: &str, cursor: Option<(usize, usize)>) {
chrome.editor.compose(text, cursor);
chrome.touched();
}
fn distance_squared(a: Point, b: Point) -> f32 {
let dx = a.x - b.x;
let dy = a.y - b.y;
dx * dx + dy * dy
}