From ad43867cb4c45b7c5f64ed288bb17160b095a7bb Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Thu, 29 Oct 2026 11:29:05 +0700 Subject: [PATCH] add input state events and key handling to InputState --- crates/core/src/input.rs | 637 ++++++++++++++++++++++++------------- crates/core/tests/input.rs | 128 +++++++- 2 files changed, 538 insertions(+), 227 deletions(-) diff --git a/crates/core/src/input.rs b/crates/core/src/input.rs index 254af4c..fd6e3cc 100644 --- a/crates/core/src/input.rs +++ b/crates/core/src/input.rs @@ -7,15 +7,28 @@ 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, + EntityInputHandler, EventEmitter, 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 _, Size}; 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. struct InputLayout { line: gpui::ShapedLine, @@ -24,6 +37,9 @@ struct InputLayout { origin: gpui::Point, } +/// Handler for key down events. +type KeyDownHandler = std::rc::Rc; + /// Input state for a single-line text field. pub struct InputState { text: String, @@ -33,18 +49,32 @@ pub struct InputState { anchor: usize, /// The byte range of in-progress IME composition, if any. marked_range: Option>, + /// Whether the field had focus at the last paint, for focus transitions. + focused: bool, focus_handle: gpui::FocusHandle, + /// The key-down handler, bound to this entity with `cx.listener`. + key_down_handler: Option, + /// The shaped line and element bounds, recorded during each paint, + /// and shared with hit-testing and the IME geometry conversions. layout: Rc>>, } +impl EventEmitter for InputState {} + impl InputState { - fn new_inner(cx: &mut App) -> Self { + fn new_inner(cx: &Context) -> Self { Self { text: String::new(), cursor: 0, anchor: 0, marked_range: None, + focused: false, 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)), } } @@ -55,8 +85,10 @@ impl InputState { } /// Creates the state entity with initial text, caret at the end. + /// + /// Newlines become spaces. pub fn new_with_text(text: impl Into, cx: &mut App) -> Entity { - let text = text.into(); + let text = sanitize(text.into()); cx.new(|cx| { let mut state = Self::new_inner(cx); state.text = text; @@ -72,14 +104,44 @@ impl InputState { } /// 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(&mut self, value: V, window: &mut Window, cx: &mut Context) where V: Into, { - 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(&mut self, value: V, window: &mut Window, cx: &mut Context) + where + V: Into, + { + self.set_value_inner(value, false, window, cx); + } + + fn set_value_inner( + &mut self, + value: V, + emit: bool, + window: &mut Window, + cx: &mut Context, + ) where + V: Into, + { + let text = sanitize(value.into()); + if text == self.text { + return; + } + self.text = text; self.cursor = self.text.len(); self.anchor = self.cursor; self.marked_range = None; + if emit { + cx.emit(InputStateEvent::TextChanged); + } self.notify(window, cx); } @@ -98,6 +160,20 @@ impl InputState { 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) { + 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. fn replace_range( &mut self, @@ -106,10 +182,17 @@ impl InputState { window: &mut Window, cx: &mut Context, ) { - 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.anchor = self.cursor; self.marked_range = None; + + cx.emit(InputStateEvent::TextChanged); self.notify(window, cx); } @@ -165,19 +248,261 @@ impl InputState { 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) { + /// Copies the selected text to the clipboard. + fn copy_selection(&mut self, _window: &mut Window, cx: &mut Context) -> bool { let range = self.selection(); if range.is_empty() { - return; + return false; } cx.write_to_clipboard(ClipboardItem::new_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) { + let range = self.selection(); + if !range.is_empty() { 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, + padding: Pixels, + line_height: Pixels, + ) -> (gpui::Point, 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, + caret_x: Pixels, + bounds: Bounds, + 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, + 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, + 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.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, + ) { + 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 { @@ -245,15 +570,22 @@ impl EntityInputHandler for InputState { || self.selection(), |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 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; + + cx.emit(InputStateEvent::TextChanged); self.notify(window, cx); } @@ -266,12 +598,15 @@ impl EntityInputHandler for InputState { ) -> Option> { 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()), @@ -304,6 +639,11 @@ impl EntityInputHandler for InputState { } } +/// Newlines have no meaning in a single-line field. +fn sanitize(text: impl Into) -> String { + text.into().replace('\n', " ").replace('\r', "") +} + /// 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) @@ -433,7 +773,6 @@ impl RenderOnce for Input { let radius = theme.radius; let metrics = theme.sizes.resolve(self.size); 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); @@ -443,10 +782,7 @@ impl RenderOnce for Input { 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 @@ -485,100 +821,50 @@ impl RenderOnce for Input { 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) - } + move |_bounds, window, cx| state.read(cx).shape_current_text(window) }, { let state = self.state.clone(); - let layout_cell = layout_cell.clone(); let focus = focus_handle.clone(); let dragging = paint_dragging; - let padding = metrics.padding_x / 2.0; let disabled = self.disabled; + // `line` comes from the prepaint closure above. 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(); - ( - text_style.font_size.to_pixels(window.rem_size()), - text_style - .line_height - .to_pixels(text_style.font_size, window.rem_size()), - ) - }; + let font_size = text_style.font_size.to_pixels(window.rem_size()); + let line_height = text_style + .line_height + .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(); - 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, + if focused && !state.selection().is_empty() { + state.paint_selection( + 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 { return; } @@ -590,9 +876,9 @@ impl RenderOnce for Input { ); if *dragging.read(cx) { - window.on_mouse_event({ - let state = state.clone(); - let layout_cell = layout_cell.clone(); + let state = state.clone(); + let dragging = dragging.clone(); + window.on_mouse_event( move |event: &MouseMoveEvent, phase, window, cx| { if phase != DispatchPhase::Bubble || event.pressed_button != Some(MouseButton::Left) @@ -600,28 +886,22 @@ impl RenderOnce for Input { 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); + state.drag_select_to(event.position.x, 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); - } - }); + window.on_mouse_event( + move |event: &MouseUpEvent, phase, _window, cx| { + if phase != DispatchPhase::Bubble + || event.button != MouseButton::Left + { + return; + } + dragging.update(cx, |dragging, _| *dragging = false); + }, + ); + } } }, ) @@ -632,13 +912,12 @@ impl RenderOnce for 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()); + let index = state.index_at_x(event.position.x); if event.click_count == 2 { let start = previous_word_start(&state.text, index); let end = next_word_end(&state.text, index); @@ -655,108 +934,14 @@ impl RenderOnce for Input { } }) .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(); - } - } - }); - } + let key_down_handler = self + .state + .read(cx) + .key_down_handler + .clone() + .expect("input state always carries its key handler"); + move |event: &KeyDownEvent, window, cx| key_down_handler(event, window, cx) }) }) } } - -/// The text index at window x, using the last painted layout. -fn index_at_x(layout_cell: &RefCell>, 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) -} diff --git a/crates/core/tests/input.rs b/crates/core/tests/input.rs index 7bc124a..f2b94ef 100644 --- a/crates/core/tests/input.rs +++ b/crates/core/tests/input.rs @@ -2,8 +2,10 @@ 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_core::input::{Input, InputState, InputStateEvent}; use gpui_native_theme::Theme; +use std::cell::RefCell; +use std::rc::Rc; struct Harness { state: Entity, @@ -136,3 +138,127 @@ fn set_value_replaces_the_buffer() { }); 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, +) -> (Rc>>, 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>>, 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"); +}