use std::cell::{Cell, RefCell};
use std::rc::Rc;
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};
use smudge_core::paint::PaintOp;
use smudge_core::reactive::{RwSignal, prelude::*};
use smudge_core::semantics::{Role, Semantics};
use smudge_core::style::Length;
use smudge_core::theme::ColorToken;
use smudge_core::tree::Dirty;
use smudge_core::widget::{AvailableSpace, MeasureInput, PaintCx, Widget};
use smudge_core::window::Cursor;
use crate::chrome::{
Direction, InputChrome, ParagraphCache, Placeholder, SELECTION_ALPHA, apply_edit_count,
apply_move, apply_outside_write, caret_node, copy_selection, cut_selection, handle_commit,
handle_focus, handle_move, handle_preedit, handle_press, handle_release, handle_wheel,
is_shortcut, outside_write, paint_paragraphs, paint_selection, types_text,
};
use crate::style::{TextStyle, current_text_style};
pub struct TextArea {
chrome: Rc<InputChrome>,
placeholder: RefCell<Placeholder>,
style: TextStyle,
runs: RefCell<Option<ParagraphCache>>,
measured: Rc<Cell<f32>>,
relayout: RwSignal<u64>,
}
impl TextArea {
fn new(chrome: Rc<InputChrome>, measured: Rc<Cell<f32>>, relayout: RwSignal<u64>) -> TextArea {
TextArea {
chrome,
placeholder: RefCell::new(Placeholder::new()),
style: TextStyle::default(),
runs: RefCell::new(None),
measured,
relayout,
}
}
fn relayout_if_resized(&self) {
if self.chrome.editor.layout_size().height != self.measured.get() {
self.relayout.update(|count| *count += 1);
}
}
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 TextArea {
fn semantics(&self) -> Option<Semantics> {
let label = (!self.placeholder.borrow().text().is_empty())
.then(|| self.placeholder.borrow().text().into());
Some(Semantics {
role: Role::MultilineTextInput,
label,
value: Some(self.chrome.editor.text()),
})
}
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)));
self.chrome
.editor
.set_wrap_width(Some(cx.content.width().max(0.0)));
let (scroll_x, scroll_y) = self.chrome.place(cx.content, color);
self.chrome
.line_height
.set(self.style.size * self.style.line_height);
let text_origin = Point {
x: cx.content.x0 - scroll_x,
y: cx.content.y0 - scroll_y,
};
let selection_color = Color {
a: SELECTION_ALPHA,
..cx.theme.color(ColorToken::ACCENT)
};
paint_selection(
&self.chrome,
text_origin,
(scroll_y, scroll_y + cx.content.height()),
selection_color,
out,
);
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.borrow().text().is_empty()
{
let muted = cx.theme.color(ColorToken::TEXT_MUTED);
self.placeholder
.borrow_mut()
.paint(&self.style, text_origin, muted, out);
} else {
let top = scroll_y;
let bottom = scroll_y + cx.content.height();
paint_paragraphs(
&mut self.runs.borrow_mut(),
&self.chrome.editor,
text_origin,
color,
(top, bottom),
out,
);
}
}
fn ime_area(&self) -> Option<Rect> {
let (content, _, _) = self.chrome.painted.get()?;
let area = self.chrome.editor.ime_area();
let scroll_x = self.chrome.scroll_x.get();
let scroll_y = self.chrome.scroll_y.get();
Some(Rect::new(
content.x0 + area.x0 - scroll_x,
content.y0 + area.y0 - scroll_y,
content.x0 + area.x1 - scroll_x,
content.y0 + area.y1 - scroll_y,
))
}
}
pub fn text_area(value: RwSignal<String>) -> Element<TextArea> {
let chrome = Rc::new(InputChrome::new(value.clone(), true));
let measured = Rc::new(Cell::new(0.0));
let relayout = RwSignal::new(0u64);
let relayout_source = relayout.clone();
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 wheel_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);
let value_chrome = Rc::clone(&chrome);
let body = Element::new(Body {
chrome: Rc::clone(&chrome),
measured: Rc::clone(&measured),
})
.width(Length::pct(100.0))
.bind(
RelayoutKey,
move || relayout_source.get(),
|_, _| Dirty::Layout,
);
Element::new(TextArea::new(Rc::clone(&chrome), measured, relayout))
.clip(true)
.cursor(Cursor::Text)
.column()
.min_height(|| line_floor(Value::Static(1)))
.bind(AmbientStyleKey, current_text_style, apply_ambient_style)
.bind(
ValueKey,
move || outside_write(&value_chrome, &value),
apply_value,
)
.bind(EditsKey, 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_wheel(move |delta| handle_wheel(&wheel_chrome, delta))
.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(body)
.child(caret_node(&caret_chrome))
}
struct Body {
chrome: Rc<InputChrome>,
measured: Rc<Cell<f32>>,
}
impl Widget for Body {
fn measure(&mut self, input: MeasureInput) -> Option<Size> {
let offered = input.known_width.or(match input.available_width {
AvailableSpace::Definite(width) => Some(width),
AvailableSpace::MinContent | AvailableSpace::MaxContent => None,
});
let editor = &self.chrome.editor;
if let Some(width) = offered {
editor.set_wrap_width(Some(width.max(0.0)));
}
let height = editor.layout_size().height;
self.measured.set(height);
Some(Size {
width: offered.unwrap_or(0.0).max(0.0),
height,
})
}
fn paint(&self, _cx: &PaintCx<'_>, _out: &mut Vec<PaintOp>) {}
}
pub trait TextAreaExt: Sized {
fn placeholder(self, text: impl Into<Value<String>>) -> Self;
fn min_lines(self, lines: impl Into<Value<u32>>) -> Self;
}
struct AmbientStyleKey;
struct ValueKey;
struct EditsKey;
struct PlaceholderKey;
struct RelayoutKey;
impl TextAreaExt for Element<TextArea> {
fn placeholder(self, text: impl Into<Value<String>>) -> Self {
self.bind(PlaceholderKey, text, set_placeholder)
}
fn min_lines(self, lines: impl Into<Value<u32>>) -> Self {
let lines = lines.into();
self.min_height(move || line_floor(lines.clone()))
}
}
fn line_floor(lines: Value<u32>) -> Length {
let lines = match lines {
Value::Static(lines) => lines,
Value::Dynamic(get) => get(),
};
let style = current_text_style();
Length::px(lines as f32 * style.size * style.line_height)
}
fn apply_ambient_style(area: &mut TextArea, ambient: TextStyle) -> Dirty {
if area.style == ambient {
return Dirty::Clean;
}
area.style = ambient;
area.chrome.editor.set_style(&area.style);
area.placeholder.borrow_mut().invalidate_style();
*area.runs.borrow_mut() = None;
area.relayout_if_resized();
Dirty::Layout
}
fn apply_value(area: &mut TextArea, value: Option<String>) -> Dirty {
match value {
Some(value) => {
let dirty = apply_outside_write(&area.chrome, &value);
area.relayout_if_resized();
dirty
}
None => Dirty::Clean,
}
}
fn apply_edits(area: &mut TextArea, _edits: u64) -> Dirty {
let dirty = apply_edit_count(&area.chrome);
area.relayout_if_resized();
dirty
}
fn set_placeholder(area: &mut TextArea, text: String) -> Dirty {
let mut placeholder = area.placeholder.borrow_mut();
if placeholder.text() == text {
return Dirty::Clean;
}
placeholder.set_text(text);
Dirty::Paint
}
fn visible_lines(chrome: &InputChrome) -> isize {
let content = chrome
.painted
.get()
.map(|(content, _, _)| content.height())
.unwrap_or(0.0);
let line = chrome.line_height.get().max(1.0);
((content / line) as isize).max(1)
}
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::Enter if modifiers.control || modifiers.alt || modifiers.meta => Handled::No,
Key::Enter => {
chrome.editor.insert("\n");
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::ArrowUp => {
if modifiers.shift {
chrome.editor.extend_up();
} else {
chrome.editor.move_up();
}
chrome.touched();
Handled::Yes
}
Key::ArrowDown => {
if modifiers.shift {
chrome.editor.extend_down();
} else {
chrome.editor.move_down();
}
chrome.touched();
Handled::Yes
}
Key::PageUp => {
let lines = visible_lines(chrome);
if modifiers.shift {
chrome.editor.extend_lines(-lines);
} else {
chrome.editor.move_lines(-lines);
}
chrome.touched();
Handled::Yes
}
Key::PageDown => {
let lines = visible_lines(chrome);
if modifiers.shift {
chrome.editor.extend_lines(lines);
} else {
chrome.editor.move_lines(lines);
}
chrome.touched();
Handled::Yes
}
Key::Home => {
let editor = &chrome.editor;
match (modifiers.shift, modifiers.control || modifiers.meta) {
(false, false) => editor.move_to_line_start(),
(true, false) => editor.extend_to_line_start(),
(false, true) => editor.move_to_start(),
(true, true) => editor.extend_to_start(),
}
chrome.touched();
Handled::Yes
}
Key::End => {
let editor = &chrome.editor;
match (modifiers.shift, modifiers.control || modifiers.meta) {
(false, false) => editor.move_to_line_end(),
(true, false) => editor.extend_to_line_end(),
(false, true) => editor.move_to_end(),
(true, true) => editor.extend_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 paste_from_clipboard(chrome: &InputChrome) {
if let Some(text) = clipboard::with(|slot| slot.get()).flatten() {
chrome.editor.paste(&text);
chrome.touched();
}
}