T06a forms text: text input core, fields, numbers, masks, pin, inline, groups, tags, pairs, path, regex, expression, hotkey, mention
This commit is contained in:
41 files changed
+6233
-45
No files matched your search
@@ -0,0 +1,391 @@
|
||||
use gpui::{
|
||||
App, ClipboardItem, Context, CursorStyle, InteractiveElement, IntoElement, KeyBinding,
|
||||
MouseButton, MouseDownEvent, MouseMoveEvent, ParentElement, Render, Styled, Window, actions,
|
||||
div,
|
||||
};
|
||||
|
||||
use super::{
|
||||
InputEvent, TextInput,
|
||||
edit::{self, next_grapheme, prev_grapheme},
|
||||
element::TextElement,
|
||||
};
|
||||
|
||||
actions!(
|
||||
ely_input,
|
||||
[
|
||||
Backspace,
|
||||
Delete,
|
||||
DeleteWordLeft,
|
||||
Left,
|
||||
Right,
|
||||
Up,
|
||||
Down,
|
||||
SelectLeft,
|
||||
SelectRight,
|
||||
SelectUp,
|
||||
SelectDown,
|
||||
WordLeft,
|
||||
WordRight,
|
||||
SelectWordLeft,
|
||||
SelectWordRight,
|
||||
Home,
|
||||
End,
|
||||
SelectHome,
|
||||
SelectEnd,
|
||||
SelectAll,
|
||||
Copy,
|
||||
Cut,
|
||||
Paste,
|
||||
Undo,
|
||||
Redo,
|
||||
Enter,
|
||||
Submit,
|
||||
ShowCharacterPalette,
|
||||
]
|
||||
);
|
||||
|
||||
const CONTEXT: &str = "ElyInput";
|
||||
|
||||
/// Binds the text keys. `init` calls it.
|
||||
pub(crate) fn bind_keys(cx: &mut App) {
|
||||
let word = if cfg!(target_os = "macos") {
|
||||
"alt"
|
||||
} else {
|
||||
"ctrl"
|
||||
};
|
||||
let mut bindings = vec![
|
||||
KeyBinding::new("backspace", Backspace, Some(CONTEXT)),
|
||||
KeyBinding::new("delete", Delete, Some(CONTEXT)),
|
||||
KeyBinding::new(&format!("{word}-backspace"), DeleteWordLeft, Some(CONTEXT)),
|
||||
KeyBinding::new("left", Left, Some(CONTEXT)),
|
||||
KeyBinding::new("right", Right, Some(CONTEXT)),
|
||||
KeyBinding::new("up", Up, Some(CONTEXT)),
|
||||
KeyBinding::new("down", Down, Some(CONTEXT)),
|
||||
KeyBinding::new("shift-left", SelectLeft, Some(CONTEXT)),
|
||||
KeyBinding::new("shift-right", SelectRight, Some(CONTEXT)),
|
||||
KeyBinding::new("shift-up", SelectUp, Some(CONTEXT)),
|
||||
KeyBinding::new("shift-down", SelectDown, Some(CONTEXT)),
|
||||
KeyBinding::new(&format!("{word}-left"), WordLeft, Some(CONTEXT)),
|
||||
KeyBinding::new(&format!("{word}-right"), WordRight, Some(CONTEXT)),
|
||||
KeyBinding::new(&format!("{word}-shift-left"), SelectWordLeft, Some(CONTEXT)),
|
||||
KeyBinding::new(
|
||||
&format!("{word}-shift-right"),
|
||||
SelectWordRight,
|
||||
Some(CONTEXT),
|
||||
),
|
||||
KeyBinding::new("home", Home, Some(CONTEXT)),
|
||||
KeyBinding::new("end", End, Some(CONTEXT)),
|
||||
KeyBinding::new("shift-home", SelectHome, Some(CONTEXT)),
|
||||
KeyBinding::new("shift-end", SelectEnd, Some(CONTEXT)),
|
||||
KeyBinding::new("secondary-a", SelectAll, Some(CONTEXT)),
|
||||
KeyBinding::new("secondary-c", Copy, Some(CONTEXT)),
|
||||
KeyBinding::new("secondary-x", Cut, Some(CONTEXT)),
|
||||
KeyBinding::new("secondary-v", Paste, Some(CONTEXT)),
|
||||
KeyBinding::new("secondary-z", Undo, Some(CONTEXT)),
|
||||
KeyBinding::new("secondary-shift-z", Redo, Some(CONTEXT)),
|
||||
KeyBinding::new("enter", Enter, Some(CONTEXT)),
|
||||
KeyBinding::new("secondary-enter", Submit, Some(CONTEXT)),
|
||||
];
|
||||
if cfg!(target_os = "macos") {
|
||||
bindings.extend([
|
||||
KeyBinding::new("cmd-left", Home, Some(CONTEXT)),
|
||||
KeyBinding::new("cmd-right", End, Some(CONTEXT)),
|
||||
KeyBinding::new("cmd-shift-left", SelectHome, Some(CONTEXT)),
|
||||
KeyBinding::new("cmd-shift-right", SelectEnd, Some(CONTEXT)),
|
||||
KeyBinding::new("ctrl-cmd-space", ShowCharacterPalette, Some(CONTEXT)),
|
||||
]);
|
||||
} else {
|
||||
bindings.push(KeyBinding::new("ctrl-y", Redo, Some(CONTEXT)));
|
||||
}
|
||||
cx.bind_keys(bindings);
|
||||
}
|
||||
|
||||
impl TextInput {
|
||||
fn backspace(&mut self, _: &Backspace, _: &mut Window, cx: &mut Context<Self>) {
|
||||
let range = if self.selection.is_empty() {
|
||||
prev_grapheme(&self.text, self.cursor())..self.cursor()
|
||||
} else {
|
||||
self.selection.clone()
|
||||
};
|
||||
self.replace(range, "", false, cx);
|
||||
}
|
||||
|
||||
fn delete(&mut self, _: &Delete, _: &mut Window, cx: &mut Context<Self>) {
|
||||
let range = if self.selection.is_empty() {
|
||||
self.cursor()..next_grapheme(&self.text, self.cursor())
|
||||
} else {
|
||||
self.selection.clone()
|
||||
};
|
||||
self.replace(range, "", false, cx);
|
||||
}
|
||||
|
||||
fn delete_word_left(&mut self, _: &DeleteWordLeft, _: &mut Window, cx: &mut Context<Self>) {
|
||||
let range = if self.selection.is_empty() {
|
||||
edit::prev_word(&self.text, self.cursor())..self.cursor()
|
||||
} else {
|
||||
self.selection.clone()
|
||||
};
|
||||
self.replace(range, "", false, cx);
|
||||
}
|
||||
|
||||
fn left(&mut self, _: &Left, _: &mut Window, cx: &mut Context<Self>) {
|
||||
let to = if self.selection.is_empty() {
|
||||
prev_grapheme(&self.text, self.cursor())
|
||||
} else {
|
||||
self.selection.start
|
||||
};
|
||||
self.move_to(to, cx);
|
||||
}
|
||||
|
||||
fn right(&mut self, _: &Right, _: &mut Window, cx: &mut Context<Self>) {
|
||||
let to = if self.selection.is_empty() {
|
||||
next_grapheme(&self.text, self.cursor())
|
||||
} else {
|
||||
self.selection.end
|
||||
};
|
||||
self.move_to(to, cx);
|
||||
}
|
||||
|
||||
/// The offset one visual line above or below the caret.
|
||||
fn vertical(&self, down: bool) -> usize {
|
||||
let caret = self.cursor();
|
||||
let (Some(layout), Some(at)) = (
|
||||
self.layout.as_ref(),
|
||||
self.position_for(self.display_offset(caret)),
|
||||
) else {
|
||||
return caret;
|
||||
};
|
||||
let step = if down {
|
||||
layout.line_height
|
||||
} else {
|
||||
-layout.line_height
|
||||
};
|
||||
let target = gpui::point(at.x, at.y + step + layout.line_height / 2.0);
|
||||
if target.y < gpui::Pixels::ZERO {
|
||||
return 0;
|
||||
}
|
||||
self.offset_for_point(layout.bounds.origin + target - self.scroll)
|
||||
}
|
||||
|
||||
fn up(&mut self, _: &Up, _: &mut Window, cx: &mut Context<Self>) {
|
||||
let to = self.vertical(false);
|
||||
self.move_to(to, cx);
|
||||
}
|
||||
|
||||
fn down(&mut self, _: &Down, _: &mut Window, cx: &mut Context<Self>) {
|
||||
let to = if self.rows.is_some() {
|
||||
self.vertical(true)
|
||||
} else {
|
||||
self.text.len()
|
||||
};
|
||||
self.move_to(to, cx);
|
||||
}
|
||||
|
||||
fn select_left(&mut self, _: &SelectLeft, _: &mut Window, cx: &mut Context<Self>) {
|
||||
self.select_to(prev_grapheme(&self.text, self.cursor()), cx);
|
||||
}
|
||||
|
||||
fn select_right(&mut self, _: &SelectRight, _: &mut Window, cx: &mut Context<Self>) {
|
||||
self.select_to(next_grapheme(&self.text, self.cursor()), cx);
|
||||
}
|
||||
|
||||
fn select_up(&mut self, _: &SelectUp, _: &mut Window, cx: &mut Context<Self>) {
|
||||
let to = self.vertical(false);
|
||||
self.select_to(to, cx);
|
||||
}
|
||||
|
||||
fn select_down(&mut self, _: &SelectDown, _: &mut Window, cx: &mut Context<Self>) {
|
||||
let to = self.vertical(true);
|
||||
self.select_to(to, cx);
|
||||
}
|
||||
|
||||
fn word_left(&mut self, _: &WordLeft, _: &mut Window, cx: &mut Context<Self>) {
|
||||
self.move_to(edit::prev_word(&self.text, self.cursor()), cx);
|
||||
}
|
||||
|
||||
fn word_right(&mut self, _: &WordRight, _: &mut Window, cx: &mut Context<Self>) {
|
||||
self.move_to(edit::next_word(&self.text, self.cursor()), cx);
|
||||
}
|
||||
|
||||
fn select_word_left(&mut self, _: &SelectWordLeft, _: &mut Window, cx: &mut Context<Self>) {
|
||||
self.select_to(edit::prev_word(&self.text, self.cursor()), cx);
|
||||
}
|
||||
|
||||
fn select_word_right(&mut self, _: &SelectWordRight, _: &mut Window, cx: &mut Context<Self>) {
|
||||
self.select_to(edit::next_word(&self.text, self.cursor()), cx);
|
||||
}
|
||||
|
||||
fn home(&mut self, _: &Home, _: &mut Window, cx: &mut Context<Self>) {
|
||||
self.move_to(edit::line_start(&self.text, self.cursor()), cx);
|
||||
}
|
||||
|
||||
fn end(&mut self, _: &End, _: &mut Window, cx: &mut Context<Self>) {
|
||||
self.move_to(edit::line_end(&self.text, self.cursor()), cx);
|
||||
}
|
||||
|
||||
fn select_home(&mut self, _: &SelectHome, _: &mut Window, cx: &mut Context<Self>) {
|
||||
self.select_to(edit::line_start(&self.text, self.cursor()), cx);
|
||||
}
|
||||
|
||||
fn select_end(&mut self, _: &SelectEnd, _: &mut Window, cx: &mut Context<Self>) {
|
||||
self.select_to(edit::line_end(&self.text, self.cursor()), cx);
|
||||
}
|
||||
|
||||
fn select_all(&mut self, _: &SelectAll, _: &mut Window, cx: &mut Context<Self>) {
|
||||
self.selection = 0..self.text.len();
|
||||
self.reversed = false;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn copy(&mut self, _: &Copy, _: &mut Window, cx: &mut Context<Self>) {
|
||||
if self.masked || self.selection.is_empty() {
|
||||
return;
|
||||
}
|
||||
cx.write_to_clipboard(ClipboardItem::new_string(
|
||||
self.text[self.selection.clone()].to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
fn cut(&mut self, _: &Cut, _: &mut Window, cx: &mut Context<Self>) {
|
||||
if self.masked || self.selection.is_empty() {
|
||||
return;
|
||||
}
|
||||
cx.write_to_clipboard(ClipboardItem::new_string(
|
||||
self.text[self.selection.clone()].to_string(),
|
||||
));
|
||||
self.replace(self.selection.clone(), "", false, cx);
|
||||
}
|
||||
|
||||
fn paste(&mut self, _: &Paste, _: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(text) = cx.read_from_clipboard().and_then(|item| item.text()) else {
|
||||
return;
|
||||
};
|
||||
self.replace(self.selection.clone(), &text, false, cx);
|
||||
}
|
||||
|
||||
fn undo(&mut self, _: &Undo, _: &mut Window, cx: &mut Context<Self>) {
|
||||
if self.disabled {
|
||||
return;
|
||||
}
|
||||
if let Some(previous) = self.history.undo(self.snapshot()) {
|
||||
self.restore(previous, cx);
|
||||
}
|
||||
}
|
||||
|
||||
fn redo(&mut self, _: &Redo, _: &mut Window, cx: &mut Context<Self>) {
|
||||
if self.disabled {
|
||||
return;
|
||||
}
|
||||
if let Some(next) = self.history.redo(self.snapshot()) {
|
||||
self.restore(next, cx);
|
||||
}
|
||||
}
|
||||
|
||||
fn enter(&mut self, _: &Enter, _: &mut Window, cx: &mut Context<Self>) {
|
||||
if self.rows.is_some() {
|
||||
self.replace(self.selection.clone(), "\n", false, cx);
|
||||
} else {
|
||||
log::info!("text input: submitted");
|
||||
cx.emit(InputEvent::Submit);
|
||||
}
|
||||
}
|
||||
|
||||
fn submit(&mut self, _: &Submit, _: &mut Window, cx: &mut Context<Self>) {
|
||||
log::info!("text input: submitted");
|
||||
cx.emit(InputEvent::Submit);
|
||||
}
|
||||
|
||||
fn show_character_palette(
|
||||
&mut self,
|
||||
_: &ShowCharacterPalette,
|
||||
window: &mut Window,
|
||||
_: &mut Context<Self>,
|
||||
) {
|
||||
window.show_character_palette();
|
||||
}
|
||||
|
||||
fn mouse_down(&mut self, event: &MouseDownEvent, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if self.disabled {
|
||||
return;
|
||||
}
|
||||
window.focus(&self.focus);
|
||||
let at = self.offset_for_point(event.position);
|
||||
match event.click_count {
|
||||
2 => {
|
||||
let word = edit::word_at(&self.text, at);
|
||||
self.selection = word;
|
||||
self.reversed = false;
|
||||
cx.notify();
|
||||
}
|
||||
3.. => {
|
||||
self.selection = edit::line_start(&self.text, at)..edit::line_end(&self.text, at);
|
||||
self.reversed = false;
|
||||
cx.notify();
|
||||
}
|
||||
_ if event.modifiers.shift => self.select_to(at, cx),
|
||||
_ => {
|
||||
self.selecting = true;
|
||||
self.move_to(at, cx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn mouse_move(&mut self, event: &MouseMoveEvent, _: &mut Window, cx: &mut Context<Self>) {
|
||||
if self.selecting && event.dragging() {
|
||||
let at = self.offset_for_point(event.position);
|
||||
self.select_to(at, cx);
|
||||
}
|
||||
}
|
||||
|
||||
fn mouse_up(&mut self, _: &gpui::MouseUpEvent, _: &mut Window, _: &mut Context<Self>) {
|
||||
self.selecting = false;
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for TextInput {
|
||||
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
div()
|
||||
.id("ely-text-input")
|
||||
.key_context(CONTEXT)
|
||||
.track_focus(&self.focus)
|
||||
.w_full()
|
||||
.cursor(if self.disabled {
|
||||
CursorStyle::OperationNotAllowed
|
||||
} else {
|
||||
CursorStyle::IBeam
|
||||
})
|
||||
.on_action(cx.listener(Self::backspace))
|
||||
.on_action(cx.listener(Self::delete))
|
||||
.on_action(cx.listener(Self::delete_word_left))
|
||||
.on_action(cx.listener(Self::left))
|
||||
.on_action(cx.listener(Self::right))
|
||||
.on_action(cx.listener(Self::up))
|
||||
.on_action(cx.listener(Self::down))
|
||||
.on_action(cx.listener(Self::select_left))
|
||||
.on_action(cx.listener(Self::select_right))
|
||||
.on_action(cx.listener(Self::select_up))
|
||||
.on_action(cx.listener(Self::select_down))
|
||||
.on_action(cx.listener(Self::word_left))
|
||||
.on_action(cx.listener(Self::word_right))
|
||||
.on_action(cx.listener(Self::select_word_left))
|
||||
.on_action(cx.listener(Self::select_word_right))
|
||||
.on_action(cx.listener(Self::home))
|
||||
.on_action(cx.listener(Self::end))
|
||||
.on_action(cx.listener(Self::select_home))
|
||||
.on_action(cx.listener(Self::select_end))
|
||||
.on_action(cx.listener(Self::select_all))
|
||||
.on_action(cx.listener(Self::copy))
|
||||
.on_action(cx.listener(Self::cut))
|
||||
.on_action(cx.listener(Self::paste))
|
||||
.on_action(cx.listener(Self::undo))
|
||||
.on_action(cx.listener(Self::redo))
|
||||
.on_action(cx.listener(Self::enter))
|
||||
.on_action(cx.listener(Self::submit))
|
||||
.on_action(cx.listener(Self::show_character_palette))
|
||||
.on_mouse_down(MouseButton::Left, cx.listener(Self::mouse_down))
|
||||
.on_mouse_move(cx.listener(Self::mouse_move))
|
||||
.on_mouse_up(MouseButton::Left, cx.listener(Self::mouse_up))
|
||||
.on_mouse_up_out(MouseButton::Left, cx.listener(Self::mouse_up))
|
||||
.child(TextElement::new(cx.entity()))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
use std::{
|
||||
ops::Range,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use unicode_segmentation::UnicodeSegmentation;
|
||||
|
||||
pub(crate) fn prev_grapheme(text: &str, offset: usize) -> usize {
|
||||
text.grapheme_indices(true)
|
||||
.rev()
|
||||
.find_map(|(ix, _)| (ix < offset).then_some(ix))
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
pub(crate) fn next_grapheme(text: &str, offset: usize) -> usize {
|
||||
text.grapheme_indices(true)
|
||||
.find_map(|(ix, _)| (ix > offset).then_some(ix))
|
||||
.unwrap_or(text.len())
|
||||
}
|
||||
|
||||
fn words(text: &str) -> impl DoubleEndedIterator<Item = (usize, &str)> {
|
||||
text.split_word_bound_indices()
|
||||
.filter(|(_, word)| !word.trim().is_empty())
|
||||
}
|
||||
|
||||
/// Start of the word before `offset`.
|
||||
pub(crate) fn prev_word(text: &str, offset: usize) -> usize {
|
||||
words(text)
|
||||
.rev()
|
||||
.find_map(|(ix, _)| (ix < offset).then_some(ix))
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// End of the word after `offset`.
|
||||
pub(crate) fn next_word(text: &str, offset: usize) -> usize {
|
||||
words(text)
|
||||
.find_map(|(ix, word)| (ix + word.len() > offset).then_some(ix + word.len()))
|
||||
.unwrap_or(text.len())
|
||||
}
|
||||
|
||||
/// The word around `offset`, for double-click selection.
|
||||
pub(crate) fn word_at(text: &str, offset: usize) -> Range<usize> {
|
||||
text.split_word_bound_indices()
|
||||
.find(|(ix, word)| *ix <= offset && offset < ix + word.len())
|
||||
.map(|(ix, word)| ix..ix + word.len())
|
||||
.unwrap_or(offset..offset)
|
||||
}
|
||||
|
||||
pub(crate) fn line_start(text: &str, offset: usize) -> usize {
|
||||
text[..offset].rfind('\n').map_or(0, |ix| ix + 1)
|
||||
}
|
||||
|
||||
pub(crate) fn line_end(text: &str, offset: usize) -> usize {
|
||||
text[offset..]
|
||||
.find('\n')
|
||||
.map_or(text.len(), |ix| offset + ix)
|
||||
}
|
||||
|
||||
pub(crate) fn to_utf16(text: &str, offset: usize) -> usize {
|
||||
text[..offset].chars().map(char::len_utf16).sum()
|
||||
}
|
||||
|
||||
pub(crate) fn from_utf16(text: &str, offset: usize) -> usize {
|
||||
let mut units = 0;
|
||||
for (ix, ch) in text.char_indices() {
|
||||
if units >= offset {
|
||||
return ix;
|
||||
}
|
||||
units += ch.len_utf16();
|
||||
}
|
||||
text.len()
|
||||
}
|
||||
|
||||
/// Display offset of a content offset when each grapheme shows as `bullet`.
|
||||
pub(crate) fn masked_offset(text: &str, offset: usize, bullet: char) -> usize {
|
||||
text[..offset].graphemes(true).count() * bullet.len_utf8()
|
||||
}
|
||||
|
||||
/// Content offset of a display offset in masked text.
|
||||
pub(crate) fn unmasked_offset(text: &str, display: usize, bullet: char) -> usize {
|
||||
let count = display / bullet.len_utf8();
|
||||
text.grapheme_indices(true)
|
||||
.nth(count)
|
||||
.map_or(text.len(), |(ix, _)| ix)
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) struct Snapshot {
|
||||
pub text: String,
|
||||
pub selection: Range<usize>,
|
||||
}
|
||||
|
||||
/// Typing within this pause joins one undo step.
|
||||
const JOIN: Duration = Duration::from_millis(900);
|
||||
|
||||
/// Undo and redo as whole-text snapshots.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct History {
|
||||
undo: Vec<Snapshot>,
|
||||
redo: Vec<Snapshot>,
|
||||
typing_since: Option<Instant>,
|
||||
}
|
||||
|
||||
impl History {
|
||||
/// Records the state before an edit. Typing in one burst makes one step.
|
||||
pub fn record(&mut self, before: Snapshot, typing: bool) {
|
||||
let joins = typing && self.typing_since.is_some_and(|at| at.elapsed() < JOIN);
|
||||
if !joins {
|
||||
self.undo.push(before);
|
||||
}
|
||||
self.typing_since = typing.then(Instant::now);
|
||||
self.redo.clear();
|
||||
}
|
||||
|
||||
pub fn undo(&mut self, current: Snapshot) -> Option<Snapshot> {
|
||||
let previous = self.undo.pop()?;
|
||||
self.redo.push(current);
|
||||
self.typing_since = None;
|
||||
Some(previous)
|
||||
}
|
||||
|
||||
pub fn redo(&mut self, current: Snapshot) -> Option<Snapshot> {
|
||||
let next = self.redo.pop()?;
|
||||
self.undo.push(current);
|
||||
self.typing_since = None;
|
||||
Some(next)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn graphemes_keep_clusters_whole() {
|
||||
let text = "ae\u{301}🇯🇵z";
|
||||
assert_eq!(next_grapheme(text, 0), 1);
|
||||
assert_eq!(next_grapheme(text, 1), 4);
|
||||
assert_eq!(next_grapheme(text, 4), 12);
|
||||
assert_eq!(prev_grapheme(text, 12), 4);
|
||||
assert_eq!(prev_grapheme(text, 0), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn words_skip_spaces_and_punctuation_stops() {
|
||||
let text = "hello brave, new";
|
||||
assert_eq!(next_word(text, 0), 5);
|
||||
assert_eq!(next_word(text, 5), 12);
|
||||
assert_eq!(prev_word(text, 17), 14);
|
||||
assert_eq!(prev_word(text, 14), 12);
|
||||
assert_eq!(word_at(text, 8), 7..12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lines_and_utf16_round_trip() {
|
||||
let text = "one\ntwo😀\nthree";
|
||||
assert_eq!(line_start(text, 6), 4);
|
||||
assert_eq!(line_end(text, 6), 11);
|
||||
let emoji_end = 11;
|
||||
assert_eq!(to_utf16(text, emoji_end), 9);
|
||||
assert_eq!(from_utf16(text, 9), emoji_end);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn masking_maps_graphemes_to_bullets() {
|
||||
let text = "pa😀s";
|
||||
assert_eq!(masked_offset(text, 6, '•'), 9);
|
||||
assert_eq!(unmasked_offset(text, 9, '•'), 6);
|
||||
assert_eq!(unmasked_offset(text, 12, '•'), text.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typing_joins_one_step_and_redo_clears_on_edit() {
|
||||
let snap = |text: &str| Snapshot {
|
||||
text: text.into(),
|
||||
selection: text.len()..text.len(),
|
||||
};
|
||||
let mut history = History::default();
|
||||
history.record(snap(""), true);
|
||||
history.record(snap("a"), true);
|
||||
history.record(snap("ab"), false);
|
||||
assert_eq!(history.undo(snap("")).unwrap(), snap("ab"));
|
||||
assert_eq!(history.undo(snap("ab")).unwrap(), snap(""));
|
||||
assert!(history.undo(snap("")).is_none());
|
||||
assert_eq!(history.redo(snap("")).unwrap(), snap("ab"));
|
||||
history.record(snap("ab"), false);
|
||||
assert!(history.redo(snap("abc")).is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,410 @@
|
||||
use gpui::{
|
||||
App, AvailableSpace, Bounds, ContentMask, DispatchPhase, Element, ElementId,
|
||||
ElementInputHandler, Entity, GlobalElementId, Hsla, InspectorElementId, IntoElement, LayoutId,
|
||||
MouseMoveEvent, Pixels, Point, SharedString, Style, TextAlign, TextRun, UnderlineStyle, Window,
|
||||
WrappedLine, fill, point, relative, size,
|
||||
};
|
||||
|
||||
use super::{Layout, TextInput};
|
||||
use crate::theme::ActiveTheme;
|
||||
|
||||
pub(crate) struct TextElement {
|
||||
input: Entity<TextInput>,
|
||||
}
|
||||
|
||||
impl TextElement {
|
||||
pub fn new(input: Entity<TextInput>) -> Self {
|
||||
Self { input }
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoElement for TextElement {
|
||||
type Element = Self;
|
||||
|
||||
fn into_element(self) -> Self::Element {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct Prepaint {
|
||||
lines: Vec<(usize, WrappedLine)>,
|
||||
selections: Vec<Bounds<Pixels>>,
|
||||
caret: Option<Bounds<Pixels>>,
|
||||
selection_color: Hsla,
|
||||
caret_color: Hsla,
|
||||
}
|
||||
|
||||
/// What to show, and whether it is the placeholder.
|
||||
fn shown(input: &TextInput) -> (String, bool) {
|
||||
if input.is_empty() {
|
||||
(input.placeholder_text().to_string(), true)
|
||||
} else {
|
||||
(input.display_text(), false)
|
||||
}
|
||||
}
|
||||
|
||||
fn plain_run(len: usize, window: &Window, color: Hsla) -> TextRun {
|
||||
TextRun {
|
||||
len,
|
||||
font: window.text_style().font(),
|
||||
color,
|
||||
background_color: None,
|
||||
underline: None,
|
||||
strikethrough: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Splits `len` bytes into runs: highlights and the marked range over a base color.
|
||||
fn runs(
|
||||
len: usize,
|
||||
base: TextRun,
|
||||
spans: Vec<(std::ops::Range<usize>, Hsla, Option<Hsla>, bool)>,
|
||||
thickness: Pixels,
|
||||
) -> Vec<TextRun> {
|
||||
let mut cuts: Vec<usize> = vec![0, len];
|
||||
for (range, ..) in &spans {
|
||||
cuts.extend([range.start.min(len), range.end.min(len)]);
|
||||
}
|
||||
cuts.sort_unstable();
|
||||
cuts.dedup();
|
||||
cuts.windows(2)
|
||||
.map(|pair| {
|
||||
let (start, end) = (pair[0], pair[1]);
|
||||
let mut run = TextRun {
|
||||
len: end - start,
|
||||
..base.clone()
|
||||
};
|
||||
for (range, color, background, underline) in &spans {
|
||||
if range.start <= start && end <= range.end {
|
||||
run.color = *color;
|
||||
if background.is_some() {
|
||||
run.background_color = *background;
|
||||
}
|
||||
if *underline {
|
||||
run.underline = Some(UnderlineStyle {
|
||||
color: Some(*color),
|
||||
thickness,
|
||||
wavy: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
run
|
||||
})
|
||||
.filter(|run| run.len > 0)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Starts of the hard lines in `text`.
|
||||
fn line_starts(text: &str) -> Vec<usize> {
|
||||
std::iter::once(0)
|
||||
.chain(text.match_indices('\n').map(|(ix, _)| ix + 1))
|
||||
.collect()
|
||||
}
|
||||
|
||||
impl Element for TextElement {
|
||||
type RequestLayoutState = ();
|
||||
type PrepaintState = Prepaint;
|
||||
|
||||
fn id(&self) -> Option<ElementId> {
|
||||
None
|
||||
}
|
||||
|
||||
fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
|
||||
None
|
||||
}
|
||||
|
||||
fn request_layout(
|
||||
&mut self,
|
||||
_: Option<&GlobalElementId>,
|
||||
_: Option<&InspectorElementId>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> (LayoutId, ()) {
|
||||
let input = self.input.read(cx);
|
||||
let line_height = window.line_height();
|
||||
let mut style = Style::default();
|
||||
style.size.width = relative(1.).into();
|
||||
let Some((min, max)) = input.rows() else {
|
||||
style.size.height = line_height.into();
|
||||
return (window.request_layout(style, [], cx), ());
|
||||
};
|
||||
let (text, _) = shown(input);
|
||||
let font_size = window.text_style().font_size.to_pixels(window.rem_size());
|
||||
let run = plain_run(text.len(), window, Hsla::default());
|
||||
let layout = window.request_measured_layout(style, move |known, available, window, _| {
|
||||
let width = known.width.or(match available.width {
|
||||
AvailableSpace::Definite(width) => Some(width),
|
||||
_ => None,
|
||||
});
|
||||
let lines = window
|
||||
.text_system()
|
||||
.shape_text(
|
||||
SharedString::from(text.clone()),
|
||||
font_size,
|
||||
std::slice::from_ref(&run),
|
||||
width,
|
||||
None,
|
||||
)
|
||||
.expect("text input shaping failed");
|
||||
let rows: usize = lines
|
||||
.iter()
|
||||
.map(|line| line.wrap_boundaries().len() + 1)
|
||||
.sum();
|
||||
size(
|
||||
width.unwrap_or_default(),
|
||||
line_height * rows.clamp(min, max) as f32,
|
||||
)
|
||||
});
|
||||
(layout, ())
|
||||
}
|
||||
|
||||
fn prepaint(
|
||||
&mut self,
|
||||
_: Option<&GlobalElementId>,
|
||||
_: Option<&InspectorElementId>,
|
||||
bounds: Bounds<Pixels>,
|
||||
_: &mut (),
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> Prepaint {
|
||||
let theme = cx.theme();
|
||||
let (selection_color, caret_color, subtle) = (
|
||||
theme.colors.selection,
|
||||
theme.colors.focus,
|
||||
theme.colors.fg_subtle,
|
||||
);
|
||||
let caret_width = theme.caret_width().to_pixels(window.rem_size());
|
||||
let thickness = theme.underline_thickness();
|
||||
let input = self.input.read(cx);
|
||||
let (text, placeholder) = shown(input);
|
||||
let base = plain_run(
|
||||
text.len(),
|
||||
window,
|
||||
if placeholder {
|
||||
subtle
|
||||
} else {
|
||||
window.text_style().color
|
||||
},
|
||||
);
|
||||
let mut spans = Vec::new();
|
||||
if !placeholder {
|
||||
for (range, highlight) in input.highlights(cx) {
|
||||
spans.push((range, highlight.color, highlight.background, false));
|
||||
}
|
||||
if let Some(marked) = input.marked() {
|
||||
let color = window.text_style().color;
|
||||
let marked = input.display_offset(marked.start)..input.display_offset(marked.end);
|
||||
spans.push((marked, color, None, true));
|
||||
}
|
||||
}
|
||||
let font_size = window.text_style().font_size.to_pixels(window.rem_size());
|
||||
let line_height = window.line_height();
|
||||
let wrap = input.rows().map(|_| bounds.size.width);
|
||||
let shaped = window
|
||||
.text_system()
|
||||
.shape_text(
|
||||
text.clone().into(),
|
||||
font_size,
|
||||
&runs(text.len(), base, spans, thickness),
|
||||
wrap,
|
||||
None,
|
||||
)
|
||||
.expect("text input shaping failed");
|
||||
let lines: Vec<(usize, WrappedLine)> = line_starts(&text).into_iter().zip(shaped).collect();
|
||||
let focused = input.focus().is_focused(window);
|
||||
let caret_on = input.caret_on();
|
||||
self.input.update(cx, |input, _| {
|
||||
input.layout = Some(Layout {
|
||||
lines: lines.clone(),
|
||||
bounds,
|
||||
line_height,
|
||||
placeholder,
|
||||
});
|
||||
});
|
||||
let input = self.input.read(cx);
|
||||
let caret_at = input
|
||||
.position_for(input.display_offset(input.cursor()))
|
||||
.expect("caret offset lies inside the laid-out text");
|
||||
let scroll = keep_in_view(input, &lines, caret_at, caret_width, bounds, line_height);
|
||||
let selection = input.selection();
|
||||
let selections = if placeholder || selection.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
let (start, end) = (
|
||||
input.display_offset(selection.start),
|
||||
input.display_offset(selection.end),
|
||||
);
|
||||
selection_rects(&lines, start, end, line_height, bounds.size.width)
|
||||
.into_iter()
|
||||
.map(|rect| Bounds::new(rect.origin + bounds.origin - scroll, rect.size))
|
||||
.collect()
|
||||
};
|
||||
let caret = (focused && caret_on && selection.is_empty()).then(|| {
|
||||
Bounds::new(
|
||||
bounds.origin + caret_at - scroll,
|
||||
size(caret_width, line_height),
|
||||
)
|
||||
});
|
||||
self.input.update(cx, |input, _| input.scroll = scroll);
|
||||
Prepaint {
|
||||
lines,
|
||||
selections,
|
||||
caret,
|
||||
selection_color,
|
||||
caret_color,
|
||||
}
|
||||
}
|
||||
|
||||
fn paint(
|
||||
&mut self,
|
||||
_: Option<&GlobalElementId>,
|
||||
_: Option<&InspectorElementId>,
|
||||
bounds: Bounds<Pixels>,
|
||||
_: &mut (),
|
||||
prepaint: &mut Prepaint,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
let input = self.input.read(cx);
|
||||
let (focus, scroll, selecting) = (input.focus().clone(), input.scroll, input.selecting());
|
||||
window.handle_input(
|
||||
&focus,
|
||||
ElementInputHandler::new(bounds, self.input.clone()),
|
||||
cx,
|
||||
);
|
||||
let line_height = window.line_height();
|
||||
window.with_content_mask(Some(ContentMask { bounds }), |window| {
|
||||
for rect in prepaint.selections.drain(..) {
|
||||
window.paint_quad(fill(rect, prepaint.selection_color));
|
||||
}
|
||||
let mut top = Pixels::ZERO;
|
||||
for (_, line) in &prepaint.lines {
|
||||
line.paint(
|
||||
bounds.origin - scroll + point(Pixels::ZERO, top),
|
||||
line_height,
|
||||
TextAlign::Left,
|
||||
None,
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
.expect("text input paint failed");
|
||||
top += line.size(line_height).height;
|
||||
}
|
||||
if let Some(caret) = prepaint.caret.take() {
|
||||
window.paint_quad(fill(caret, prepaint.caret_color));
|
||||
}
|
||||
});
|
||||
if selecting {
|
||||
let input = self.input.clone();
|
||||
window.on_mouse_event(move |event: &MouseMoveEvent, phase, _, cx| {
|
||||
if phase == DispatchPhase::Bubble && event.dragging() {
|
||||
input.update(cx, |input, cx| {
|
||||
let at = input.offset_for_point(event.position);
|
||||
input.select_to(at, cx);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Scroll that keeps the caret inside the box, clamped to the text.
|
||||
fn keep_in_view(
|
||||
input: &TextInput,
|
||||
lines: &[(usize, WrappedLine)],
|
||||
caret: Point<Pixels>,
|
||||
caret_width: Pixels,
|
||||
bounds: Bounds<Pixels>,
|
||||
line_height: Pixels,
|
||||
) -> Point<Pixels> {
|
||||
let mut scroll = input.scroll;
|
||||
if input.is_multi_line() {
|
||||
let content = lines.iter().fold(Pixels::ZERO, |sum, (_, line)| {
|
||||
sum + line.size(line_height).height
|
||||
});
|
||||
let view = bounds.size.height;
|
||||
if caret.y < scroll.y {
|
||||
scroll.y = caret.y;
|
||||
} else if caret.y + line_height > scroll.y + view {
|
||||
scroll.y = caret.y + line_height - view;
|
||||
}
|
||||
scroll.y = scroll
|
||||
.y
|
||||
.clamp(Pixels::ZERO, (content - view).max(Pixels::ZERO));
|
||||
scroll.x = Pixels::ZERO;
|
||||
} else {
|
||||
let content = lines.first().map_or(Pixels::ZERO, |(_, line)| line.width()) + caret_width;
|
||||
let view = bounds.size.width;
|
||||
if caret.x < scroll.x {
|
||||
scroll.x = caret.x;
|
||||
} else if caret.x + caret_width > scroll.x + view {
|
||||
scroll.x = caret.x + caret_width - view;
|
||||
}
|
||||
scroll.x = scroll
|
||||
.x
|
||||
.clamp(Pixels::ZERO, (content - view).max(Pixels::ZERO));
|
||||
scroll.y = Pixels::ZERO;
|
||||
}
|
||||
scroll
|
||||
}
|
||||
|
||||
/// Boxes covering display offsets `start..end`, relative to the text origin.
|
||||
fn selection_rects(
|
||||
lines: &[(usize, WrappedLine)],
|
||||
start: usize,
|
||||
end: usize,
|
||||
line_height: Pixels,
|
||||
width: Pixels,
|
||||
) -> Vec<Bounds<Pixels>> {
|
||||
let mut rects = Vec::new();
|
||||
let mut top = Pixels::ZERO;
|
||||
for (line_start, line) in lines {
|
||||
let line_end = line_start + line.len();
|
||||
let (from, to) = (start.max(*line_start), end.min(line_end));
|
||||
if from <= to && start <= line_end && end >= *line_start {
|
||||
let at = |ix: usize| {
|
||||
line.position_for_index(ix - line_start, line_height)
|
||||
.unwrap_or_default()
|
||||
};
|
||||
let (a, b) = (at(from), at(to));
|
||||
let edge = if end > line_end { width } else { b.x };
|
||||
if a.y == b.y {
|
||||
rects.push(Bounds::from_corners(
|
||||
point(a.x, top + a.y),
|
||||
point(edge.max(a.x), top + a.y + line_height),
|
||||
));
|
||||
} else {
|
||||
rects.push(Bounds::from_corners(
|
||||
point(a.x, top + a.y),
|
||||
point(width, top + a.y + line_height),
|
||||
));
|
||||
let mut y = a.y + line_height;
|
||||
while y < b.y {
|
||||
rects.push(Bounds::from_corners(
|
||||
point(Pixels::ZERO, top + y),
|
||||
point(width, top + y + line_height),
|
||||
));
|
||||
y += line_height;
|
||||
}
|
||||
rects.push(Bounds::from_corners(
|
||||
point(Pixels::ZERO, top + b.y),
|
||||
point(edge, top + b.y + line_height),
|
||||
));
|
||||
}
|
||||
}
|
||||
top += line.size(line_height).height;
|
||||
}
|
||||
rects
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::line_starts;
|
||||
|
||||
#[test]
|
||||
fn line_starts_follow_newlines() {
|
||||
assert_eq!(line_starts("ab\ncd\n"), vec![0, 3, 6]);
|
||||
assert_eq!(line_starts(""), vec![0]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
use std::ops::Range;
|
||||
|
||||
use gpui::{Bounds, Context, EntityInputHandler, Pixels, Point, UTF16Selection, Window};
|
||||
|
||||
use super::{
|
||||
TextInput,
|
||||
edit::{from_utf16, to_utf16},
|
||||
};
|
||||
|
||||
impl TextInput {
|
||||
fn range_from_utf16(&self, range: &Range<usize>) -> Range<usize> {
|
||||
from_utf16(&self.text, range.start)..from_utf16(&self.text, range.end)
|
||||
}
|
||||
|
||||
fn range_to_utf16(&self, range: &Range<usize>) -> Range<usize> {
|
||||
to_utf16(&self.text, range.start)..to_utf16(&self.text, range.end)
|
||||
}
|
||||
}
|
||||
|
||||
impl EntityInputHandler for TextInput {
|
||||
fn text_for_range(
|
||||
&mut self,
|
||||
range_utf16: Range<usize>,
|
||||
actual_range: &mut Option<Range<usize>>,
|
||||
_: &mut Window,
|
||||
_: &mut Context<Self>,
|
||||
) -> Option<String> {
|
||||
let range = self.range_from_utf16(&range_utf16);
|
||||
actual_range.replace(self.range_to_utf16(&range));
|
||||
Some(self.text[range].to_string())
|
||||
}
|
||||
|
||||
fn selected_text_range(
|
||||
&mut self,
|
||||
_: bool,
|
||||
_: &mut Window,
|
||||
_: &mut Context<Self>,
|
||||
) -> Option<UTF16Selection> {
|
||||
Some(UTF16Selection {
|
||||
range: self.range_to_utf16(&self.selection),
|
||||
reversed: self.reversed,
|
||||
})
|
||||
}
|
||||
|
||||
fn marked_text_range(&self, _: &mut Window, _: &mut Context<Self>) -> Option<Range<usize>> {
|
||||
self.marked.as_ref().map(|range| self.range_to_utf16(range))
|
||||
}
|
||||
|
||||
fn unmark_text(&mut self, _: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(marked) = self.marked.clone() else {
|
||||
return;
|
||||
};
|
||||
let composed = self.text[marked.clone()].to_string();
|
||||
self.replace(marked, &composed, true, cx);
|
||||
}
|
||||
|
||||
fn replace_text_in_range(
|
||||
&mut self,
|
||||
range_utf16: Option<Range<usize>>,
|
||||
text: &str,
|
||||
_: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
log::debug!(
|
||||
"text input {:?}: replace {range_utf16:?} marked {:?} selection {:?} in {} bytes",
|
||||
cx.entity_id(),
|
||||
self.marked,
|
||||
self.selection,
|
||||
self.text.len()
|
||||
);
|
||||
let range = range_utf16
|
||||
.map(|range| self.range_from_utf16(&range))
|
||||
.or(self.marked.clone())
|
||||
.unwrap_or(self.selection.clone());
|
||||
self.replace(range, text, true, cx);
|
||||
}
|
||||
|
||||
fn replace_and_mark_text_in_range(
|
||||
&mut self,
|
||||
range_utf16: Option<Range<usize>>,
|
||||
text: &str,
|
||||
selected_utf16: Option<Range<usize>>,
|
||||
_: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if self.disabled {
|
||||
return;
|
||||
}
|
||||
let range = range_utf16
|
||||
.map(|range| self.range_from_utf16(&range))
|
||||
.or(self.marked.clone())
|
||||
.unwrap_or(self.selection.clone());
|
||||
if self.marked.is_none() {
|
||||
self.composing = Some(self.snapshot());
|
||||
}
|
||||
self.text.replace_range(range.clone(), text);
|
||||
self.marked = (!text.is_empty()).then(|| range.start..range.start + text.len());
|
||||
if self.marked.is_none() {
|
||||
self.composing = None;
|
||||
}
|
||||
self.selection = match selected_utf16 {
|
||||
Some(inner) => {
|
||||
let inner = from_utf16(text, inner.start)..from_utf16(text, inner.end);
|
||||
range.start + inner.start..range.start + inner.end
|
||||
}
|
||||
None => range.start + text.len()..range.start + text.len(),
|
||||
};
|
||||
self.reversed = false;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn bounds_for_range(
|
||||
&mut self,
|
||||
range_utf16: Range<usize>,
|
||||
bounds: Bounds<Pixels>,
|
||||
_: &mut Window,
|
||||
_: &mut Context<Self>,
|
||||
) -> Option<Bounds<Pixels>> {
|
||||
let range = self.range_from_utf16(&range_utf16);
|
||||
let line_height = self.layout.as_ref()?.line_height;
|
||||
let start = self.position_for(self.display_offset(range.start))?;
|
||||
let end = self.position_for(self.display_offset(range.end))?;
|
||||
let origin = bounds.origin - self.scroll;
|
||||
Some(Bounds::from_corners(
|
||||
origin + start,
|
||||
origin + gpui::point(end.x, end.y + line_height),
|
||||
))
|
||||
}
|
||||
|
||||
fn character_index_for_point(
|
||||
&mut self,
|
||||
point: Point<Pixels>,
|
||||
_: &mut Window,
|
||||
_: &mut Context<Self>,
|
||||
) -> Option<usize> {
|
||||
self.layout.as_ref()?;
|
||||
Some(to_utf16(&self.text, self.offset_for_point(point)))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,496 @@
|
||||
mod actions;
|
||||
mod edit;
|
||||
mod element;
|
||||
mod ime;
|
||||
|
||||
use std::{ops::Range, rc::Rc, time::Duration};
|
||||
|
||||
use gpui::{
|
||||
App, Bounds, Context, EventEmitter, FocusHandle, Focusable, Hsla, Pixels, Point, SharedString,
|
||||
Subscription, Task, Window, WrappedLine,
|
||||
};
|
||||
|
||||
pub(crate) use actions::{Backspace, Down, Enter, Up, bind_keys};
|
||||
use edit::{History, Snapshot};
|
||||
|
||||
use crate::theme::ActiveTheme;
|
||||
|
||||
/// What a text input tells its owner.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum InputEvent {
|
||||
Changed,
|
||||
/// Enter in one line, or the submit key in several.
|
||||
Submit,
|
||||
Focus,
|
||||
Blur,
|
||||
}
|
||||
|
||||
/// Colors a span of the text: foreground, and an optional wash behind it.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Highlight {
|
||||
pub color: Hsla,
|
||||
pub background: Option<Hsla>,
|
||||
}
|
||||
|
||||
type Highlighter = Rc<dyn Fn(&str, &App) -> Vec<(Range<usize>, Highlight)>>;
|
||||
type Filter = Rc<dyn Fn(char) -> bool>;
|
||||
pub(crate) type Fit = Rc<dyn Fn(&str, Range<usize>, &str) -> (String, usize)>;
|
||||
|
||||
/// The last shaped text: one entry per hard line, with its display offset.
|
||||
pub(crate) struct Layout {
|
||||
pub lines: Vec<(usize, WrappedLine)>,
|
||||
pub bounds: Bounds<Pixels>,
|
||||
pub line_height: Pixels,
|
||||
/// The lines show the placeholder, not content.
|
||||
pub placeholder: bool,
|
||||
}
|
||||
|
||||
const BULLET: char = '•';
|
||||
const BLINK: Duration = Duration::from_millis(530);
|
||||
|
||||
/// Editable text: one line or many, with selection, undo, IME and clipboard.
|
||||
pub struct TextInput {
|
||||
focus: FocusHandle,
|
||||
text: String,
|
||||
placeholder: SharedString,
|
||||
selection: Range<usize>,
|
||||
reversed: bool,
|
||||
marked: Option<Range<usize>>,
|
||||
composing: Option<Snapshot>,
|
||||
rows: Option<(usize, usize)>,
|
||||
masked: bool,
|
||||
filter: Option<Filter>,
|
||||
fit: Option<Fit>,
|
||||
max_len: Option<usize>,
|
||||
highlighter: Option<Highlighter>,
|
||||
disabled: bool,
|
||||
history: History,
|
||||
pub(crate) layout: Option<Layout>,
|
||||
pub(crate) scroll: Point<Pixels>,
|
||||
selecting: bool,
|
||||
caret_on: bool,
|
||||
blink_epoch: u64,
|
||||
_blink: Option<Task<()>>,
|
||||
_subscriptions: Vec<Subscription>,
|
||||
}
|
||||
|
||||
impl EventEmitter<InputEvent> for TextInput {}
|
||||
|
||||
impl Focusable for TextInput {
|
||||
fn focus_handle(&self, _: &App) -> FocusHandle {
|
||||
self.focus.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl TextInput {
|
||||
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let focus = cx.focus_handle().tab_stop(true);
|
||||
let subscriptions = vec![
|
||||
cx.on_focus(&focus, window, |input, _, cx| {
|
||||
input.restart_blink(cx);
|
||||
cx.emit(InputEvent::Focus);
|
||||
}),
|
||||
cx.on_blur(&focus, window, |input, _, cx| {
|
||||
input.selecting = false;
|
||||
input._blink = None;
|
||||
cx.emit(InputEvent::Blur);
|
||||
cx.notify();
|
||||
}),
|
||||
];
|
||||
Self {
|
||||
focus,
|
||||
text: String::new(),
|
||||
placeholder: SharedString::default(),
|
||||
selection: 0..0,
|
||||
reversed: false,
|
||||
marked: None,
|
||||
composing: None,
|
||||
rows: None,
|
||||
masked: false,
|
||||
filter: None,
|
||||
fit: None,
|
||||
max_len: None,
|
||||
highlighter: None,
|
||||
disabled: false,
|
||||
history: History::default(),
|
||||
layout: None,
|
||||
scroll: Point::default(),
|
||||
selecting: false,
|
||||
caret_on: true,
|
||||
blink_epoch: 0,
|
||||
_blink: None,
|
||||
_subscriptions: subscriptions,
|
||||
}
|
||||
}
|
||||
|
||||
/// Several lines: grows from `min` rows to `max`, then scrolls.
|
||||
pub fn multi_line(mut self, min: usize, max: usize) -> Self {
|
||||
assert!(0 < min && min <= max, "text rows {min}..={max} are empty");
|
||||
self.rows = Some((min, max));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn placeholder(mut self, text: impl Into<SharedString>) -> Self {
|
||||
self.placeholder = text.into();
|
||||
self
|
||||
}
|
||||
|
||||
/// Shows bullets instead of the text, and keeps it off the clipboard.
|
||||
pub fn masked(mut self) -> Self {
|
||||
self.masked = true;
|
||||
self
|
||||
}
|
||||
|
||||
/// Drops typed or pasted characters `allow` rejects.
|
||||
pub fn filter(mut self, allow: impl Fn(char) -> bool + 'static) -> Self {
|
||||
self.filter = Some(Rc::new(allow));
|
||||
self
|
||||
}
|
||||
|
||||
/// Longest text, in characters.
|
||||
pub fn max_len(mut self, len: usize) -> Self {
|
||||
self.max_len = Some(len);
|
||||
self
|
||||
}
|
||||
|
||||
/// Colors spans of the text as it changes.
|
||||
pub fn highlighter(
|
||||
mut self,
|
||||
highlight: impl Fn(&str, &App) -> Vec<(Range<usize>, Highlight)> + 'static,
|
||||
) -> Self {
|
||||
self.highlighter = Some(Rc::new(highlight));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn text(&self) -> &str {
|
||||
&self.text
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.text.is_empty()
|
||||
}
|
||||
|
||||
pub fn is_masked(&self) -> bool {
|
||||
self.masked
|
||||
}
|
||||
|
||||
pub fn is_multi_line(&self) -> bool {
|
||||
self.rows.is_some()
|
||||
}
|
||||
|
||||
pub fn is_disabled(&self) -> bool {
|
||||
self.disabled
|
||||
}
|
||||
|
||||
pub fn rows(&self) -> Option<(usize, usize)> {
|
||||
self.rows
|
||||
}
|
||||
|
||||
pub fn placeholder_text(&self) -> &SharedString {
|
||||
&self.placeholder
|
||||
}
|
||||
|
||||
pub fn selection(&self) -> Range<usize> {
|
||||
self.selection.clone()
|
||||
}
|
||||
|
||||
pub fn cursor(&self) -> usize {
|
||||
if self.reversed {
|
||||
self.selection.start
|
||||
} else {
|
||||
self.selection.end
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_placeholder(&mut self, text: impl Into<SharedString>, cx: &mut Context<Self>) {
|
||||
self.placeholder = text.into();
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub fn set_masked(&mut self, masked: bool, cx: &mut Context<Self>) {
|
||||
self.masked = masked;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub fn set_disabled(&mut self, disabled: bool, cx: &mut Context<Self>) {
|
||||
self.disabled = disabled;
|
||||
self.focus = self.focus.clone().tab_stop(!disabled);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Replaces all text and puts the caret at its end. Not an undo step.
|
||||
pub fn set_text(&mut self, text: impl Into<String>, cx: &mut Context<Self>) {
|
||||
let text = self.admit(&text.into(), 0);
|
||||
let text = match &self.fit {
|
||||
Some(fit) => fit(&self.text, 0..self.text.len(), &text).0,
|
||||
None => text,
|
||||
};
|
||||
self.selection = text.len()..text.len();
|
||||
self.text = text;
|
||||
self.marked = None;
|
||||
self.composing = None;
|
||||
cx.emit(InputEvent::Changed);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Reshapes every edit, as a mask does: text, replaced range and typed text in; text and caret out.
|
||||
pub(crate) fn set_fit(&mut self, fit: Fit) {
|
||||
self.fit = Some(fit);
|
||||
}
|
||||
|
||||
/// Replaces the selection, as typing would. An undo step.
|
||||
pub fn insert(&mut self, text: &str, cx: &mut Context<Self>) {
|
||||
self.replace(self.selection.clone(), text, false, cx);
|
||||
}
|
||||
|
||||
pub fn select(&mut self, range: Range<usize>, cx: &mut Context<Self>) {
|
||||
assert!(
|
||||
range.start <= range.end && range.end <= self.text.len(),
|
||||
"selection {range:?} is outside {} bytes",
|
||||
self.text.len()
|
||||
);
|
||||
self.selection = range;
|
||||
self.reversed = false;
|
||||
self.restart_blink(cx);
|
||||
}
|
||||
|
||||
/// The caret's box in window coordinates, once laid out.
|
||||
pub fn caret_bounds(&self) -> Option<Bounds<Pixels>> {
|
||||
self.bounds_for(self.cursor())
|
||||
}
|
||||
|
||||
/// An offset's box in the window, from the last layout; later offsets clamp to its end.
|
||||
pub fn bounds_for(&self, offset: usize) -> Option<Bounds<Pixels>> {
|
||||
let layout = self.layout.as_ref()?;
|
||||
let laid = layout
|
||||
.lines
|
||||
.last()
|
||||
.map_or(0, |(start, line)| start + line.len());
|
||||
let at = self.position_for(self.display_offset(offset).min(laid))?;
|
||||
Some(Bounds::new(
|
||||
layout.bounds.origin + at - self.scroll,
|
||||
gpui::size(Pixels::ZERO, layout.line_height),
|
||||
))
|
||||
}
|
||||
|
||||
/// Keeps what `filter` and `max_len` allow of `incoming`.
|
||||
fn admit(&self, incoming: &str, keep: usize) -> String {
|
||||
let allowed: String = incoming
|
||||
.chars()
|
||||
.filter(|ch| self.filter.as_ref().is_none_or(|allow| allow(*ch)))
|
||||
.collect();
|
||||
match self.max_len {
|
||||
Some(max) => allowed.chars().take(max.saturating_sub(keep)).collect(),
|
||||
None => allowed,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn replace(
|
||||
&mut self,
|
||||
range: Range<usize>,
|
||||
incoming: &str,
|
||||
typing: bool,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if self.disabled {
|
||||
return;
|
||||
}
|
||||
let incoming = if self.rows.is_none() {
|
||||
incoming.replace(['\n', '\r'], " ")
|
||||
} else {
|
||||
incoming.replace("\r\n", "\n")
|
||||
};
|
||||
let kept = self.text.chars().count() - self.text[range.clone()].chars().count();
|
||||
let incoming = self.admit(&incoming, kept);
|
||||
if incoming.is_empty() && range.is_empty() {
|
||||
return;
|
||||
}
|
||||
let composed = self.composing.take();
|
||||
let before = composed.clone().unwrap_or_else(|| self.snapshot());
|
||||
let caret = match &self.fit {
|
||||
Some(fit) => {
|
||||
let (base, range) = match &composed {
|
||||
Some(start) => (start.text.as_str(), start.selection.clone()),
|
||||
None => (self.text.as_str(), range),
|
||||
};
|
||||
let (text, caret) = fit(base, range, &incoming);
|
||||
self.text = text;
|
||||
caret
|
||||
}
|
||||
None => {
|
||||
self.text.replace_range(range.clone(), &incoming);
|
||||
range.start + incoming.len()
|
||||
}
|
||||
};
|
||||
self.selection = caret..caret;
|
||||
self.reversed = false;
|
||||
self.marked = None;
|
||||
self.commit(before, typing, cx);
|
||||
self.restart_blink(cx);
|
||||
}
|
||||
|
||||
/// Makes `before` one undo step, if the text moved on from it.
|
||||
fn commit(&mut self, before: Snapshot, typing: bool, cx: &mut Context<Self>) {
|
||||
if before.text != self.text {
|
||||
self.history.record(before, typing);
|
||||
cx.emit(InputEvent::Changed);
|
||||
}
|
||||
}
|
||||
|
||||
fn snapshot(&self) -> Snapshot {
|
||||
Snapshot {
|
||||
text: self.text.clone(),
|
||||
selection: self.selection.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn restore(&mut self, snapshot: Snapshot, cx: &mut Context<Self>) {
|
||||
self.text = snapshot.text;
|
||||
self.selection = snapshot.selection;
|
||||
self.reversed = false;
|
||||
self.marked = None;
|
||||
self.composing = None;
|
||||
cx.emit(InputEvent::Changed);
|
||||
self.restart_blink(cx);
|
||||
}
|
||||
|
||||
pub(crate) fn move_to(&mut self, offset: usize, cx: &mut Context<Self>) {
|
||||
self.selection = offset..offset;
|
||||
self.reversed = false;
|
||||
self.restart_blink(cx);
|
||||
}
|
||||
|
||||
pub(crate) fn select_to(&mut self, offset: usize, cx: &mut Context<Self>) {
|
||||
if self.reversed {
|
||||
self.selection.start = offset;
|
||||
} else {
|
||||
self.selection.end = offset;
|
||||
}
|
||||
if self.selection.end < self.selection.start {
|
||||
self.reversed = !self.reversed;
|
||||
self.selection = self.selection.end..self.selection.start;
|
||||
}
|
||||
self.restart_blink(cx);
|
||||
}
|
||||
|
||||
/// Shows the caret now and restarts its blink.
|
||||
fn restart_blink(&mut self, cx: &mut Context<Self>) {
|
||||
self.caret_on = true;
|
||||
self.blink_epoch += 1;
|
||||
let epoch = self.blink_epoch;
|
||||
let still = cx.theme().reduced_motion;
|
||||
self._blink = (!still).then(|| {
|
||||
cx.spawn(async move |input, cx| {
|
||||
loop {
|
||||
cx.background_executor().timer(BLINK).await;
|
||||
let alive = input.update(cx, |input, cx| {
|
||||
if input.blink_epoch == epoch {
|
||||
input.caret_on = !input.caret_on;
|
||||
cx.notify();
|
||||
}
|
||||
});
|
||||
if alive.is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
})
|
||||
});
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// What the element shows: bullets when masked.
|
||||
pub(crate) fn display_text(&self) -> String {
|
||||
if self.masked {
|
||||
BULLET.to_string().repeat(self.grapheme_count())
|
||||
} else {
|
||||
self.text.clone()
|
||||
}
|
||||
}
|
||||
|
||||
fn grapheme_count(&self) -> usize {
|
||||
unicode_segmentation::UnicodeSegmentation::graphemes(self.text.as_str(), true).count()
|
||||
}
|
||||
|
||||
pub(crate) fn display_offset(&self, offset: usize) -> usize {
|
||||
if self.masked {
|
||||
edit::masked_offset(&self.text, offset, BULLET)
|
||||
} else {
|
||||
offset
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn content_offset(&self, display: usize) -> usize {
|
||||
if self.masked {
|
||||
edit::unmasked_offset(&self.text, display, BULLET)
|
||||
} else {
|
||||
display
|
||||
}
|
||||
}
|
||||
|
||||
/// Top-left of a display offset, relative to the text's origin.
|
||||
pub(crate) fn position_for(&self, display: usize) -> Option<Point<Pixels>> {
|
||||
let layout = self.layout.as_ref()?;
|
||||
if layout.placeholder {
|
||||
return Some(Point::default());
|
||||
}
|
||||
let mut top = Pixels::ZERO;
|
||||
for (start, line) in &layout.lines {
|
||||
if display <= start + line.len() {
|
||||
let at = line.position_for_index(display - start, layout.line_height)?;
|
||||
return Some(gpui::point(at.x, at.y + top));
|
||||
}
|
||||
top += line.size(layout.line_height).height;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The content offset nearest a window position.
|
||||
pub(crate) fn offset_for_point(&self, point: Point<Pixels>) -> usize {
|
||||
let Some(layout) = self.layout.as_ref() else {
|
||||
return 0;
|
||||
};
|
||||
if layout.placeholder {
|
||||
return 0;
|
||||
}
|
||||
let local = point - layout.bounds.origin + self.scroll;
|
||||
if local.y < Pixels::ZERO {
|
||||
return 0;
|
||||
}
|
||||
let mut top = Pixels::ZERO;
|
||||
for (start, line) in &layout.lines {
|
||||
let height = line.size(layout.line_height).height;
|
||||
if local.y < top + height {
|
||||
let inside = gpui::point(local.x.max(Pixels::ZERO), local.y - top);
|
||||
let index = match line.closest_index_for_position(inside, layout.line_height) {
|
||||
Ok(index) | Err(index) => index,
|
||||
};
|
||||
return self.content_offset(start + index.min(line.len()));
|
||||
}
|
||||
top += height;
|
||||
}
|
||||
self.text.len()
|
||||
}
|
||||
|
||||
pub(crate) fn highlights(&self, cx: &App) -> Vec<(Range<usize>, Highlight)> {
|
||||
match (&self.highlighter, self.masked) {
|
||||
(Some(highlight), false) => highlight(&self.text, cx),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn marked(&self) -> Option<Range<usize>> {
|
||||
self.marked.clone()
|
||||
}
|
||||
|
||||
pub(crate) fn selecting(&self) -> bool {
|
||||
self.selecting
|
||||
}
|
||||
|
||||
pub(crate) fn caret_on(&self) -> bool {
|
||||
self.caret_on
|
||||
}
|
||||
|
||||
pub(crate) fn focus(&self) -> &FocusHandle {
|
||||
&self.focus
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user