T06a forms text: text input core, fields, numbers, masks, pin, inline, groups, tags, pairs, path, regex, expression, hotkey, mention

This commit is contained in:
ZacharyZhang-NY committed 2026-09-25 05:38:24 -04:00
1 parent 817da41c22
commit ec5713c10b
41 files changed
+6233 -45

No files matched your search

+391
View File
@@ -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()))
}
}
+189
View File
@@ -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());
}
}
+410
View File
@@ -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]);
}
}
+139
View File
@@ -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)))
}
}
+496
View File
@@ -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
}
}