Merge PR #86: Add EditableText 'text_input' and 'text_area' elements
* temportalflux/elements-input: (117 commits) remove legacy input example now that gpui_elements::editable_text exists remove stop_propagation on_mouse_up to avoid unintended consequence of input always highlighting/selecting while mousing over add gpui_elements root documentation so its not weird that the docs page is empty add an extremely basic example for editable_text which only shows a subset of what is documented in the module fix caret getting stuck as visible without focus when the text field pauses blinking while it is without focus stop propagation of mouse events when clicking on editable text, so that users which consume click events on wrapping elements do not receive events when they are used to focus/update the editable text state add simpler api for configuring the caret blink interval via element add external api for access to caret by routing it through keyed element data fixup typos refactor text position queries by factoring out common logic first pass at simplifying text position queries rework caret implementaiton to increase clarity and reduce code duplication reorganize element implementation to be more readable assign default editable text colors as consts reduce complexity of mouse click handler and cut/copy handlers reduce bounds_for_range to be more readable remove EditableTextState::storage() in favor of direct access to the member resolve clippy errors from using Range in reverse. Add CaretSelection to represent a bidirectional inclusive range collapse styling of conditions reorganize blocks in editable text element ...
This commit is contained in:
Generated
+3
@@ -2426,6 +2426,9 @@ name = "gpui_elements"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"gpui",
|
||||
"gpui_platform",
|
||||
"smallvec",
|
||||
"unicode-segmentation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -256,10 +256,6 @@ path = "examples/legacy/hello_world.rs"
|
||||
name = "image_loading"
|
||||
path = "examples/legacy/image_loading.rs"
|
||||
|
||||
[[example]]
|
||||
name = "input"
|
||||
path = "examples/legacy/input.rs"
|
||||
|
||||
[[example]]
|
||||
name = "layer_shell"
|
||||
path = "examples/legacy/layer_shell.rs"
|
||||
|
||||
@@ -1,753 +0,0 @@
|
||||
use std::ops::Range;
|
||||
|
||||
use gpui::{
|
||||
App, Bounds, ClipboardItem, Context, CursorStyle, ElementId, ElementInputHandler, Entity,
|
||||
EntityInputHandler, FocusHandle, Focusable, GlobalElementId, KeyBinding, Keystroke, LayoutId,
|
||||
MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, PaintQuad, Pixels, Point,
|
||||
ShapedLine, SharedString, Style, TextRun, UTF16Selection, UnderlineStyle, Window, WindowBounds,
|
||||
WindowOptions, actions, black, div, fill, hsla, opaque_grey, point, prelude::*, px, relative,
|
||||
rgb, rgba, size, white, yellow,
|
||||
};
|
||||
use unicode_segmentation::*;
|
||||
|
||||
actions!(
|
||||
text_input,
|
||||
[
|
||||
Backspace,
|
||||
Delete,
|
||||
Left,
|
||||
Right,
|
||||
SelectLeft,
|
||||
SelectRight,
|
||||
SelectAll,
|
||||
Home,
|
||||
End,
|
||||
ShowCharacterPalette,
|
||||
Paste,
|
||||
Cut,
|
||||
Copy,
|
||||
Quit,
|
||||
]
|
||||
);
|
||||
|
||||
struct TextInput {
|
||||
focus_handle: FocusHandle,
|
||||
content: SharedString,
|
||||
placeholder: SharedString,
|
||||
selected_range: Range<usize>,
|
||||
selection_reversed: bool,
|
||||
marked_range: Option<Range<usize>>,
|
||||
last_layout: Option<ShapedLine>,
|
||||
last_bounds: Option<Bounds<Pixels>>,
|
||||
is_selecting: bool,
|
||||
}
|
||||
|
||||
impl TextInput {
|
||||
fn left(&mut self, _: &Left, _: &mut Window, cx: &mut Context<Self>) {
|
||||
if self.selected_range.is_empty() {
|
||||
self.move_to(self.previous_boundary(self.cursor_offset()), cx);
|
||||
} else {
|
||||
self.move_to(self.selected_range.start, cx)
|
||||
}
|
||||
}
|
||||
|
||||
fn right(&mut self, _: &Right, _: &mut Window, cx: &mut Context<Self>) {
|
||||
if self.selected_range.is_empty() {
|
||||
self.move_to(self.next_boundary(self.selected_range.end), cx);
|
||||
} else {
|
||||
self.move_to(self.selected_range.end, cx)
|
||||
}
|
||||
}
|
||||
|
||||
fn select_left(&mut self, _: &SelectLeft, _: &mut Window, cx: &mut Context<Self>) {
|
||||
self.select_to(self.previous_boundary(self.cursor_offset()), cx);
|
||||
}
|
||||
|
||||
fn select_right(&mut self, _: &SelectRight, _: &mut Window, cx: &mut Context<Self>) {
|
||||
self.select_to(self.next_boundary(self.cursor_offset()), cx);
|
||||
}
|
||||
|
||||
fn select_all(&mut self, _: &SelectAll, _: &mut Window, cx: &mut Context<Self>) {
|
||||
self.move_to(0, cx);
|
||||
self.select_to(self.content.len(), cx)
|
||||
}
|
||||
|
||||
fn home(&mut self, _: &Home, _: &mut Window, cx: &mut Context<Self>) {
|
||||
self.move_to(0, cx);
|
||||
}
|
||||
|
||||
fn end(&mut self, _: &End, _: &mut Window, cx: &mut Context<Self>) {
|
||||
self.move_to(self.content.len(), cx);
|
||||
}
|
||||
|
||||
fn backspace(&mut self, _: &Backspace, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if self.selected_range.is_empty() {
|
||||
self.select_to(self.previous_boundary(self.cursor_offset()), cx)
|
||||
}
|
||||
self.replace_text_in_range(None, "", window, cx)
|
||||
}
|
||||
|
||||
fn delete(&mut self, _: &Delete, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if self.selected_range.is_empty() {
|
||||
self.select_to(self.next_boundary(self.cursor_offset()), cx)
|
||||
}
|
||||
self.replace_text_in_range(None, "", window, cx)
|
||||
}
|
||||
|
||||
fn on_mouse_down(
|
||||
&mut self,
|
||||
event: &MouseDownEvent,
|
||||
_window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.is_selecting = true;
|
||||
|
||||
if event.modifiers.shift {
|
||||
self.select_to(self.index_for_mouse_position(event.position), cx);
|
||||
} else {
|
||||
self.move_to(self.index_for_mouse_position(event.position), cx)
|
||||
}
|
||||
}
|
||||
|
||||
fn on_mouse_up(&mut self, _: &MouseUpEvent, _window: &mut Window, _: &mut Context<Self>) {
|
||||
self.is_selecting = false;
|
||||
}
|
||||
|
||||
fn on_mouse_move(&mut self, event: &MouseMoveEvent, _: &mut Window, cx: &mut Context<Self>) {
|
||||
if self.is_selecting {
|
||||
self.select_to(self.index_for_mouse_position(event.position), cx);
|
||||
}
|
||||
}
|
||||
|
||||
fn show_character_palette(
|
||||
&mut self,
|
||||
_: &ShowCharacterPalette,
|
||||
window: &mut Window,
|
||||
_: &mut Context<Self>,
|
||||
) {
|
||||
window.show_character_palette();
|
||||
}
|
||||
|
||||
fn paste(&mut self, _: &Paste, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if let Some(text) = cx.read_from_clipboard().and_then(|item| item.text()) {
|
||||
self.replace_text_in_range(None, &text.replace("\n", " "), window, cx);
|
||||
}
|
||||
}
|
||||
|
||||
fn copy(&mut self, _: &Copy, _: &mut Window, cx: &mut Context<Self>) {
|
||||
if !self.selected_range.is_empty() {
|
||||
cx.write_to_clipboard(ClipboardItem::new_string(
|
||||
self.content[self.selected_range.clone()].to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
fn cut(&mut self, _: &Cut, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if !self.selected_range.is_empty() {
|
||||
cx.write_to_clipboard(ClipboardItem::new_string(
|
||||
self.content[self.selected_range.clone()].to_string(),
|
||||
));
|
||||
self.replace_text_in_range(None, "", window, cx)
|
||||
}
|
||||
}
|
||||
|
||||
fn move_to(&mut self, offset: usize, cx: &mut Context<Self>) {
|
||||
self.selected_range = offset..offset;
|
||||
cx.notify()
|
||||
}
|
||||
|
||||
fn cursor_offset(&self) -> usize {
|
||||
if self.selection_reversed {
|
||||
self.selected_range.start
|
||||
} else {
|
||||
self.selected_range.end
|
||||
}
|
||||
}
|
||||
|
||||
fn index_for_mouse_position(&self, position: Point<Pixels>) -> usize {
|
||||
if self.content.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let (Some(bounds), Some(line)) = (self.last_bounds.as_ref(), self.last_layout.as_ref())
|
||||
else {
|
||||
return 0;
|
||||
};
|
||||
if position.y < bounds.top() {
|
||||
return 0;
|
||||
}
|
||||
if position.y > bounds.bottom() {
|
||||
return self.content.len();
|
||||
}
|
||||
line.closest_index_for_x(position.x - bounds.left())
|
||||
}
|
||||
|
||||
fn select_to(&mut self, offset: usize, cx: &mut Context<Self>) {
|
||||
if self.selection_reversed {
|
||||
self.selected_range.start = offset
|
||||
} else {
|
||||
self.selected_range.end = offset
|
||||
};
|
||||
if self.selected_range.end < self.selected_range.start {
|
||||
self.selection_reversed = !self.selection_reversed;
|
||||
self.selected_range = self.selected_range.end..self.selected_range.start;
|
||||
}
|
||||
cx.notify()
|
||||
}
|
||||
|
||||
fn offset_from_utf16(&self, offset: usize) -> usize {
|
||||
let mut utf8_offset = 0;
|
||||
let mut utf16_count = 0;
|
||||
|
||||
for ch in self.content.chars() {
|
||||
if utf16_count >= offset {
|
||||
break;
|
||||
}
|
||||
utf16_count += ch.len_utf16();
|
||||
utf8_offset += ch.len_utf8();
|
||||
}
|
||||
|
||||
utf8_offset
|
||||
}
|
||||
|
||||
fn offset_to_utf16(&self, offset: usize) -> usize {
|
||||
let mut utf16_offset = 0;
|
||||
let mut utf8_count = 0;
|
||||
|
||||
for ch in self.content.chars() {
|
||||
if utf8_count >= offset {
|
||||
break;
|
||||
}
|
||||
utf8_count += ch.len_utf8();
|
||||
utf16_offset += ch.len_utf16();
|
||||
}
|
||||
|
||||
utf16_offset
|
||||
}
|
||||
|
||||
fn range_to_utf16(&self, range: &Range<usize>) -> Range<usize> {
|
||||
self.offset_to_utf16(range.start)..self.offset_to_utf16(range.end)
|
||||
}
|
||||
|
||||
fn range_from_utf16(&self, range_utf16: &Range<usize>) -> Range<usize> {
|
||||
self.offset_from_utf16(range_utf16.start)..self.offset_from_utf16(range_utf16.end)
|
||||
}
|
||||
|
||||
fn previous_boundary(&self, offset: usize) -> usize {
|
||||
self.content
|
||||
.grapheme_indices(true)
|
||||
.rev()
|
||||
.find_map(|(idx, _)| (idx < offset).then_some(idx))
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn next_boundary(&self, offset: usize) -> usize {
|
||||
self.content
|
||||
.grapheme_indices(true)
|
||||
.find_map(|(idx, _)| (idx > offset).then_some(idx))
|
||||
.unwrap_or(self.content.len())
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.content = "".into();
|
||||
self.selected_range = 0..0;
|
||||
self.selection_reversed = false;
|
||||
self.marked_range = None;
|
||||
self.last_layout = None;
|
||||
self.last_bounds = None;
|
||||
self.is_selecting = false;
|
||||
}
|
||||
}
|
||||
|
||||
impl EntityInputHandler for TextInput {
|
||||
fn text_for_range(
|
||||
&mut self,
|
||||
range_utf16: Range<usize>,
|
||||
actual_range: &mut Option<Range<usize>>,
|
||||
_window: &mut Window,
|
||||
_cx: &mut Context<Self>,
|
||||
) -> Option<String> {
|
||||
let range = self.range_from_utf16(&range_utf16);
|
||||
actual_range.replace(self.range_to_utf16(&range));
|
||||
Some(self.content[range].to_string())
|
||||
}
|
||||
|
||||
fn selected_text_range(
|
||||
&mut self,
|
||||
_ignore_disabled_input: bool,
|
||||
_window: &mut Window,
|
||||
_cx: &mut Context<Self>,
|
||||
) -> Option<UTF16Selection> {
|
||||
Some(UTF16Selection {
|
||||
range: self.range_to_utf16(&self.selected_range),
|
||||
reversed: self.selection_reversed,
|
||||
})
|
||||
}
|
||||
|
||||
fn marked_text_range(
|
||||
&self,
|
||||
_window: &mut Window,
|
||||
_cx: &mut Context<Self>,
|
||||
) -> Option<Range<usize>> {
|
||||
self.marked_range
|
||||
.as_ref()
|
||||
.map(|range| self.range_to_utf16(range))
|
||||
}
|
||||
|
||||
fn unmark_text(&mut self, _window: &mut Window, _cx: &mut Context<Self>) {
|
||||
self.marked_range = None;
|
||||
}
|
||||
|
||||
fn replace_text_in_range(
|
||||
&mut self,
|
||||
range_utf16: Option<Range<usize>>,
|
||||
new_text: &str,
|
||||
_: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let range = range_utf16
|
||||
.as_ref()
|
||||
.map(|range_utf16| self.range_from_utf16(range_utf16))
|
||||
.or(self.marked_range.clone())
|
||||
.unwrap_or(self.selected_range.clone());
|
||||
|
||||
self.content =
|
||||
(self.content[0..range.start].to_owned() + new_text + &self.content[range.end..])
|
||||
.into();
|
||||
self.selected_range = range.start + new_text.len()..range.start + new_text.len();
|
||||
self.marked_range.take();
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn replace_and_mark_text_in_range(
|
||||
&mut self,
|
||||
range_utf16: Option<Range<usize>>,
|
||||
new_text: &str,
|
||||
new_selected_range_utf16: Option<Range<usize>>,
|
||||
_window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let range = range_utf16
|
||||
.as_ref()
|
||||
.map(|range_utf16| self.range_from_utf16(range_utf16))
|
||||
.or(self.marked_range.clone())
|
||||
.unwrap_or(self.selected_range.clone());
|
||||
|
||||
self.content =
|
||||
(self.content[0..range.start].to_owned() + new_text + &self.content[range.end..])
|
||||
.into();
|
||||
if !new_text.is_empty() {
|
||||
self.marked_range = Some(range.start..range.start + new_text.len());
|
||||
} else {
|
||||
self.marked_range = None;
|
||||
}
|
||||
self.selected_range = new_selected_range_utf16
|
||||
.as_ref()
|
||||
.map(|range_utf16| self.range_from_utf16(range_utf16))
|
||||
.map(|new_range| new_range.start + range.start..new_range.end + range.end)
|
||||
.unwrap_or_else(|| range.start + new_text.len()..range.start + new_text.len());
|
||||
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn bounds_for_range(
|
||||
&mut self,
|
||||
range_utf16: Range<usize>,
|
||||
bounds: Bounds<Pixels>,
|
||||
_window: &mut Window,
|
||||
_cx: &mut Context<Self>,
|
||||
) -> Option<Bounds<Pixels>> {
|
||||
let last_layout = self.last_layout.as_ref()?;
|
||||
let range = self.range_from_utf16(&range_utf16);
|
||||
Some(Bounds::from_corners(
|
||||
point(
|
||||
bounds.left() + last_layout.x_for_index(range.start),
|
||||
bounds.top(),
|
||||
),
|
||||
point(
|
||||
bounds.left() + last_layout.x_for_index(range.end),
|
||||
bounds.bottom(),
|
||||
),
|
||||
))
|
||||
}
|
||||
|
||||
fn character_index_for_point(
|
||||
&mut self,
|
||||
point: gpui::Point<Pixels>,
|
||||
_window: &mut Window,
|
||||
_cx: &mut Context<Self>,
|
||||
) -> Option<usize> {
|
||||
let line_point = self.last_bounds?.localize(&point)?;
|
||||
let last_layout = self.last_layout.as_ref()?;
|
||||
|
||||
assert_eq!(last_layout.text, self.content);
|
||||
let utf8_index = last_layout.index_for_x(point.x - line_point.x)?;
|
||||
Some(self.offset_to_utf16(utf8_index))
|
||||
}
|
||||
}
|
||||
|
||||
struct TextElement {
|
||||
input: Entity<TextInput>,
|
||||
}
|
||||
|
||||
struct PrepaintState {
|
||||
line: Option<ShapedLine>,
|
||||
cursor: Option<PaintQuad>,
|
||||
selection: Option<PaintQuad>,
|
||||
}
|
||||
|
||||
impl IntoElement for TextElement {
|
||||
type Element = Self;
|
||||
|
||||
fn into_element(self) -> Self::Element {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Element for TextElement {
|
||||
type RequestLayoutState = ();
|
||||
type PrepaintState = PrepaintState;
|
||||
|
||||
fn id(&self) -> Option<ElementId> {
|
||||
None
|
||||
}
|
||||
|
||||
fn source_location(&self) -> Option<&'static core::panic::Location<'static>> {
|
||||
None
|
||||
}
|
||||
|
||||
fn request_layout(
|
||||
&mut self,
|
||||
_id: Option<&GlobalElementId>,
|
||||
_inspector_id: Option<&gpui::InspectorElementId>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> (LayoutId, Self::RequestLayoutState) {
|
||||
let mut style = Style::default();
|
||||
style.size.width = relative(1.).into();
|
||||
style.size.height = window.line_height().into();
|
||||
(window.request_layout(style, [], cx), ())
|
||||
}
|
||||
|
||||
fn prepaint(
|
||||
&mut self,
|
||||
_id: Option<&GlobalElementId>,
|
||||
_inspector_id: Option<&gpui::InspectorElementId>,
|
||||
bounds: Bounds<Pixels>,
|
||||
_request_layout: &mut Self::RequestLayoutState,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> Self::PrepaintState {
|
||||
let input = self.input.read(cx);
|
||||
let content = input.content.clone();
|
||||
let selected_range = input.selected_range.clone();
|
||||
let cursor = input.cursor_offset();
|
||||
let style = window.text_style();
|
||||
|
||||
let (display_text, text_color) = if content.is_empty() {
|
||||
(input.placeholder.clone(), hsla(0., 0., 0., 0.2))
|
||||
} else {
|
||||
(content, style.color)
|
||||
};
|
||||
|
||||
let run = TextRun {
|
||||
len: display_text.len(),
|
||||
font: style.font(),
|
||||
color: text_color,
|
||||
background_color: None,
|
||||
underline: None,
|
||||
strikethrough: None,
|
||||
};
|
||||
let runs = if let Some(marked_range) = input.marked_range.as_ref() {
|
||||
vec![
|
||||
TextRun {
|
||||
len: marked_range.start,
|
||||
..run.clone()
|
||||
},
|
||||
TextRun {
|
||||
len: marked_range.end - marked_range.start,
|
||||
underline: Some(UnderlineStyle {
|
||||
color: Some(run.color),
|
||||
thickness: px(1.0),
|
||||
wavy: false,
|
||||
}),
|
||||
..run.clone()
|
||||
},
|
||||
TextRun {
|
||||
len: display_text.len() - marked_range.end,
|
||||
..run
|
||||
},
|
||||
]
|
||||
.into_iter()
|
||||
.filter(|run| run.len > 0)
|
||||
.collect()
|
||||
} else {
|
||||
vec![run]
|
||||
};
|
||||
|
||||
let font_size = style.font_size.to_pixels(window.rem_size());
|
||||
let line = window
|
||||
.text_system()
|
||||
.shape_line(display_text, font_size, &runs, None);
|
||||
|
||||
let cursor_pos = line.x_for_index(cursor);
|
||||
let (selection, cursor) = if selected_range.is_empty() {
|
||||
(
|
||||
None,
|
||||
Some(fill(
|
||||
Bounds::new(
|
||||
point(bounds.left() + cursor_pos, bounds.top()),
|
||||
size(px(2.), bounds.bottom() - bounds.top()),
|
||||
),
|
||||
gpui::blue(),
|
||||
)),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
Some(fill(
|
||||
Bounds::from_corners(
|
||||
point(
|
||||
bounds.left() + line.x_for_index(selected_range.start),
|
||||
bounds.top(),
|
||||
),
|
||||
point(
|
||||
bounds.left() + line.x_for_index(selected_range.end),
|
||||
bounds.bottom(),
|
||||
),
|
||||
),
|
||||
rgba(0x3311ff30),
|
||||
)),
|
||||
None,
|
||||
)
|
||||
};
|
||||
PrepaintState {
|
||||
line: Some(line),
|
||||
cursor,
|
||||
selection,
|
||||
}
|
||||
}
|
||||
|
||||
fn paint(
|
||||
&mut self,
|
||||
_id: Option<&GlobalElementId>,
|
||||
_inspector_id: Option<&gpui::InspectorElementId>,
|
||||
bounds: Bounds<Pixels>,
|
||||
_request_layout: &mut Self::RequestLayoutState,
|
||||
prepaint: &mut Self::PrepaintState,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
let focus_handle = self.input.read(cx).focus_handle.clone();
|
||||
window.handle_input(
|
||||
&focus_handle,
|
||||
ElementInputHandler::new(bounds, self.input.clone()),
|
||||
cx,
|
||||
);
|
||||
if let Some(selection) = prepaint.selection.take() {
|
||||
window.paint_quad(selection)
|
||||
}
|
||||
let line = prepaint.line.take().unwrap();
|
||||
line.paint(
|
||||
bounds.origin,
|
||||
window.line_height(),
|
||||
gpui::TextAlign::Left,
|
||||
None,
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
if focus_handle.is_focused(window)
|
||||
&& let Some(cursor) = prepaint.cursor.take()
|
||||
{
|
||||
window.paint_quad(cursor);
|
||||
}
|
||||
|
||||
self.input.update(cx, |input, _cx| {
|
||||
input.last_layout = Some(line);
|
||||
input.last_bounds = Some(bounds);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for TextInput {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
div()
|
||||
.flex()
|
||||
.key_context("TextInput")
|
||||
.track_focus(&self.focus_handle(cx))
|
||||
.cursor(CursorStyle::IBeam)
|
||||
.on_action(cx.listener(Self::backspace))
|
||||
.on_action(cx.listener(Self::delete))
|
||||
.on_action(cx.listener(Self::left))
|
||||
.on_action(cx.listener(Self::right))
|
||||
.on_action(cx.listener(Self::select_left))
|
||||
.on_action(cx.listener(Self::select_right))
|
||||
.on_action(cx.listener(Self::select_all))
|
||||
.on_action(cx.listener(Self::home))
|
||||
.on_action(cx.listener(Self::end))
|
||||
.on_action(cx.listener(Self::show_character_palette))
|
||||
.on_action(cx.listener(Self::paste))
|
||||
.on_action(cx.listener(Self::cut))
|
||||
.on_action(cx.listener(Self::copy))
|
||||
.on_mouse_down(MouseButton::Left, cx.listener(Self::on_mouse_down))
|
||||
.on_mouse_up(MouseButton::Left, cx.listener(Self::on_mouse_up))
|
||||
.on_mouse_up_out(MouseButton::Left, cx.listener(Self::on_mouse_up))
|
||||
.on_mouse_move(cx.listener(Self::on_mouse_move))
|
||||
.bg(rgb(0xeeeeee))
|
||||
.line_height(px(30.))
|
||||
.text_size(px(24.))
|
||||
.child(
|
||||
div()
|
||||
.h(px(30. + 4. * 2.))
|
||||
.w_full()
|
||||
.p(px(4.))
|
||||
.bg(white())
|
||||
.child(TextElement { input: cx.entity() }),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl Focusable for TextInput {
|
||||
fn focus_handle(&self, _: &App) -> FocusHandle {
|
||||
self.focus_handle.clone()
|
||||
}
|
||||
}
|
||||
|
||||
struct InputExample {
|
||||
text_input: Entity<TextInput>,
|
||||
recent_keystrokes: Vec<Keystroke>,
|
||||
focus_handle: FocusHandle,
|
||||
}
|
||||
|
||||
impl Focusable for InputExample {
|
||||
fn focus_handle(&self, _: &App) -> FocusHandle {
|
||||
self.focus_handle.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl InputExample {
|
||||
fn on_reset_click(&mut self, _: &MouseUpEvent, _window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.recent_keystrokes.clear();
|
||||
self.text_input
|
||||
.update(cx, |text_input, _cx| text_input.reset());
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for InputExample {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
div()
|
||||
.bg(rgb(0xaaaaaa))
|
||||
.track_focus(&self.focus_handle(cx))
|
||||
.flex()
|
||||
.flex_col()
|
||||
.size_full()
|
||||
.child(
|
||||
div()
|
||||
.bg(white())
|
||||
.border_b_1()
|
||||
.border_color(black())
|
||||
.flex()
|
||||
.flex_row()
|
||||
.justify_between()
|
||||
.child(format!("Keyboard {}", cx.keyboard_layout().name()))
|
||||
.child(
|
||||
div()
|
||||
.border_1()
|
||||
.border_color(black())
|
||||
.px_2()
|
||||
.bg(yellow())
|
||||
.child("Reset")
|
||||
.hover(|style| {
|
||||
style
|
||||
.bg(yellow().blend(opaque_grey(0.5, 0.5)))
|
||||
.cursor_pointer()
|
||||
})
|
||||
.on_mouse_up(MouseButton::Left, cx.listener(Self::on_reset_click)),
|
||||
),
|
||||
)
|
||||
.child(self.text_input.clone())
|
||||
.children(self.recent_keystrokes.iter().rev().map(|ks| {
|
||||
format!(
|
||||
"{:} {}",
|
||||
ks.unparse(),
|
||||
if let Some(key_char) = ks.key_char.as_ref() {
|
||||
format!("-> {:?}", key_char)
|
||||
} else {
|
||||
"".to_owned()
|
||||
}
|
||||
)
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
gpui_platform::application().run(|cx: &mut App| {
|
||||
let bounds = Bounds::centered(None, size(px(300.0), px(300.0)), cx);
|
||||
cx.bind_keys([
|
||||
KeyBinding::new("backspace", Backspace, None),
|
||||
KeyBinding::new("delete", Delete, None),
|
||||
KeyBinding::new("left", Left, None),
|
||||
KeyBinding::new("right", Right, None),
|
||||
KeyBinding::new("shift-left", SelectLeft, None),
|
||||
KeyBinding::new("shift-right", SelectRight, None),
|
||||
KeyBinding::new("cmd-a", SelectAll, None),
|
||||
KeyBinding::new("cmd-v", Paste, None),
|
||||
KeyBinding::new("cmd-c", Copy, None),
|
||||
KeyBinding::new("cmd-x", Cut, None),
|
||||
KeyBinding::new("home", Home, None),
|
||||
KeyBinding::new("end", End, None),
|
||||
KeyBinding::new("ctrl-cmd-space", ShowCharacterPalette, None),
|
||||
]);
|
||||
|
||||
let window = cx
|
||||
.open_window(
|
||||
WindowOptions {
|
||||
window_bounds: Some(WindowBounds::Windowed(bounds)),
|
||||
..Default::default()
|
||||
},
|
||||
|_, cx| {
|
||||
let text_input = cx.new(|cx| TextInput {
|
||||
focus_handle: cx.focus_handle(),
|
||||
content: "".into(),
|
||||
placeholder: "Type here...".into(),
|
||||
selected_range: 0..0,
|
||||
selection_reversed: false,
|
||||
marked_range: None,
|
||||
last_layout: None,
|
||||
last_bounds: None,
|
||||
is_selecting: false,
|
||||
});
|
||||
cx.new(|cx| InputExample {
|
||||
text_input,
|
||||
recent_keystrokes: vec![],
|
||||
focus_handle: cx.focus_handle(),
|
||||
})
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let view = window.update(cx, |_, _, cx| cx.entity()).unwrap();
|
||||
cx.observe_keystrokes(move |ev, _, cx| {
|
||||
view.update(cx, |view, cx| {
|
||||
view.recent_keystrokes.push(ev.keystroke.clone());
|
||||
cx.notify();
|
||||
})
|
||||
})
|
||||
.detach();
|
||||
cx.on_keyboard_layout_change({
|
||||
move |cx| {
|
||||
window.update(cx, |_, _, cx| cx.notify()).ok();
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
window
|
||||
.update(cx, |view, window, cx| {
|
||||
window.focus(&view.text_input.focus_handle(cx), cx);
|
||||
cx.activate(true);
|
||||
})
|
||||
.unwrap();
|
||||
cx.on_action(|_: &Quit, cx| cx.quit());
|
||||
cx.bind_keys([KeyBinding::new("cmd-q", Quit, None)]);
|
||||
});
|
||||
}
|
||||
@@ -1866,6 +1866,35 @@ pub struct Interactivity {
|
||||
pub(crate) debug_selector: Option<String>,
|
||||
}
|
||||
|
||||
impl Interactivity {
|
||||
/// Assigns the current scroll offset of an element. No-op if the element's
|
||||
/// style does not have overflow enabled.
|
||||
///
|
||||
/// Should only be called during the `request_layout` phase.
|
||||
pub fn set_scroll_offset(
|
||||
&self,
|
||||
global_id: Option<&GlobalElementId>,
|
||||
window: &mut Window,
|
||||
point: Point<Pixels>,
|
||||
) {
|
||||
window.with_optional_element_state::<gpui::InteractiveElementState, _>(
|
||||
global_id,
|
||||
|element_state, _window| {
|
||||
let mut element_state =
|
||||
element_state.map(|element_state| element_state.unwrap_or_default());
|
||||
let overflow = &self.base_style.overflow;
|
||||
if (overflow.x == Some(Overflow::Scroll) || overflow.y == Some(Overflow::Scroll))
|
||||
&& let Some(element_state) = element_state.as_mut()
|
||||
{
|
||||
let scroll_offset = element_state.scroll_offset.get_or_insert_with(Rc::default);
|
||||
*scroll_offset.borrow_mut() = point;
|
||||
}
|
||||
((), element_state)
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
impl Interactivity {
|
||||
/// Layout this element according to this interactivity state's configured styles
|
||||
pub fn request_layout(
|
||||
@@ -2278,7 +2307,7 @@ impl Interactivity {
|
||||
if let Some(text) = window
|
||||
.text_system()
|
||||
.shape_text(
|
||||
element_id.into(),
|
||||
&element_id,
|
||||
FONT_SIZE,
|
||||
&[window.text_style().to_run(str_len)],
|
||||
None,
|
||||
|
||||
@@ -622,7 +622,121 @@ struct TextLayoutInner {
|
||||
bounds: Option<Bounds<Pixels>>,
|
||||
}
|
||||
|
||||
/// Metadata about how text should be truncated. Generated during text layout via `TextLayout::evaluate_overflow`.
|
||||
pub struct TextLayoutTruncation {
|
||||
/// The width that the text can occupy before it is truncated.
|
||||
pub width: Option<Pixels>,
|
||||
/// The text to affix to the displayed text if truncating (e.g. an ellipsis `...`).
|
||||
pub affix: SharedString,
|
||||
/// What side of the text will be truncated if it does not fit.
|
||||
pub source: TruncateFrom,
|
||||
}
|
||||
|
||||
impl TextLayoutTruncation {
|
||||
/// Creates a truncation by using the overflow as the affix, given the provided width.
|
||||
fn overflow_width(text_overflow: TextOverflow, width: Option<Pixels>) -> Self {
|
||||
match text_overflow {
|
||||
TextOverflow::Truncate(s) => TextLayoutTruncation {
|
||||
width,
|
||||
affix: s,
|
||||
source: TruncateFrom::End,
|
||||
},
|
||||
TextOverflow::TruncateStart(s) => TextLayoutTruncation {
|
||||
width,
|
||||
affix: s,
|
||||
source: TruncateFrom::Start,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TextLayout {
|
||||
/// Evaluates the width to wrap the text at.
|
||||
pub fn evaluate_wrap_width(
|
||||
white_space: &WhiteSpace,
|
||||
known_dimensions: Size<Option<Pixels>>,
|
||||
available_space: Size<crate::AvailableSpace>,
|
||||
) -> Option<Pixels> {
|
||||
use crate::AvailableSpace::*;
|
||||
match white_space {
|
||||
// Text does not wrap, no max width
|
||||
WhiteSpace::Nowrap => None,
|
||||
// If the text wraps, return the already calculated width.
|
||||
WhiteSpace::Normal => known_dimensions.width.or(match available_space.width {
|
||||
// Otherwise if the available space is a concrete value, then that is the width to wrap to.
|
||||
Definite(x) => Some(x),
|
||||
// If the wrapping is content-based, then there is no wrapping of text.
|
||||
MaxContent | MinContent => None,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Evaluates how truncation should be applied if the text overflows the available space.
|
||||
pub fn evaluate_overflow(
|
||||
text_style: &TextStyle,
|
||||
known_dimensions: Size<Option<Pixels>>,
|
||||
available_space: Size<crate::AvailableSpace>,
|
||||
) -> TextLayoutTruncation {
|
||||
match text_style.text_overflow.clone() {
|
||||
Some(text_overflow) => {
|
||||
// Calculate the desired width, prioritizing the calculated dimensions,
|
||||
// falling back on calculating a width from the available space and
|
||||
// number of lines to clamp to via text style.
|
||||
let width = known_dimensions.width.or(match available_space.width {
|
||||
crate::AvailableSpace::Definite(x) => match text_style.line_clamp {
|
||||
Some(max_lines) => Some(x * max_lines),
|
||||
None => Some(x),
|
||||
},
|
||||
_ => None,
|
||||
});
|
||||
|
||||
TextLayoutTruncation::overflow_width(text_overflow, width)
|
||||
}
|
||||
None => TextLayoutTruncation {
|
||||
width: None,
|
||||
affix: SharedString::default(),
|
||||
source: TruncateFrom::End,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Conditionally applies truncation to some text and outputs how the text should be displayed.
|
||||
pub fn apply_truncation<'runs>(
|
||||
text: SharedString,
|
||||
text_style: &TextStyle,
|
||||
font_size: Pixels,
|
||||
wrap_width: Option<Pixels>,
|
||||
truncation: &TextLayoutTruncation,
|
||||
runs: &'runs [TextRun],
|
||||
cx: &mut App,
|
||||
) -> (SharedString, Cow<'runs, [TextRun]>) {
|
||||
let mut line_wrapper = cx.text_system().line_wrapper(text_style.font(), font_size);
|
||||
if truncation.width.is_some() {
|
||||
if let Some(max_lines) = text_style.line_clamp
|
||||
&& let Some(wrap_width) = wrap_width
|
||||
{
|
||||
line_wrapper.truncate_wrapped_line(
|
||||
text,
|
||||
wrap_width,
|
||||
max_lines,
|
||||
&truncation.affix,
|
||||
&runs,
|
||||
truncation.source,
|
||||
)
|
||||
} else {
|
||||
line_wrapper.truncate_line(
|
||||
text,
|
||||
truncation.width.unwrap_or(Pixels::MAX),
|
||||
&truncation.affix,
|
||||
&runs,
|
||||
truncation.source,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
(text, std::borrow::Cow::Borrowed(runs))
|
||||
}
|
||||
}
|
||||
|
||||
fn layout(
|
||||
&self,
|
||||
text: SharedString,
|
||||
@@ -647,32 +761,14 @@ impl TextLayout {
|
||||
let element_state = self.clone();
|
||||
|
||||
move |known_dimensions, available_space, window, cx| {
|
||||
let wrap_width = if text_style.white_space == WhiteSpace::Normal {
|
||||
known_dimensions.width.or(match available_space.width {
|
||||
crate::AvailableSpace::Definite(x) => Some(x),
|
||||
_ => None,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let wrap_width = Self::evaluate_wrap_width(
|
||||
&text_style.white_space,
|
||||
known_dimensions,
|
||||
available_space,
|
||||
);
|
||||
|
||||
let (truncate_width, truncation_affix, truncate_from) =
|
||||
if let Some(text_overflow) = text_style.text_overflow.clone() {
|
||||
let width = known_dimensions.width.or(match available_space.width {
|
||||
crate::AvailableSpace::Definite(x) => match text_style.line_clamp {
|
||||
Some(max_lines) => Some(x * max_lines),
|
||||
None => Some(x),
|
||||
},
|
||||
_ => None,
|
||||
});
|
||||
|
||||
match text_overflow {
|
||||
TextOverflow::Truncate(s) => (width, s, TruncateFrom::End),
|
||||
TextOverflow::TruncateStart(s) => (width, s, TruncateFrom::Start),
|
||||
}
|
||||
} else {
|
||||
(None, "".into(), TruncateFrom::End)
|
||||
};
|
||||
let truncation =
|
||||
Self::evaluate_overflow(&text_style, known_dimensions, available_space);
|
||||
|
||||
// Only use cached layout if:
|
||||
// 1. We have a cached size
|
||||
@@ -682,36 +778,20 @@ impl TextLayout {
|
||||
if let Some(text_layout) = element_state.0.borrow().as_ref()
|
||||
&& let Some(size) = text_layout.size
|
||||
&& (wrap_width.is_none() || wrap_width == text_layout.wrap_width)
|
||||
&& truncate_width.is_none()
|
||||
&& truncation.width.is_none()
|
||||
{
|
||||
return size;
|
||||
}
|
||||
|
||||
let mut line_wrapper = cx.text_system().line_wrapper(text_style.font(), font_size);
|
||||
let (text, runs) = if truncate_width.is_some() {
|
||||
if let Some(max_lines) = text_style.line_clamp
|
||||
&& let Some(wrap_width) = wrap_width
|
||||
{
|
||||
line_wrapper.truncate_wrapped_line(
|
||||
text.clone(),
|
||||
wrap_width,
|
||||
max_lines,
|
||||
&truncation_affix,
|
||||
&runs,
|
||||
truncate_from,
|
||||
)
|
||||
} else {
|
||||
line_wrapper.truncate_line(
|
||||
text.clone(),
|
||||
truncate_width.unwrap_or(Pixels::MAX),
|
||||
&truncation_affix,
|
||||
&runs,
|
||||
truncate_from,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
(text.clone(), Cow::Borrowed(&*runs))
|
||||
};
|
||||
let (text, runs) = Self::apply_truncation(
|
||||
text.clone(),
|
||||
&text_style,
|
||||
font_size,
|
||||
wrap_width,
|
||||
&truncation,
|
||||
&runs,
|
||||
cx,
|
||||
);
|
||||
let len = text.len();
|
||||
|
||||
let Some(lines) = window
|
||||
|
||||
@@ -218,6 +218,21 @@ impl Point<Pixels> {
|
||||
pub fn magnitude(&self) -> f64 {
|
||||
((self.x.0.powi(2) + self.y.0.powi(2)) as f64).sqrt()
|
||||
}
|
||||
|
||||
/// Returns true if the difference between self and `other` is less than `epsilon` in both coordinates.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// # use gpui::{Pixels, Point};
|
||||
/// let p1 = Point { x: Pixels::from(1.0), y: Pixels::from(1.0) };
|
||||
/// let p2 = Point { x: Pixels::from(3.0), y: Pixels::from(-2.0) };
|
||||
/// assert_eq!(p1.is_nearly_eq(&p2, Pixels::from(4.0)), true);
|
||||
/// ```
|
||||
pub fn is_nearly_eq(&self, other: &Self, epsilon: Pixels) -> bool {
|
||||
let diff = *self - *other;
|
||||
diff.x.abs() < epsilon && diff.y.abs() < epsilon
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> Point<T>
|
||||
|
||||
@@ -504,11 +504,20 @@ impl WindowTextSystem {
|
||||
}
|
||||
|
||||
/// Shape a multi line string of text, at the given font_size, for painting to the screen.
|
||||
/// Subsets of the text can be styled independently with the `runs` parameter.
|
||||
/// Subsets of the text can be styled independently with the `runs` parameter,
|
||||
/// where each run dictates the length of utf8 characters in `text` that it styles.
|
||||
/// The length (utf8 characters) of last item in `runs` is semantically ignored as it
|
||||
/// represents the "rest" of the `text`.
|
||||
///
|
||||
/// If `wrap_width` is provided, the line breaks will be adjusted to fit within the given width.
|
||||
pub fn shape_text(
|
||||
///
|
||||
/// If the text provided is SharedString and does not contain new-lines,
|
||||
/// it will be used as-is without additional allocations.
|
||||
/// If the text provided is not a SharedString or contains new-lines, new SharedStrings
|
||||
/// will be allocated for each substring between new-line characters (minimum of 1).
|
||||
pub fn shape_text<S: AsRef<str> + Into<SharedString>>(
|
||||
&self,
|
||||
text: SharedString,
|
||||
text: S,
|
||||
font_size: Pixels,
|
||||
runs: &[TextRun],
|
||||
wrap_width: Option<Pixels>,
|
||||
@@ -598,7 +607,7 @@ impl WindowTextSystem {
|
||||
}
|
||||
};
|
||||
|
||||
let mut split_lines = text.split('\n');
|
||||
let mut split_lines = text.as_ref().split('\n');
|
||||
|
||||
// Special case single lines to prevent allocating a sharedstring
|
||||
if let Some(first_line) = split_lines.next()
|
||||
@@ -625,8 +634,8 @@ impl WindowTextSystem {
|
||||
);
|
||||
}
|
||||
} else {
|
||||
let end = text.len();
|
||||
process_line(text, 0, end);
|
||||
let end = text.as_ref().len();
|
||||
process_line(text.into(), 0, end);
|
||||
}
|
||||
|
||||
self.font_runs_pool.lock().push(font_runs);
|
||||
|
||||
@@ -3357,6 +3357,8 @@ impl Window {
|
||||
}
|
||||
|
||||
/// Use a piece of state that exists as long this element is being rendered in consecutive frames.
|
||||
///
|
||||
/// This method should only be called during `Render::render`, `RenderOnce::render`, or the drawing functions of `Element`.
|
||||
pub fn use_keyed_state<S: 'static>(
|
||||
&mut self,
|
||||
key: impl Into<ElementId>,
|
||||
@@ -3401,7 +3403,9 @@ impl Window {
|
||||
/// Updates or initializes state for an element with the given id that lives across multiple
|
||||
/// frames. If an element with this ID existed in the rendered frame, its state will be passed
|
||||
/// to the given closure. The state returned by the closure will be stored so it can be referenced
|
||||
/// when drawing the next frame. This method should only be called as part of element drawing.
|
||||
/// when drawing the next frame.
|
||||
///
|
||||
/// This method should only be called during `Render::render`, `RenderOnce::render`, or the drawing functions of `Element`.
|
||||
pub fn with_element_state<S, R>(
|
||||
&mut self,
|
||||
global_id: &GlobalElementId,
|
||||
@@ -3531,6 +3535,8 @@ impl Window {
|
||||
/// persist across renders as long as the key remains the same. This is the
|
||||
/// recommended method for most use cases where you want smooth, continuous
|
||||
/// animations.
|
||||
///
|
||||
/// This method should only be called during `Render::render`, `RenderOnce::render`, or the drawing functions of `Element`.
|
||||
pub fn use_keyed_transition<T: Lerp + Clone + PartialEq + 'static>(
|
||||
&mut self,
|
||||
key: impl Into<ElementId>,
|
||||
|
||||
@@ -13,6 +13,13 @@ ignored = ["gpui"]
|
||||
|
||||
[dependencies]
|
||||
gpui.workspace = true
|
||||
unicode-segmentation.workspace = true
|
||||
smallvec.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
gpui = { path = "../gpui", features = ["test-support"] }
|
||||
gpui_platform = { workspace = true, features = ["font-kit", "wayland", "x11"] }
|
||||
|
||||
[[example]]
|
||||
name = "editable_text"
|
||||
path = "examples/editable_text.rs"
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
use gpui::{
|
||||
App, Bounds, Context, Hsla, Window, WindowBounds, WindowOptions, div, prelude::*, px, rgb, size,
|
||||
};
|
||||
use gpui_elements::editable_text::{
|
||||
actions::{DEFAULT_INPUT_CONTEXT, default_bindings},
|
||||
text_area, text_input,
|
||||
};
|
||||
|
||||
struct Example;
|
||||
impl Render for Example {
|
||||
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
||||
div()
|
||||
.size_full()
|
||||
.bg(rgb(0x505050))
|
||||
.flex()
|
||||
.flex_col()
|
||||
.p_2()
|
||||
.gap_2()
|
||||
.items_start()
|
||||
.justify_start()
|
||||
.child(
|
||||
text_input("input-field")
|
||||
.caret_blink_interval_500ms()
|
||||
.placeholder("some placeholder text")
|
||||
.border_1()
|
||||
.rounded_lg()
|
||||
.border_color(Hsla::white()) // has a border
|
||||
.p_2() // padding between the text and border
|
||||
.min_w_10()
|
||||
.max_w_128()
|
||||
.min_h_auto()
|
||||
.max_h_auto()
|
||||
.whitespace_nowrap(),
|
||||
)
|
||||
.child(
|
||||
text_area("text-area")
|
||||
.placeholder("empty text")
|
||||
.border_1()
|
||||
.rounded_lg()
|
||||
.border_color(Hsla::white()) // has a border
|
||||
.p_2() // padding between the text and border
|
||||
.w_full()
|
||||
.min_h_24()
|
||||
.max_h_128()
|
||||
.whitespace_normal() // default
|
||||
.overflow_y_scroll(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
gpui_platform::application().run(|cx: &mut App| {
|
||||
cx.bind_keys(default_bindings().as_keybindings(Some(DEFAULT_INPUT_CONTEXT)));
|
||||
|
||||
let bounds = Bounds::centered(None, size(px(500.), px(500.0)), cx);
|
||||
cx.open_window(
|
||||
WindowOptions {
|
||||
window_bounds: Some(WindowBounds::Windowed(bounds)),
|
||||
..Default::default()
|
||||
},
|
||||
|_, cx| cx.new(|_| Example),
|
||||
)
|
||||
.unwrap();
|
||||
cx.activate(true);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
//! Implementation for editable-text elements (gpui equivalent of html
|
||||
//! [`<input>`](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/input) and
|
||||
//! [`<textarea>`](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/textarea)).
|
||||
//!
|
||||
//! Both [`text_input`] and [`text_area`] create an [`EditableTextElement`]. This element supports:
|
||||
//! - navigating via keyboard & mouse (by character, word, line, and document)
|
||||
//! - highlight selection via keyboard & mouse (holding shift, double/triple click mouse, mouse drag)
|
||||
//! - typing using an InputMethodEditor (IME) for writing Chinese, Japanese, and Korean utf-16
|
||||
//! - inserting newlines (`\n`) and tabs (`\t`)
|
||||
//! - cut/copy/paste
|
||||
//! - caret / text cursor that can blink
|
||||
//! - simple undo/redo within a single field
|
||||
//!
|
||||
//! For all input actions, see documentation in the [`actions`] module.
|
||||
//!
|
||||
//! Editable text elements will default to using [`String`] as the storage medium (see [`StringStorage`]).
|
||||
//! Standard library strings are not ideal though for large text documents. For such uses,
|
||||
//! it is encouraged that implementers consider rolling their own [`UnicodeTextStorage`] medium.
|
||||
//!
|
||||
//! Unlike other elements, editable text internally owns its [`FocusHandle`](gpui::FocusHandle).
|
||||
//! This is required due to limitations of the [`Interactivity`](gpui::Interactivity) api and
|
||||
//! that a user cannot interact with a text-input field if it cannot be focused.
|
||||
//!
|
||||
//! ### Usage Samples
|
||||
//!
|
||||
//! A single-line text input with a fixed width and text that does not wrap
|
||||
//! (overflow text is clipped and does not scroll).
|
||||
//! ```
|
||||
//! # use gpui::prelude::*;
|
||||
//! # fn test() -> gpui_elements::editable_text::EditableTextElement {
|
||||
//! use gpui_elements::editable_text::text_input;
|
||||
//! text_input("my_input")
|
||||
//! .placeholder("empty text")
|
||||
//! .w_5()
|
||||
//! .min_h_auto()
|
||||
//! .whitespace_nowrap()
|
||||
//! # }
|
||||
//! ```
|
||||
//!
|
||||
//! A single-line text input with a flexible width and text that does not wrap, but will scroll if overflowing.
|
||||
//! ```
|
||||
//! # use gpui::{prelude::*, Hsla};
|
||||
//! # fn test() -> gpui_elements::editable_text::EditableTextElement {
|
||||
//! use gpui_elements::editable_text::text_input;
|
||||
//! text_input("my_input")
|
||||
//! .placeholder("empty text")
|
||||
//! .border_1().rounded_lg().border_color(Hsla::white()) // has a border
|
||||
//! .p_2() // padding between the text and border
|
||||
//! .min_w_10().max_w_128()
|
||||
//! .min_h_auto()
|
||||
//! .whitespace_nowrap()
|
||||
//! .overflow_x_scroll()
|
||||
//! # }
|
||||
//! ```
|
||||
//!
|
||||
//! A multi-line text area with flexible height, wrapping text, and scrolling overflow on both axes.
|
||||
//! ```
|
||||
//! # use gpui::{prelude::*, Hsla};
|
||||
//! # fn test() -> gpui_elements::editable_text::EditableTextElement {
|
||||
//! use gpui_elements::editable_text::text_area;
|
||||
//! text_area("message")
|
||||
//! .placeholder("empty text")
|
||||
//! .border_1().rounded_lg().border_color(Hsla::white()) // has a border
|
||||
//! .p_2() // padding between the text and border
|
||||
//! .min_w_10().max_w_128()
|
||||
//! .min_h_24().max_h_128()
|
||||
//! .whitespace_normal() // default
|
||||
//! .overflow_y_scroll()
|
||||
//! # }
|
||||
//! ```
|
||||
//!
|
||||
//! The user-inputted text can be accessed via event callbacks on the element.
|
||||
//! There is no callback representing the concept of "user is done editing". Its recommended that
|
||||
//! users write a [debounce](https://developer.mozilla.org/en-US/docs/Glossary/Debounce)
|
||||
//! or some way to detect "focus lost" to signify the user leaving the field.
|
||||
//! ```
|
||||
//! # use gpui::{prelude::*, App, Entity, Window, AppContext, ElementId};
|
||||
//! # fn test(window: &mut Window, cx: &mut App) -> gpui_elements::editable_text::EditableTextElement {
|
||||
//! use gpui_elements::editable_text::{text_input, EditableTextState, TextChanged};
|
||||
//!
|
||||
//! // A unique id to the editable text element within the outer scope.
|
||||
//! let id = ElementId::from("my_input");
|
||||
//!
|
||||
//! // Find or lazily create the state entity backing the element.
|
||||
//! // Then attach the entity to the element, thereby keeping it alive across consecutive frames.
|
||||
//! let state = EditableTextState::use_keyed(id.clone(), window, cx);
|
||||
//!
|
||||
//! // This will trigger on every character input or other mutation to the underlying string
|
||||
//! cx.subscribe(&state, |state, _: &TextChanged, cx| {
|
||||
//! println!("{:?}", state.read(cx).as_str());
|
||||
//! }).detach();
|
||||
//!
|
||||
//! // Using state explicitly attaches the state we already have attached to the ElementId.
|
||||
//! text_input(id).state(state.downgrade())
|
||||
//! # }
|
||||
//! ```
|
||||
//!
|
||||
//! You can configure the default value of the editable text by using [`use_keyed_init`]:
|
||||
//! ```
|
||||
//! # use gpui::{prelude::*, App, Entity, Window, AppContext, ElementId};
|
||||
//! # use gpui_elements::editable_text::{text_input, EditableTextState, StringStorage};
|
||||
//! # fn test(window: &mut Window, cx: &mut App) -> gpui_elements::editable_text::EditableTextElement {
|
||||
//! let id = ElementId::from("my_input");
|
||||
//!
|
||||
//! // The function parameter will only be called when the state is created/initialized.
|
||||
//! // All successive renders across consecutive frames will re-use the existing state.
|
||||
//! let _state = EditableTextState::use_keyed_init(id.clone(), window, cx,
|
||||
//! |_window, _cx| StringStorage::from("this is some default text content"));
|
||||
//!
|
||||
//! // Its also plausible to omit the state function call. The element will try to find the state
|
||||
//! // according to its id (which we are trusting here was guaranteed to be at that id above).
|
||||
//! // Despite this functionality, its recommended that callers which construct a state explicitly
|
||||
//! // provide it to the element, at least for clarity and debugging.
|
||||
//! text_input(id)
|
||||
//! # }
|
||||
//! ```
|
||||
//!
|
||||
//! To use a blinking caret, you can use one of the templated functions:
|
||||
//! ```
|
||||
//! # use gpui::{prelude::*, App, Entity, Window, AppContext, ElementId};
|
||||
//! # fn test(window: &mut Window, cx: &mut App) -> gpui_elements::editable_text::EditableTextElement {
|
||||
//! use gpui_elements::editable_text::{text_input};
|
||||
//! let id = ElementId::from("my_input");
|
||||
//! text_input(id)
|
||||
//! .caret_blink_interval_500ms()
|
||||
//! // or use the parameterized one, e.g. 200ms
|
||||
//! .caret_blink_interval(std::time::Duration::from_millis(200))
|
||||
//! # }
|
||||
//! ```
|
||||
//!
|
||||
//! or construct a caret entity with a blinking interval when constructing the state:
|
||||
//! ```
|
||||
//! # use gpui::{prelude::*, App, Entity, Window, AppContext, ElementId};
|
||||
//! # fn test(window: &mut Window, cx: &mut App) -> gpui_elements::editable_text::EditableTextElement {
|
||||
//! use gpui_elements::editable_text::{text_input, EditableTextState, TextChanged, Caret};
|
||||
//! let id = ElementId::from("my_input");
|
||||
//!
|
||||
//! let state = EditableTextState::use_keyed(id.clone(), window, cx);
|
||||
//!
|
||||
//! // Ensure the caret exists, linked to the input element by id.
|
||||
//! window.use_keyed_state(id.clone(), cx, |window, cx| {
|
||||
//! // using the default interval of 500ms
|
||||
//! let mut caret = Caret::default().with_blink_interval_500ms();
|
||||
//! // ensures the caret receives events from the input state during typing & other actions
|
||||
//! caret.subscribe_to(&state, cx);
|
||||
//! caret
|
||||
//! });
|
||||
//!
|
||||
//! text_input(id).state(state.downgrade())
|
||||
//! # }
|
||||
//! ```
|
||||
//!
|
||||
//! Full-text examples can be found in the [examples folder](https://github.com/gpui-ce/gpui-ce/tree/main/crates/gpui_elements/examples)
|
||||
//!
|
||||
//! ### Backlog of not-yet implemented features:
|
||||
//! - detecting focus being lost on an EditableText field
|
||||
//! - text sanitation & validation (see no-op implementation of [`EditableTextState::validate_incoming_text`])
|
||||
//! - nav & select via PageUp/PageDown
|
||||
//! - screen reader support via a11y
|
||||
//! - masking text (e.g. for passwords)
|
||||
//! - disabling `insert_tab` if favor of tab being used to change focus between elements (i.e. escaping the field)
|
||||
//!
|
||||
|
||||
pub mod actions;
|
||||
mod caret;
|
||||
mod element;
|
||||
mod history;
|
||||
mod layout;
|
||||
mod state;
|
||||
mod storage;
|
||||
|
||||
pub use caret::*;
|
||||
pub use element::*;
|
||||
pub use state::*;
|
||||
pub use storage::*;
|
||||
@@ -0,0 +1,356 @@
|
||||
//! Module containing user-input actions that are bound by EditableText elements
|
||||
use gpui::{InteractiveElement, WeakEntity, Window};
|
||||
use std::{cell::RefCell, rc::Rc};
|
||||
|
||||
/// The key context used for EditableText element keybindings.
|
||||
pub const DEFAULT_INPUT_CONTEXT: &str = "EditableText";
|
||||
|
||||
gpui::actions!(
|
||||
actions,
|
||||
[
|
||||
/// Blur focus from the input.
|
||||
Escape,
|
||||
/// Insert a newline at the cursor position.
|
||||
Enter,
|
||||
/// Insert a tab character at the cursor position.
|
||||
Tab,
|
||||
/// Delete the character before the cursor.
|
||||
DeleteLeft,
|
||||
/// Delete the character after the cursor.
|
||||
DeleteRight,
|
||||
/// Delete the word before the cursor.
|
||||
DeleteWordLeft,
|
||||
/// Delete the word after the cursor.
|
||||
DeleteWordRight,
|
||||
/// Delete from the cursor to the beginning of the line.
|
||||
DeleteToLineStart,
|
||||
/// Delete from the cursor to the end of the line.
|
||||
DeleteToLineEnd,
|
||||
/// Move the cursor one character to the left.
|
||||
NavLeft,
|
||||
/// Move the cursor one character to the right.
|
||||
NavRight,
|
||||
/// Move the cursor up one visual line.
|
||||
NavUp,
|
||||
/// Move the cursor down one visual line.
|
||||
NavDown,
|
||||
/// Move cursor to the start of the current line.
|
||||
NavLineStart,
|
||||
/// Move cursor to the end of the current line.
|
||||
NavLineEnd,
|
||||
/// Move cursor to the beginning of the content.
|
||||
NavDocumentStart,
|
||||
/// Move cursor to the end of the content.
|
||||
NavDocumentEnd,
|
||||
/// Move cursor one word to the left.
|
||||
NavWordLeft,
|
||||
/// Move cursor one word to the right.
|
||||
NavWordRight,
|
||||
/// Select all text content.
|
||||
SelectAll,
|
||||
/// Extend selection one character to the left.
|
||||
SelectLeft,
|
||||
/// Extend selection one character to the right.
|
||||
SelectRight,
|
||||
/// Extend selection up one visual line.
|
||||
SelectUp,
|
||||
/// Extend selection down one visual line.
|
||||
SelectDown,
|
||||
/// Extend selection to the beginning of the content.
|
||||
SelectDocumentStart,
|
||||
/// Extend selection to the end of the content.
|
||||
SelectDocumentEnd,
|
||||
/// Extend selection one word to the left.
|
||||
SelectWordLeft,
|
||||
/// Extend selection one word to the right.
|
||||
SelectWordRight,
|
||||
/// Cut selected text to clipboard.
|
||||
Cut,
|
||||
/// Copy selected text to clipboard.
|
||||
Copy,
|
||||
/// Paste from clipboard at the cursor position.
|
||||
Paste,
|
||||
/// Undo the last edit.
|
||||
Undo,
|
||||
/// Redo the last undone edit.
|
||||
Redo,
|
||||
/// Show the platform character palette.
|
||||
ShowCharacterPalette,
|
||||
]
|
||||
);
|
||||
|
||||
/// Creates a collection of default keystroke bindings for EditableText actions.
|
||||
/// See [`ActionBindingCollection`](gpui::ActionBindingCollection) docs on how to override these bindings.
|
||||
///
|
||||
/// Apple keyboards dont have Home or End keys, so there are common bindings that replace those keys.
|
||||
/// | Action | All | Linux & Windows | MacOS |
|
||||
/// | --------------------- | ----------- | ------------------------- | --------------- |
|
||||
/// | Escape | escape | | |
|
||||
/// | Enter | enter | | |
|
||||
/// | Tab | tab | | |
|
||||
/// | DeleteLeft | backspace | | |
|
||||
/// | DeleteRight | delete | | |
|
||||
/// | DeleteWordLeft | | ctrl + backspace | alt + backspace |
|
||||
/// | DeleteWordRight | | ctrl + delete | alt + delete |
|
||||
/// | DeleteToLineStart | | ctrl + shift + backspace | cmd + backspace |
|
||||
/// | DeleteToLineEnd | | ctrl + shift + delete | ctrl + k |
|
||||
/// | NavLeft | 🡄 | | |
|
||||
/// | NavRight | 🡆 | | |
|
||||
/// | NavUp | 🡅 | | |
|
||||
/// | NavDown | 🡇 | | |
|
||||
/// | NavLineStart | | home | cmd + 🡄 |
|
||||
/// | NavLineEnd | | end | cmd + 🡆 |
|
||||
/// | NavDocumentStart | | ctrl + home | cmd + 🡅 |
|
||||
/// | NavDocumentEnd | | ctrl + end | cmd + 🡇 |
|
||||
/// | NavWordLeft | | ctrl + 🡄 | alt + 🡄 |
|
||||
/// | NavWordRight | | ctrl + 🡆 | alt + 🡆 |
|
||||
/// | SelectAll | | ctrl + a | cmd + a |
|
||||
/// | SelectLeft | shift + 🡄 | | |
|
||||
/// | SelectRight | shift + 🡆 | | |
|
||||
/// | SelectUp | shift + 🡅 | | |
|
||||
/// | SelectDown | shift + 🡇 | | |
|
||||
/// | SelectDocumentStart | | ctrl + shift + home | cmd + shift + 🡅 |
|
||||
/// | SelectDocumentEnd | | ctrl + shift + end | cmd + shift + 🡇 |
|
||||
/// | SelectWordLeft | | ctrl + shift + 🡄 | alt + shift + 🡄 |
|
||||
/// | SelectWordRight | | ctrl + shift + 🡆 | alt + shift + 🡆 |
|
||||
/// | Cut | | ctrl + x | cmd + x |
|
||||
/// | Copy | | ctrl + c | cmd + c |
|
||||
/// | Paste | | ctrl + v | cmd + v |
|
||||
/// | Undo | | ctrl + z | cmd + z |
|
||||
/// | Redo | | ctrl + shift + z | cmd + shift + z |
|
||||
/// | ShowCharacterPalette | | ctrl + space | cmd + space |
|
||||
///
|
||||
/// TODO: Collection does not supply a way to unbind a default keystroke
|
||||
pub fn default_bindings() -> gpui::ActionBindingCollection {
|
||||
let mut bindings = gpui::ActionBindingCollection::default()
|
||||
.with::<DeleteLeft>("backspace")
|
||||
.with::<DeleteRight>("delete")
|
||||
.with::<Tab>("tab")
|
||||
.with::<Enter>("enter")
|
||||
.with::<NavLeft>("left")
|
||||
.with::<NavRight>("right")
|
||||
.with::<NavUp>("up")
|
||||
.with::<NavDown>("down")
|
||||
.with::<SelectAll>("secondary-a")
|
||||
.with::<SelectLeft>("shift-left")
|
||||
.with::<SelectRight>("shift-right")
|
||||
.with::<SelectUp>("shift-up")
|
||||
.with::<SelectDown>("shift-down")
|
||||
.with::<Copy>("secondary-c")
|
||||
.with::<Cut>("secondary-x")
|
||||
.with::<Paste>("secondary-v")
|
||||
.with::<Undo>("secondary-z")
|
||||
.with::<Redo>("secondary-shift-z")
|
||||
.with::<Escape>("escape")
|
||||
.with::<ShowCharacterPalette>("secondary-space");
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
bindings = bindings
|
||||
.with::<DeleteWordLeft>("alt-backspace")
|
||||
.with::<DeleteWordRight>("alt-delete")
|
||||
.with::<DeleteToLineStart>("cmd-backspace")
|
||||
.with::<DeleteToLineEnd>("ctrl-k")
|
||||
// Mac keyboards don't have Home/End keys, so cmd-left/right are standard
|
||||
.with::<NavLineStart>("cmd-left")
|
||||
.with::<NavLineEnd>("cmd-right")
|
||||
.with::<NavDocumentStart>("cmd-up")
|
||||
.with::<NavDocumentEnd>("cmd-down")
|
||||
.with::<SelectDocumentStart>("cmd-shift-up")
|
||||
.with::<SelectDocumentEnd>("cmd-shift-down")
|
||||
.with::<NavWordLeft>("alt-left")
|
||||
.with::<NavWordRight>("alt-right")
|
||||
.with::<SelectWordLeft>("alt-shift-left")
|
||||
.with::<SelectWordRight>("alt-shift-right");
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
{
|
||||
bindings = bindings
|
||||
.with::<DeleteWordLeft>("ctrl-backspace")
|
||||
.with::<DeleteWordRight>("ctrl-delete")
|
||||
.with::<DeleteToLineStart>("ctrl-shift-backspace")
|
||||
.with::<DeleteToLineEnd>("ctrl-shift-delete")
|
||||
.with::<NavLineStart>("home")
|
||||
.with::<NavLineEnd>("end")
|
||||
.with::<NavDocumentStart>("ctrl-home")
|
||||
.with::<NavDocumentEnd>("ctrl-end")
|
||||
.with::<SelectDocumentStart>("ctrl-shift-home")
|
||||
.with::<SelectDocumentEnd>("ctrl-shift-end")
|
||||
.with::<NavWordLeft>("ctrl-left")
|
||||
.with::<NavWordRight>("ctrl-right")
|
||||
.with::<SelectWordLeft>("ctrl-shift-left")
|
||||
.with::<SelectWordRight>("ctrl-shift-right");
|
||||
}
|
||||
|
||||
bindings
|
||||
}
|
||||
|
||||
/// Declares stubs for all editable-text actions that an element's state entity can implement.
|
||||
pub trait EditableTextActionHandler<Context>: Sized {
|
||||
/// Blur focus from the input.
|
||||
fn escape(&mut self, _: &Escape, _w: &mut Window, _cx: &mut Context) {}
|
||||
|
||||
/// Insert a newline at the cursor position.
|
||||
fn insert_enter(&mut self, _: &Enter, _w: &mut Window, _cx: &mut Context) {}
|
||||
/// Insert a tab character at the cursor position.
|
||||
fn insert_tab(&mut self, _: &Tab, _w: &mut Window, _cx: &mut Context) {}
|
||||
|
||||
/// Delete the character before the cursor.
|
||||
fn delete_left(&mut self, _: &DeleteLeft, _w: &mut Window, _cx: &mut Context) {}
|
||||
/// Delete the character after the cursor.
|
||||
fn delete_right(&mut self, _: &DeleteRight, _w: &mut Window, _cx: &mut Context) {}
|
||||
/// Delete the word before the cursor.
|
||||
fn delete_word_left(&mut self, _: &DeleteWordLeft, _w: &mut Window, _cx: &mut Context) {}
|
||||
/// Delete the word after the cursor.
|
||||
fn delete_word_right(&mut self, _: &DeleteWordRight, _w: &mut Window, _cx: &mut Context) {}
|
||||
/// Delete from the cursor to the beginning of the line.
|
||||
fn delete_to_line_start(&mut self, _: &DeleteToLineStart, _w: &mut Window, _cx: &mut Context) {}
|
||||
/// Delete from the cursor to the end of the line.
|
||||
fn delete_to_line_end(&mut self, _: &DeleteToLineEnd, _w: &mut Window, _cx: &mut Context) {}
|
||||
|
||||
/// Move the cursor one character to the left.
|
||||
fn nav_left(&mut self, _: &NavLeft, _w: &mut Window, _cx: &mut Context) {}
|
||||
/// Move the cursor one character to the right.
|
||||
fn nav_right(&mut self, _: &NavRight, _w: &mut Window, _cx: &mut Context) {}
|
||||
/// Move the cursor up one visual line.
|
||||
fn nav_up(&mut self, _: &NavUp, _w: &mut Window, _cx: &mut Context) {}
|
||||
/// Move the cursor down one visual line.
|
||||
fn nav_down(&mut self, _: &NavDown, _w: &mut Window, _cx: &mut Context) {}
|
||||
/// Move cursor to the start of the current line.
|
||||
fn nav_line_start(&mut self, _: &NavLineStart, _w: &mut Window, _cx: &mut Context) {}
|
||||
/// Move cursor to the end of the current line.
|
||||
fn nav_line_end(&mut self, _: &NavLineEnd, _w: &mut Window, _cx: &mut Context) {}
|
||||
/// Move cursor to the start of the document.
|
||||
fn nav_start(&mut self, _: &NavDocumentStart, _w: &mut Window, _cx: &mut Context) {}
|
||||
/// Move cursor to the end of the document.
|
||||
fn nav_end(&mut self, _: &NavDocumentEnd, _w: &mut Window, _cx: &mut Context) {}
|
||||
/// Move cursor one word to the left.
|
||||
fn nav_left_word(&mut self, _: &NavWordLeft, _w: &mut Window, _cx: &mut Context) {}
|
||||
/// Move cursor one word to the right.
|
||||
fn nav_right_word(&mut self, _: &NavWordRight, _w: &mut Window, _cx: &mut Context) {}
|
||||
|
||||
/// Select the entire document.
|
||||
fn select_all(&mut self, _: &SelectAll, _w: &mut Window, _cx: &mut Context) {}
|
||||
/// Extend selection one character to the left.
|
||||
fn select_left(&mut self, _: &SelectLeft, _w: &mut Window, _cx: &mut Context) {}
|
||||
/// Extend selection one character to the right.
|
||||
fn select_right(&mut self, _: &SelectRight, _w: &mut Window, _cx: &mut Context) {}
|
||||
/// Extend selection up one visual line.
|
||||
fn select_up(&mut self, _: &SelectUp, _w: &mut Window, _cx: &mut Context) {}
|
||||
/// Extend selection down one visual line.
|
||||
fn select_down(&mut self, _: &SelectDown, _w: &mut Window, _cx: &mut Context) {}
|
||||
/// Extend selection to the beginning of the document.
|
||||
fn select_start(&mut self, _: &SelectDocumentStart, _w: &mut Window, _cx: &mut Context) {}
|
||||
/// Extend selection to the end of the document.
|
||||
fn select_end(&mut self, _: &SelectDocumentEnd, _w: &mut Window, _cx: &mut Context) {}
|
||||
/// Extend selection one word to the left.
|
||||
fn select_left_word(&mut self, _: &SelectWordLeft, _w: &mut Window, _cx: &mut Context) {}
|
||||
/// Extend selection one word to the right.
|
||||
fn select_right_word(&mut self, _: &SelectWordRight, _w: &mut Window, _cx: &mut Context) {}
|
||||
|
||||
/// Cut selected text to clipboard.
|
||||
fn cut(&mut self, _: &Cut, _w: &mut Window, _cx: &mut Context) {}
|
||||
/// Copy selected text to clipboard.
|
||||
fn copy(&mut self, _: &Copy, _w: &mut Window, _cx: &mut Context) {}
|
||||
/// Paste from clipboard at the cursor position.
|
||||
fn paste(&mut self, _: &Paste, _w: &mut Window, _cx: &mut Context) {}
|
||||
|
||||
/// Undo the last edit.
|
||||
fn undo(&mut self, _: &Undo, _w: &mut Window, _cx: &mut Context) {}
|
||||
/// Redo the last undone edit.
|
||||
fn redo(&mut self, _: &Redo, _w: &mut Window, _cx: &mut Context) {}
|
||||
|
||||
/// Show the platform character palette.
|
||||
fn show_character_palette(
|
||||
&mut self,
|
||||
_: &ShowCharacterPalette,
|
||||
window: &mut Window,
|
||||
_cx: &mut Context,
|
||||
) {
|
||||
window.show_character_palette();
|
||||
}
|
||||
|
||||
fn on_mouse_down(
|
||||
&mut self,
|
||||
_event: &gpui::MouseDownEvent,
|
||||
_text_position: gpui::Point<gpui::Pixels>,
|
||||
_w: &mut Window,
|
||||
_cx: &mut Context,
|
||||
) {
|
||||
}
|
||||
fn on_mouse_up(&mut self, _event: &gpui::MouseUpEvent, _w: &mut Window, _cx: &mut Context) {}
|
||||
fn on_mouse_move(
|
||||
&mut self,
|
||||
_event: &gpui::MouseMoveEvent,
|
||||
_text_position: gpui::Point<gpui::Pixels>,
|
||||
_w: &mut Window,
|
||||
_cx: &mut Context,
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Registers an handler function of [`EditableTextActionHandler`]
|
||||
/// which is processed via the return value of [`EditableTextActionElement::state_entity_rc`].
|
||||
macro_rules! register_action {
|
||||
($action_element:expr, $func:ident) => {{
|
||||
let entity_rc = $action_element.state_entity_rc().clone();
|
||||
$action_element
|
||||
.interactivity()
|
||||
.on_action(move |action, window, cx| {
|
||||
let weak_entity = entity_rc.borrow();
|
||||
if let Some(entity) = weak_entity.upgrade() {
|
||||
entity.update(cx, |state, cx| {
|
||||
state.$func(action, window, cx);
|
||||
});
|
||||
}
|
||||
});
|
||||
}};
|
||||
}
|
||||
|
||||
/// Generic trait to support an element backed by an internal state entity to bind to all editable-text input actions.
|
||||
pub(super) trait EditableTextActionElement<State> {
|
||||
fn state_entity_rc(&self) -> &Rc<RefCell<WeakEntity<State>>>;
|
||||
|
||||
fn register_actions(&mut self)
|
||||
where
|
||||
Self: InteractiveElement,
|
||||
State: for<'app> EditableTextActionHandler<gpui::Context<'app, State>>,
|
||||
State: 'static,
|
||||
{
|
||||
register_action!(self, escape);
|
||||
register_action!(self, insert_enter);
|
||||
register_action!(self, insert_tab);
|
||||
register_action!(self, delete_left);
|
||||
register_action!(self, delete_right);
|
||||
register_action!(self, delete_word_left);
|
||||
register_action!(self, delete_word_right);
|
||||
register_action!(self, delete_to_line_start);
|
||||
register_action!(self, delete_to_line_end);
|
||||
register_action!(self, nav_left);
|
||||
register_action!(self, nav_right);
|
||||
register_action!(self, nav_up);
|
||||
register_action!(self, nav_down);
|
||||
register_action!(self, nav_line_start);
|
||||
register_action!(self, nav_line_end);
|
||||
register_action!(self, nav_start);
|
||||
register_action!(self, nav_end);
|
||||
register_action!(self, nav_left_word);
|
||||
register_action!(self, nav_right_word);
|
||||
register_action!(self, select_all);
|
||||
register_action!(self, select_left);
|
||||
register_action!(self, select_right);
|
||||
register_action!(self, select_up);
|
||||
register_action!(self, select_down);
|
||||
register_action!(self, select_start);
|
||||
register_action!(self, select_end);
|
||||
register_action!(self, select_left_word);
|
||||
register_action!(self, select_right_word);
|
||||
register_action!(self, cut);
|
||||
register_action!(self, copy);
|
||||
register_action!(self, paste);
|
||||
register_action!(self, undo);
|
||||
register_action!(self, redo);
|
||||
register_action!(self, show_character_palette);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
use gpui::{Context, Entity, EventEmitter, Subscription};
|
||||
use smallvec::SmallVec;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Default interval for caret blinking (500ms).
|
||||
pub const BLINK_INTERVAL_500MS: Duration = Duration::from_millis(500);
|
||||
|
||||
/// Events emitted that the [`Caret`] listens to.
|
||||
pub enum CaretNotify {
|
||||
/// The caret should pause blinking in response to a user-action
|
||||
PauseBlinking,
|
||||
}
|
||||
|
||||
/// State of an EditableText caret cursor, which supports features like blinking.
|
||||
/// Blinking is disabled by default.
|
||||
pub struct Caret {
|
||||
/// The frequency at which the caret blinks
|
||||
interval: Duration,
|
||||
generation: usize,
|
||||
/// Whether the caret is presently visible in this frame
|
||||
visible: bool,
|
||||
/// Whether the caret's EditableText element is currently focused.
|
||||
/// Caret is only eligible to be blinking if currently focused.
|
||||
has_focus: bool,
|
||||
/// true when blinking is active but paused for some number of frames
|
||||
paused: bool,
|
||||
#[allow(dead_code)]
|
||||
subscriptions: SmallVec<[Subscription; 2]>,
|
||||
/// Tracks whether we were focused on the last update.
|
||||
was_focused: bool,
|
||||
}
|
||||
impl Default for Caret {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
interval: Duration::ZERO,
|
||||
generation: Default::default(),
|
||||
visible: false,
|
||||
has_focus: false,
|
||||
paused: false,
|
||||
subscriptions: SmallVec::new(),
|
||||
was_focused: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Caret {
|
||||
/// Returns the duration of the current blink interval
|
||||
pub fn blink_interval(&self) -> Duration {
|
||||
self.interval
|
||||
}
|
||||
|
||||
/// Sets the blinking interval of the caret.
|
||||
pub fn set_blink_interval(&mut self, interval: Duration) {
|
||||
self.interval = interval;
|
||||
}
|
||||
|
||||
/// Sets the blinking interval of the caret.
|
||||
pub fn with_blink_interval(mut self, interval: Duration) -> Self {
|
||||
self.set_blink_interval(interval);
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the blinking interval of the caret to the global "default".
|
||||
/// The true default of the caret is "do not blink".
|
||||
pub fn with_blink_interval_500ms(self) -> Self {
|
||||
self.with_blink_interval(BLINK_INTERVAL_500MS)
|
||||
}
|
||||
|
||||
/// Listens for CaretNotify events on an entity (e.g. [`EditableTextState`]).
|
||||
pub fn subscribe_to<E>(&mut self, emitter: &Entity<E>, cx: &mut Context<Self>)
|
||||
where
|
||||
E: EventEmitter<CaretNotify>,
|
||||
{
|
||||
let handle = cx.subscribe(emitter, |state, _emitter, event, cx| match event {
|
||||
CaretNotify::PauseBlinking => {
|
||||
if state.interval.is_zero() || !state.has_focus {
|
||||
return;
|
||||
}
|
||||
|
||||
// Temporarily pauses blinking and leaves the caret visible. Blinking will resume after
|
||||
// the pre-established interval elapses from the time this is called.
|
||||
if !state.visible {
|
||||
state.visible = true;
|
||||
}
|
||||
state.paused = true;
|
||||
state.restart_blink_ticker(cx);
|
||||
cx.notify();
|
||||
}
|
||||
});
|
||||
self.subscriptions.push(handle);
|
||||
}
|
||||
|
||||
/// Processes updates during prepaint and returns whether the caret is currently visible.
|
||||
pub(super) fn update_focus(&mut self, is_focused: bool, cx: &mut Context<Self>) -> bool {
|
||||
let was_focused = self.was_focused;
|
||||
self.was_focused = is_focused;
|
||||
|
||||
// Caret has no blinking interval, it is always visible
|
||||
if self.interval.is_zero() {
|
||||
return is_focused;
|
||||
}
|
||||
|
||||
match (was_focused, is_focused) {
|
||||
// Caret has a blinking interval, and gained focused.
|
||||
(false, true) => {
|
||||
self.has_focus = true;
|
||||
self.paused = false;
|
||||
|
||||
// Render in this frame and restart the blinking ticker.
|
||||
self.visible = true;
|
||||
self.restart_blink_ticker(cx);
|
||||
true
|
||||
}
|
||||
// Caret has a blinking interval and lost focus
|
||||
(true, false) => {
|
||||
self.has_focus = false;
|
||||
self.visible = false;
|
||||
self.paused = false;
|
||||
cx.notify();
|
||||
false
|
||||
}
|
||||
// Has a blinking interval, but focus has not changed.
|
||||
// Only render if currently visible (based on blink ticker).
|
||||
_ => self.visible,
|
||||
}
|
||||
}
|
||||
|
||||
fn restart_blink_ticker(&mut self, cx: &mut Context<Self>) {
|
||||
let generation = self.generation.wrapping_add(1);
|
||||
self.generation = generation;
|
||||
|
||||
let interval = self.interval;
|
||||
cx.spawn(async move |this, cx| {
|
||||
cx.background_executor().timer(interval).await;
|
||||
|
||||
let Some(this) = this.upgrade() else { return };
|
||||
this.update(cx, |this, cx| {
|
||||
// If the generation has changed, that means a new task was spawned.
|
||||
// This one should be no-op since a new task is owning the blinking state.
|
||||
if this.generation == generation {
|
||||
// PauseBlinking increments the generation via restart_ticker,
|
||||
// so we can always unpause the blinking if the generation is unchanged.
|
||||
this.paused = false;
|
||||
|
||||
// This was the last tick/blink before we lost focus.
|
||||
// Should now go inert until focus is regained.
|
||||
if !this.has_focus {
|
||||
return;
|
||||
}
|
||||
|
||||
// We still have focus, toggle whether caret is visible and make sure the owning element re-renders.
|
||||
this.visible = !this.visible;
|
||||
cx.notify();
|
||||
|
||||
// Start a fresh cycle by respawning the task.
|
||||
this.restart_blink_ticker(cx);
|
||||
}
|
||||
});
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,899 @@
|
||||
use crate::editable_text::{
|
||||
BLINK_INTERVAL_500MS, Caret, EditableTextState,
|
||||
actions::{DEFAULT_INPUT_CONTEXT, EditableTextActionElement, EditableTextActionHandler},
|
||||
layout::{EditableTextLayoutResult, EditableTextLayoutState, TextLineSegment},
|
||||
};
|
||||
use gpui::{
|
||||
App, Bounds, CursorStyle, DispatchPhase, Display, Element, ElementId, ElementInputHandler,
|
||||
Entity, FocusHandle, Focusable, Hitbox, HitboxBehavior, Hsla, InteractiveElement,
|
||||
Interactivity, IntoElement, LayoutId, MouseButton, MouseDownEvent, MouseMoveEvent,
|
||||
MouseUpEvent, PaintQuad, Pixels, Point, SharedString, Size, StatefulInteractiveElement, Style,
|
||||
StyleRefinement, Styled, TextAlign, TextLayout, WeakEntity, Window, WrappedLine, fill, point,
|
||||
px, size,
|
||||
};
|
||||
use smallvec::SmallVec;
|
||||
use std::{cell::RefCell, ops::Range, rc::Rc, sync::Arc, time::Duration};
|
||||
|
||||
const CARET_RENDER_WIDTH: f32 = 2.0;
|
||||
|
||||
/// Creates a text input element.
|
||||
/// See [`EditableTextElement`] for usage.
|
||||
///
|
||||
/// By default it is multiline, and therefore this is semantically equivalent to [`text_area`].
|
||||
#[track_caller]
|
||||
pub fn editable_text(id: impl Into<ElementId>) -> EditableTextElement {
|
||||
let mut this = EditableTextElement {
|
||||
interactivity: Interactivity::default(),
|
||||
state_entity: Rc::new(RefCell::new(WeakEntity::new_invalid())),
|
||||
supports_multiline: true,
|
||||
placeholder: None,
|
||||
accepts_input: true,
|
||||
colors: EditableTextColors::default(),
|
||||
caret_blink_interval: None,
|
||||
};
|
||||
this.interactivity.element_id = Some(id.into());
|
||||
|
||||
this = this.key_context(DEFAULT_INPUT_CONTEXT);
|
||||
this.register_actions();
|
||||
|
||||
this
|
||||
}
|
||||
|
||||
/// Creates a singleline text input element.
|
||||
/// See [`EditableTextElement`] for usage.
|
||||
#[track_caller]
|
||||
pub fn text_input(id: impl Into<ElementId>) -> EditableTextElement {
|
||||
editable_text(id).multiline(false)
|
||||
}
|
||||
|
||||
/// Creates a multiline text input element.
|
||||
/// See [`EditableTextElement`] for usage.
|
||||
#[track_caller]
|
||||
pub fn text_area(id: impl Into<ElementId>) -> EditableTextElement {
|
||||
editable_text(id).multiline(true)
|
||||
}
|
||||
|
||||
/// An input field which users can type text into.
|
||||
pub struct EditableTextElement {
|
||||
interactivity: Interactivity,
|
||||
// Populated on first render with an entity stored/attached to the view.
|
||||
// This reference is shared with the action handlers, which are processed between renders
|
||||
// and therefore cannot otherwise access state attached to the view.
|
||||
state_entity: Rc<RefCell<WeakEntity<EditableTextState>>>,
|
||||
supports_multiline: bool,
|
||||
placeholder: Option<SharedString>,
|
||||
accepts_input: bool,
|
||||
colors: EditableTextColors,
|
||||
caret_blink_interval: Option<Duration>,
|
||||
}
|
||||
|
||||
/// EditableText styling that goes beyond what Style/StyleRefinement supports
|
||||
struct EditableTextColors {
|
||||
/// Color of the placeholder text when the storage is empty.
|
||||
/// Could be reconceived as a refinement of text_color when the field is empty
|
||||
placeholder: Hsla,
|
||||
/// Color of the selection box.
|
||||
/// Could be driven by platform-provided styling?
|
||||
selection: Hsla,
|
||||
/// Color of the caret / text cursor
|
||||
caret: Hsla,
|
||||
/// Color of IME marked underlines
|
||||
ime_underline: Hsla,
|
||||
}
|
||||
impl Default for EditableTextColors {
|
||||
fn default() -> Self {
|
||||
const WHITE_50PC: Hsla = Hsla {
|
||||
h: 0.0,
|
||||
s: 0.0,
|
||||
l: 1.0,
|
||||
a: 0.5,
|
||||
};
|
||||
const WHITE_70PC: Hsla = Hsla {
|
||||
h: 0.0,
|
||||
s: 0.0,
|
||||
l: 1.0,
|
||||
a: 0.7,
|
||||
};
|
||||
// approx rgb(38 79 120) or oklch(41.9% 0.0829 250.4)
|
||||
const LIGHT_NAVY_BLUE_50PC: Hsla = Hsla {
|
||||
h: 0.583,
|
||||
s: 0.519,
|
||||
l: 0.31,
|
||||
a: 0.5,
|
||||
};
|
||||
Self {
|
||||
placeholder: WHITE_50PC,
|
||||
selection: LIGHT_NAVY_BLUE_50PC,
|
||||
caret: Hsla::white(),
|
||||
ime_underline: WHITE_70PC,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl EditableTextElement {
|
||||
/// Assigns the underlying state of this element, which should persist across multiple frames.
|
||||
/// The user should either create the entity once or utilize `Window::use_keyed_state`
|
||||
/// to create an entity intrinsicly linked to the element.
|
||||
/// If no state is configured, one will be linked to this element on first render via `Window::use_keyed_state`.
|
||||
pub fn state(self, state: WeakEntity<EditableTextState>) -> Self {
|
||||
*self.state_entity.borrow_mut() = state;
|
||||
self
|
||||
}
|
||||
|
||||
/// Configures whether the field supports multiple lines of text.
|
||||
/// Disabling this prevents actions like `enter` and navigating up and down.
|
||||
///
|
||||
/// It doesnt not automatically sanitize inputs from containing newlines (e.g. on paste).
|
||||
/// This is a limitation of the current state of implementation and requires further iteration.
|
||||
pub fn multiline(mut self, enabled: bool) -> Self {
|
||||
self.supports_multiline = enabled;
|
||||
self
|
||||
}
|
||||
|
||||
/// Assigns the text that should be displayed when storage of the element is empty.
|
||||
pub fn placeholder(mut self, text: impl Into<SharedString>) -> Self {
|
||||
self.placeholder = Some(text.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Configures whether the element can accept input (effectively means "is the element currently enabled").
|
||||
pub fn accepts_input(mut self, enabled: bool) -> Self {
|
||||
self.accepts_input = enabled;
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the blinking interval of the caret.
|
||||
pub fn caret_blink_interval(mut self, duration: Duration) -> Self {
|
||||
self.caret_blink_interval = Some(duration);
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the blinking interval of the caret to 500ms
|
||||
pub fn caret_blink_interval_500ms(self) -> Self {
|
||||
self.caret_blink_interval(BLINK_INTERVAL_500MS)
|
||||
}
|
||||
|
||||
/// Sets the color of the placeholder text which is rendered when the element's stored text is empty.
|
||||
///
|
||||
/// Cannot be refined via [`StyleRefinement`](gpui::StyleRefinement) due to limitations in the fields of [`Style`](gpui::Style).
|
||||
pub fn placeholder_color(mut self, color: Hsla) -> Self {
|
||||
self.colors.placeholder = color;
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the color of the box highlighting selected text.
|
||||
///
|
||||
/// Cannot be refined via [`StyleRefinement`](gpui::StyleRefinement) due to limitations in the fields of [`Style`](gpui::Style).
|
||||
pub fn selection_color(mut self, color: Hsla) -> Self {
|
||||
self.colors.selection = color;
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the color of the caret / text-cursor.
|
||||
///
|
||||
/// Cannot be refined via [`StyleRefinement`](gpui::StyleRefinement) due to limitations in the fields of [`Style`](gpui::Style).
|
||||
pub fn caret_color(mut self, color: Hsla) -> Self {
|
||||
self.colors.caret = color;
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the color of the underlines rendered underneath text being editted/marked by InputMethodEditors
|
||||
/// (for writing Chinese, Japanese, and Korean utf-16).
|
||||
///
|
||||
/// Cannot be refined via [`StyleRefinement`](gpui::StyleRefinement) due to limitations in the fields of [`Style`](gpui::Style).
|
||||
pub fn marked_color(mut self, color: Hsla) -> Self {
|
||||
self.colors.ime_underline = color;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl InteractiveElement for EditableTextElement {
|
||||
fn interactivity(&mut self) -> &mut Interactivity {
|
||||
&mut self.interactivity
|
||||
}
|
||||
}
|
||||
|
||||
// forced implementation since the API for the element doesnt use Stateful<Element>
|
||||
impl StatefulInteractiveElement for EditableTextElement {}
|
||||
|
||||
impl Styled for EditableTextElement {
|
||||
fn style(&mut self) -> &mut StyleRefinement {
|
||||
&mut self.interactivity.base_style
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoElement for EditableTextElement {
|
||||
type Element = Self;
|
||||
fn into_element(self) -> Self::Element {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl EditableTextActionElement<EditableTextState> for EditableTextElement {
|
||||
fn state_entity_rc(&self) -> &Rc<RefCell<WeakEntity<EditableTextState>>> {
|
||||
&self.state_entity
|
||||
}
|
||||
}
|
||||
|
||||
struct PrelayoutState {
|
||||
state: Entity<EditableTextState>,
|
||||
prev_layout_state: EditableTextLayoutState,
|
||||
storage_version: u16,
|
||||
show_placeholder: bool,
|
||||
text: Option<SharedString>,
|
||||
placeholder_color: Hsla,
|
||||
supports_multiline: bool,
|
||||
accepts_input: bool,
|
||||
}
|
||||
|
||||
#[doc(hidden)]
|
||||
pub struct LayoutState {
|
||||
state: Entity<EditableTextState>,
|
||||
caret: Entity<Caret>,
|
||||
}
|
||||
|
||||
struct InteractivityPrepaint {
|
||||
hitbox: Option<Hitbox>,
|
||||
scroll_offset: Point<Pixels>,
|
||||
inner_bounds: Bounds<Pixels>,
|
||||
caret_visible: bool,
|
||||
}
|
||||
|
||||
/// Internal type containing prepaint information used to paint the element
|
||||
#[doc(hidden)]
|
||||
pub struct PrepaintState {
|
||||
interactivity: InteractivityPrepaint,
|
||||
focus_handle: FocusHandle,
|
||||
elements: PrepaintElements,
|
||||
}
|
||||
|
||||
impl Element for EditableTextElement {
|
||||
type RequestLayoutState = LayoutState;
|
||||
type PrepaintState = PrepaintState;
|
||||
|
||||
fn id(&self) -> Option<ElementId> {
|
||||
self.interactivity.element_id.clone()
|
||||
}
|
||||
|
||||
fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
|
||||
self.interactivity.source_location()
|
||||
}
|
||||
|
||||
fn request_layout(
|
||||
&mut self,
|
||||
global_id: Option<&gpui::GlobalElementId>,
|
||||
inspector_id: Option<&gpui::InspectorElementId>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> (gpui::LayoutId, Self::RequestLayoutState) {
|
||||
let entity = self.find_or_create_state(window, cx);
|
||||
let caret = self.find_or_create_caret(&entity, window, cx);
|
||||
|
||||
if let Some(duration) = self.caret_blink_interval.take()
|
||||
&& caret.read(cx).blink_interval() != duration
|
||||
{
|
||||
caret.update(cx, |caret, _cx| caret.set_blink_interval(duration));
|
||||
}
|
||||
|
||||
// Read new state information from the underlying entity.
|
||||
// Block-wrapped so that the state being read is dropped before continuing.
|
||||
let (prelayout, next_scroll_offset) = {
|
||||
let state = entity.read(cx);
|
||||
let show_placeholder = state.as_str().is_empty();
|
||||
let text = match show_placeholder {
|
||||
false => Some(SharedString::from(state.as_str())),
|
||||
true => self.placeholder.clone(),
|
||||
};
|
||||
|
||||
let prelayout = PrelayoutState {
|
||||
state: entity.clone(),
|
||||
prev_layout_state: state.layout_data.state,
|
||||
show_placeholder,
|
||||
storage_version: state.version(),
|
||||
text,
|
||||
placeholder_color: self.colors.placeholder,
|
||||
supports_multiline: self.supports_multiline,
|
||||
accepts_input: self.accepts_input,
|
||||
};
|
||||
(prelayout, state.layout_data.next_scroll_offset)
|
||||
};
|
||||
|
||||
// Update the scroll offset of the element when the user's caret goes out of scope.
|
||||
if let Some(scroll_offset) = next_scroll_offset {
|
||||
self.interactivity
|
||||
.set_scroll_offset(global_id, window, -scroll_offset);
|
||||
|
||||
// Clear scroll_layout here in the very likely event that we wont need to
|
||||
// recompute layout, in which case the layout result isnt rebuilt during `perform_text_layout`.
|
||||
entity.update(cx, |state, _cx| {
|
||||
state.layout_data.next_scroll_offset = None;
|
||||
});
|
||||
}
|
||||
|
||||
let layout_id = self.interactivity.request_layout(
|
||||
global_id,
|
||||
inspector_id,
|
||||
window,
|
||||
cx,
|
||||
|style, window, cx| {
|
||||
window.with_text_style(style.text_style().cloned(), move |window| {
|
||||
let text_layout_id = prelayout.perform_text_layout(window);
|
||||
window.request_layout(style.clone(), Some(text_layout_id), cx)
|
||||
})
|
||||
},
|
||||
);
|
||||
|
||||
(
|
||||
layout_id,
|
||||
LayoutState {
|
||||
state: entity,
|
||||
caret,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn prepaint(
|
||||
&mut self,
|
||||
global_id: Option<&gpui::GlobalElementId>,
|
||||
inspector_id: Option<&gpui::InspectorElementId>,
|
||||
bounds: Bounds<Pixels>,
|
||||
request_layout: &mut Self::RequestLayoutState,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> Self::PrepaintState {
|
||||
// should reflect the text content layout size of the stored text,
|
||||
// so that scrolling can take it into account during prepaint.
|
||||
let (content_size, focus_handle) = {
|
||||
let state = request_layout.state.read(cx);
|
||||
let content_size = state.layout_data.state.size.unwrap_or_else(|| bounds.size);
|
||||
let focus_handle = state.focus_handle(cx);
|
||||
(content_size, focus_handle)
|
||||
};
|
||||
|
||||
let is_focused = focus_handle.is_focused(window);
|
||||
let caret_visible = request_layout
|
||||
.caret
|
||||
.update(cx, |caret, cx| caret.update_focus(is_focused, cx));
|
||||
window.set_focus_handle(&focus_handle, cx);
|
||||
|
||||
let prepaint = self.interactivity.prepaint(
|
||||
global_id,
|
||||
inspector_id,
|
||||
bounds,
|
||||
content_size,
|
||||
window,
|
||||
cx,
|
||||
|style, scroll_offset, hitbox, window, cx| {
|
||||
let hitbox =
|
||||
hitbox.or_else(|| Some(window.insert_hitbox(bounds, HitboxBehavior::Normal)));
|
||||
let inner_bounds = {
|
||||
let padding = style
|
||||
.padding
|
||||
.to_pixels(bounds.size.into(), window.rem_size());
|
||||
|
||||
let mut bounds = bounds;
|
||||
bounds.origin += point(padding.left, padding.top);
|
||||
bounds.size.width -= padding.left + padding.right;
|
||||
bounds.size.height -= padding.top + padding.bottom;
|
||||
bounds
|
||||
};
|
||||
request_layout.state.update(cx, |state, _cx| {
|
||||
// while gpui tracks scroll_offset with negative values,
|
||||
// this is converted into positive for usage with bounds
|
||||
state.layout_data.scroll_bounds =
|
||||
Bounds::new(-scroll_offset, inner_bounds.size);
|
||||
});
|
||||
InteractivityPrepaint {
|
||||
hitbox,
|
||||
scroll_offset,
|
||||
inner_bounds,
|
||||
caret_visible,
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
let state = request_layout.state.read(cx);
|
||||
let elements = PrepaintElements::build_elements(state, &prepaint, &self.colors, window);
|
||||
|
||||
PrepaintState {
|
||||
interactivity: prepaint,
|
||||
focus_handle,
|
||||
elements,
|
||||
}
|
||||
}
|
||||
|
||||
fn paint(
|
||||
&mut self,
|
||||
global_id: Option<&gpui::GlobalElementId>,
|
||||
inspector_id: Option<&gpui::InspectorElementId>,
|
||||
bounds: Bounds<Pixels>,
|
||||
request_layout: &mut Self::RequestLayoutState,
|
||||
prepaint: &mut Self::PrepaintState,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
if let Some(hitbox) = &prepaint.interactivity.hitbox {
|
||||
window.set_cursor_style(CursorStyle::IBeam, hitbox);
|
||||
}
|
||||
|
||||
let accepts_input = self.accepts_input;
|
||||
let hitbox = prepaint.interactivity.hitbox.clone();
|
||||
let perform_paint = |style: &Style, window: &mut Window, cx: &mut App| {
|
||||
if style.display == Display::None {
|
||||
return;
|
||||
}
|
||||
|
||||
// Register event listeners to the window for the next frame
|
||||
if accepts_input {
|
||||
Self::process_frame_events(prepaint, bounds, &request_layout.state, window, cx);
|
||||
}
|
||||
|
||||
// Actually draw the elements we constructed during prepaint
|
||||
let line_h = window.line_height();
|
||||
for PrepaintLine { line, point, align } in prepaint.elements.lines.drain(..) {
|
||||
let _ = line.paint(point, line_h, align, Some(bounds), window, cx);
|
||||
}
|
||||
for quad in prepaint.elements.ime_marked.drain(..) {
|
||||
window.paint_quad(quad);
|
||||
}
|
||||
for quad in prepaint.elements.selection.drain(..) {
|
||||
window.paint_quad(quad);
|
||||
}
|
||||
if let Some(quad) = prepaint.elements.caret.take() {
|
||||
window.paint_quad(quad);
|
||||
}
|
||||
};
|
||||
|
||||
self.interactivity.paint(
|
||||
global_id,
|
||||
inspector_id,
|
||||
bounds,
|
||||
hitbox.as_ref(),
|
||||
window,
|
||||
cx,
|
||||
perform_paint,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
impl EditableTextElement {
|
||||
fn find_or_create_state(&self, window: &mut Window, cx: &mut App) -> Entity<EditableTextState> {
|
||||
if let Some(entity) = self.state_entity.borrow().upgrade() {
|
||||
return entity;
|
||||
}
|
||||
let Some(element_id) = self.interactivity.element_id.clone() else {
|
||||
unimplemented!("all input elements must be assigned an id")
|
||||
};
|
||||
|
||||
let state = EditableTextState::use_keyed(element_id, window, cx);
|
||||
// store a reference to the entity owned by the element for access in action handlers
|
||||
*self.state_entity_rc().borrow_mut() = state.downgrade();
|
||||
state
|
||||
}
|
||||
|
||||
fn find_or_create_caret(
|
||||
&self,
|
||||
state: &Entity<EditableTextState>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> Entity<Caret> {
|
||||
let Some(element_id) = self.interactivity.element_id.clone() else {
|
||||
unimplemented!("all input elements must be assigned an id")
|
||||
};
|
||||
|
||||
window.use_keyed_state(element_id, cx, |_window, cx| {
|
||||
let mut caret = Caret::default();
|
||||
caret.subscribe_to(state, cx);
|
||||
caret
|
||||
})
|
||||
}
|
||||
|
||||
fn process_frame_events(
|
||||
prepaint: &PrepaintState,
|
||||
bounds: Bounds<Pixels>,
|
||||
entity: &Entity<EditableTextState>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
let inner_bounds = prepaint.interactivity.inner_bounds;
|
||||
let to_local_position = -(bounds.origin + prepaint.interactivity.scroll_offset);
|
||||
|
||||
let ime_handler = ElementInputHandler::new(inner_bounds, entity.clone());
|
||||
window.handle_input(&prepaint.focus_handle, ime_handler, cx);
|
||||
|
||||
window.on_mouse_event({
|
||||
let focus_handle = prepaint.focus_handle.clone();
|
||||
let state = entity.clone();
|
||||
move |event: &MouseDownEvent, phase, window, cx| {
|
||||
if phase != DispatchPhase::Bubble {
|
||||
return;
|
||||
}
|
||||
if !bounds.contains(&event.position) {
|
||||
return;
|
||||
}
|
||||
if event.button != MouseButton::Left {
|
||||
return;
|
||||
}
|
||||
|
||||
cx.stop_propagation();
|
||||
window.focus(&focus_handle, cx);
|
||||
|
||||
let text_position = event.position + to_local_position;
|
||||
state.update(cx, |state, cx| {
|
||||
state.on_mouse_down(event, text_position, window, cx);
|
||||
});
|
||||
}
|
||||
});
|
||||
window.on_mouse_event({
|
||||
let state = entity.clone();
|
||||
move |event: &MouseUpEvent, phase, window, cx| {
|
||||
if phase != DispatchPhase::Bubble {
|
||||
return;
|
||||
}
|
||||
if event.button != MouseButton::Left {
|
||||
return;
|
||||
}
|
||||
|
||||
state.update(cx, |state, cx| {
|
||||
state.on_mouse_up(event, window, cx);
|
||||
});
|
||||
}
|
||||
});
|
||||
window.on_mouse_event({
|
||||
let state = entity.clone();
|
||||
move |event: &MouseMoveEvent, phase, window, cx| {
|
||||
if phase != DispatchPhase::Bubble {
|
||||
return;
|
||||
}
|
||||
|
||||
let text_position = event.position + to_local_position;
|
||||
state.update(cx, |state, cx| {
|
||||
state.on_mouse_move(event, text_position, window, cx);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl PrelayoutState {
|
||||
fn perform_text_layout(self, window: &mut Window) -> LayoutId {
|
||||
// NOTE: Loosely mirrors TextLayout::layout
|
||||
let text_style = window.text_style();
|
||||
let font_size = text_style.font_size.to_pixels(window.rem_size());
|
||||
let line_height = window.pixel_snap(
|
||||
text_style
|
||||
.line_height
|
||||
.to_pixels(font_size.into(), window.rem_size()),
|
||||
);
|
||||
|
||||
let color = match self.show_placeholder {
|
||||
false => text_style.color,
|
||||
true => self.placeholder_color,
|
||||
};
|
||||
|
||||
let text = self.text.unwrap_or_default();
|
||||
|
||||
window.request_measured_layout(
|
||||
Default::default(),
|
||||
// This is invoked sometime in the near future (before prepaint but not immediately),
|
||||
// so we avoid doing any pre-emptive work until the layout engine is ready.
|
||||
move |known_dimensions, available_space, window, cx| {
|
||||
let runs = vec![gpui::TextRun {
|
||||
len: text.len(),
|
||||
font: text_style.font(),
|
||||
color,
|
||||
background_color: None,
|
||||
underline: None,
|
||||
strikethrough: None,
|
||||
}];
|
||||
|
||||
let wrap_width = TextLayout::evaluate_wrap_width(
|
||||
&text_style.white_space,
|
||||
known_dimensions,
|
||||
available_space,
|
||||
);
|
||||
|
||||
let truncation =
|
||||
TextLayout::evaluate_overflow(&text_style, known_dimensions, available_space);
|
||||
|
||||
if let Some(size) = self.prev_layout_state.size
|
||||
&& (wrap_width.is_none() || wrap_width == self.prev_layout_state.wrap_width)
|
||||
&& truncation.width.is_none()
|
||||
&& self.storage_version == self.prev_layout_state.last_seen_storage_version
|
||||
{
|
||||
return size;
|
||||
}
|
||||
|
||||
let (text, runs) = TextLayout::apply_truncation(
|
||||
text.clone(),
|
||||
&text_style,
|
||||
font_size,
|
||||
wrap_width,
|
||||
&truncation,
|
||||
&runs,
|
||||
cx,
|
||||
);
|
||||
let text_len = text.len();
|
||||
|
||||
let wrapped_lines = window
|
||||
.text_system()
|
||||
.shape_text(text, font_size, &runs, wrap_width, text_style.line_clamp)
|
||||
.unwrap_or_default();
|
||||
|
||||
// Build the size of the text and convert the wrapped_lines into
|
||||
// lines that will be cached in state and painted.
|
||||
let mut size: Size<Pixels> = Size::default();
|
||||
let mut pos_y = 0;
|
||||
let mut line_start = 0;
|
||||
let mut lines = Vec::with_capacity(wrapped_lines.len());
|
||||
for line in wrapped_lines {
|
||||
let line_size = line.size(line_height);
|
||||
size.height += line_size.height;
|
||||
size.width = size.width.max(line_size.width).ceil();
|
||||
|
||||
let mut line_len = line.len();
|
||||
if line_len < text_len {
|
||||
// to offset for new-line characters that are
|
||||
// omitted from WrappedLine range
|
||||
line_len += 1;
|
||||
}
|
||||
|
||||
let segment = TextLineSegment {
|
||||
text_range: line_start..line_start + line_len,
|
||||
wrapped_line: Some(Arc::new(line)),
|
||||
pos_y,
|
||||
};
|
||||
line_start += line_len;
|
||||
pos_y += segment.row_count();
|
||||
lines.push(segment);
|
||||
}
|
||||
|
||||
let layout_data = EditableTextLayoutResult {
|
||||
supports_multiline: self.supports_multiline,
|
||||
accepts_input: self.accepts_input,
|
||||
// updated during prepaint
|
||||
scroll_bounds: Bounds::default(),
|
||||
state: EditableTextLayoutState {
|
||||
wrap_width,
|
||||
size: Some(size),
|
||||
last_seen_storage_version: self.storage_version,
|
||||
},
|
||||
lines,
|
||||
line_height,
|
||||
next_scroll_offset: None,
|
||||
};
|
||||
|
||||
// Update the state for use in prepaint, paint, and action handlers.
|
||||
// request_measured_layout caches this scope for processing later
|
||||
// between layout and prepaint, so we cant just copy/move these values to the outer scope.
|
||||
self.state.update(cx, move |state, _cx| {
|
||||
state.layout_data = layout_data;
|
||||
});
|
||||
|
||||
size
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
struct PrepaintLine {
|
||||
line: Arc<WrappedLine>,
|
||||
point: Point<Pixels>,
|
||||
align: TextAlign,
|
||||
}
|
||||
|
||||
const STACK_ALLOCATED_LINES: usize = 100usize;
|
||||
const STACK_ALLOCATED_QUADS_SELECTION: usize = 20usize;
|
||||
const STACK_ALLOCATED_QUADS_IME_MARKED: usize = 2usize;
|
||||
|
||||
#[derive(Default)]
|
||||
struct PrepaintElements {
|
||||
lines: SmallVec<[PrepaintLine; STACK_ALLOCATED_LINES]>,
|
||||
selection: SmallVec<[PaintQuad; STACK_ALLOCATED_QUADS_SELECTION]>,
|
||||
ime_marked: SmallVec<[PaintQuad; STACK_ALLOCATED_QUADS_IME_MARKED]>,
|
||||
caret: Option<PaintQuad>,
|
||||
}
|
||||
|
||||
impl PrepaintElements {
|
||||
fn build_quads(
|
||||
offset_corners: Vec<(Point<Pixels>, Point<Pixels>)>,
|
||||
origin: Point<Pixels>,
|
||||
color: Hsla,
|
||||
) -> impl Iterator<Item = PaintQuad> {
|
||||
offset_corners
|
||||
.into_iter()
|
||||
.map(move |(offset_start, offset_end)| {
|
||||
let bounds = Bounds::from_corners(origin + offset_start, origin + offset_end);
|
||||
fill(bounds, color)
|
||||
})
|
||||
}
|
||||
|
||||
fn build_elements(
|
||||
state: &EditableTextState,
|
||||
prepaint: &InteractivityPrepaint,
|
||||
colors: &EditableTextColors,
|
||||
window: &mut Window,
|
||||
) -> PrepaintElements {
|
||||
let InteractivityPrepaint {
|
||||
hitbox: _,
|
||||
scroll_offset,
|
||||
inner_bounds,
|
||||
caret_visible,
|
||||
} = prepaint;
|
||||
|
||||
let caret_pos = state.caret_pos();
|
||||
let selection = state.selected_range();
|
||||
let ime_range = state.marked_range();
|
||||
|
||||
let mut elements = PrepaintElements::default();
|
||||
|
||||
let line_height = window.line_height();
|
||||
let is_range_contained_by_range =
|
||||
|text_range: &Range<usize>, containing_range: &Range<usize>| {
|
||||
if text_range.is_empty() {
|
||||
containing_range.start <= text_range.start
|
||||
&& containing_range.end > text_range.start
|
||||
} else {
|
||||
containing_range.end > text_range.start
|
||||
&& containing_range.start < text_range.end
|
||||
}
|
||||
};
|
||||
let mut caret_point = None::<Point<Pixels>>;
|
||||
for segment in &state.layout_data.lines {
|
||||
let line_distance_from_top = segment.pos_y * line_height;
|
||||
let line_y = line_distance_from_top + scroll_offset.y;
|
||||
let line_bottom = line_y + line_height * segment.row_count() as f32;
|
||||
let line_visible = line_bottom >= Pixels::ZERO && line_y <= inner_bounds.size.height;
|
||||
if !line_visible {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(wrapped) = &segment.wrapped_line {
|
||||
let point = inner_bounds.origin + point(scroll_offset.x, line_y);
|
||||
elements.lines.push(PrepaintLine {
|
||||
line: wrapped.clone(),
|
||||
point,
|
||||
align: TextAlign::Left,
|
||||
});
|
||||
}
|
||||
|
||||
let segment_is_empty = segment.text_range.is_empty();
|
||||
|
||||
if is_range_contained_by_range(&segment.text_range, &selection) {
|
||||
if segment_is_empty {
|
||||
const EMPTY_LINE_SELECTION_WIDTH: Pixels = px(6.);
|
||||
elements.selection.push(fill(
|
||||
Bounds::from_corners(
|
||||
inner_bounds.origin + point(Pixels::ZERO, line_y),
|
||||
inner_bounds.origin
|
||||
+ point(EMPTY_LINE_SELECTION_WIDTH, line_y + line_height),
|
||||
),
|
||||
colors.selection,
|
||||
));
|
||||
} else {
|
||||
let offset_corners = build_quad_over_text(
|
||||
&selection,
|
||||
segment,
|
||||
line_y,
|
||||
line_height,
|
||||
Pixels::ZERO,
|
||||
);
|
||||
elements.selection.extend(PrepaintElements::build_quads(
|
||||
offset_corners,
|
||||
inner_bounds.origin,
|
||||
colors.selection,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if !segment_is_empty && let Some(ime_range) = &ime_range {
|
||||
if !ime_range.is_empty()
|
||||
&& is_range_contained_by_range(&segment.text_range, &ime_range)
|
||||
{
|
||||
const MARKED_TEXT_UNDERLINE_THICKNESS: f32 = 2.0;
|
||||
let underline_thickness = px(MARKED_TEXT_UNDERLINE_THICKNESS);
|
||||
let underline_offset = line_height - underline_thickness;
|
||||
|
||||
let offset_corners = build_quad_over_text(
|
||||
&ime_range,
|
||||
segment,
|
||||
line_y,
|
||||
line_height,
|
||||
underline_offset,
|
||||
);
|
||||
elements.ime_marked.extend(PrepaintElements::build_quads(
|
||||
offset_corners,
|
||||
inner_bounds.origin,
|
||||
colors.ime_underline,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let is_cursor_in_line = segment.contains_position(caret_pos, true);
|
||||
if is_cursor_in_line && let Some(wrapped) = &segment.wrapped_line {
|
||||
let local_offset = caret_pos.saturating_sub(segment.text_range.start);
|
||||
let caret_px = wrapped
|
||||
.position_for_index(local_offset, line_height)
|
||||
.unwrap_or_default();
|
||||
caret_point = Some(caret_px + point(scroll_offset.x, line_y));
|
||||
}
|
||||
}
|
||||
|
||||
if *caret_visible && let Some(carent_point) = caret_point {
|
||||
let quad = fill(
|
||||
Bounds::new(
|
||||
inner_bounds.origin + carent_point,
|
||||
size(gpui::px(CARET_RENDER_WIDTH), line_height),
|
||||
),
|
||||
colors.caret,
|
||||
);
|
||||
elements.caret = Some(quad);
|
||||
}
|
||||
|
||||
elements
|
||||
}
|
||||
}
|
||||
|
||||
fn build_quad_over_text(
|
||||
containing_range: &Range<usize>,
|
||||
segment: &TextLineSegment,
|
||||
line_y: Pixels,
|
||||
line_height: Pixels,
|
||||
offset_y: Pixels,
|
||||
) -> Vec<(Point<Pixels>, Point<Pixels>)> {
|
||||
let Some(wrapped) = &segment.wrapped_line else {
|
||||
return vec![];
|
||||
};
|
||||
|
||||
let line_start = segment.text_range.start;
|
||||
let line_end = segment.text_range.end;
|
||||
|
||||
let subrange_start = containing_range.start.max(line_start) - line_start;
|
||||
let subrange_end = containing_range.end.min(line_end) - line_start;
|
||||
|
||||
let start_pos = wrapped
|
||||
.position_for_index(subrange_start, line_height)
|
||||
.unwrap_or_default();
|
||||
let end_pos = wrapped
|
||||
.position_for_index(subrange_end, line_height)
|
||||
.unwrap_or_else(|| {
|
||||
let last_line_y = line_height * (segment.row_count() - 1) as f32;
|
||||
point(wrapped.width(), last_line_y)
|
||||
});
|
||||
|
||||
let start_visual_line = (start_pos.y / line_height).floor() as usize;
|
||||
let end_visual_line = (end_pos.y / line_height).floor() as usize;
|
||||
|
||||
if start_visual_line == end_visual_line {
|
||||
vec![(
|
||||
point(start_pos.x, line_y + start_pos.y + offset_y),
|
||||
point(end_pos.x, line_y + start_pos.y + line_height),
|
||||
)]
|
||||
} else {
|
||||
let line_width = wrapped.width();
|
||||
let middle_lines = (start_visual_line + 1)..end_visual_line;
|
||||
let mut quad_corners = Vec::with_capacity(middle_lines.end - middle_lines.start + 2);
|
||||
|
||||
quad_corners.push((
|
||||
point(start_pos.x, line_y + start_pos.y + offset_y),
|
||||
point(line_width, line_y + start_pos.y + line_height),
|
||||
));
|
||||
|
||||
// Middle visual lines
|
||||
for visual_line in (start_visual_line + 1)..end_visual_line {
|
||||
let y = line_height * visual_line as f32;
|
||||
quad_corners.push((
|
||||
point(Pixels::ZERO, line_y + y + offset_y),
|
||||
point(line_width, line_y + y + line_height),
|
||||
));
|
||||
}
|
||||
|
||||
// Last visual line
|
||||
quad_corners.push((
|
||||
point(Pixels::ZERO, line_y + end_pos.y + offset_y),
|
||||
point(end_pos.x, line_y + end_pos.y + line_height),
|
||||
));
|
||||
|
||||
quad_corners
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
use smallvec::SmallVec;
|
||||
use std::{
|
||||
ops::Range,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
/// Maximum number of history entries to keep.
|
||||
pub const MAX_HISTORY_LEN: usize = 1000;
|
||||
|
||||
/// Default interval for grouping consecutive edits into a single undo entry.
|
||||
pub const DEFAULT_GROUP_INTERVAL: Duration = Duration::from_millis(300);
|
||||
|
||||
// TODO: Should history get attached directly to storage? currently its per text field and operate both on storage and selection
|
||||
pub struct EditableTextHistory {
|
||||
/// The maximum duration between changes to `content` that can be grouped together as a single entry in the history log.
|
||||
grouping_interval: Duration,
|
||||
/// Stack of previous states for undo.
|
||||
undo_stack: SmallVec<[HistoryEntry; MAX_HISTORY_LEN]>,
|
||||
/// Stack of undone states for redo.
|
||||
redo_stack: SmallVec<[HistoryEntry; MAX_HISTORY_LEN]>,
|
||||
}
|
||||
impl Default for EditableTextHistory {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
grouping_interval: DEFAULT_GROUP_INTERVAL,
|
||||
undo_stack: Default::default(),
|
||||
redo_stack: Default::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A patch-based history entry for memory-efficient undo/redo operations.
|
||||
/// Instead of storing the full content, we store only the change needed to reverse the edit.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct HistoryEntry {
|
||||
/// The byte range that was modified (after the edit, for undo; before the edit, for redo).
|
||||
pub range: Range<usize>,
|
||||
/// The text that was replaced (to restore on undo).
|
||||
pub old_text: String,
|
||||
/// The length of the new text that replaced old_text (to know how much to remove on undo).
|
||||
pub new_text_len: usize,
|
||||
/// The selection range before the edit.
|
||||
pub selected_range: (usize, usize),
|
||||
/// Timestamp for grouping consecutive edits.
|
||||
pub timestamp: Instant,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum HistoryKind {
|
||||
Undo,
|
||||
Redo,
|
||||
}
|
||||
|
||||
impl EditableTextHistory {
|
||||
pub fn set_grouping_interval(&mut self, interval: Duration) {
|
||||
self.grouping_interval = interval;
|
||||
}
|
||||
|
||||
pub fn record(
|
||||
&mut self,
|
||||
range: Range<usize>,
|
||||
old_text: &str,
|
||||
new_text_len: usize,
|
||||
selected_range: (usize, usize),
|
||||
) {
|
||||
let now = Instant::now();
|
||||
|
||||
// Check if we should group with the last entry
|
||||
if let Some(last) = self.undo_stack.last_mut()
|
||||
&& now.duration_since(last.timestamp) < self.grouping_interval
|
||||
{
|
||||
// The change was triggered within group interval timing.
|
||||
// Try to extend the existing patch (which is a mutation).
|
||||
// If extending successeds, then we can early-out. Otherwise the mutation is non-contiguous.
|
||||
if last.extend(&range, new_text_len) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Limit history size
|
||||
if self.undo_stack.len() >= MAX_HISTORY_LEN {
|
||||
self.undo_stack.remove(0);
|
||||
}
|
||||
|
||||
self.push(
|
||||
HistoryKind::Undo,
|
||||
HistoryEntry {
|
||||
range: range.start..range.start + new_text_len,
|
||||
old_text: old_text.to_string(),
|
||||
new_text_len,
|
||||
selected_range,
|
||||
timestamp: now,
|
||||
},
|
||||
);
|
||||
|
||||
// New edit invalidates redo stack
|
||||
self.redo_stack.clear();
|
||||
}
|
||||
|
||||
fn stack(&self, kind: HistoryKind) -> &SmallVec<[HistoryEntry; MAX_HISTORY_LEN]> {
|
||||
// NOTE: Could be an internal map
|
||||
match kind {
|
||||
HistoryKind::Undo => &self.undo_stack,
|
||||
HistoryKind::Redo => &self.redo_stack,
|
||||
}
|
||||
}
|
||||
|
||||
fn stack_mut(&mut self, kind: HistoryKind) -> &mut SmallVec<[HistoryEntry; MAX_HISTORY_LEN]> {
|
||||
match kind {
|
||||
HistoryKind::Undo => &mut self.undo_stack,
|
||||
HistoryKind::Redo => &mut self.redo_stack,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn has_next(&self, kind: HistoryKind) -> bool {
|
||||
!self.stack(kind).is_empty()
|
||||
}
|
||||
|
||||
pub fn push(&mut self, kind: HistoryKind, entry: HistoryEntry) {
|
||||
self.stack_mut(kind).push(entry);
|
||||
}
|
||||
|
||||
pub fn take(&mut self, kind: HistoryKind) -> Option<HistoryEntry> {
|
||||
self.stack_mut(kind).pop()
|
||||
}
|
||||
}
|
||||
|
||||
impl HistoryEntry {
|
||||
fn extend(&mut self, range: &Range<usize>, new_text_len: usize) -> bool {
|
||||
// NOTE: Could be more robust. Currently only supports human-written extensions from start towards end.
|
||||
|
||||
// ranges must be contiguous in order to integrate/extend
|
||||
if self.range.end != range.start {
|
||||
return false;
|
||||
}
|
||||
|
||||
self.range.end = range.start + new_text_len;
|
||||
self.new_text_len += new_text_len;
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
pub fn char_range(&self, max_len: usize) -> Range<usize> {
|
||||
let undo_start = self.range.start;
|
||||
let undo_end = (self.range.start + self.new_text_len).min(max_len);
|
||||
undo_start..undo_end
|
||||
}
|
||||
|
||||
pub fn as_inverted(self, prev_text_at_range: String) -> Self {
|
||||
HistoryEntry {
|
||||
range: self.range.start..self.range.start + self.old_text.len(),
|
||||
old_text: prev_text_at_range,
|
||||
new_text_len: self.old_text.len(),
|
||||
selected_range: self.selected_range,
|
||||
timestamp: self.timestamp,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
use gpui::{Bounds, Pixels, Point, Size, WrappedLine};
|
||||
use std::{ops::Range, sync::Arc};
|
||||
|
||||
/// Data used across successive layout requests to gauge whether layout must be recomputed.
|
||||
#[derive(Default, Clone, Copy)]
|
||||
pub(super) struct EditableTextLayoutState {
|
||||
/// The last known width at which the lines were wrapped.
|
||||
pub wrap_width: Option<Pixels>,
|
||||
/// The last known size of the text, as generated during layout.
|
||||
pub size: Option<Size<Pixels>>,
|
||||
/// The last seen version of `storage` (for tracking when lines need to be reprocessed during layout)
|
||||
pub last_seen_storage_version: u16,
|
||||
}
|
||||
|
||||
/// Internal state/result after the element has recomputed layout.
|
||||
#[derive(Default)]
|
||||
pub(super) struct EditableTextLayoutResult {
|
||||
/// Whether the element supports multiple lines of text
|
||||
pub supports_multiline: bool,
|
||||
/// Whether the element is currently accepting inputs
|
||||
pub accepts_input: bool,
|
||||
/// The last seen scroll position and size of the element
|
||||
pub scroll_bounds: Bounds<Pixels>,
|
||||
pub state: EditableTextLayoutState,
|
||||
/// The `ShapedLine` produced by the painter's `prepaint`.
|
||||
/// Cached so IME `bounds_for_range` / `character_index_for_point` can evaluate without re-shaping.
|
||||
pub lines: Vec<TextLineSegment>,
|
||||
pub line_height: Pixels,
|
||||
/// The next position the scroll view should move to.
|
||||
/// Set by the state in response to user actions.
|
||||
pub next_scroll_offset: Option<Point<Pixels>>,
|
||||
}
|
||||
|
||||
/// A segment of text that is a single logical/document line but can take up multiple rows due to wrapping.
|
||||
pub(super) struct TextLineSegment {
|
||||
/// The utf8 byte range in the content string that this line covers.
|
||||
pub text_range: Range<usize>,
|
||||
/// The shaped and wrapped text for this line, if available.
|
||||
pub wrapped_line: Option<Arc<WrappedLine>>,
|
||||
|
||||
/// The y-coordinate of this segment which can be multiplied by the line_height
|
||||
/// to get its pixel location relative to the bounds of the text area.
|
||||
pub pos_y: usize,
|
||||
}
|
||||
|
||||
impl TextLineSegment {
|
||||
/// The number of visual lines this segment encapsulates,
|
||||
/// since it can occupy multiple rows due to wrapping.
|
||||
pub fn row_count(&self) -> usize {
|
||||
let count = self
|
||||
.wrapped_line
|
||||
.as_ref()
|
||||
.map(|line| line.wrap_boundaries().len());
|
||||
count.unwrap_or_default() + 1
|
||||
}
|
||||
|
||||
/// Returns true if the line contains a given position (e.g. for finding the line containing the caret).
|
||||
/// If `includes_end` is true, the end of the line is treated as inclusive instead of exclusive.
|
||||
pub fn contains_position(&self, pos: usize, include_end: bool) -> bool {
|
||||
if self.text_range.is_empty() {
|
||||
return pos == self.text_range.start;
|
||||
}
|
||||
|
||||
if include_end {
|
||||
(self.text_range.start..=self.text_range.end).contains(&pos)
|
||||
} else {
|
||||
self.text_range.contains(&pos)
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the index of the character within this segment that is closest
|
||||
/// to the provided screen space position.
|
||||
/// The character index returned is in absolute space; it is not relative to this segment.
|
||||
pub fn character_index_at_point(&self, point: Point<Pixels>, line_height: Pixels) -> usize {
|
||||
let mut offset = 0usize;
|
||||
if !self.text_range.is_empty()
|
||||
&& let Some(wrapped) = &self.wrapped_line
|
||||
{
|
||||
offset = wrapped
|
||||
.closest_index_for_position(point, line_height)
|
||||
.unwrap_or_else(|closest| closest)
|
||||
.min(wrapped.text.len());
|
||||
}
|
||||
self.text_range.start + offset
|
||||
}
|
||||
|
||||
/// Returns the screen space position of the character at the position provided.
|
||||
/// The position of the character must be absolute to the string this segment
|
||||
/// partially represents, it is converted to a relative offset internally.
|
||||
pub fn position_for_index(
|
||||
&self,
|
||||
character_index: usize,
|
||||
line_height: Pixels,
|
||||
) -> Option<Point<Pixels>> {
|
||||
let wrapped = self.wrapped_line.as_ref()?;
|
||||
// the position in the text relative to this line segment
|
||||
let relative_text_pos = character_index
|
||||
.saturating_sub(self.text_range.start)
|
||||
.min(wrapped.text.len());
|
||||
// the screen position of the character in this line segment
|
||||
wrapped.position_for_index(relative_text_pos, line_height)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,241 @@
|
||||
use gpui::NavigationDirection;
|
||||
use std::ops::Range;
|
||||
use unicode_segmentation::UnicodeSegmentation;
|
||||
|
||||
/// Describes a boundary within a chunk of text.
|
||||
pub enum TextBoundary {
|
||||
/// The utf-8 character
|
||||
Graphmeme,
|
||||
/// The current word (using whitespace as delimiters)
|
||||
Word,
|
||||
/// The current line
|
||||
Line,
|
||||
/// The entire document
|
||||
Document,
|
||||
}
|
||||
|
||||
/// Implement this trait to create a storage medium that can be used as the content of EditableText elements.
|
||||
/// Default implementation is [`StringStorage`].
|
||||
pub trait UnicodeTextStorage {
|
||||
/// Returns the version/generation of the content, which should be incremented ever time the
|
||||
/// content is changed so that rendering elements can reprocess the contents via the text layout engine.
|
||||
fn version(&self) -> u16;
|
||||
|
||||
/// Returns a reference to the utf8 string.
|
||||
fn content_utf8(&self) -> &str;
|
||||
|
||||
/// Returns the UTF-16 length of the content.
|
||||
fn len_utf16(&self) -> usize;
|
||||
|
||||
/// Replace contents within the provided range with the given str slice.
|
||||
fn replace_range(&mut self, range: Range<usize>, text: &str);
|
||||
|
||||
/// Returns the utf16 position equivalent of the provided utf8 character position.
|
||||
fn utf_offset_8to16(&self, pos_uft8: usize) -> usize {
|
||||
// Fast path: if offset is 0, return 0
|
||||
if pos_uft8 == 0 {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Fast path: if offset is at or past end, return cached length
|
||||
if pos_uft8 >= self.content_utf8().len() {
|
||||
return self.len_utf16();
|
||||
}
|
||||
|
||||
let mut count_utf16 = 0;
|
||||
for (idx, character) in self.content_utf8().char_indices() {
|
||||
if idx >= pos_uft8 {
|
||||
break;
|
||||
}
|
||||
count_utf16 += character.len_utf16();
|
||||
}
|
||||
count_utf16
|
||||
}
|
||||
|
||||
/// Returns the utf8 position equivalent of the provided utf16 character position.
|
||||
fn utf_offset_16to8(&self, pos_utf16: usize) -> usize {
|
||||
// Fast path: if offset is 0, return 0
|
||||
if pos_utf16 == 0 {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let mut count_utf16 = 0;
|
||||
for (idx, character) in self.content_utf8().char_indices() {
|
||||
if count_utf16 >= pos_utf16 {
|
||||
return idx;
|
||||
}
|
||||
count_utf16 += character.len_utf16();
|
||||
}
|
||||
self.content_utf8().len()
|
||||
}
|
||||
|
||||
/// Converts a utf8 character range into a utf16 character range.
|
||||
fn utf_range_8to16(&self, range_utf8: &Range<usize>) -> Range<usize> {
|
||||
self.utf_offset_8to16(range_utf8.start)..self.utf_offset_8to16(range_utf8.end)
|
||||
}
|
||||
|
||||
/// Converts a utf16 character range into a utf8 character range.
|
||||
fn utf_range_16to8(&self, range_utf16: &Range<usize>) -> Range<usize> {
|
||||
self.utf_offset_16to8(range_utf16.start)..self.utf_offset_16to8(range_utf16.end)
|
||||
}
|
||||
|
||||
/// Builds a utf8 character range based on a caret position within the storage,
|
||||
/// the direction to traverse, and the boundary to stop at.
|
||||
/// The start of the range will be the earlier position (destination if Back, caret if Forward),
|
||||
/// and the end will be the later position (caret if Back, destination if Forward).
|
||||
fn range_from_caret(
|
||||
&self,
|
||||
caret: usize,
|
||||
direction: NavigationDirection,
|
||||
magnitude: TextBoundary,
|
||||
) -> Range<usize> {
|
||||
let offset = self.offset_from_caret(caret, direction, magnitude);
|
||||
match direction {
|
||||
NavigationDirection::Back => offset..caret,
|
||||
NavigationDirection::Forward => caret..offset,
|
||||
}
|
||||
}
|
||||
|
||||
/// Finds the next location from the caret based on the direction to traverse and the boundary to stop at.
|
||||
fn offset_from_caret(
|
||||
&self,
|
||||
caret: usize,
|
||||
direction: NavigationDirection,
|
||||
boundary: TextBoundary,
|
||||
) -> usize {
|
||||
use NavigationDirection::*;
|
||||
use TextBoundary::*;
|
||||
match (direction, boundary) {
|
||||
(Back, Graphmeme) => {
|
||||
if caret == 0 {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let str = self.content_utf8();
|
||||
let iter = str[..caret.min(str.len())].grapheme_indices(true);
|
||||
iter.map(|(i, _)| i).next_back().unwrap_or(0)
|
||||
}
|
||||
(Forward, Graphmeme) => {
|
||||
let str = self.content_utf8();
|
||||
let len_utf8 = str.len();
|
||||
if caret >= len_utf8 {
|
||||
return len_utf8;
|
||||
}
|
||||
|
||||
let mut iter = str[caret..].grapheme_indices(true);
|
||||
iter.nth(1).map(|(i, _)| caret + i).unwrap_or(len_utf8)
|
||||
}
|
||||
(Back, Word) => {
|
||||
if caret == 0 {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let str = self.content_utf8();
|
||||
let str = &str[..caret.min(str.len())];
|
||||
|
||||
let mut last_word_start = 0;
|
||||
for (idx, _) in str.unicode_word_indices() {
|
||||
if idx < caret {
|
||||
last_word_start = idx;
|
||||
}
|
||||
}
|
||||
|
||||
if last_word_start == 0 && caret > 0 {
|
||||
let trimmed = str.trim_end();
|
||||
if trimmed.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
for (idx, _) in trimmed.unicode_word_indices() {
|
||||
last_word_start = idx;
|
||||
}
|
||||
}
|
||||
|
||||
last_word_start
|
||||
}
|
||||
(Forward, Word) => {
|
||||
let str = self.content_utf8();
|
||||
let len_utf8 = str.len();
|
||||
if caret >= len_utf8 {
|
||||
return len_utf8;
|
||||
}
|
||||
|
||||
let str = &str[caret..];
|
||||
for (idx, word) in str.unicode_word_indices() {
|
||||
let word_end = caret + idx + word.len();
|
||||
if word_end > caret {
|
||||
return word_end;
|
||||
}
|
||||
}
|
||||
len_utf8
|
||||
}
|
||||
// Returns the utf-8 character position of first character after the first new-line
|
||||
// preceding the character at the provided utf-8 character position.
|
||||
(Back, Line) => {
|
||||
let str = self.content_utf8();
|
||||
let iter = str[..caret.min(str.len())].rfind('\n');
|
||||
iter.map(|pos| pos + 1).unwrap_or(0)
|
||||
}
|
||||
// Returns the utf-8 character position of the character immediately before the first
|
||||
// new-line character after the character at the provided utf-8 character position.
|
||||
(Forward, Line) => {
|
||||
let str = self.content_utf8();
|
||||
let iter = str[caret.min(str.len())..].find('\n');
|
||||
iter.map(|pos| caret + pos).unwrap_or(str.len())
|
||||
}
|
||||
(Back, Document) => 0,
|
||||
(Forward, Document) => self.content_utf8().len(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the start and end of the word the position resides within.
|
||||
fn word_range_at(&self, position: usize) -> Range<usize> {
|
||||
let offset = position.min(self.content_utf8().len());
|
||||
|
||||
for (idx, word) in self.content_utf8().unicode_word_indices() {
|
||||
let word_end = idx + word.len();
|
||||
if offset >= idx && offset <= word_end {
|
||||
return idx..word_end;
|
||||
}
|
||||
}
|
||||
|
||||
offset..offset
|
||||
}
|
||||
}
|
||||
|
||||
/// [`UnicodeTextStorage`] implementation for [`String`].
|
||||
/// This is not the most performant, especially for large text documents.
|
||||
/// Its a decent default for editable text fields though.
|
||||
#[derive(Clone, Default)]
|
||||
pub struct StringStorage {
|
||||
value: String,
|
||||
version: u16,
|
||||
}
|
||||
impl<S> From<S> for StringStorage
|
||||
where
|
||||
S: Into<String>,
|
||||
{
|
||||
fn from(value: S) -> Self {
|
||||
Self {
|
||||
value: value.into(),
|
||||
version: u16::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
impl UnicodeTextStorage for StringStorage {
|
||||
fn version(&self) -> u16 {
|
||||
self.version
|
||||
}
|
||||
|
||||
fn content_utf8(&self) -> &str {
|
||||
self.value.as_str()
|
||||
}
|
||||
|
||||
fn len_utf16(&self) -> usize {
|
||||
self.value.chars().map(|c| c.len_utf16()).sum()
|
||||
}
|
||||
|
||||
fn replace_range(&mut self, range: Range<usize>, text: &str) {
|
||||
self.value.replace_range(range, &text);
|
||||
self.version = self.version.wrapping_add(1);
|
||||
}
|
||||
}
|
||||
@@ -1 +1,3 @@
|
||||
//! Element library written alongside gpui to be as unopinionated as possible while still providing fundamental components.
|
||||
|
||||
pub mod editable_text;
|
||||
|
||||
Reference in New Issue
Block a user