add input
This commit is contained in:
Generated
+1
@@ -1702,6 +1702,7 @@ dependencies = [
|
|||||||
"gpui",
|
"gpui",
|
||||||
"gpui-native-icons",
|
"gpui-native-icons",
|
||||||
"gpui-native-theme",
|
"gpui-native-theme",
|
||||||
|
"unicode-segmentation",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ description = "The GPUI components every application needs, one cargo feature ea
|
|||||||
gpui = { workspace = true }
|
gpui = { workspace = true }
|
||||||
gpui-native-icons = { workspace = true }
|
gpui-native-icons = { workspace = true }
|
||||||
gpui-native-theme = { workspace = true }
|
gpui-native-theme = { workspace = true }
|
||||||
|
unicode-segmentation = "1.12"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
gpui = { workspace = true, features = ["test-support"] }
|
gpui = { workspace = true, features = ["test-support"] }
|
||||||
|
|||||||
@@ -0,0 +1,753 @@
|
|||||||
|
use std::cell::RefCell;
|
||||||
|
use std::ops::Range;
|
||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
use unicode_segmentation::UnicodeSegmentation;
|
||||||
|
|
||||||
|
use gpui::prelude::FluentBuilder as _;
|
||||||
|
use gpui::{
|
||||||
|
App, AppContext as _, Bounds, ClipboardItem, Context, DispatchPhase, ElementId, Entity,
|
||||||
|
EntityInputHandler, Font, InteractiveElement as _, IntoElement, KeyDownEvent, MouseButton,
|
||||||
|
MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement as _, Pixels, RenderOnce, Role,
|
||||||
|
SharedString, StatefulInteractiveElement as _, Styled as _, TextAlign, TextRun, UTF16Selection,
|
||||||
|
Window, canvas, div, fill, point, px,
|
||||||
|
};
|
||||||
|
use gpui_native_theme::ActiveTheme as _;
|
||||||
|
|
||||||
|
use crate::common::h_flex;
|
||||||
|
|
||||||
|
/// Input layout information for hit testing and caret positioning.
|
||||||
|
struct InputLayout {
|
||||||
|
line: gpui::ShapedLine,
|
||||||
|
bounds: Bounds<Pixels>,
|
||||||
|
/// Where the text was painted inside `bounds` (padding minus scroll).
|
||||||
|
origin: gpui::Point<Pixels>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Input state for a single-line text field.
|
||||||
|
pub struct InputState {
|
||||||
|
text: String,
|
||||||
|
/// The caret's byte offset.
|
||||||
|
cursor: usize,
|
||||||
|
/// The byte offset the selection extends from.
|
||||||
|
anchor: usize,
|
||||||
|
/// The byte range of in-progress IME composition, if any.
|
||||||
|
marked_range: Option<Range<usize>>,
|
||||||
|
focus_handle: gpui::FocusHandle,
|
||||||
|
layout: Rc<RefCell<Option<InputLayout>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl InputState {
|
||||||
|
fn new_inner(cx: &mut App) -> Self {
|
||||||
|
Self {
|
||||||
|
text: String::new(),
|
||||||
|
cursor: 0,
|
||||||
|
anchor: 0,
|
||||||
|
marked_range: None,
|
||||||
|
focus_handle: cx.focus_handle(),
|
||||||
|
layout: Rc::new(RefCell::new(None)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates the state entity for an application-owned input.
|
||||||
|
pub fn new(cx: &mut App) -> Entity<Self> {
|
||||||
|
cx.new(|cx| Self::new_inner(cx))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates the state entity with initial text, caret at the end.
|
||||||
|
pub fn new_with_text(text: impl Into<String>, cx: &mut App) -> Entity<Self> {
|
||||||
|
let text = text.into();
|
||||||
|
cx.new(|cx| {
|
||||||
|
let mut state = Self::new_inner(cx);
|
||||||
|
state.text = text;
|
||||||
|
state.cursor = state.text.len();
|
||||||
|
state.anchor = state.cursor;
|
||||||
|
state
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The current text.
|
||||||
|
pub fn value(&self) -> &str {
|
||||||
|
&self.text
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replaces the whole value and collapses the selection to the end.
|
||||||
|
pub fn set_value<V>(&mut self, value: V, window: &mut Window, cx: &mut Context<Self>)
|
||||||
|
where
|
||||||
|
V: Into<String>,
|
||||||
|
{
|
||||||
|
self.text = value.into();
|
||||||
|
self.cursor = self.text.len();
|
||||||
|
self.anchor = self.cursor;
|
||||||
|
self.marked_range = None;
|
||||||
|
self.notify(window, cx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The focus handle, for applications that focus the field programmatically.
|
||||||
|
pub fn focus_handle(&self) -> gpui::FocusHandle {
|
||||||
|
self.focus_handle.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The selected byte range of the buffer.
|
||||||
|
fn selection(&self) -> Range<usize> {
|
||||||
|
self.cursor.min(self.anchor)..self.cursor.max(self.anchor)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn notify(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
cx.notify();
|
||||||
|
window.notify_text_input_changed(&self.focus_handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replaces a byte range with `text` and collapses the caret to its end.
|
||||||
|
fn replace_range(
|
||||||
|
&mut self,
|
||||||
|
range: Range<usize>,
|
||||||
|
text: &str,
|
||||||
|
window: &mut Window,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) {
|
||||||
|
self.text.replace_range(range.clone(), text);
|
||||||
|
self.cursor = range.start + text.len();
|
||||||
|
self.anchor = self.cursor;
|
||||||
|
self.marked_range = None;
|
||||||
|
self.notify(window, cx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deletes the selection, or one character/word before the caret.
|
||||||
|
fn delete_backward(&mut self, word: bool, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
if !self.selection().is_empty() {
|
||||||
|
let range = self.selection();
|
||||||
|
self.replace_range(range, "", window, cx);
|
||||||
|
} else if self.cursor > 0 {
|
||||||
|
let start = if word {
|
||||||
|
previous_word_start(&self.text, self.cursor)
|
||||||
|
} else {
|
||||||
|
previous_grapheme_start(&self.text, self.cursor)
|
||||||
|
};
|
||||||
|
self.replace_range(start..self.cursor, "", window, cx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deletes the selection, or one character/word after the caret.
|
||||||
|
fn delete_forward(&mut self, word: bool, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
if !self.selection().is_empty() {
|
||||||
|
let range = self.selection();
|
||||||
|
self.replace_range(range, "", window, cx);
|
||||||
|
} else if self.cursor < self.text.len() {
|
||||||
|
let end = if word {
|
||||||
|
next_word_end(&self.text, self.cursor)
|
||||||
|
} else {
|
||||||
|
next_grapheme_start(&self.text, self.cursor)
|
||||||
|
};
|
||||||
|
self.replace_range(self.cursor..end, "", window, cx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Moves the caret, extending the selection when `extend`.
|
||||||
|
fn move_cursor(
|
||||||
|
&mut self,
|
||||||
|
to: usize,
|
||||||
|
extend: bool,
|
||||||
|
window: &mut Window,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) {
|
||||||
|
self.cursor = to.clamp(0, self.text.len());
|
||||||
|
if !extend {
|
||||||
|
self.anchor = self.cursor;
|
||||||
|
}
|
||||||
|
self.notify(window, cx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Selects `range`.
|
||||||
|
fn select_range(&mut self, range: Range<usize>, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
self.anchor = range.start;
|
||||||
|
self.cursor = range.end;
|
||||||
|
self.notify(window, cx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Copies or cuts the selection to the clipboard.
|
||||||
|
fn copy_or_cut(&mut self, cut: bool, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
let range = self.selection();
|
||||||
|
if range.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
cx.write_to_clipboard(ClipboardItem::new_string(
|
||||||
|
self.text[range.clone()].to_string(),
|
||||||
|
));
|
||||||
|
if cut {
|
||||||
|
self.replace_range(range, "", window, cx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EntityInputHandler for InputState {
|
||||||
|
fn text_for_range(
|
||||||
|
&mut self,
|
||||||
|
range_utf16: Range<usize>,
|
||||||
|
adjusted_range: &mut Option<Range<usize>>,
|
||||||
|
_window: &mut Window,
|
||||||
|
_cx: &mut Context<Self>,
|
||||||
|
) -> Option<String> {
|
||||||
|
let range = utf16_range_to_utf8(&self.text, range_utf16);
|
||||||
|
adjusted_range.replace(utf8_range_to_utf16(&self.text, range.clone()));
|
||||||
|
Some(self.text[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: utf8_range_to_utf16(&self.text, self.selection()),
|
||||||
|
reversed: self.cursor < self.anchor,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn marked_text_range(
|
||||||
|
&self,
|
||||||
|
_window: &mut Window,
|
||||||
|
_cx: &mut Context<Self>,
|
||||||
|
) -> Option<Range<usize>> {
|
||||||
|
self.marked_range
|
||||||
|
.clone()
|
||||||
|
.map(|range| utf8_range_to_utf16(&self.text, 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>>,
|
||||||
|
text: &str,
|
||||||
|
window: &mut Window,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) {
|
||||||
|
let range = range_utf16.map_or_else(
|
||||||
|
|| self.selection(),
|
||||||
|
|range| utf16_range_to_utf8(&self.text, range),
|
||||||
|
);
|
||||||
|
self.replace_range(range, text, window, cx);
|
||||||
|
}
|
||||||
|
|
||||||
|
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.map_or_else(
|
||||||
|
|| self.selection(),
|
||||||
|
|range| utf16_range_to_utf8(&self.text, range),
|
||||||
|
);
|
||||||
|
self.text.replace_range(range.clone(), new_text);
|
||||||
|
let start = range.start;
|
||||||
|
let end = start + new_text.len();
|
||||||
|
self.marked_range = Some(start..end);
|
||||||
|
let selected_end = new_selected_range_utf16
|
||||||
|
.map(|selected| utf16_range_to_utf8(&self.text, selected).end)
|
||||||
|
.unwrap_or(end);
|
||||||
|
self.cursor = selected_end;
|
||||||
|
self.anchor = selected_end;
|
||||||
|
self.notify(window, cx);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bounds_for_range(
|
||||||
|
&mut self,
|
||||||
|
range_utf16: Range<usize>,
|
||||||
|
_element_bounds: Bounds<Pixels>,
|
||||||
|
_window: &mut Window,
|
||||||
|
_cx: &mut Context<Self>,
|
||||||
|
) -> Option<Bounds<Pixels>> {
|
||||||
|
let layout = self.layout.borrow();
|
||||||
|
let layout = layout.as_ref()?;
|
||||||
|
let start = layout
|
||||||
|
.line
|
||||||
|
.x_for_index(utf16_to_utf8_offset(&self.text, range_utf16.start));
|
||||||
|
let end = layout
|
||||||
|
.line
|
||||||
|
.x_for_index(utf16_to_utf8_offset(&self.text, range_utf16.end));
|
||||||
|
// `origin` is already an absolute window point.
|
||||||
|
Some(Bounds::from_corners(
|
||||||
|
point(layout.origin.x + start.min(end), layout.bounds.top()),
|
||||||
|
point(layout.origin.x + start.max(end), layout.bounds.bottom()),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn character_index_for_point(
|
||||||
|
&mut self,
|
||||||
|
window_point: gpui::Point<Pixels>,
|
||||||
|
_window: &mut Window,
|
||||||
|
_cx: &mut Context<Self>,
|
||||||
|
) -> Option<usize> {
|
||||||
|
let layout = self.layout.borrow();
|
||||||
|
let layout = layout.as_ref()?;
|
||||||
|
Some(
|
||||||
|
layout
|
||||||
|
.line
|
||||||
|
.closest_index_for_x(window_point.x - layout.origin.x)
|
||||||
|
.min(self.text.len()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn text_length_utf16(
|
||||||
|
&mut self,
|
||||||
|
_window: &mut Window,
|
||||||
|
_cx: &mut Context<Self>,
|
||||||
|
) -> Option<usize> {
|
||||||
|
Some(self.text.encode_utf16().count())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Floors `index` to the nearest grapheme-cluster boundary at or below it.
|
||||||
|
fn previous_grapheme_start(text: &str, index: usize) -> usize {
|
||||||
|
text.grapheme_indices(true)
|
||||||
|
.rev()
|
||||||
|
.find(|(offset, _)| *offset < index)
|
||||||
|
.map(|(offset, _)| offset)
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Advances `index` to the next grapheme-cluster boundary.
|
||||||
|
fn next_grapheme_start(text: &str, index: usize) -> usize {
|
||||||
|
text.grapheme_indices(true)
|
||||||
|
.find(|(offset, _)| *offset > index)
|
||||||
|
.map(|(offset, _)| offset)
|
||||||
|
.unwrap_or(text.len())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The start of the word before `from`.
|
||||||
|
fn previous_word_start(text: &str, from: usize) -> usize {
|
||||||
|
let mut best = 0;
|
||||||
|
for (offset, word) in text.split_word_bound_indices() {
|
||||||
|
if offset >= from {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if !word.starts_with(char::is_whitespace) {
|
||||||
|
best = offset;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
best
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The end of the word after `from`.
|
||||||
|
fn next_word_end(text: &str, from: usize) -> usize {
|
||||||
|
for (offset, word) in text.split_word_bound_indices() {
|
||||||
|
let end = offset + word.len();
|
||||||
|
if end > from && !word.starts_with(char::is_whitespace) {
|
||||||
|
return end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
text.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Converts a UTF-16 offset into a UTF-8 byte offset.
|
||||||
|
fn utf16_to_utf8_offset(text: &str, utf16_offset: usize) -> usize {
|
||||||
|
let mut utf16_count = 0;
|
||||||
|
for (byte_offset, char) in text.char_indices() {
|
||||||
|
if utf16_count >= utf16_offset {
|
||||||
|
return byte_offset;
|
||||||
|
}
|
||||||
|
utf16_count += char.len_utf16();
|
||||||
|
}
|
||||||
|
text.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Converts a UTF-8 byte range to a UTF-16 range.
|
||||||
|
fn utf8_range_to_utf16(text: &str, range: Range<usize>) -> Range<usize> {
|
||||||
|
utf16_to_utf8_offset(text, range.start)..utf16_to_utf8_offset(text, range.end)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Converts a UTF-16 range to a UTF-8 byte range.
|
||||||
|
fn utf16_range_to_utf8(text: &str, range: Range<usize>) -> Range<usize> {
|
||||||
|
utf16_to_utf8_offset(text, range.start)..utf16_to_utf8_offset(text, range.end)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A single-line text field bound to an application-owned [`InputState`].
|
||||||
|
#[derive(IntoElement)]
|
||||||
|
pub struct Input {
|
||||||
|
id: ElementId,
|
||||||
|
state: Entity<InputState>,
|
||||||
|
placeholder: SharedString,
|
||||||
|
disabled: bool,
|
||||||
|
a11y_label: Option<SharedString>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Input {
|
||||||
|
/// Creates a text field bound to `state`.
|
||||||
|
pub fn new<I>(id: I, state: Entity<InputState>) -> Self
|
||||||
|
where
|
||||||
|
I: Into<ElementId>,
|
||||||
|
{
|
||||||
|
Self {
|
||||||
|
id: id.into(),
|
||||||
|
state,
|
||||||
|
placeholder: SharedString::default(),
|
||||||
|
disabled: false,
|
||||||
|
a11y_label: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the placeholder shown when the field is empty and unfocused.
|
||||||
|
pub fn placeholder<P>(mut self, placeholder: P) -> Self
|
||||||
|
where
|
||||||
|
P: Into<SharedString>,
|
||||||
|
{
|
||||||
|
self.placeholder = placeholder.into();
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Renders the field inert, unfocusable, and dimmed; disabled always wins.
|
||||||
|
pub fn disabled(mut self, disabled: bool) -> Self {
|
||||||
|
self.disabled = disabled;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Overrides the accessibility label.
|
||||||
|
pub fn accessibility_label<L>(mut self, label: L) -> Self
|
||||||
|
where
|
||||||
|
L: Into<SharedString>,
|
||||||
|
{
|
||||||
|
self.a11y_label = Some(label.into());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RenderOnce for Input {
|
||||||
|
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||||
|
let theme = cx.theme();
|
||||||
|
let colors = theme.colors;
|
||||||
|
let radius = theme.radius;
|
||||||
|
let focus_handle = self.state.read(cx).focus_handle.clone();
|
||||||
|
let layout_cell = self.state.read(cx).layout.clone();
|
||||||
|
|
||||||
|
// Drag selection crosses frames, so it lives in keyed element state.
|
||||||
|
let dragging = window.use_keyed_state("input-dragging", cx, |_, _| false);
|
||||||
|
let down_dragging = dragging.clone();
|
||||||
|
let paint_dragging = dragging.clone();
|
||||||
|
|
||||||
|
let focused = focus_handle.is_focused(window);
|
||||||
|
let is_empty = self.state.read(cx).text.is_empty();
|
||||||
|
|
||||||
|
let key_state = self.state.clone();
|
||||||
|
let key_focus = focus_handle.clone();
|
||||||
|
let down_state = self.state.clone();
|
||||||
|
let down_layout = layout_cell.clone();
|
||||||
|
|
||||||
|
let fallback_label = self
|
||||||
|
.a11y_label
|
||||||
|
.clone()
|
||||||
|
.or_else(|| (!self.placeholder.is_empty()).then_some(self.placeholder.clone()))
|
||||||
|
.unwrap_or_else(|| SharedString::from(self.id.to_string()));
|
||||||
|
|
||||||
|
h_flex()
|
||||||
|
.id(self.id.clone())
|
||||||
|
.relative()
|
||||||
|
.overflow_hidden()
|
||||||
|
.w_full()
|
||||||
|
.h_7()
|
||||||
|
.px_2()
|
||||||
|
.rounded(radius.control)
|
||||||
|
.border_1()
|
||||||
|
.border_color(colors.border)
|
||||||
|
.bg(colors.input)
|
||||||
|
.text_sm()
|
||||||
|
.text_color(colors.foreground)
|
||||||
|
.role(Role::TextInput)
|
||||||
|
.aria_label(fallback_label)
|
||||||
|
.when(self.disabled, |input| input.opacity(0.5))
|
||||||
|
.when(!self.disabled, |input| {
|
||||||
|
input.track_focus(&focus_handle).tab_stop(true)
|
||||||
|
})
|
||||||
|
.when(focused, |input| input.border_color(colors.ring))
|
||||||
|
.when(is_empty && !focused, |input| {
|
||||||
|
input.child(
|
||||||
|
div()
|
||||||
|
.text_color(colors.muted_foreground)
|
||||||
|
.child(self.placeholder.clone()),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.child(
|
||||||
|
canvas(
|
||||||
|
{
|
||||||
|
let state = self.state.clone();
|
||||||
|
|
||||||
|
move |_bounds, window, cx| {
|
||||||
|
let text = state.read(cx).text.clone();
|
||||||
|
let text_style = window.text_style();
|
||||||
|
let font = Font {
|
||||||
|
family: text_style.font_family.clone(),
|
||||||
|
features: text_style.font_features.clone(),
|
||||||
|
weight: text_style.font_weight,
|
||||||
|
style: text_style.font_style,
|
||||||
|
fallbacks: text_style.font_fallbacks.clone(),
|
||||||
|
};
|
||||||
|
let font_size = text_style.font_size.to_pixels(window.rem_size());
|
||||||
|
let runs = [TextRun {
|
||||||
|
len: text.len(),
|
||||||
|
font,
|
||||||
|
color: text_style.color,
|
||||||
|
background_color: None,
|
||||||
|
underline: None,
|
||||||
|
strikethrough: None,
|
||||||
|
}];
|
||||||
|
window
|
||||||
|
.text_system()
|
||||||
|
.shape_line(text.into(), font_size, &runs, None)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
let state = self.state.clone();
|
||||||
|
let layout_cell = layout_cell.clone();
|
||||||
|
let focus = focus_handle.clone();
|
||||||
|
let dragging = paint_dragging;
|
||||||
|
let disabled = self.disabled;
|
||||||
|
// `line` comes from the prepaint closure above.
|
||||||
|
move |bounds, line, window, cx| {
|
||||||
|
let (font_size, line_height) = {
|
||||||
|
let text_style = window.text_style();
|
||||||
|
(
|
||||||
|
text_style.font_size.to_pixels(window.rem_size()),
|
||||||
|
text_style
|
||||||
|
.line_height
|
||||||
|
.to_pixels(text_style.font_size, window.rem_size()),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
let padding = px(8.);
|
||||||
|
let selection = state.read(cx).selection();
|
||||||
|
let caret_x = line.x_for_index(selection.end);
|
||||||
|
|
||||||
|
// Horizontal scroll keeps the caret inside the field.
|
||||||
|
let visible = bounds.size.width - padding * 2.0;
|
||||||
|
let scroll_x = if caret_x > visible {
|
||||||
|
caret_x - visible
|
||||||
|
} else {
|
||||||
|
Pixels::ZERO
|
||||||
|
};
|
||||||
|
|
||||||
|
let origin = point(
|
||||||
|
bounds.left() + padding - scroll_x,
|
||||||
|
bounds.top() + (bounds.size.height - line_height) / 2.0,
|
||||||
|
);
|
||||||
|
*layout_cell.borrow_mut() = Some(InputLayout {
|
||||||
|
line,
|
||||||
|
bounds,
|
||||||
|
origin,
|
||||||
|
});
|
||||||
|
|
||||||
|
let layout = layout_cell.borrow();
|
||||||
|
let layout = layout.as_ref().expect("layout just stored");
|
||||||
|
let line = &layout.line;
|
||||||
|
let origin = layout.origin;
|
||||||
|
|
||||||
|
if focused && !selection.is_empty() {
|
||||||
|
let start_x = line.x_for_index(selection.start);
|
||||||
|
let rect = Bounds::from_corners(
|
||||||
|
point(origin.x + start_x.min(caret_x), bounds.top() + px(1.)),
|
||||||
|
point(
|
||||||
|
origin.x + start_x.max(caret_x),
|
||||||
|
bounds.bottom() - px(1.),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
window.paint_quad(fill(rect, colors.selection));
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ =
|
||||||
|
line.paint(origin, line_height, TextAlign::Left, None, window, cx);
|
||||||
|
|
||||||
|
if focused && selection.is_empty() {
|
||||||
|
let caret_top = origin.y + (line_height - font_size) / 2.0;
|
||||||
|
let caret = Bounds::from_corners(
|
||||||
|
point(origin.x + caret_x, caret_top),
|
||||||
|
point(origin.x + caret_x + px(1.5), caret_top + font_size),
|
||||||
|
);
|
||||||
|
window.paint_quad(fill(caret, colors.foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
if disabled {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.handle_input(
|
||||||
|
&focus,
|
||||||
|
gpui::ElementInputHandler::new(bounds, state.clone()),
|
||||||
|
cx,
|
||||||
|
);
|
||||||
|
|
||||||
|
if *dragging.read(cx) {
|
||||||
|
window.on_mouse_event({
|
||||||
|
let state = state.clone();
|
||||||
|
let layout_cell = layout_cell.clone();
|
||||||
|
move |event: &MouseMoveEvent, phase, window, cx| {
|
||||||
|
if phase != DispatchPhase::Bubble
|
||||||
|
|| event.pressed_button != Some(MouseButton::Left)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.update(cx, |state, cx| {
|
||||||
|
let index = index_at_x(
|
||||||
|
&layout_cell,
|
||||||
|
event.position.x,
|
||||||
|
state.text.len(),
|
||||||
|
);
|
||||||
|
state.cursor = index;
|
||||||
|
state.notify(window, cx);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
window.on_mouse_event({
|
||||||
|
let dragging = dragging.clone();
|
||||||
|
move |event: &MouseUpEvent, phase, _window, cx| {
|
||||||
|
if phase != DispatchPhase::Bubble
|
||||||
|
|| event.button != MouseButton::Left
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
dragging.update(cx, |dragging, _| *dragging = false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.absolute()
|
||||||
|
.inset_0(),
|
||||||
|
)
|
||||||
|
.when(!self.disabled, |input| {
|
||||||
|
input
|
||||||
|
.on_mouse_down(MouseButton::Left, {
|
||||||
|
let state = down_state;
|
||||||
|
let layout_cell = down_layout;
|
||||||
|
let dragging = down_dragging;
|
||||||
|
move |event: &MouseDownEvent, window, cx| {
|
||||||
|
dragging.update(cx, |dragging, _| *dragging = true);
|
||||||
|
state.update(cx, |state, cx| {
|
||||||
|
let index =
|
||||||
|
index_at_x(&layout_cell, event.position.x, state.text.len());
|
||||||
|
if event.click_count == 2 {
|
||||||
|
let start = previous_word_start(&state.text, index);
|
||||||
|
let end = next_word_end(&state.text, index);
|
||||||
|
state.select_range(start..end, window, cx);
|
||||||
|
} else if event.modifiers.shift {
|
||||||
|
state.cursor = index;
|
||||||
|
state.notify(window, cx);
|
||||||
|
} else {
|
||||||
|
state.cursor = index;
|
||||||
|
state.anchor = index;
|
||||||
|
state.notify(window, cx);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.on_key_down({
|
||||||
|
move |event: &KeyDownEvent, window, cx| {
|
||||||
|
if !key_focus.contains_focused(window, cx) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let keystroke = &event.keystroke;
|
||||||
|
let command =
|
||||||
|
keystroke.modifiers.platform || keystroke.modifiers.control;
|
||||||
|
key_state.update(cx, |state, cx| match keystroke.key.as_str() {
|
||||||
|
"left" => {
|
||||||
|
let to = if command || keystroke.modifiers.alt {
|
||||||
|
previous_word_start(&state.text, state.cursor)
|
||||||
|
} else {
|
||||||
|
previous_grapheme_start(&state.text, state.cursor)
|
||||||
|
};
|
||||||
|
state.move_cursor(to, keystroke.modifiers.shift, window, cx);
|
||||||
|
cx.stop_propagation();
|
||||||
|
}
|
||||||
|
"right" => {
|
||||||
|
let to = if command || keystroke.modifiers.alt {
|
||||||
|
next_word_end(&state.text, state.cursor)
|
||||||
|
} else {
|
||||||
|
next_grapheme_start(&state.text, state.cursor)
|
||||||
|
};
|
||||||
|
state.move_cursor(to, keystroke.modifiers.shift, window, cx);
|
||||||
|
cx.stop_propagation();
|
||||||
|
}
|
||||||
|
"home" => {
|
||||||
|
state.move_cursor(0, keystroke.modifiers.shift, window, cx);
|
||||||
|
cx.stop_propagation();
|
||||||
|
}
|
||||||
|
"end" => {
|
||||||
|
state.move_cursor(
|
||||||
|
state.text.len(),
|
||||||
|
keystroke.modifiers.shift,
|
||||||
|
window,
|
||||||
|
cx,
|
||||||
|
);
|
||||||
|
cx.stop_propagation();
|
||||||
|
}
|
||||||
|
"backspace" => {
|
||||||
|
state.delete_backward(
|
||||||
|
command || keystroke.modifiers.alt,
|
||||||
|
window,
|
||||||
|
cx,
|
||||||
|
);
|
||||||
|
cx.stop_propagation();
|
||||||
|
}
|
||||||
|
"delete" => {
|
||||||
|
state.delete_forward(
|
||||||
|
command || keystroke.modifiers.alt,
|
||||||
|
window,
|
||||||
|
cx,
|
||||||
|
);
|
||||||
|
cx.stop_propagation();
|
||||||
|
}
|
||||||
|
"a" if command => {
|
||||||
|
let len = state.text.len();
|
||||||
|
state.select_range(0..len, window, cx);
|
||||||
|
cx.stop_propagation();
|
||||||
|
}
|
||||||
|
"c" if command => {
|
||||||
|
state.copy_or_cut(false, window, cx);
|
||||||
|
cx.stop_propagation();
|
||||||
|
}
|
||||||
|
"x" if command => {
|
||||||
|
state.copy_or_cut(true, window, cx);
|
||||||
|
cx.stop_propagation();
|
||||||
|
}
|
||||||
|
"v" if command => {
|
||||||
|
if let Some(item) = cx.read_from_clipboard()
|
||||||
|
&& let Some(text) = item.text()
|
||||||
|
{
|
||||||
|
let range = state.selection();
|
||||||
|
state.replace_range(range, &text, window, cx);
|
||||||
|
}
|
||||||
|
cx.stop_propagation();
|
||||||
|
}
|
||||||
|
"enter" | "tab" => {}
|
||||||
|
_ => {
|
||||||
|
if let Some(text) = &keystroke.key_char
|
||||||
|
&& !command
|
||||||
|
{
|
||||||
|
let range = state.selection();
|
||||||
|
state.replace_range(range, text, window, cx);
|
||||||
|
cx.stop_propagation();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The text index at window x, using the last painted layout.
|
||||||
|
fn index_at_x(layout_cell: &RefCell<Option<InputLayout>>, x: Pixels, text_len: usize) -> usize {
|
||||||
|
let layout = layout_cell.borrow();
|
||||||
|
let Some(layout) = layout.as_ref() else {
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
layout
|
||||||
|
.line
|
||||||
|
.closest_index_for_x(x - layout.origin.x)
|
||||||
|
.min(text_len)
|
||||||
|
}
|
||||||
@@ -28,3 +28,6 @@ pub mod progress;
|
|||||||
|
|
||||||
#[cfg(feature = "avatar")]
|
#[cfg(feature = "avatar")]
|
||||||
pub mod avatar;
|
pub mod avatar;
|
||||||
|
|
||||||
|
#[cfg(feature = "input")]
|
||||||
|
pub mod input;
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
use gpui::{
|
||||||
|
Entity, Modifiers, ParentElement as _, Render, Styled as _, TestAppContext,
|
||||||
|
VisualTestContext, Window, div, point, px,
|
||||||
|
};
|
||||||
|
use gpui_native_core::input::{Input, InputState};
|
||||||
|
use gpui_native_theme::Theme;
|
||||||
|
|
||||||
|
struct Harness {
|
||||||
|
state: Entity<InputState>,
|
||||||
|
disabled: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Render for Harness {
|
||||||
|
fn render(
|
||||||
|
&mut self,
|
||||||
|
_window: &mut Window,
|
||||||
|
_cx: &mut gpui::Context<Self>,
|
||||||
|
) -> impl gpui::IntoElement {
|
||||||
|
div().size_full().child(
|
||||||
|
Input::new("test-input", self.state.clone())
|
||||||
|
.placeholder("Placeholder")
|
||||||
|
.disabled(self.disabled),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn input_window(
|
||||||
|
initial: &str,
|
||||||
|
disabled: bool,
|
||||||
|
) -> (TestAppContext, Entity<InputState>, VisualTestContext) {
|
||||||
|
let mut cx = TestAppContext::single();
|
||||||
|
cx.update(Theme::init);
|
||||||
|
let state = cx.update(|cx| InputState::new_with_text(initial, cx));
|
||||||
|
let state_for_window = state.clone();
|
||||||
|
let window = cx.add_window(move |_, _| Harness {
|
||||||
|
state: state_for_window,
|
||||||
|
disabled,
|
||||||
|
});
|
||||||
|
let mut visual = VisualTestContext::from_window(window.into(), &cx);
|
||||||
|
visual.update(|window, cx| {
|
||||||
|
window.draw(cx).clear(cx);
|
||||||
|
});
|
||||||
|
(cx, state, visual)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn value(cx: &TestAppContext, state: &Entity<InputState>) -> String {
|
||||||
|
cx.update(|cx| state.read(cx).value().to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn focus(visual: &mut VisualTestContext, state: &Entity<InputState>) {
|
||||||
|
// Programmatic focus: a click would move the caret to the click point.
|
||||||
|
visual.update(|window, cx| {
|
||||||
|
let handle = state.read(cx).focus_handle();
|
||||||
|
window.focus(&handle, cx);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn typing_inserts_at_the_caret() {
|
||||||
|
let (cx, state, mut visual) = input_window("", false);
|
||||||
|
focus(&mut visual, &state);
|
||||||
|
visual.simulate_keystrokes("h e l l o");
|
||||||
|
assert_eq!(value(&cx, &state), "hello");
|
||||||
|
visual.simulate_keystrokes("space w o r l d");
|
||||||
|
assert_eq!(value(&cx, &state), "hello world");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn arrow_left_moves_the_caret_and_typing_splits_the_text() {
|
||||||
|
let (cx, state, mut visual) = input_window("ab", false);
|
||||||
|
focus(&mut visual, &state);
|
||||||
|
visual.simulate_keystrokes("left");
|
||||||
|
visual.simulate_keystrokes("x");
|
||||||
|
assert_eq!(value(&cx, &state), "axb");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn backspace_deletes_before_the_caret_and_delete_after() {
|
||||||
|
let (cx, state, mut visual) = input_window("abc", false);
|
||||||
|
focus(&mut visual, &state);
|
||||||
|
visual.simulate_keystrokes("backspace");
|
||||||
|
assert_eq!(value(&cx, &state), "ab");
|
||||||
|
// The caret sits between 'a' and 'b'; forward delete removes 'b'.
|
||||||
|
visual.simulate_keystrokes("left");
|
||||||
|
visual.simulate_keystrokes("delete");
|
||||||
|
assert_eq!(value(&cx, &state), "a");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn alt_arrow_moves_by_words() {
|
||||||
|
let (cx, state, mut visual) = input_window("hello world", false);
|
||||||
|
focus(&mut visual, &state);
|
||||||
|
visual.simulate_keystrokes("alt-left");
|
||||||
|
visual.simulate_keystrokes("x");
|
||||||
|
assert_eq!(value(&cx, &state), "hello xworld");
|
||||||
|
visual.simulate_keystrokes("alt-left alt-left");
|
||||||
|
visual.simulate_keystrokes("shift-right shift-right");
|
||||||
|
visual.simulate_keystrokes("z");
|
||||||
|
assert_eq!(value(&cx, &state), "zllo xworld");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn select_all_then_type_replaces_the_value() {
|
||||||
|
let (cx, state, mut visual) = input_window("abcd", false);
|
||||||
|
focus(&mut visual, &state);
|
||||||
|
visual.simulate_keystrokes("cmd-a");
|
||||||
|
visual.simulate_keystrokes("z");
|
||||||
|
assert_eq!(value(&cx, &state), "z");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shift_arrow_selects_and_backspace_removes_the_selection() {
|
||||||
|
let (cx, state, mut visual) = input_window("ab", false);
|
||||||
|
focus(&mut visual, &state);
|
||||||
|
// Caret starts at the end; selecting left twice covers "ab".
|
||||||
|
visual.simulate_keystrokes("shift-left shift-left");
|
||||||
|
visual.simulate_keystrokes("backspace");
|
||||||
|
assert_eq!(value(&cx, &state), "");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn disabled_input_ignores_typing_and_clicks() {
|
||||||
|
// No programmatic focus: a disabled field must not take focus, so the
|
||||||
|
// key events fall to nothing and the input handler is never registered.
|
||||||
|
let (cx, state, mut visual) = input_window("", true);
|
||||||
|
visual.simulate_click(point(px(20.), px(14.)), Modifiers::default());
|
||||||
|
visual.simulate_keystrokes("h");
|
||||||
|
assert_eq!(value(&cx, &state), "");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn set_value_replaces_the_buffer() {
|
||||||
|
let (cx, state, mut visual) = input_window("", false);
|
||||||
|
visual.update(|window, cx| {
|
||||||
|
state.update(cx, |state, cx| state.set_value("preset", window, cx));
|
||||||
|
});
|
||||||
|
assert_eq!(value(&cx, &state), "preset");
|
||||||
|
}
|
||||||
@@ -1,13 +1,14 @@
|
|||||||
use gpui::prelude::FluentBuilder as _;
|
use gpui::prelude::FluentBuilder as _;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
App, AppContext as _, Bounds, ClickEvent, Context, InteractiveElement as _, IntoElement,
|
App, AppContext as _, Bounds, ClickEvent, Context, Entity, InteractiveElement as _,
|
||||||
ParentElement as _, Render, SharedString, StatefulInteractiveElement as _, Styled as _, Window,
|
IntoElement, ParentElement as _, Render, SharedString, StatefulInteractiveElement as _,
|
||||||
WindowBounds, WindowOptions, div, px, size,
|
Styled as _, Window, WindowBounds, WindowOptions, div, px, size,
|
||||||
};
|
};
|
||||||
use gpui_native_core::avatar::Avatar;
|
use gpui_native_core::avatar::Avatar;
|
||||||
use gpui_native_core::button::{Button, ButtonVariant};
|
use gpui_native_core::button::{Button, ButtonVariant};
|
||||||
use gpui_native_core::checkbox::Checkbox;
|
use gpui_native_core::checkbox::Checkbox;
|
||||||
use gpui_native_core::common::{h_flex, v_flex};
|
use gpui_native_core::common::{h_flex, v_flex};
|
||||||
|
use gpui_native_core::input::{Input, InputState};
|
||||||
use gpui_native_core::link::Link;
|
use gpui_native_core::link::Link;
|
||||||
use gpui_native_core::progress::Progress;
|
use gpui_native_core::progress::Progress;
|
||||||
use gpui_native_core::radio::{Radio, RadioGroup};
|
use gpui_native_core::radio::{Radio, RadioGroup};
|
||||||
@@ -25,6 +26,7 @@ struct Gallery {
|
|||||||
plan: SharedString,
|
plan: SharedString,
|
||||||
toggle_pressed: bool,
|
toggle_pressed: bool,
|
||||||
volume: f32,
|
volume: f32,
|
||||||
|
input_state: Entity<InputState>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Render for Gallery {
|
impl Render for Gallery {
|
||||||
@@ -211,6 +213,27 @@ impl Render for Gallery {
|
|||||||
.child(format!("clicked {} times", self.button_clicks)),
|
.child(format!("clicked {} times", self.button_clicks)),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
.child(
|
||||||
|
v_flex()
|
||||||
|
.gap_2()
|
||||||
|
.child(section_label(
|
||||||
|
"input · native refs — NSTextField, WinUI TextBox, Adwaita Entry, Breeze LineEdit",
|
||||||
|
colors,
|
||||||
|
))
|
||||||
|
.child(
|
||||||
|
Input::new("gallery-input", self.input_state.clone())
|
||||||
|
.placeholder("Type here…"),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_xs()
|
||||||
|
.text_color(colors.muted_foreground)
|
||||||
|
.child(format!(
|
||||||
|
"value: {}",
|
||||||
|
self.input_state.read(cx).value()
|
||||||
|
)),
|
||||||
|
),
|
||||||
|
)
|
||||||
.child(
|
.child(
|
||||||
v_flex()
|
v_flex()
|
||||||
.gap_2()
|
.gap_2()
|
||||||
@@ -566,6 +589,7 @@ where
|
|||||||
fn main() {
|
fn main() {
|
||||||
application().with_assets(IconAssets).run(|cx: &mut App| {
|
application().with_assets(IconAssets).run(|cx: &mut App| {
|
||||||
Theme::init(cx);
|
Theme::init(cx);
|
||||||
|
let input_state = InputState::new(cx);
|
||||||
let bounds = Bounds::centered(None, size(px(720.0), px(560.0)), cx);
|
let bounds = Bounds::centered(None, size(px(720.0), px(560.0)), cx);
|
||||||
if let Err(err) = cx.open_window(
|
if let Err(err) = cx.open_window(
|
||||||
WindowOptions {
|
WindowOptions {
|
||||||
@@ -592,6 +616,7 @@ fn main() {
|
|||||||
plan: SharedString::from("starter"),
|
plan: SharedString::from("starter"),
|
||||||
toggle_pressed: false,
|
toggle_pressed: false,
|
||||||
volume: 40.0,
|
volume: 40.0,
|
||||||
|
input_state,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
|
|||||||
Reference in New Issue
Block a user