476 lines
14 KiB
Rust
476 lines
14 KiB
Rust
use crate::prelude::*;
|
|
use iris_core::{TextData, UiColor};
|
|
use parley::{Affinity, Layout, Selection};
|
|
use std::ops::{Deref, DerefMut};
|
|
use winit::{
|
|
event::KeyEvent,
|
|
keyboard::{Key, NamedKey},
|
|
};
|
|
|
|
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
|
pub enum Motion {
|
|
Left,
|
|
Right,
|
|
LeftWord,
|
|
RightWord,
|
|
Up,
|
|
Down,
|
|
LineStart,
|
|
LineEnd,
|
|
}
|
|
|
|
pub struct TextEdit {
|
|
view: TextView,
|
|
/// `None` represents unfocused, which Parley's `Selection` cannot express.
|
|
selection: Option<Selection>,
|
|
history: Vec<(String, Option<Selection>)>,
|
|
double_hit: Option<usize>,
|
|
pub mode: EditMode,
|
|
}
|
|
|
|
#[derive(Clone, Copy, PartialEq)]
|
|
pub enum EditMode {
|
|
SingleLine,
|
|
MultiLine,
|
|
}
|
|
|
|
impl TextEdit {
|
|
pub fn new(view: TextView, mode: EditMode) -> Self {
|
|
Self {
|
|
view,
|
|
selection: None,
|
|
history: Default::default(),
|
|
double_hit: None,
|
|
mode,
|
|
}
|
|
}
|
|
|
|
pub fn selected_text(&self) -> Option<String> {
|
|
let sel = self.selection?;
|
|
if sel.is_collapsed() {
|
|
return None;
|
|
}
|
|
Some(self.buf.text()[sel.text_range()].to_string())
|
|
}
|
|
}
|
|
|
|
impl Widget for TextEdit {
|
|
fn draw(&mut self, painter: &mut Painter) -> Size {
|
|
let base = painter.layer;
|
|
painter.child_layer();
|
|
let (_, size) = self.view.draw(painter);
|
|
painter.layer = base;
|
|
let region = self.region();
|
|
|
|
let Some(selection) = self.selection else {
|
|
return size;
|
|
};
|
|
let layout = self.view.buf.layout();
|
|
|
|
// parley reports selection as boxes in layout space, so bidi and
|
|
// wrapped lines come out right without this code knowing about either.
|
|
for (rect, _) in selection.geometry(layout) {
|
|
let rect_size = vec2(rect.width() as f32, rect.height() as f32);
|
|
let top_left = vec2(rect.x0 as f32, rect.y0 as f32);
|
|
painter.primitive_within(
|
|
RectPrimitive::color(Color::SKY),
|
|
rect_size
|
|
.align(Align::TOP_LEFT)
|
|
.offset(top_left)
|
|
.within(®ion),
|
|
);
|
|
}
|
|
|
|
let caret = selection.focus().geometry(layout, CARET_WIDTH);
|
|
let caret_size = vec2(caret.width() as f32, caret.height() as f32);
|
|
let top_left = vec2(caret.x0 as f32, caret.y0 as f32);
|
|
painter.primitive_within(
|
|
RectPrimitive::color(Color::WHITE),
|
|
caret_size
|
|
.align(Align::TOP_LEFT)
|
|
.offset(top_left)
|
|
.within(®ion),
|
|
);
|
|
size
|
|
}
|
|
}
|
|
|
|
const CARET_WIDTH: f32 = 1.0;
|
|
|
|
pub struct TextEditCtx<'a> {
|
|
pub text: &'a mut TextEdit,
|
|
pub data: &'a mut TextData,
|
|
}
|
|
|
|
impl<'a> TextEditCtx<'a> {
|
|
fn layout(&mut self) -> &Layout<UiColor> {
|
|
let attrs = self.text.view.attrs.clone();
|
|
let width = self.text.view.wrap_width();
|
|
self.text.view.buf.shape(self.data, &attrs, width);
|
|
self.text.view.buf.layout()
|
|
}
|
|
|
|
fn clamp_selection_to_layout(&mut self) {
|
|
if let Some(sel) = self.text.selection {
|
|
let layout = self.layout();
|
|
self.text.selection = Some(sel.refresh(layout));
|
|
}
|
|
}
|
|
|
|
pub fn take(&mut self) -> String {
|
|
let text = std::mem::take(self.text.view.buf.edit());
|
|
self.text.selection = None;
|
|
text
|
|
}
|
|
|
|
pub fn set(&mut self, text: &str) {
|
|
let text = self.string(text);
|
|
self.text.view.buf.set_text(text);
|
|
self.text.selection = None;
|
|
}
|
|
|
|
pub fn motion(&mut self, motion: Motion, select: bool) {
|
|
let Some(sel) = self.text.selection else {
|
|
return;
|
|
};
|
|
let layout = self.layout();
|
|
let sel = apply_motion(sel, layout, motion, select);
|
|
self.text.selection = Some(sel);
|
|
}
|
|
|
|
/// Replace the `len` characters before the caret. This is the IME's
|
|
/// preedit path: it re-sends the whole composition each time.
|
|
pub fn replace(&mut self, len: usize, text: &str) {
|
|
let text = self.string(text);
|
|
for _ in 0..len {
|
|
self.backspace(false);
|
|
}
|
|
self.insert_str(&text);
|
|
}
|
|
|
|
fn string(&self, text: &str) -> String {
|
|
if self.text.mode == EditMode::SingleLine {
|
|
text.replace('\n', "")
|
|
} else {
|
|
text.to_string()
|
|
}
|
|
}
|
|
|
|
pub fn insert(&mut self, text: &str) {
|
|
let text = self.string(text);
|
|
self.insert_str(&text);
|
|
}
|
|
|
|
fn insert_str(&mut self, text: &str) {
|
|
if text.is_empty() {
|
|
return;
|
|
}
|
|
self.clear_span();
|
|
let at = match self.text.selection {
|
|
Some(sel) => sel.focus().index(),
|
|
None => return,
|
|
};
|
|
let at = at.min(self.text.view.buf.text().len());
|
|
self.text.view.buf.edit().insert_str(at, text);
|
|
self.set_caret(at + text.len());
|
|
}
|
|
|
|
pub fn clear_span(&mut self) -> bool {
|
|
let Some(sel) = self.text.selection else {
|
|
return false;
|
|
};
|
|
if sel.is_collapsed() {
|
|
return false;
|
|
}
|
|
let range = sel.text_range();
|
|
self.text.view.buf.edit().replace_range(range.clone(), "");
|
|
self.set_caret(range.start);
|
|
true
|
|
}
|
|
|
|
fn set_caret(&mut self, index: usize) {
|
|
let index = index.min(self.text.view.buf.text().len());
|
|
let layout = self.layout();
|
|
self.text.selection = Some(Selection::from_byte_index(
|
|
layout,
|
|
index,
|
|
Affinity::default(),
|
|
));
|
|
}
|
|
|
|
pub fn newline(&mut self) {
|
|
if self.text.mode == EditMode::MultiLine {
|
|
self.insert_str("\n");
|
|
}
|
|
}
|
|
|
|
pub fn backspace(&mut self, word: bool) {
|
|
if self.clear_span() {
|
|
return;
|
|
}
|
|
let Some(sel) = self.text.selection else {
|
|
return;
|
|
};
|
|
let end = sel.focus().index();
|
|
if end == 0 {
|
|
return;
|
|
}
|
|
let layout = self.layout();
|
|
let start = if word {
|
|
sel.focus().previous_logical_word(layout).index()
|
|
} else {
|
|
let Some(cluster) = sel.focus().logical_clusters(layout)[0] else {
|
|
return;
|
|
};
|
|
let range = cluster.text_range();
|
|
if cluster.is_hard_line_break() || cluster.is_emoji() {
|
|
range.start
|
|
} else {
|
|
self.text.view.buf.text()[..range.end]
|
|
.char_indices()
|
|
.next_back()
|
|
.map_or(range.start, |(start, _)| start)
|
|
}
|
|
};
|
|
self.delete_range(start, end);
|
|
}
|
|
|
|
pub fn delete(&mut self, word: bool) {
|
|
if self.clear_span() {
|
|
return;
|
|
}
|
|
let Some(sel) = self.text.selection else {
|
|
return;
|
|
};
|
|
let start = sel.focus().index();
|
|
if start >= self.text.view.buf.text().len() {
|
|
return;
|
|
}
|
|
let layout = self.layout();
|
|
let end = if word {
|
|
sel.focus().next_logical_word(layout).index()
|
|
} else {
|
|
let clusters = sel.focus().logical_clusters(layout);
|
|
let Some(cluster) = clusters[1].as_ref() else {
|
|
return;
|
|
};
|
|
cluster.text_range().end
|
|
};
|
|
self.delete_range(start, end);
|
|
}
|
|
|
|
fn delete_range(&mut self, start: usize, end: usize) {
|
|
self.text.view.buf.edit().replace_range(start..end, "");
|
|
self.set_caret(start);
|
|
}
|
|
|
|
pub fn select_all(&mut self) {
|
|
let len = self.text.view.buf.text().len();
|
|
if len == 0 {
|
|
return;
|
|
}
|
|
let layout = self.layout();
|
|
let anchor = parley::Cursor::from_byte_index(layout, 0, Affinity::default());
|
|
let focus = parley::Cursor::from_byte_index(layout, len, Affinity::default());
|
|
self.text.selection = Some(Selection::new(anchor, focus));
|
|
}
|
|
|
|
pub fn select(&mut self, pos: Vec2, size: Vec2, drag: bool, recent: bool) {
|
|
let pos = pos - self.text.region().top_left().to_px(size);
|
|
let prev_sel = self.text.selection;
|
|
let prev_hit = self.text.double_hit;
|
|
|
|
let layout = self.layout();
|
|
let (selection, double_hit) = if drag {
|
|
let Some(selection) = prev_sel else {
|
|
return;
|
|
};
|
|
(selection.extend_to_point(layout, pos.x, pos.y), prev_hit)
|
|
} else {
|
|
let hit = Selection::from_point(layout, pos.x, pos.y);
|
|
let index = hit.focus().index();
|
|
// Successive clicks at one index select the word, then the line.
|
|
if recent && prev_hit == Some(index) {
|
|
(Selection::line_from_point(layout, pos.x, pos.y), None)
|
|
} else if recent && prev_sel.map(|s| s.focus().index()) == Some(index) {
|
|
(
|
|
Selection::word_from_point(layout, pos.x, pos.y),
|
|
Some(index),
|
|
)
|
|
} else {
|
|
(hit, None)
|
|
}
|
|
};
|
|
|
|
self.text.selection = Some(selection);
|
|
self.text.double_hit = double_hit;
|
|
}
|
|
|
|
pub fn deselect(&mut self) {
|
|
self.text.selection = None;
|
|
self.text.double_hit = None;
|
|
}
|
|
|
|
pub fn apply_event(&mut self, event: &KeyEvent, modifiers: &Modifiers) -> TextInputResult {
|
|
let old = (self.text.view.buf.text().to_string(), self.text.selection);
|
|
let mut undo = false;
|
|
let res = self.apply_event_inner(event, modifiers, &mut undo);
|
|
if undo {
|
|
if let Some((old, selection)) = self.text.history.pop() {
|
|
self.set(&old);
|
|
self.text.selection = selection;
|
|
self.clamp_selection_to_layout();
|
|
}
|
|
} else if self.text.view.buf.text() != old.0 {
|
|
self.text.history.push(old);
|
|
}
|
|
res
|
|
}
|
|
|
|
fn apply_event_inner(
|
|
&mut self,
|
|
event: &KeyEvent,
|
|
modifiers: &Modifiers,
|
|
undo: &mut bool,
|
|
) -> TextInputResult {
|
|
match &event.logical_key {
|
|
Key::Named(named) => match named {
|
|
NamedKey::Backspace => self.backspace(modifiers.control),
|
|
NamedKey::Delete => self.delete(modifiers.control),
|
|
NamedKey::Space => self.insert(" "),
|
|
NamedKey::Enter => {
|
|
if modifiers.shift {
|
|
self.newline();
|
|
} else {
|
|
return TextInputResult::Submit;
|
|
}
|
|
}
|
|
NamedKey::ArrowRight => {
|
|
let motion = if modifiers.control {
|
|
Motion::RightWord
|
|
} else {
|
|
Motion::Right
|
|
};
|
|
self.motion(motion, modifiers.shift);
|
|
}
|
|
NamedKey::ArrowLeft => {
|
|
let motion = if modifiers.control {
|
|
Motion::LeftWord
|
|
} else {
|
|
Motion::Left
|
|
};
|
|
self.motion(motion, modifiers.shift);
|
|
}
|
|
NamedKey::ArrowUp => self.motion(Motion::Up, modifiers.shift),
|
|
NamedKey::ArrowDown => self.motion(Motion::Down, modifiers.shift),
|
|
NamedKey::Home => self.motion(Motion::LineStart, modifiers.shift),
|
|
NamedKey::End => self.motion(Motion::LineEnd, modifiers.shift),
|
|
NamedKey::Escape => {
|
|
self.deselect();
|
|
return TextInputResult::Unfocus;
|
|
}
|
|
_ => return TextInputResult::Unused,
|
|
},
|
|
Key::Character(text) => {
|
|
if modifiers.control {
|
|
match text.as_str() {
|
|
"v" => return TextInputResult::Paste,
|
|
"c" => {
|
|
if let Some(content) = self.text.selected_text() {
|
|
return TextInputResult::Copy(content);
|
|
}
|
|
}
|
|
"x" => {
|
|
if let Some(content) = self.text.selected_text() {
|
|
self.clear_span();
|
|
return TextInputResult::Copy(content);
|
|
}
|
|
}
|
|
"a" => self.select_all(),
|
|
"z" => *undo = true,
|
|
_ => self.insert(text),
|
|
}
|
|
} else {
|
|
self.insert(text);
|
|
}
|
|
}
|
|
_ => return TextInputResult::Unused,
|
|
}
|
|
TextInputResult::Used
|
|
}
|
|
}
|
|
|
|
fn apply_motion(
|
|
sel: Selection,
|
|
layout: &Layout<UiColor>,
|
|
motion: Motion,
|
|
extend: bool,
|
|
) -> Selection {
|
|
match motion {
|
|
Motion::Left => sel.previous_visual(layout, extend),
|
|
Motion::Right => sel.next_visual(layout, extend),
|
|
Motion::LeftWord => sel.previous_visual_word(layout, extend),
|
|
Motion::RightWord => sel.next_visual_word(layout, extend),
|
|
Motion::Up => sel.previous_line(layout, extend),
|
|
Motion::Down => sel.next_line(layout, extend),
|
|
Motion::LineStart => sel.line_start(layout, extend),
|
|
Motion::LineEnd => sel.line_end(layout, extend),
|
|
}
|
|
}
|
|
|
|
#[derive(Default)]
|
|
pub struct Modifiers {
|
|
pub shift: bool,
|
|
pub control: bool,
|
|
}
|
|
|
|
impl Modifiers {
|
|
pub fn clear(&mut self) {
|
|
self.shift = false;
|
|
self.control = false;
|
|
}
|
|
}
|
|
|
|
pub enum TextInputResult {
|
|
Used,
|
|
Unused,
|
|
Unfocus,
|
|
Submit,
|
|
Copy(String),
|
|
Paste,
|
|
}
|
|
|
|
impl TextInputResult {
|
|
pub fn unfocus(&self) -> bool {
|
|
matches!(self, TextInputResult::Unfocus)
|
|
}
|
|
}
|
|
|
|
impl Deref for TextEdit {
|
|
type Target = TextView;
|
|
|
|
fn deref(&self) -> &Self::Target {
|
|
&self.view
|
|
}
|
|
}
|
|
|
|
impl DerefMut for TextEdit {
|
|
fn deref_mut(&mut self) -> &mut Self::Target {
|
|
&mut self.view
|
|
}
|
|
}
|
|
|
|
pub trait TextEditable {
|
|
fn edit<'a>(&self, ui: &'a mut impl UiRsc) -> TextEditCtx<'a>;
|
|
}
|
|
|
|
impl<I: IdLike<Widget = TextEdit>> TextEditable for I {
|
|
fn edit<'a>(&self, ui: &'a mut impl UiRsc) -> TextEditCtx<'a> {
|
|
let ui = ui.ui_mut();
|
|
TextEditCtx {
|
|
text: ui.widgets.get_mut(self).unwrap(),
|
|
data: &mut ui.text,
|
|
}
|
|
}
|
|
}
|