custome input

This commit is contained in:
2026-06-03 16:21:22 +07:00
parent 5d4c8634ef
commit 619f3f62c9
11 changed files with 4075 additions and 25 deletions

17
Cargo.lock generated
View File

@@ -1171,6 +1171,7 @@ dependencies = [
"flume 0.11.1",
"gpui",
"gpui_tokio",
"input",
"itertools 0.13.0",
"linkify",
"log",
@@ -3675,6 +3676,22 @@ dependencies = [
"generic-array",
]
[[package]]
name = "input"
version = "1.0.0-beta4"
dependencies = [
"anyhow",
"common",
"gpui",
"serde",
"serde_json",
"smol",
"theme",
"ui",
"unicode-bidi",
"unicode-segmentation",
]
[[package]]
name = "instant"
version = "0.1.13"

View File

@@ -7,6 +7,7 @@ publish.workspace = true
[dependencies]
state = { path = "../state" }
ui = { path = "../ui" }
input = { path = "../input" }
theme = { path = "../theme" }
common = { path = "../common" }
person = { path = "../person" }

View File

@@ -13,6 +13,7 @@ use gpui::{
StatefulInteractiveElement, Styled, StyledImage, Subscription, Task, WeakEntity, Window,
deferred, div, img, list, px, red, relative, svg, white,
};
use input::{Input, InputState, InputStateEvent, input};
use itertools::Itertools;
use nostr_sdk::prelude::*;
use person::{Person, PersonRegistry};
@@ -24,7 +25,6 @@ use theme::ActiveTheme;
use ui::avatar::Avatar;
use ui::button::{Button, ButtonVariants};
use ui::dock::{Panel, PanelEvent};
use ui::input::{InputEvent, InputState, TextInput};
use ui::menu::DropdownMenu;
use ui::notification::Notification;
use ui::scroll::Scrollbar;
@@ -116,15 +116,17 @@ impl ChatPanel {
// Define input state
let input = cx.new(|cx| {
InputState::new(window, cx)
.placeholder(format!("Message {}", name))
.auto_grow(1, 20)
.prevent_new_line_on_enter()
.clean_on_escape()
let mut this = InputState::new_multiline(cx);
this.set_placeholder(format!("Message {}", name), cx);
this
});
// Define subject input state
let subject_input = cx.new(|cx| InputState::new(window, cx).placeholder("New subject..."));
let subject_input = cx.new(|cx| {
let mut this = InputState::new(cx);
this.set_placeholder("New subject...", cx);
this
});
let subject_bar = cx.new(|_cx| false);
// Define subscriptions
@@ -133,7 +135,7 @@ impl ChatPanel {
subscriptions.push(
// Subscribe the chat input event
cx.subscribe_in(&input, window, move |this, _input, event, window, cx| {
if let InputEvent::PressEnter { .. } = event {
if let InputStateEvent::Enter = event {
this.send_text_message(window, cx);
};
}),
@@ -145,7 +147,7 @@ impl ChatPanel {
&subject_input,
window,
move |this, _input, event, window, cx| {
if let InputEvent::PressEnter { .. } = event {
if let InputStateEvent::Enter = event {
this.change_subject(window, cx);
};
},
@@ -294,7 +296,7 @@ impl ChatPanel {
/// Get user input content and merged all attachments if available
fn get_input_value(&self, cx: &Context<Self>) -> String {
// Get input's value
let mut content = self.input.read(cx).value().trim().to_string();
let mut content = self.input.read(cx).content().trim().to_string();
// Get all attaches and merge its with message
let attachments = self.attachments.read(cx);
@@ -317,7 +319,7 @@ impl ChatPanel {
}
fn change_subject(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
let subject = self.subject_input.read(cx).value();
let subject = self.subject_input.read(cx).content().to_string();
self.room
.update(cx, |this, cx| {
@@ -414,9 +416,9 @@ impl ChatPanel {
/// Clear the input field, attachments, and replies
///
/// Only run after sending a message
fn clear(&mut self, window: &mut Window, cx: &mut Context<Self>) {
fn clear(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
self.input.update(cx, |this, cx| {
this.set_value("", window, cx);
this.set_content("", cx);
});
self.attachments.update(cx, |this, cx| {
this.clear();
@@ -1497,12 +1499,7 @@ impl Render for ChatPanel {
.gap_2()
.border_b_1()
.border_color(cx.theme().border)
.child(
TextInput::new(&self.subject_input)
.text_sm()
.small()
.bordered(false),
)
.child(input(&self.subject_input, cx).text_sm())
.child(
Button::new("change")
.icon(IconName::CheckCircle)
@@ -1553,12 +1550,7 @@ impl Render for ChatPanel {
this.upload(window, cx);
})),
)
.child(
TextInput::new(&self.input)
.appearance(false)
.text_sm()
.flex_1(),
)
.child(input(&self.input, cx).text_sm().flex_1())
.child(
h_flex()
.pl_1()

19
crates/input/Cargo.toml Normal file
View File

@@ -0,0 +1,19 @@
[package]
name = "input"
version.workspace = true
edition.workspace = true
publish.workspace = true
[dependencies]
common = { path = "../common" }
theme = { path = "../theme" }
ui = { path = "../ui" }
gpui.workspace = true
serde.workspace = true
serde_json.workspace = true
anyhow.workspace = true
smol.workspace = true
unicode-segmentation = "1.12.0"
unicode-bidi = "0.3"

166
crates/input/src/bidi.rs Normal file
View File

@@ -0,0 +1,166 @@
use unicode_bidi::{BidiClass, bidi_class};
/// Text direction for bidirectional text support.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum TextDirection {
/// Left-to-right text direction (default for Latin, Greek, Cyrillic, etc.)
#[default]
Ltr,
/// Right-to-left text direction (for Arabic, Hebrew, etc.)
Rtl,
}
impl TextDirection {
/// Returns true if this is left-to-right direction.
pub fn is_ltr(self) -> bool {
matches!(self, TextDirection::Ltr)
}
/// Returns true if this is right-to-left direction.
pub fn is_rtl(self) -> bool {
matches!(self, TextDirection::Rtl)
}
}
/// Detects the base direction of text using the first strong directional character.
///
/// This follows the Unicode Bidirectional Algorithm (UBA) rule P2/P3:
/// - Find the first character with a strong directional type (L, R, or AL)
/// - If it's L, the paragraph direction is LTR
/// - If it's R or AL, the paragraph direction is RTL
/// - If no strong character is found, defaults to LTR
///
/// # Examples
///
/// ```ignore
/// use gpui::input::bidi::detect_base_direction;
///
/// // English text is LTR
/// assert!(detect_base_direction("Hello world").is_ltr());
///
/// // Arabic text is RTL
/// assert!(detect_base_direction("مرحبا").is_rtl());
///
/// // Hebrew text is RTL
/// assert!(detect_base_direction("שלום").is_rtl());
///
/// // Mixed text uses first strong character
/// assert!(detect_base_direction("Hello مرحبا").is_ltr());
/// assert!(detect_base_direction("مرحبا Hello").is_rtl());
///
/// // Empty or neutral-only text defaults to LTR
/// assert!(detect_base_direction("").is_ltr());
/// assert!(detect_base_direction("123").is_ltr());
/// ```
pub fn detect_base_direction(text: &str) -> TextDirection {
for c in text.chars() {
match bidi_class(c) {
BidiClass::L => return TextDirection::Ltr,
BidiClass::R | BidiClass::AL => return TextDirection::Rtl,
_ => continue,
}
}
TextDirection::Ltr
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_empty_string() {
assert_eq!(detect_base_direction(""), TextDirection::Ltr);
}
#[test]
fn test_whitespace_only() {
assert_eq!(detect_base_direction(" "), TextDirection::Ltr);
}
#[test]
fn test_numbers_only() {
assert_eq!(detect_base_direction("12345"), TextDirection::Ltr);
}
#[test]
fn test_punctuation_only() {
assert_eq!(detect_base_direction("!@#$%"), TextDirection::Ltr);
}
#[test]
fn test_latin_text() {
assert_eq!(detect_base_direction("Hello world"), TextDirection::Ltr);
}
#[test]
fn test_arabic_text() {
assert_eq!(detect_base_direction("مرحبا بالعالم"), TextDirection::Rtl);
}
#[test]
fn test_hebrew_text() {
assert_eq!(detect_base_direction("שלום עולם"), TextDirection::Rtl);
}
#[test]
fn test_mixed_ltr_first() {
assert_eq!(detect_base_direction("Hello مرحبا"), TextDirection::Ltr);
}
#[test]
fn test_mixed_rtl_first() {
assert_eq!(detect_base_direction("مرحبا Hello"), TextDirection::Rtl);
}
#[test]
fn test_numbers_before_arabic() {
assert_eq!(detect_base_direction("123 مرحبا"), TextDirection::Rtl);
}
#[test]
fn test_numbers_before_latin() {
assert_eq!(detect_base_direction("123 Hello"), TextDirection::Ltr);
}
#[test]
fn test_punctuation_before_hebrew() {
assert_eq!(detect_base_direction("... שלום"), TextDirection::Rtl);
}
#[test]
fn test_greek_text() {
assert_eq!(detect_base_direction("Γειά σου κόσμε"), TextDirection::Ltr);
}
#[test]
fn test_cyrillic_text() {
assert_eq!(detect_base_direction("Привет мир"), TextDirection::Ltr);
}
#[test]
fn test_chinese_text() {
assert_eq!(detect_base_direction("你好世界"), TextDirection::Ltr);
}
#[test]
fn test_japanese_text() {
assert_eq!(detect_base_direction("こんにちは"), TextDirection::Ltr);
}
#[test]
fn test_direction_is_ltr() {
assert!(TextDirection::Ltr.is_ltr());
assert!(!TextDirection::Rtl.is_ltr());
}
#[test]
fn test_direction_is_rtl() {
assert!(TextDirection::Rtl.is_rtl());
assert!(!TextDirection::Ltr.is_rtl());
}
#[test]
fn test_direction_default() {
assert_eq!(TextDirection::default(), TextDirection::Ltr);
}
}

View File

@@ -0,0 +1,512 @@
use gpui::{App, KeyBinding, actions};
actions!(
input,
[
/// Delete the character before the cursor.
Backspace,
/// Delete the character after the cursor.
Delete,
/// Blur focus from the input.
Escape,
/// Delete the word before the cursor.
DeleteWordLeft,
/// Delete the word after the cursor.
DeleteWordRight,
/// Delete from the cursor to the beginning of the line.
DeleteToBeginningOfLine,
/// Delete from the cursor to the end of the line.
DeleteToEndOfLine,
/// Insert a tab character at the cursor position.
Tab,
/// Move the cursor one character to the left.
Left,
/// Move the cursor one character to the right.
Right,
/// Move the cursor up one visual line.
Up,
/// Move the cursor down one visual line.
Down,
/// Extend selection one character to the left.
SelectLeft,
/// Extend selection one character to the right.
SelectRight,
/// Extend selection up one visual line.
SelectUp,
/// Extend selection down one visual line.
SelectDown,
/// Select all text content.
SelectAll,
/// Move cursor to the start of the current line.
Home,
/// Move cursor to the end of the current line.
End,
/// Extend selection to the beginning of the content.
SelectToBeginning,
/// Extend selection to the end of the content.
SelectToEnd,
/// Move cursor to the beginning of the content.
MoveToBeginning,
/// Move cursor to the end of the content.
MoveToEnd,
/// Paste from clipboard at the cursor position.
Paste,
/// Cut selected text to clipboard.
Cut,
/// Copy selected text to clipboard.
Copy,
/// Insert a newline at the cursor position.
Enter,
/// Move cursor one word to the left.
WordLeft,
/// Move cursor one word to the right.
WordRight,
/// Extend selection one word to the left.
SelectWordLeft,
/// Extend selection one word to the right.
SelectWordRight,
/// Undo the last edit.
Undo,
/// Redo the last undone edit.
Redo,
]
);
/// The key context used for input element keybindings.
pub const INPUT_CONTEXT: &str = "Input";
/// Keybindings configuration for input elements.
///
/// Each field is an `Option<KeyBinding>` to allow:
/// - Using defaults (via `Default::default()`)
/// - Overriding with custom bindings
/// - Unbinding keys by setting fields to `None`
///
/// The `Default` implementation returns platform-specific keybindings.
#[derive(Clone)]
pub struct InputBindings {
/// Binding for deleting the character before the cursor.
/// Default: `backspace`
pub backspace: Option<KeyBinding>,
/// Binding for deleting the character after the cursor.
/// Default: `delete`
pub delete: Option<KeyBinding>,
/// Binding for deleting the word before the cursor.
/// Default: `alt-backspace` (macOS) / `ctrl-backspace` (other platforms)
pub delete_word_left: Option<KeyBinding>,
/// Binding for deleting the word after the cursor.
/// Default: `alt-delete` (macOS) / `ctrl-delete` (other platforms)
pub delete_word_right: Option<KeyBinding>,
/// Binding for deleting from cursor to beginning of line.
/// Default: `cmd-backspace` (macOS) / `ctrl-shift-backspace` (other platforms)
pub delete_to_beginning_of_line: Option<KeyBinding>,
/// Binding for deleting from cursor to end of line.
/// Default: `ctrl-k` (macOS) / `ctrl-shift-delete` (other platforms)
pub delete_to_end_of_line: Option<KeyBinding>,
/// Binding for inserting a tab character.
/// Default: `tab`
pub tab: Option<KeyBinding>,
/// Binding for inserting a newline (multi-line) or confirming input (single-line).
/// Default: `enter`
pub enter: Option<KeyBinding>,
/// Binding for moving the cursor one character to the left.
/// Default: `left`
pub left: Option<KeyBinding>,
/// Binding for moving the cursor one character to the right.
/// Default: `right`
pub right: Option<KeyBinding>,
/// Binding for moving the cursor up one line (multi-line) or to the start of the line (single-line).
/// Default: `up`
pub up: Option<KeyBinding>,
/// Binding for moving the cursor down one line (multi-line) or to the end of the line (single-line).
/// Default: `down`
pub down: Option<KeyBinding>,
/// Binding for extending selection one character to the left.
/// Default: `shift-left`
pub select_left: Option<KeyBinding>,
/// Binding for extending selection one character to the right.
/// Default: `shift-right`
pub select_right: Option<KeyBinding>,
/// Binding for extending selection up one line (multi-line) or to the start (single-line).
/// Default: `shift-up`
pub select_up: Option<KeyBinding>,
/// Binding for extending selection down one line (multi-line) or to the end (single-line).
/// Default: `shift-down`
pub select_down: Option<KeyBinding>,
/// Binding for selecting all text content.
/// Default: `cmd-a` (macOS) / `ctrl-a` (other platforms)
pub select_all: Option<KeyBinding>,
/// Binding for moving cursor to the start of the current line.
/// Default: `home`
pub home: Option<KeyBinding>,
/// Binding for moving cursor to the end of the current line.
/// Default: `end`
pub end: Option<KeyBinding>,
/// Binding for moving cursor to the beginning of all content.
/// Default: `cmd-up` (macOS) / `ctrl-home` (other platforms)
pub move_to_beginning: Option<KeyBinding>,
/// Binding for moving cursor to the end of all content.
/// Default: `cmd-down` (macOS) / `ctrl-end` (other platforms)
pub move_to_end: Option<KeyBinding>,
/// Binding for extending selection to the beginning of all content.
/// Default: `cmd-shift-up` (macOS) / `ctrl-shift-home` (other platforms)
pub select_to_beginning: Option<KeyBinding>,
/// Binding for extending selection to the end of all content.
/// Default: `cmd-shift-down` (macOS) / `ctrl-shift-end` (other platforms)
pub select_to_end: Option<KeyBinding>,
/// Binding for moving cursor one word to the left.
/// Default: `alt-left` (macOS) / `ctrl-left` (other platforms)
pub word_left: Option<KeyBinding>,
/// Binding for moving cursor one word to the right.
/// Default: `alt-right` (macOS) / `ctrl-right` (other platforms)
pub word_right: Option<KeyBinding>,
/// Binding for extending selection one word to the left.
/// Default: `alt-shift-left` (macOS) / `ctrl-shift-left` (other platforms)
pub select_word_left: Option<KeyBinding>,
/// Binding for extending selection one word to the right.
/// Default: `alt-shift-right` (macOS) / `ctrl-shift-right` (other platforms)
pub select_word_right: Option<KeyBinding>,
/// Binding for copying selected text to clipboard.
/// Default: `cmd-c` (macOS) / `ctrl-c` (other platforms)
pub copy: Option<KeyBinding>,
/// Binding for cutting selected text to clipboard.
/// Default: `cmd-x` (macOS) / `ctrl-x` (other platforms)
pub cut: Option<KeyBinding>,
/// Binding for pasting from clipboard.
/// Default: `cmd-v` (macOS) / `ctrl-v` (other platforms)
pub paste: Option<KeyBinding>,
/// Binding for undoing the last edit.
/// Default: `cmd-z` (macOS) / `ctrl-z` (other platforms)
pub undo: Option<KeyBinding>,
/// Binding for redoing the last undone edit.
/// Default: `cmd-shift-z` (macOS) / `ctrl-shift-z` (other platforms)
pub redo: Option<KeyBinding>,
/// Binding for blurring focus from the input.
/// Default: `escape`
pub escape: Option<KeyBinding>,
}
impl Default for InputBindings {
/// Returns platform-specific default keybindings for input elements.
fn default() -> Self {
let context = Some(INPUT_CONTEXT);
#[cfg(target_os = "macos")]
{
Self {
backspace: Some(KeyBinding::new("backspace", Backspace, context)),
delete: Some(KeyBinding::new("delete", Delete, context)),
delete_word_left: Some(KeyBinding::new("alt-backspace", DeleteWordLeft, context)),
delete_word_right: Some(KeyBinding::new("alt-delete", DeleteWordRight, context)),
delete_to_beginning_of_line: Some(KeyBinding::new(
"cmd-backspace",
DeleteToBeginningOfLine,
context,
)),
delete_to_end_of_line: Some(KeyBinding::new("ctrl-k", DeleteToEndOfLine, context)),
tab: Some(KeyBinding::new("tab", Tab, context)),
enter: Some(KeyBinding::new("enter", Enter, context)),
left: Some(KeyBinding::new("left", Left, context)),
right: Some(KeyBinding::new("right", Right, context)),
up: Some(KeyBinding::new("up", Up, context)),
down: Some(KeyBinding::new("down", Down, context)),
select_left: Some(KeyBinding::new("shift-left", SelectLeft, context)),
select_right: Some(KeyBinding::new("shift-right", SelectRight, context)),
select_up: Some(KeyBinding::new("shift-up", SelectUp, context)),
select_down: Some(KeyBinding::new("shift-down", SelectDown, context)),
select_all: Some(KeyBinding::new("cmd-a", SelectAll, context)),
home: Some(KeyBinding::new("home", Home, context)),
end: Some(KeyBinding::new("end", End, context)),
move_to_beginning: Some(KeyBinding::new("cmd-up", MoveToBeginning, context)),
move_to_end: Some(KeyBinding::new("cmd-down", MoveToEnd, context)),
select_to_beginning: Some(KeyBinding::new(
"cmd-shift-up",
SelectToBeginning,
context,
)),
select_to_end: Some(KeyBinding::new("cmd-shift-down", SelectToEnd, context)),
word_left: Some(KeyBinding::new("alt-left", WordLeft, context)),
word_right: Some(KeyBinding::new("alt-right", WordRight, context)),
select_word_left: Some(KeyBinding::new("alt-shift-left", SelectWordLeft, context)),
select_word_right: Some(KeyBinding::new(
"alt-shift-right",
SelectWordRight,
context,
)),
copy: Some(KeyBinding::new("cmd-c", Copy, context)),
cut: Some(KeyBinding::new("cmd-x", Cut, context)),
paste: Some(KeyBinding::new("cmd-v", Paste, context)),
undo: Some(KeyBinding::new("cmd-z", Undo, context)),
redo: Some(KeyBinding::new("cmd-shift-z", Redo, context)),
escape: Some(KeyBinding::new("escape", Escape, context)),
}
}
#[cfg(not(target_os = "macos"))]
{
Self {
backspace: Some(KeyBinding::new("backspace", Backspace, context)),
delete: Some(KeyBinding::new("delete", Delete, context)),
delete_word_left: Some(KeyBinding::new("ctrl-backspace", DeleteWordLeft, context)),
delete_word_right: Some(KeyBinding::new("ctrl-delete", DeleteWordRight, context)),
delete_to_beginning_of_line: Some(KeyBinding::new(
"ctrl-shift-backspace",
DeleteToBeginningOfLine,
context,
)),
delete_to_end_of_line: Some(KeyBinding::new(
"ctrl-shift-delete",
DeleteToEndOfLine,
context,
)),
tab: Some(KeyBinding::new("tab", Tab, context)),
enter: Some(KeyBinding::new("enter", Enter, context)),
left: Some(KeyBinding::new("left", Left, context)),
right: Some(KeyBinding::new("right", Right, context)),
up: Some(KeyBinding::new("up", Up, context)),
down: Some(KeyBinding::new("down", Down, context)),
select_left: Some(KeyBinding::new("shift-left", SelectLeft, context)),
select_right: Some(KeyBinding::new("shift-right", SelectRight, context)),
select_up: Some(KeyBinding::new("shift-up", SelectUp, context)),
select_down: Some(KeyBinding::new("shift-down", SelectDown, context)),
select_all: Some(KeyBinding::new("ctrl-a", SelectAll, context)),
home: Some(KeyBinding::new("home", Home, context)),
end: Some(KeyBinding::new("end", End, context)),
move_to_beginning: Some(KeyBinding::new("ctrl-home", MoveToBeginning, context)),
move_to_end: Some(KeyBinding::new("ctrl-end", MoveToEnd, context)),
select_to_beginning: Some(KeyBinding::new(
"ctrl-shift-home",
SelectToBeginning,
context,
)),
select_to_end: Some(KeyBinding::new("ctrl-shift-end", SelectToEnd, context)),
word_left: Some(KeyBinding::new("ctrl-left", WordLeft, context)),
word_right: Some(KeyBinding::new("ctrl-right", WordRight, context)),
select_word_left: Some(KeyBinding::new("ctrl-shift-left", SelectWordLeft, context)),
select_word_right: Some(KeyBinding::new(
"ctrl-shift-right",
SelectWordRight,
context,
)),
copy: Some(KeyBinding::new("ctrl-c", Copy, context)),
cut: Some(KeyBinding::new("ctrl-x", Cut, context)),
paste: Some(KeyBinding::new("ctrl-v", Paste, context)),
undo: Some(KeyBinding::new("ctrl-z", Undo, context)),
redo: Some(KeyBinding::new("ctrl-shift-z", Redo, context)),
escape: Some(KeyBinding::new("escape", Escape, context)),
}
}
}
}
impl InputBindings {
/// Creates an empty `InputBindings` with all fields set to `None`.
///
/// Use this as a starting point when you want to override only specific bindings:
///
/// ```ignore
/// let bindings = InputBindings::empty();
/// bindings.select_all = Some(KeyBinding::new("ctrl-shift-a", SelectAll, Some(INPUT_CONTEXT)));
/// ```
pub fn empty() -> Self {
Self {
backspace: None,
delete: None,
delete_word_left: None,
delete_word_right: None,
delete_to_beginning_of_line: None,
delete_to_end_of_line: None,
tab: None,
enter: None,
left: None,
right: None,
up: None,
down: None,
select_left: None,
select_right: None,
select_up: None,
select_down: None,
select_all: None,
home: None,
end: None,
move_to_beginning: None,
move_to_end: None,
select_to_beginning: None,
select_to_end: None,
word_left: None,
word_right: None,
select_word_left: None,
select_word_right: None,
copy: None,
cut: None,
paste: None,
undo: None,
redo: None,
escape: None,
}
}
/// Merges these bindings with defaults, using `self` values where `Some`,
/// falling back to defaults for `None` values.
pub fn merged_with_defaults(self) -> Self {
let defaults = Self::default();
Self {
backspace: self.backspace.or(defaults.backspace),
delete: self.delete.or(defaults.delete),
delete_word_left: self.delete_word_left.or(defaults.delete_word_left),
delete_word_right: self.delete_word_right.or(defaults.delete_word_right),
delete_to_beginning_of_line: self
.delete_to_beginning_of_line
.or(defaults.delete_to_beginning_of_line),
delete_to_end_of_line: self
.delete_to_end_of_line
.or(defaults.delete_to_end_of_line),
tab: self.tab.or(defaults.tab),
enter: self.enter.or(defaults.enter),
left: self.left.or(defaults.left),
right: self.right.or(defaults.right),
up: self.up.or(defaults.up),
down: self.down.or(defaults.down),
select_left: self.select_left.or(defaults.select_left),
select_right: self.select_right.or(defaults.select_right),
select_up: self.select_up.or(defaults.select_up),
select_down: self.select_down.or(defaults.select_down),
select_all: self.select_all.or(defaults.select_all),
home: self.home.or(defaults.home),
end: self.end.or(defaults.end),
move_to_beginning: self.move_to_beginning.or(defaults.move_to_beginning),
move_to_end: self.move_to_end.or(defaults.move_to_end),
select_to_beginning: self.select_to_beginning.or(defaults.select_to_beginning),
select_to_end: self.select_to_end.or(defaults.select_to_end),
word_left: self.word_left.or(defaults.word_left),
word_right: self.word_right.or(defaults.word_right),
select_word_left: self.select_word_left.or(defaults.select_word_left),
select_word_right: self.select_word_right.or(defaults.select_word_right),
copy: self.copy.or(defaults.copy),
cut: self.cut.or(defaults.cut),
paste: self.paste.or(defaults.paste),
undo: self.undo.or(defaults.undo),
redo: self.redo.or(defaults.redo),
escape: self.escape.or(defaults.escape),
}
}
/// Collects all `Some` bindings into a `Vec<KeyBinding>`.
pub fn into_bindings(self) -> Vec<KeyBinding> {
[
self.backspace,
self.delete,
self.delete_word_left,
self.delete_word_right,
self.delete_to_beginning_of_line,
self.delete_to_end_of_line,
self.tab,
self.enter,
self.left,
self.right,
self.up,
self.down,
self.select_left,
self.select_right,
self.select_up,
self.select_down,
self.select_all,
self.home,
self.end,
self.move_to_beginning,
self.move_to_end,
self.select_to_beginning,
self.select_to_end,
self.word_left,
self.word_right,
self.select_word_left,
self.select_word_right,
self.copy,
self.cut,
self.paste,
self.undo,
self.redo,
self.escape,
]
.into_iter()
.flatten()
.collect()
}
}
/// Binds input keybindings to the application.
///
/// If no bindings are provided, platform defaults are used. When bindings are
/// provided, they are used exactly as-is - fields set to `None` will not have
/// any keybinding registered for that action.
///
/// # Examples
///
/// Use all platform defaults:
///
/// ```ignore
/// bind_input_keys(cx, None);
/// ```
///
/// Unbind a specific key while keeping other defaults:
///
/// ```ignore
/// bind_input_keys(cx, InputBindings {
/// up: None, // Unbind up arrow
/// ..Default::default()
/// });
/// ```
///
/// Override a specific binding while keeping other defaults:
///
/// ```ignore
/// bind_input_keys(cx, InputBindings {
/// select_all: Some(KeyBinding::new("ctrl-shift-a", SelectAll, Some(INPUT_CONTEXT))),
/// ..Default::default()
/// });
/// ```
///
/// Use [`InputBindings::empty()`] with [`merged_with_defaults()`](InputBindings::merged_with_defaults)
/// if you only want to specify a few custom bindings and fill in the rest with defaults:
///
/// ```ignore
/// let mut bindings = InputBindings::empty();
/// bindings.select_all = Some(KeyBinding::new("ctrl-shift-a", SelectAll, Some(INPUT_CONTEXT)));
/// bind_input_keys(cx, bindings.merged_with_defaults());
/// ```
pub fn bind_input_keys(cx: &mut App, bindings: impl Into<Option<InputBindings>>) {
let bindings = bindings.into().unwrap_or_default();
cx.bind_keys(bindings.into_bindings());
}

121
crates/input/src/blink.rs Normal file
View File

@@ -0,0 +1,121 @@
use std::time::Duration;
use gpui::Context;
use smol::Timer;
/// Manages the blinking state of a text cursor.
///
/// The cursor blinks at a configurable interval when enabled. Blinking can be
/// temporarily paused (e.g., during typing) to provide immediate visual feedback.
pub struct CursorBlink {
interval: Duration,
generation: usize,
visible: bool,
active: bool,
paused: bool,
}
impl CursorBlink {
/// Creates a new cursor blink manager with the given interval.
///
/// The cursor starts in a disabled state with visibility set to true.
pub fn new(interval: Duration, _cx: &mut Context<Self>) -> Self {
Self {
interval,
generation: 0,
visible: true,
active: false,
paused: false,
}
}
/// Returns whether the cursor should currently be rendered.
pub fn visible(&self) -> bool {
self.visible
}
/// Returns whether blinking is currently active.
pub fn is_active(&self) -> bool {
self.active
}
/// Activates cursor blinking.
///
/// When activated, the cursor will alternate between visible and hidden
/// states at the configured interval. Has no effect if already active.
pub fn enable(&mut self, cx: &mut Context<Self>) {
if self.active {
return;
}
self.active = true;
self.visible = false;
self.paused = false;
self.tick(cx);
}
/// Deactivates cursor blinking.
///
/// The cursor visibility is set to false when disabled. Call
/// `pause_blinking` instead if you want to temporarily stop blinking
/// while keeping the cursor visible.
pub fn disable(&mut self, cx: &mut Context<Self>) {
self.active = false;
self.visible = false;
self.paused = false;
cx.notify();
}
/// Temporarily pauses blinking and shows the cursor.
///
/// This is useful during user input to provide immediate feedback.
/// Blinking resumes automatically after the blink interval elapses.
pub fn pause_blinking(&mut self, cx: &mut Context<Self>) {
if !self.visible {
self.visible = true;
cx.notify();
}
self.paused = true;
self.generation = self.generation.wrapping_add(1);
let generation = self.generation;
let interval = self.interval;
cx.spawn(async move |this, cx| {
Timer::after(interval).await;
this.update(cx, |this, cx| {
if this.generation == generation {
this.paused = false;
this.tick(cx);
}
})
})
.detach();
}
fn tick(&mut self, cx: &mut Context<Self>) {
if !self.active || self.paused {
return;
}
self.visible = !self.visible;
cx.notify();
self.generation = self.generation.wrapping_add(1);
let generation = self.generation;
let interval = self.interval;
cx.spawn(async move |this, cx| {
Timer::after(interval).await;
if let Some(this) = this.upgrade() {
this.update(cx, |this, cx| {
if this.generation == generation {
this.tick(cx);
}
});
}
})
.detach();
}
}

181
crates/input/src/handler.rs Normal file
View File

@@ -0,0 +1,181 @@
use std::ops::Range;
use gpui::{App, Bounds, Context, Entity, InputHandler, Pixels, Point, UTF16Selection, Window};
/// Implement this trait to allow views to handle textual input when implementing an editor, field, etc.
///
/// Once your view implements this trait, you can use it to construct an [`ElementInputHandler<V>`].
/// This input handler can then be assigned during paint by calling [`Window::handle_input`].
///
/// See [`InputHandler`] for details on how to implement each method.
pub trait EntityInputHandler: 'static + Sized {
/// See [`InputHandler::text_for_range`] for details
fn text_for_range(
&mut self,
range: Range<usize>,
adjusted_range: &mut Option<Range<usize>>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Option<String>;
/// See [`InputHandler::selected_text_range`] for details
fn selected_text_range(
&mut self,
ignore_disabled_input: bool,
window: &mut Window,
cx: &mut Context<Self>,
) -> Option<UTF16Selection>;
/// See [`InputHandler::marked_text_range`] for details
fn marked_text_range(
&self,
window: &mut Window,
cx: &mut Context<Self>,
) -> Option<Range<usize>>;
/// See [`InputHandler::unmark_text`] for details
fn unmark_text(&mut self, window: &mut Window, cx: &mut Context<Self>);
/// See [`InputHandler::replace_text_in_range`] for details
fn replace_text_in_range(
&mut self,
range: Option<Range<usize>>,
text: &str,
window: &mut Window,
cx: &mut Context<Self>,
);
/// See [`InputHandler::replace_and_mark_text_in_range`] for details
fn replace_and_mark_text_in_range(
&mut self,
range: Option<Range<usize>>,
new_text: &str,
new_selected_range: Option<Range<usize>>,
window: &mut Window,
cx: &mut Context<Self>,
);
/// See [`InputHandler::bounds_for_range`] for details
fn bounds_for_range(
&mut self,
range_utf16: Range<usize>,
element_bounds: Bounds<Pixels>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Option<Bounds<Pixels>>;
/// See [`InputHandler::character_index_for_point`] for details
fn character_index_for_point(
&mut self,
point: Point<Pixels>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Option<usize>;
}
/// The canonical implementation of [`gpui::PlatformInputHandler`]. Call [`Window::handle_input`]
/// with an instance during your element's paint.
pub struct ElementInputHandler<V> {
view: Entity<V>,
element_bounds: Bounds<Pixels>,
}
impl<V: 'static> ElementInputHandler<V> {
/// Used in [`Element::paint`][element_paint] with the element's bounds, a `Window`, and a `App` context.
///
/// [element_paint]: gpui::Element::paint
pub fn new(element_bounds: Bounds<Pixels>, view: Entity<V>) -> Self {
ElementInputHandler {
view,
element_bounds,
}
}
}
impl<V: EntityInputHandler> InputHandler for ElementInputHandler<V> {
fn selected_text_range(
&mut self,
ignore_disabled_input: bool,
window: &mut Window,
cx: &mut App,
) -> Option<UTF16Selection> {
self.view.update(cx, |view, cx| {
view.selected_text_range(ignore_disabled_input, window, cx)
})
}
fn marked_text_range(&mut self, window: &mut Window, cx: &mut App) -> Option<Range<usize>> {
self.view
.update(cx, |view, cx| view.marked_text_range(window, cx))
}
fn text_for_range(
&mut self,
range_utf16: Range<usize>,
adjusted_range: &mut Option<Range<usize>>,
window: &mut Window,
cx: &mut App,
) -> Option<String> {
self.view.update(cx, |view, cx| {
view.text_for_range(range_utf16, adjusted_range, window, cx)
})
}
fn replace_text_in_range(
&mut self,
replacement_range: Option<Range<usize>>,
text: &str,
window: &mut Window,
cx: &mut App,
) {
self.view.update(cx, |view, cx| {
view.replace_text_in_range(replacement_range, text, window, cx)
});
}
fn replace_and_mark_text_in_range(
&mut self,
range_utf16: Option<Range<usize>>,
new_text: &str,
new_selected_range: Option<Range<usize>>,
window: &mut Window,
cx: &mut App,
) {
self.view.update(cx, |view, cx| {
view.replace_and_mark_text_in_range(
range_utf16,
new_text,
new_selected_range,
window,
cx,
)
});
}
fn unmark_text(&mut self, window: &mut Window, cx: &mut App) {
self.view
.update(cx, |view, cx| view.unmark_text(window, cx));
}
fn bounds_for_range(
&mut self,
range_utf16: Range<usize>,
window: &mut Window,
cx: &mut App,
) -> Option<Bounds<Pixels>> {
self.view.update(cx, |view, cx| {
view.bounds_for_range(range_utf16, self.element_bounds, window, cx)
})
}
fn character_index_for_point(
&mut self,
point: Point<Pixels>,
window: &mut Window,
cx: &mut App,
) -> Option<usize> {
self.view.update(cx, |view, cx| {
view.character_index_for_point(point, window, cx)
})
}
}

1301
crates/input/src/input.rs Normal file

File diff suppressed because it is too large Load Diff

9
crates/input/src/lib.rs Normal file
View File

@@ -0,0 +1,9 @@
mod bidi;
mod bindings;
mod blink;
mod handler;
mod input;
mod state;
pub use input::*;
pub use state::*;

1731
crates/input/src/state.rs Normal file

File diff suppressed because it is too large Load Diff