diff --git a/Cargo.lock b/Cargo.lock index 24cb3e6..883a8cb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1702,6 +1702,7 @@ dependencies = [ "gpui", "gpui-native-icons", "gpui-native-theme", + "unicode-segmentation", ] [[package]] diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 36b9b22..c045562 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -8,6 +8,7 @@ description = "The GPUI components every application needs, one cargo feature ea gpui = { workspace = true } gpui-native-icons = { workspace = true } gpui-native-theme = { workspace = true } +unicode-segmentation = "1.12" [dev-dependencies] gpui = { workspace = true, features = ["test-support"] } diff --git a/crates/core/src/input.rs b/crates/core/src/input.rs new file mode 100644 index 0000000..899ee4a --- /dev/null +++ b/crates/core/src/input.rs @@ -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, + /// Where the text was painted inside `bounds` (padding minus scroll). + origin: gpui::Point, +} + +/// 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>, + focus_handle: gpui::FocusHandle, + layout: Rc>>, +} + +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 { + 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, cx: &mut App) -> Entity { + 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(&mut self, value: V, window: &mut Window, cx: &mut Context) + where + V: Into, + { + 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 { + self.cursor.min(self.anchor)..self.cursor.max(self.anchor) + } + + fn notify(&mut self, window: &mut Window, cx: &mut Context) { + 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, + text: &str, + window: &mut Window, + cx: &mut Context, + ) { + 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) { + 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) { + 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.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, window: &mut Window, cx: &mut Context) { + 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) { + 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, + adjusted_range: &mut Option>, + _window: &mut Window, + _cx: &mut Context, + ) -> Option { + 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, + ) -> Option { + 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, + ) -> Option> { + 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.marked_range = None; + } + + fn replace_text_in_range( + &mut self, + range_utf16: Option>, + text: &str, + window: &mut Window, + cx: &mut Context, + ) { + 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>, + new_text: &str, + new_selected_range_utf16: Option>, + window: &mut Window, + cx: &mut Context, + ) { + 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, + _element_bounds: Bounds, + _window: &mut Window, + _cx: &mut Context, + ) -> 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()), + point(layout.origin.x + start.max(end), layout.bounds.bottom()), + )) + } + + fn character_index_for_point( + &mut self, + window_point: gpui::Point, + _window: &mut Window, + _cx: &mut Context, + ) -> Option { + 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, + ) -> Option { + 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) -> Range { + 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) -> Range { + 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, + placeholder: SharedString, + disabled: bool, + a11y_label: Option, +} + +impl Input { + /// Creates a text field bound to `state`. + pub fn new(id: I, state: Entity) -> Self + where + I: Into, + { + 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

(mut self, placeholder: P) -> Self + where + P: Into, + { + 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(mut self, label: L) -> Self + where + L: Into, + { + 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>, 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/src/lib.rs b/crates/core/src/lib.rs index 6d5f9a6..a4a7097 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -28,3 +28,6 @@ pub mod progress; #[cfg(feature = "avatar")] pub mod avatar; + +#[cfg(feature = "input")] +pub mod input; diff --git a/crates/core/tests/input.rs b/crates/core/tests/input.rs new file mode 100644 index 0000000..972070c --- /dev/null +++ b/crates/core/tests/input.rs @@ -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, + disabled: bool, +} + +impl Render for Harness { + fn render( + &mut self, + _window: &mut Window, + _cx: &mut gpui::Context, + ) -> 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, 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) -> String { + cx.update(|cx| state.read(cx).value().to_string()) +} + +fn focus(visual: &mut VisualTestContext, state: &Entity) { + // 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"); +} diff --git a/examples/gallery/src/main.rs b/examples/gallery/src/main.rs index a8e75d4..414a107 100644 --- a/examples/gallery/src/main.rs +++ b/examples/gallery/src/main.rs @@ -1,13 +1,14 @@ use gpui::prelude::FluentBuilder as _; use gpui::{ - App, AppContext as _, Bounds, ClickEvent, Context, InteractiveElement as _, IntoElement, - ParentElement as _, Render, SharedString, StatefulInteractiveElement as _, Styled as _, Window, - WindowBounds, WindowOptions, div, px, size, + App, AppContext as _, Bounds, ClickEvent, Context, Entity, InteractiveElement as _, + IntoElement, ParentElement as _, Render, SharedString, StatefulInteractiveElement as _, + Styled as _, Window, WindowBounds, WindowOptions, div, px, size, }; use gpui_native_core::avatar::Avatar; use gpui_native_core::button::{Button, ButtonVariant}; use gpui_native_core::checkbox::Checkbox; 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::progress::Progress; use gpui_native_core::radio::{Radio, RadioGroup}; @@ -25,6 +26,7 @@ struct Gallery { plan: SharedString, toggle_pressed: bool, volume: f32, + input_state: Entity, } impl Render for Gallery { @@ -211,6 +213,27 @@ impl Render for Gallery { .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( v_flex() .gap_2() @@ -566,6 +589,7 @@ where fn main() { application().with_assets(IconAssets).run(|cx: &mut App| { Theme::init(cx); + let input_state = InputState::new(cx); let bounds = Bounds::centered(None, size(px(720.0), px(560.0)), cx); if let Err(err) = cx.open_window( WindowOptions { @@ -592,6 +616,7 @@ fn main() { plan: SharedString::from("starter"), toggle_pressed: false, volume: 40.0, + input_state, }) }, ) {