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

+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
}
}