add input state events and key handling to InputState
This commit is contained in:
+400
-215
@@ -7,15 +7,28 @@ use unicode_segmentation::UnicodeSegmentation;
|
|||||||
use gpui::prelude::FluentBuilder as _;
|
use gpui::prelude::FluentBuilder as _;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
App, AppContext as _, Bounds, ClipboardItem, Context, DispatchPhase, ElementId, Entity,
|
App, AppContext as _, Bounds, ClipboardItem, Context, DispatchPhase, ElementId, Entity,
|
||||||
EntityInputHandler, Font, InteractiveElement as _, IntoElement, KeyDownEvent, MouseButton,
|
EntityInputHandler, EventEmitter, Font, InteractiveElement as _, IntoElement, KeyDownEvent,
|
||||||
MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement as _, Pixels, RenderOnce, Role,
|
MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement as _, Pixels,
|
||||||
SharedString, StatefulInteractiveElement as _, Styled as _, TextAlign, TextRun, UTF16Selection,
|
RenderOnce, Role, SharedString, StatefulInteractiveElement as _, Styled as _, TextAlign,
|
||||||
Window, canvas, div, fill, point, px,
|
TextRun, UTF16Selection, Window, canvas, div, fill, point, px,
|
||||||
};
|
};
|
||||||
use gpui_native_theme::{ActiveTheme as _, Size};
|
use gpui_native_theme::{ActiveTheme as _, Size};
|
||||||
|
|
||||||
use crate::common::{Sizable, h_flex};
|
use crate::common::{Sizable, h_flex};
|
||||||
|
|
||||||
|
/// Significant changes in an [`InputState`], observable via `cx.subscribe`.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub enum InputStateEvent {
|
||||||
|
/// The text content changed: typing, paste, IME commit, or `set_value`.
|
||||||
|
TextChanged,
|
||||||
|
/// The field gained focus.
|
||||||
|
Focused,
|
||||||
|
/// The field lost focus.
|
||||||
|
Blurred,
|
||||||
|
/// Enter was pressed
|
||||||
|
Submitted,
|
||||||
|
}
|
||||||
|
|
||||||
/// Input layout information for hit testing and caret positioning.
|
/// Input layout information for hit testing and caret positioning.
|
||||||
struct InputLayout {
|
struct InputLayout {
|
||||||
line: gpui::ShapedLine,
|
line: gpui::ShapedLine,
|
||||||
@@ -24,6 +37,9 @@ struct InputLayout {
|
|||||||
origin: gpui::Point<Pixels>,
|
origin: gpui::Point<Pixels>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Handler for key down events.
|
||||||
|
type KeyDownHandler = std::rc::Rc<dyn Fn(&KeyDownEvent, &mut Window, &mut App)>;
|
||||||
|
|
||||||
/// Input state for a single-line text field.
|
/// Input state for a single-line text field.
|
||||||
pub struct InputState {
|
pub struct InputState {
|
||||||
text: String,
|
text: String,
|
||||||
@@ -33,18 +49,32 @@ pub struct InputState {
|
|||||||
anchor: usize,
|
anchor: usize,
|
||||||
/// The byte range of in-progress IME composition, if any.
|
/// The byte range of in-progress IME composition, if any.
|
||||||
marked_range: Option<Range<usize>>,
|
marked_range: Option<Range<usize>>,
|
||||||
|
/// Whether the field had focus at the last paint, for focus transitions.
|
||||||
|
focused: bool,
|
||||||
focus_handle: gpui::FocusHandle,
|
focus_handle: gpui::FocusHandle,
|
||||||
|
/// The key-down handler, bound to this entity with `cx.listener`.
|
||||||
|
key_down_handler: Option<KeyDownHandler>,
|
||||||
|
/// The shaped line and element bounds, recorded during each paint,
|
||||||
|
/// and shared with hit-testing and the IME geometry conversions.
|
||||||
layout: Rc<RefCell<Option<InputLayout>>>,
|
layout: Rc<RefCell<Option<InputLayout>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl EventEmitter<InputStateEvent> for InputState {}
|
||||||
|
|
||||||
impl InputState {
|
impl InputState {
|
||||||
fn new_inner(cx: &mut App) -> Self {
|
fn new_inner(cx: &Context<Self>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
text: String::new(),
|
text: String::new(),
|
||||||
cursor: 0,
|
cursor: 0,
|
||||||
anchor: 0,
|
anchor: 0,
|
||||||
marked_range: None,
|
marked_range: None,
|
||||||
|
focused: false,
|
||||||
focus_handle: cx.focus_handle(),
|
focus_handle: cx.focus_handle(),
|
||||||
|
key_down_handler: Some(Rc::new(cx.listener(
|
||||||
|
|state, event: &KeyDownEvent, window, cx| {
|
||||||
|
state.handle_key_down(event, window, cx);
|
||||||
|
},
|
||||||
|
))),
|
||||||
layout: Rc::new(RefCell::new(None)),
|
layout: Rc::new(RefCell::new(None)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -55,8 +85,10 @@ impl InputState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Creates the state entity with initial text, caret at the end.
|
/// Creates the state entity with initial text, caret at the end.
|
||||||
|
///
|
||||||
|
/// Newlines become spaces.
|
||||||
pub fn new_with_text(text: impl Into<String>, cx: &mut App) -> Entity<Self> {
|
pub fn new_with_text(text: impl Into<String>, cx: &mut App) -> Entity<Self> {
|
||||||
let text = text.into();
|
let text = sanitize(text.into());
|
||||||
cx.new(|cx| {
|
cx.new(|cx| {
|
||||||
let mut state = Self::new_inner(cx);
|
let mut state = Self::new_inner(cx);
|
||||||
state.text = text;
|
state.text = text;
|
||||||
@@ -72,14 +104,44 @@ impl InputState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Replaces the whole value and collapses the selection to the end.
|
/// Replaces the whole value and collapses the selection to the end.
|
||||||
|
///
|
||||||
|
/// Newlines become spaces. Emits [`InputStateEvent::TextChanged`]
|
||||||
|
/// when the value changed, setting the current value is a no-op.
|
||||||
pub fn set_value<V>(&mut self, value: V, window: &mut Window, cx: &mut Context<Self>)
|
pub fn set_value<V>(&mut self, value: V, window: &mut Window, cx: &mut Context<Self>)
|
||||||
where
|
where
|
||||||
V: Into<String>,
|
V: Into<String>,
|
||||||
{
|
{
|
||||||
self.text = value.into();
|
self.set_value_inner(value, true, window, cx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Like [`set_value`], but does not emit `TextChanged`.
|
||||||
|
pub fn set_value_silent<V>(&mut self, value: V, window: &mut Window, cx: &mut Context<Self>)
|
||||||
|
where
|
||||||
|
V: Into<String>,
|
||||||
|
{
|
||||||
|
self.set_value_inner(value, false, window, cx);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_value_inner<V>(
|
||||||
|
&mut self,
|
||||||
|
value: V,
|
||||||
|
emit: bool,
|
||||||
|
window: &mut Window,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) where
|
||||||
|
V: Into<String>,
|
||||||
|
{
|
||||||
|
let text = sanitize(value.into());
|
||||||
|
if text == self.text {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.text = text;
|
||||||
self.cursor = self.text.len();
|
self.cursor = self.text.len();
|
||||||
self.anchor = self.cursor;
|
self.anchor = self.cursor;
|
||||||
self.marked_range = None;
|
self.marked_range = None;
|
||||||
|
if emit {
|
||||||
|
cx.emit(InputStateEvent::TextChanged);
|
||||||
|
}
|
||||||
self.notify(window, cx);
|
self.notify(window, cx);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,6 +160,20 @@ impl InputState {
|
|||||||
window.notify_text_input_changed(&self.focus_handle);
|
window.notify_text_input_changed(&self.focus_handle);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Emits `Focused`/`Blurred` when the field's focus state changed since the last paint.
|
||||||
|
fn sync_focus(&mut self, focused: bool, cx: &mut Context<Self>) {
|
||||||
|
if self.focused == focused {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.focused = focused;
|
||||||
|
|
||||||
|
cx.emit(if focused {
|
||||||
|
InputStateEvent::Focused
|
||||||
|
} else {
|
||||||
|
InputStateEvent::Blurred
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// Replaces a byte range with `text` and collapses the caret to its end.
|
/// Replaces a byte range with `text` and collapses the caret to its end.
|
||||||
fn replace_range(
|
fn replace_range(
|
||||||
&mut self,
|
&mut self,
|
||||||
@@ -106,10 +182,17 @@ impl InputState {
|
|||||||
window: &mut Window,
|
window: &mut Window,
|
||||||
cx: &mut Context<Self>,
|
cx: &mut Context<Self>,
|
||||||
) {
|
) {
|
||||||
self.text.replace_range(range.clone(), text);
|
if range.is_empty() && text.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let text = sanitize(text);
|
||||||
|
self.text.replace_range(range.clone(), &text);
|
||||||
self.cursor = range.start + text.len();
|
self.cursor = range.start + text.len();
|
||||||
self.anchor = self.cursor;
|
self.anchor = self.cursor;
|
||||||
self.marked_range = None;
|
self.marked_range = None;
|
||||||
|
|
||||||
|
cx.emit(InputStateEvent::TextChanged);
|
||||||
self.notify(window, cx);
|
self.notify(window, cx);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,19 +248,261 @@ impl InputState {
|
|||||||
self.notify(window, cx);
|
self.notify(window, cx);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Copies or cuts the selection to the clipboard.
|
/// Copies the selected text to the clipboard.
|
||||||
fn copy_or_cut(&mut self, cut: bool, window: &mut Window, cx: &mut Context<Self>) {
|
fn copy_selection(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> bool {
|
||||||
let range = self.selection();
|
let range = self.selection();
|
||||||
if range.is_empty() {
|
if range.is_empty() {
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
cx.write_to_clipboard(ClipboardItem::new_string(
|
cx.write_to_clipboard(ClipboardItem::new_string(
|
||||||
self.text[range.clone()].to_string(),
|
self.text[range.clone()].to_string(),
|
||||||
));
|
));
|
||||||
if cut {
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deletes the selected range, if any.
|
||||||
|
fn delete_selection(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
let range = self.selection();
|
||||||
|
if !range.is_empty() {
|
||||||
self.replace_range(range, "", window, cx);
|
self.replace_range(range, "", window, cx);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Shapes the buffer with the paint-time text style.
|
||||||
|
fn shape_current_text(&self, window: &Window) -> gpui::ShapedLine {
|
||||||
|
let text = self.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)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stores the paint geometry for hit-testing and the IME conversions.
|
||||||
|
fn store_layout(
|
||||||
|
&mut self,
|
||||||
|
line: gpui::ShapedLine,
|
||||||
|
bounds: Bounds<Pixels>,
|
||||||
|
padding: Pixels,
|
||||||
|
line_height: Pixels,
|
||||||
|
) -> (gpui::Point<Pixels>, Pixels) {
|
||||||
|
let selection = self.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,
|
||||||
|
);
|
||||||
|
|
||||||
|
*self.layout.borrow_mut() = Some(InputLayout {
|
||||||
|
line,
|
||||||
|
bounds,
|
||||||
|
origin,
|
||||||
|
});
|
||||||
|
|
||||||
|
(origin, caret_x)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Paints the selection highlight behind the text.
|
||||||
|
fn paint_selection(
|
||||||
|
&self,
|
||||||
|
origin: gpui::Point<Pixels>,
|
||||||
|
caret_x: Pixels,
|
||||||
|
bounds: Bounds<Pixels>,
|
||||||
|
color: gpui::Hsla,
|
||||||
|
window: &mut Window,
|
||||||
|
) {
|
||||||
|
let selection = self.selection();
|
||||||
|
if selection.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let layout = self.layout.borrow();
|
||||||
|
let Some(layout) = layout.as_ref() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let start_x = layout.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, color));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Paints the text itself.
|
||||||
|
fn paint_text(
|
||||||
|
&self,
|
||||||
|
origin: gpui::Point<Pixels>,
|
||||||
|
line_height: Pixels,
|
||||||
|
window: &mut Window,
|
||||||
|
cx: &mut App,
|
||||||
|
) {
|
||||||
|
let layout = self.layout.borrow();
|
||||||
|
let Some(layout) = layout.as_ref() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
layout
|
||||||
|
.line
|
||||||
|
.paint(origin, line_height, TextAlign::Left, None, window, cx)
|
||||||
|
.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Paints the caret at the cursor position.
|
||||||
|
fn paint_caret(
|
||||||
|
&self,
|
||||||
|
origin: gpui::Point<Pixels>,
|
||||||
|
caret_x: Pixels,
|
||||||
|
font_size: Pixels,
|
||||||
|
line_height: Pixels,
|
||||||
|
color: gpui::Hsla,
|
||||||
|
window: &mut Window,
|
||||||
|
) {
|
||||||
|
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, color));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The text index at window x, using the last painted layout.
|
||||||
|
fn index_at_x(&self, x: Pixels) -> usize {
|
||||||
|
let layout = self.layout.borrow();
|
||||||
|
let Some(layout) = layout.as_ref() else {
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
layout
|
||||||
|
.line
|
||||||
|
.closest_index_for_x(x - layout.origin.x)
|
||||||
|
.min(self.text.len())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extends the selection to `x` while drag-selecting.
|
||||||
|
fn drag_select_to(&mut self, x: Pixels, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
self.cursor = self.index_at_x(x);
|
||||||
|
self.notify(window, cx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The key-down handler: navigation, editing, clipboard, and submit.
|
||||||
|
///
|
||||||
|
/// Runs only while the field is focused.
|
||||||
|
fn handle_key_down(
|
||||||
|
&mut self,
|
||||||
|
event: &KeyDownEvent,
|
||||||
|
window: &mut Window,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) {
|
||||||
|
if !self.focus_handle.contains_focused(window, cx) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let keystroke = &event.keystroke;
|
||||||
|
let command = keystroke.modifiers.platform || keystroke.modifiers.control;
|
||||||
|
|
||||||
|
match keystroke.key.as_str() {
|
||||||
|
"left" => {
|
||||||
|
let to = if command || keystroke.modifiers.alt {
|
||||||
|
previous_word_start(&self.text, self.cursor)
|
||||||
|
} else {
|
||||||
|
previous_grapheme_start(&self.text, self.cursor)
|
||||||
|
};
|
||||||
|
self.move_cursor(to, keystroke.modifiers.shift, window, cx);
|
||||||
|
cx.stop_propagation();
|
||||||
|
}
|
||||||
|
"right" => {
|
||||||
|
let to = if command || keystroke.modifiers.alt {
|
||||||
|
next_word_end(&self.text, self.cursor)
|
||||||
|
} else {
|
||||||
|
next_grapheme_start(&self.text, self.cursor)
|
||||||
|
};
|
||||||
|
self.move_cursor(to, keystroke.modifiers.shift, window, cx);
|
||||||
|
cx.stop_propagation();
|
||||||
|
}
|
||||||
|
"home" => {
|
||||||
|
self.move_cursor(0, keystroke.modifiers.shift, window, cx);
|
||||||
|
cx.stop_propagation();
|
||||||
|
}
|
||||||
|
"end" => {
|
||||||
|
self.move_cursor(self.text.len(), keystroke.modifiers.shift, window, cx);
|
||||||
|
cx.stop_propagation();
|
||||||
|
}
|
||||||
|
"backspace" => {
|
||||||
|
self.delete_backward(command || keystroke.modifiers.alt, window, cx);
|
||||||
|
cx.stop_propagation();
|
||||||
|
}
|
||||||
|
"delete" => {
|
||||||
|
self.delete_forward(command || keystroke.modifiers.alt, window, cx);
|
||||||
|
cx.stop_propagation();
|
||||||
|
}
|
||||||
|
"a" if command => {
|
||||||
|
let len = self.text.len();
|
||||||
|
self.select_range(0..len, window, cx);
|
||||||
|
cx.stop_propagation();
|
||||||
|
}
|
||||||
|
"c" if command => {
|
||||||
|
if self.copy_selection(window, cx) {
|
||||||
|
cx.stop_propagation();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"x" if command => {
|
||||||
|
if self.copy_selection(window, cx) {
|
||||||
|
self.delete_selection(window, cx);
|
||||||
|
cx.stop_propagation();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"v" if command => {
|
||||||
|
if let Some(item) = cx.read_from_clipboard()
|
||||||
|
&& let Some(text) = item.text()
|
||||||
|
{
|
||||||
|
let range = self.selection();
|
||||||
|
self.replace_range(range, &text, window, cx);
|
||||||
|
}
|
||||||
|
cx.stop_propagation();
|
||||||
|
}
|
||||||
|
"enter" => {
|
||||||
|
cx.emit(InputStateEvent::Submitted);
|
||||||
|
cx.stop_propagation();
|
||||||
|
}
|
||||||
|
"tab" => {}
|
||||||
|
_ => {
|
||||||
|
if let Some(text) = &keystroke.key_char
|
||||||
|
&& !command
|
||||||
|
{
|
||||||
|
let range = self.selection();
|
||||||
|
self.replace_range(range, text, window, cx);
|
||||||
|
cx.stop_propagation();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl EntityInputHandler for InputState {
|
impl EntityInputHandler for InputState {
|
||||||
@@ -245,15 +570,22 @@ impl EntityInputHandler for InputState {
|
|||||||
|| self.selection(),
|
|| self.selection(),
|
||||||
|range| utf16_range_to_utf8(&self.text, range),
|
|range| utf16_range_to_utf8(&self.text, range),
|
||||||
);
|
);
|
||||||
self.text.replace_range(range.clone(), new_text);
|
|
||||||
|
let new_text = sanitize(new_text);
|
||||||
|
self.text.replace_range(range.clone(), &new_text);
|
||||||
|
|
||||||
let start = range.start;
|
let start = range.start;
|
||||||
let end = start + new_text.len();
|
let end = start + new_text.len();
|
||||||
self.marked_range = Some(start..end);
|
self.marked_range = Some(start..end);
|
||||||
|
|
||||||
let selected_end = new_selected_range_utf16
|
let selected_end = new_selected_range_utf16
|
||||||
.map(|selected| utf16_range_to_utf8(&self.text, selected).end)
|
.map(|selected| utf16_range_to_utf8(&self.text, selected).end)
|
||||||
.unwrap_or(end);
|
.unwrap_or(end);
|
||||||
|
|
||||||
self.cursor = selected_end;
|
self.cursor = selected_end;
|
||||||
self.anchor = selected_end;
|
self.anchor = selected_end;
|
||||||
|
|
||||||
|
cx.emit(InputStateEvent::TextChanged);
|
||||||
self.notify(window, cx);
|
self.notify(window, cx);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -266,12 +598,15 @@ impl EntityInputHandler for InputState {
|
|||||||
) -> Option<Bounds<Pixels>> {
|
) -> Option<Bounds<Pixels>> {
|
||||||
let layout = self.layout.borrow();
|
let layout = self.layout.borrow();
|
||||||
let layout = layout.as_ref()?;
|
let layout = layout.as_ref()?;
|
||||||
|
|
||||||
let start = layout
|
let start = layout
|
||||||
.line
|
.line
|
||||||
.x_for_index(utf16_to_utf8_offset(&self.text, range_utf16.start));
|
.x_for_index(utf16_to_utf8_offset(&self.text, range_utf16.start));
|
||||||
|
|
||||||
let end = layout
|
let end = layout
|
||||||
.line
|
.line
|
||||||
.x_for_index(utf16_to_utf8_offset(&self.text, range_utf16.end));
|
.x_for_index(utf16_to_utf8_offset(&self.text, range_utf16.end));
|
||||||
|
|
||||||
// `origin` is already an absolute window point.
|
// `origin` is already an absolute window point.
|
||||||
Some(Bounds::from_corners(
|
Some(Bounds::from_corners(
|
||||||
point(layout.origin.x + start.min(end), layout.bounds.top()),
|
point(layout.origin.x + start.min(end), layout.bounds.top()),
|
||||||
@@ -304,6 +639,11 @@ impl EntityInputHandler for InputState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Newlines have no meaning in a single-line field.
|
||||||
|
fn sanitize(text: impl Into<String>) -> String {
|
||||||
|
text.into().replace('\n', " ").replace('\r', "")
|
||||||
|
}
|
||||||
|
|
||||||
/// Floors `index` to the nearest grapheme-cluster boundary at or below it.
|
/// Floors `index` to the nearest grapheme-cluster boundary at or below it.
|
||||||
fn previous_grapheme_start(text: &str, index: usize) -> usize {
|
fn previous_grapheme_start(text: &str, index: usize) -> usize {
|
||||||
text.grapheme_indices(true)
|
text.grapheme_indices(true)
|
||||||
@@ -433,7 +773,6 @@ impl RenderOnce for Input {
|
|||||||
let radius = theme.radius;
|
let radius = theme.radius;
|
||||||
let metrics = theme.sizes.resolve(self.size);
|
let metrics = theme.sizes.resolve(self.size);
|
||||||
let focus_handle = self.state.read(cx).focus_handle.clone();
|
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.
|
// Drag selection crosses frames, so it lives in keyed element state.
|
||||||
let dragging = window.use_keyed_state("input-dragging", cx, |_, _| false);
|
let dragging = window.use_keyed_state("input-dragging", cx, |_, _| false);
|
||||||
@@ -443,10 +782,7 @@ impl RenderOnce for Input {
|
|||||||
let focused = focus_handle.is_focused(window);
|
let focused = focus_handle.is_focused(window);
|
||||||
let is_empty = self.state.read(cx).text.is_empty();
|
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_state = self.state.clone();
|
||||||
let down_layout = layout_cell.clone();
|
|
||||||
|
|
||||||
let fallback_label = self
|
let fallback_label = self
|
||||||
.a11y_label
|
.a11y_label
|
||||||
@@ -485,100 +821,50 @@ impl RenderOnce for Input {
|
|||||||
canvas(
|
canvas(
|
||||||
{
|
{
|
||||||
let state = self.state.clone();
|
let state = self.state.clone();
|
||||||
|
move |_bounds, window, cx| state.read(cx).shape_current_text(window)
|
||||||
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 state = self.state.clone();
|
||||||
let layout_cell = layout_cell.clone();
|
|
||||||
let focus = focus_handle.clone();
|
let focus = focus_handle.clone();
|
||||||
let dragging = paint_dragging;
|
let dragging = paint_dragging;
|
||||||
let padding = metrics.padding_x / 2.0;
|
|
||||||
let disabled = self.disabled;
|
let disabled = self.disabled;
|
||||||
|
|
||||||
// `line` comes from the prepaint closure above.
|
// `line` comes from the prepaint closure above.
|
||||||
move |bounds, line, window, cx| {
|
move |bounds, line, window, cx| {
|
||||||
let (font_size, line_height) = {
|
state.update(cx, |state, cx| {
|
||||||
|
state.sync_focus(focused, cx);
|
||||||
|
|
||||||
let text_style = window.text_style();
|
let text_style = window.text_style();
|
||||||
(
|
let font_size = text_style.font_size.to_pixels(window.rem_size());
|
||||||
text_style.font_size.to_pixels(window.rem_size()),
|
let line_height = text_style
|
||||||
text_style
|
|
||||||
.line_height
|
.line_height
|
||||||
.to_pixels(text_style.font_size, window.rem_size()),
|
.to_pixels(text_style.font_size, window.rem_size());
|
||||||
)
|
let padding = metrics.padding_x / 2.0;
|
||||||
};
|
let (origin, caret_x) =
|
||||||
|
state.store_layout(line, bounds, padding, line_height);
|
||||||
|
|
||||||
let selection = state.read(cx).selection();
|
if focused && !state.selection().is_empty() {
|
||||||
let caret_x = line.x_for_index(selection.end);
|
state.paint_selection(
|
||||||
|
|
||||||
// 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,
|
origin,
|
||||||
|
caret_x,
|
||||||
|
bounds,
|
||||||
|
colors.selection,
|
||||||
|
window,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
state.paint_text(origin, line_height, window, cx);
|
||||||
|
if focused && state.selection().is_empty() {
|
||||||
|
state.paint_caret(
|
||||||
|
origin,
|
||||||
|
caret_x,
|
||||||
|
font_size,
|
||||||
|
line_height,
|
||||||
|
colors.foreground,
|
||||||
|
window,
|
||||||
|
);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
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 {
|
if disabled {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -590,9 +876,9 @@ impl RenderOnce for Input {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if *dragging.read(cx) {
|
if *dragging.read(cx) {
|
||||||
window.on_mouse_event({
|
|
||||||
let state = state.clone();
|
let state = state.clone();
|
||||||
let layout_cell = layout_cell.clone();
|
let dragging = dragging.clone();
|
||||||
|
window.on_mouse_event(
|
||||||
move |event: &MouseMoveEvent, phase, window, cx| {
|
move |event: &MouseMoveEvent, phase, window, cx| {
|
||||||
if phase != DispatchPhase::Bubble
|
if phase != DispatchPhase::Bubble
|
||||||
|| event.pressed_button != Some(MouseButton::Left)
|
|| event.pressed_button != Some(MouseButton::Left)
|
||||||
@@ -600,19 +886,12 @@ impl RenderOnce for Input {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
state.update(cx, |state, cx| {
|
state.update(cx, |state, cx| {
|
||||||
let index = index_at_x(
|
state.drag_select_to(event.position.x, window, cx)
|
||||||
&layout_cell,
|
});
|
||||||
event.position.x,
|
},
|
||||||
state.text.len(),
|
|
||||||
);
|
);
|
||||||
state.cursor = index;
|
|
||||||
state.notify(window, cx);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
window.on_mouse_event({
|
|
||||||
let dragging = dragging.clone();
|
let dragging = dragging.clone();
|
||||||
|
window.on_mouse_event(
|
||||||
move |event: &MouseUpEvent, phase, _window, cx| {
|
move |event: &MouseUpEvent, phase, _window, cx| {
|
||||||
if phase != DispatchPhase::Bubble
|
if phase != DispatchPhase::Bubble
|
||||||
|| event.button != MouseButton::Left
|
|| event.button != MouseButton::Left
|
||||||
@@ -620,8 +899,9 @@ impl RenderOnce for Input {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
dragging.update(cx, |dragging, _| *dragging = false);
|
dragging.update(cx, |dragging, _| *dragging = false);
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -632,13 +912,12 @@ impl RenderOnce for Input {
|
|||||||
input
|
input
|
||||||
.on_mouse_down(MouseButton::Left, {
|
.on_mouse_down(MouseButton::Left, {
|
||||||
let state = down_state;
|
let state = down_state;
|
||||||
let layout_cell = down_layout;
|
|
||||||
let dragging = down_dragging;
|
let dragging = down_dragging;
|
||||||
|
|
||||||
move |event: &MouseDownEvent, window, cx| {
|
move |event: &MouseDownEvent, window, cx| {
|
||||||
dragging.update(cx, |dragging, _| *dragging = true);
|
dragging.update(cx, |dragging, _| *dragging = true);
|
||||||
state.update(cx, |state, cx| {
|
state.update(cx, |state, cx| {
|
||||||
let index =
|
let index = state.index_at_x(event.position.x);
|
||||||
index_at_x(&layout_cell, event.position.x, state.text.len());
|
|
||||||
if event.click_count == 2 {
|
if event.click_count == 2 {
|
||||||
let start = previous_word_start(&state.text, index);
|
let start = previous_word_start(&state.text, index);
|
||||||
let end = next_word_end(&state.text, index);
|
let end = next_word_end(&state.text, index);
|
||||||
@@ -655,108 +934,14 @@ impl RenderOnce for Input {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.on_key_down({
|
.on_key_down({
|
||||||
move |event: &KeyDownEvent, window, cx| {
|
let key_down_handler = self
|
||||||
if !key_focus.contains_focused(window, cx) {
|
.state
|
||||||
return;
|
.read(cx)
|
||||||
}
|
.key_down_handler
|
||||||
let keystroke = &event.keystroke;
|
.clone()
|
||||||
let command =
|
.expect("input state always carries its key handler");
|
||||||
keystroke.modifiers.platform || keystroke.modifiers.control;
|
move |event: &KeyDownEvent, window, cx| key_down_handler(event, window, cx)
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|||||||
+127
-1
@@ -2,8 +2,10 @@ use gpui::{
|
|||||||
Entity, Modifiers, ParentElement as _, Render, Styled as _, TestAppContext, VisualTestContext,
|
Entity, Modifiers, ParentElement as _, Render, Styled as _, TestAppContext, VisualTestContext,
|
||||||
Window, div, point, px,
|
Window, div, point, px,
|
||||||
};
|
};
|
||||||
use gpui_native_core::input::{Input, InputState};
|
use gpui_native_core::input::{Input, InputState, InputStateEvent};
|
||||||
use gpui_native_theme::Theme;
|
use gpui_native_theme::Theme;
|
||||||
|
use std::cell::RefCell;
|
||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
struct Harness {
|
struct Harness {
|
||||||
state: Entity<InputState>,
|
state: Entity<InputState>,
|
||||||
@@ -136,3 +138,127 @@ fn set_value_replaces_the_buffer() {
|
|||||||
});
|
});
|
||||||
assert_eq!(value(&cx, &state), "preset");
|
assert_eq!(value(&cx, &state), "preset");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Subscribes to `state`'s events, recording each variant into a sink that
|
||||||
|
/// lives for the rest of the test. The returned `Subscription` must stay
|
||||||
|
/// bound for the same lifetime — dropping it unsubscribes.
|
||||||
|
fn record_events(
|
||||||
|
cx: &mut TestAppContext,
|
||||||
|
state: &Entity<InputState>,
|
||||||
|
) -> (Rc<RefCell<Vec<InputStateEvent>>>, gpui::Subscription) {
|
||||||
|
let sink = Rc::new(RefCell::new(Vec::new()));
|
||||||
|
let sink_for_sub = sink.clone();
|
||||||
|
let subscription = cx.update(|cx| {
|
||||||
|
cx.subscribe(state, move |_, event, _| {
|
||||||
|
sink_for_sub.borrow_mut().push(event.clone());
|
||||||
|
})
|
||||||
|
});
|
||||||
|
// Subscriptions activate through a deferred effect.
|
||||||
|
cx.run_until_parked();
|
||||||
|
(sink, subscription)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn count(sink: &Rc<RefCell<Vec<InputStateEvent>>>, event: &InputStateEvent) -> usize {
|
||||||
|
sink.borrow().iter().filter(|e| e == &event).count()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn text_changed_emits_for_edits_but_not_for_caret_moves() {
|
||||||
|
let (mut cx, state, mut visual) = input_window("", false);
|
||||||
|
let (sink, _subscription) = record_events(&mut cx, &state);
|
||||||
|
focus(&mut visual, &state);
|
||||||
|
|
||||||
|
visual.simulate_keystrokes("a space b");
|
||||||
|
assert_eq!(count(&sink, &InputStateEvent::TextChanged), 3);
|
||||||
|
|
||||||
|
// Caret movement repaints but is not a text change.
|
||||||
|
visual.simulate_keystrokes("left");
|
||||||
|
assert_eq!(count(&sink, &InputStateEvent::TextChanged), 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn set_value_no_op_does_not_emit_changed() {
|
||||||
|
let (mut cx, state, mut visual) = input_window("same", false);
|
||||||
|
let (sink, _subscription) = record_events(&mut cx, &state);
|
||||||
|
|
||||||
|
visual.update(|window, cx| {
|
||||||
|
state.update(cx, |state, cx| state.set_value("same", window, cx));
|
||||||
|
});
|
||||||
|
visual.run_until_parked();
|
||||||
|
assert_eq!(count(&sink, &InputStateEvent::TextChanged), 0);
|
||||||
|
|
||||||
|
visual.update(|window, cx| {
|
||||||
|
state.update(cx, |state, cx| state.set_value("other", window, cx));
|
||||||
|
});
|
||||||
|
visual.run_until_parked();
|
||||||
|
assert_eq!(count(&sink, &InputStateEvent::TextChanged), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn set_value_silent_updates_without_emitting() {
|
||||||
|
let (mut cx, state, mut visual) = input_window("same", false);
|
||||||
|
let (sink, _subscription) = record_events(&mut cx, &state);
|
||||||
|
|
||||||
|
visual.update(|window, cx| {
|
||||||
|
state.update(cx, |state, cx| state.set_value_silent("other", window, cx));
|
||||||
|
});
|
||||||
|
visual.run_until_parked();
|
||||||
|
assert_eq!(value(&cx, &state), "other");
|
||||||
|
assert_eq!(count(&sink, &InputStateEvent::TextChanged), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn focus_and_blur_emit_events() {
|
||||||
|
let (mut cx, state, mut visual) = input_window("", false);
|
||||||
|
let (sink, _subscription) = record_events(&mut cx, &state);
|
||||||
|
|
||||||
|
focus(&mut visual, &state);
|
||||||
|
visual.update(|window, cx| {
|
||||||
|
window.draw(cx).clear(cx);
|
||||||
|
});
|
||||||
|
visual.run_until_parked();
|
||||||
|
assert_eq!(count(&sink, &InputStateEvent::Focused), 1);
|
||||||
|
|
||||||
|
visual.update(|window, cx| window.blur(cx));
|
||||||
|
visual.update(|window, cx| {
|
||||||
|
window.draw(cx).clear(cx);
|
||||||
|
});
|
||||||
|
visual.run_until_parked();
|
||||||
|
assert_eq!(count(&sink, &InputStateEvent::Blurred), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pasted_newlines_become_spaces() {
|
||||||
|
let (cx, state, mut visual) = input_window("", false);
|
||||||
|
focus(&mut visual, &state);
|
||||||
|
cx.update(|cx| {
|
||||||
|
cx.write_to_clipboard(gpui::ClipboardItem::new_string("a\nb\rc".to_string()));
|
||||||
|
});
|
||||||
|
visual.simulate_keystrokes("cmd-v");
|
||||||
|
// `\r` is dropped and `\n` becomes a space, matching the sanitizer.
|
||||||
|
assert_eq!(value(&cx, &state), "a bc");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn set_value_sanitizes_newlines() {
|
||||||
|
let (cx, state, mut visual) = input_window("", false);
|
||||||
|
visual.update(|window, cx| {
|
||||||
|
state.update(cx, |state, cx| state.set_value("a\nb", window, cx));
|
||||||
|
});
|
||||||
|
assert_eq!(value(&cx, &state), "a b");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn enter_emits_submitted() {
|
||||||
|
let (mut cx, state, mut visual) = input_window("draft", false);
|
||||||
|
let (sink, _subscription) = record_events(&mut cx, &state);
|
||||||
|
focus(&mut visual, &state);
|
||||||
|
visual.update(|window, cx| {
|
||||||
|
window.draw(cx).clear(cx);
|
||||||
|
});
|
||||||
|
|
||||||
|
visual.simulate_keystrokes("enter");
|
||||||
|
assert_eq!(count(&sink, &InputStateEvent::Submitted), 1);
|
||||||
|
// The content is left in place for the subscriber to read or clear.
|
||||||
|
assert_eq!(value(&cx, &state), "draft");
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user