add input

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