feat(ui): add a real text field widget and use it for the connections filter

Every "input" in the app is a label div: a `div` that takes focus, swallows key
events and prints the draft string. There is no caret, no selection, no pointer
positioning and no composition, so the moment anyone tries to edit rather than
append it reads as broken — which is exactly the complaint about the filter box.

`TextField` is a real GPUI widget instead. It is an `Entity` that owns its
buffer and focus, implements `EntityInputHandler` so the platform routes IME and
clipboard through it, and paints through a custom `Element` that shapes the line
once and reuses that shaping for hit-testing, the selection quads and the caret.
Owners learn about edits by subscribing to `TextFieldEvent::Changed`, which
carries the new content, rather than reading back through the entity.

It deliberately does not claim Enter, Escape, Tab or the arrows: those are
dialog and list gestures, so they stay unconsumed for the owner's own key
handler. That is what lets the saved-connections filter keep driving result
navigation while the field handles the text.

The editing rules live in `nyaterm_core::TextEdit` — caret motion across
multi-byte boundaries, what counts as a word (hosts and paths make `.`, `-`, `/`
and `@` stops), and which end of a selection shift moves — so they are testable
without a window.

Adopting it in the filter box retires the placeholder caret drawn by splicing a
`|` into the display string, and the seven arms this panel had bolted onto
`NyaTermApp`'s `EntityInputHandler`; the widget owns its own composition now.
This commit is contained in:
Kang
2026-07-26 23:43:35 +08:00
parent 900e83af73
commit f0655b59c1
12 changed files with 1346 additions and 557 deletions
Generated
+1
View File
@@ -4348,6 +4348,7 @@ name = "nyaterm-ui"
version = "1.1.12"
dependencies = [
"gpui",
"nyaterm-core",
"nyaterm-terminal",
]
+2
View File
@@ -20,6 +20,7 @@ pub mod terminal_input_tracker;
pub mod terminal_mouse;
pub mod terminal_resize;
pub mod terminal_wire_write;
pub mod text_edit;
pub mod translation;
pub mod updater;
@@ -128,6 +129,7 @@ pub use terminal_resize::{
pub use terminal_wire_write::{
TerminalWireWriteDisposition, TerminalWireWriteKind, terminal_wire_write_disposition,
};
pub use text_edit::{CursorMotion, TextEdit};
pub use translation::{
AliSignature, TranslateResult, TranslationError, TranslationSettings, ali_content_sha256,
ali_signature, ali_translate_body, ali_translate_lang, baidu_translate_lang,
+468
View File
@@ -0,0 +1,468 @@
//! Cursor-aware editing for a single-line text field.
//!
//! Kept UI-independent so the rules that are easy to get subtly wrong — caret
//! motion across grapheme-unfriendly byte offsets, what a word boundary is,
//! which end of a selection moves when shift is held — are testable without a
//! window. The GPUI widget in `nyaterm-ui` owns the pixels; this owns the text.
use std::ops::Range;
/// Where a caret motion should land.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CursorMotion {
Left,
Right,
/// Previous word boundary, the usual Ctrl+Left.
WordLeft,
WordRight,
Start,
End,
}
/// A single-line editing buffer: content, caret, and an optional selection.
///
/// Offsets are byte offsets into [`TextEdit::content`] and are always kept on
/// `char` boundaries, so callers can slice with them without checking.
#[derive(Debug, Clone, Default)]
pub struct TextEdit {
content: String,
cursor: usize,
/// The fixed end of a selection. `None` means the caret is collapsed.
anchor: Option<usize>,
}
impl TextEdit {
pub fn new(content: impl Into<String>) -> Self {
let content = content.into();
let cursor = content.len();
Self {
content,
cursor,
anchor: None,
}
}
pub fn content(&self) -> &str {
&self.content
}
pub fn is_empty(&self) -> bool {
self.content.is_empty()
}
/// Replace the whole buffer, keeping the caret inside it.
///
/// Used when the owner refreshes the field from its own state; the caret is
/// clamped rather than reset so an unrelated redraw does not jump it.
pub fn set_content(&mut self, content: impl Into<String>) {
self.content = content.into();
self.cursor = self.floor_boundary(self.cursor);
self.anchor = self.anchor.map(|anchor| self.floor_boundary(anchor));
self.collapse_empty_selection();
}
pub fn cursor(&self) -> usize {
self.cursor
}
/// The selected span, empty when the caret is collapsed.
pub fn selection(&self) -> Range<usize> {
match self.anchor {
Some(anchor) if anchor <= self.cursor => anchor..self.cursor,
Some(anchor) => self.cursor..anchor,
None => self.cursor..self.cursor,
}
}
pub fn has_selection(&self) -> bool {
self.anchor.is_some()
}
/// Whether the caret sits at the *start* of the selection, which platform
/// IME APIs need in order to place a candidate window correctly.
pub fn selection_is_reversed(&self) -> bool {
self.anchor.is_some_and(|anchor| anchor > self.cursor)
}
pub fn set_selection(&mut self, range: Range<usize>, reversed: bool) {
let start = self.floor_boundary(range.start);
let end = self.floor_boundary(range.end.max(range.start));
if reversed {
self.cursor = start;
self.anchor = Some(end);
} else {
self.cursor = end;
self.anchor = Some(start);
}
self.collapse_empty_selection();
}
pub fn set_cursor(&mut self, offset: usize) {
self.cursor = self.floor_boundary(offset);
self.anchor = None;
}
pub fn select_all(&mut self) {
self.anchor = Some(0);
self.cursor = self.content.len();
self.collapse_empty_selection();
}
/// Replace `range` with `text` and leave the caret after the insertion.
pub fn replace(&mut self, range: Range<usize>, text: &str) {
let start = self.floor_boundary(range.start);
let end = self.floor_boundary(range.end).max(start);
self.content.replace_range(start..end, text);
self.cursor = start + text.len();
self.anchor = None;
}
/// Insert at the caret, replacing the selection if there is one.
pub fn insert(&mut self, text: &str) {
let range = self.selection();
self.replace(range, text);
}
/// Backspace: delete the selection, or the character before the caret.
pub fn delete_backward(&mut self) -> bool {
let range = self.selection();
let range = if range.is_empty() {
self.previous_boundary(self.cursor)..self.cursor
} else {
range
};
if range.is_empty() {
return false;
}
self.replace(range, "");
true
}
/// Delete: the selection, or the character after the caret.
pub fn delete_forward(&mut self) -> bool {
let range = self.selection();
let range = if range.is_empty() {
self.cursor..self.next_boundary(self.cursor)
} else {
range
};
if range.is_empty() {
return false;
}
self.replace(range, "");
true
}
/// Ctrl+Backspace: delete the selection, or back to the previous word.
pub fn delete_word_backward(&mut self) -> bool {
let range = self.selection();
let range = if range.is_empty() {
self.word_boundary_before(self.cursor)..self.cursor
} else {
range
};
if range.is_empty() {
return false;
}
self.replace(range, "");
true
}
/// Move the caret, extending the selection when `extend` is set.
///
/// A plain move with a selection active collapses to the edge the motion
/// points at, which is what every other text field does.
pub fn move_cursor(&mut self, motion: CursorMotion, extend: bool) {
if !extend && self.has_selection() {
let selection = self.selection();
match motion {
CursorMotion::Left | CursorMotion::WordLeft => {
self.set_cursor(selection.start);
return;
}
CursorMotion::Right | CursorMotion::WordRight => {
self.set_cursor(selection.end);
return;
}
CursorMotion::Start | CursorMotion::End => {}
}
}
let next = match motion {
CursorMotion::Left => self.previous_boundary(self.cursor),
CursorMotion::Right => self.next_boundary(self.cursor),
CursorMotion::WordLeft => self.word_boundary_before(self.cursor),
CursorMotion::WordRight => self.word_boundary_after(self.cursor),
CursorMotion::Start => 0,
CursorMotion::End => self.content.len(),
};
if extend {
let anchor = self.anchor.unwrap_or(self.cursor);
self.anchor = Some(anchor);
self.cursor = next;
self.collapse_empty_selection();
} else {
self.set_cursor(next);
}
}
/// The word surrounding `offset`, for double-click selection.
///
/// Between two words — on a run of separators — it returns that run, so a
/// double click always selects something contiguous under the pointer.
pub fn word_range_at(&self, offset: usize) -> Range<usize> {
let offset = self.floor_boundary(offset);
if self.content.is_empty() {
return 0..0;
}
let probe = if offset == self.content.len() {
self.previous_boundary(offset)
} else {
offset
};
let Some(kind) = self.content[probe..].chars().next().map(char_class) else {
return offset..offset;
};
let mut start = probe;
while start > 0 {
let previous = self.previous_boundary(start);
match self.content[previous..].chars().next() {
Some(c) if char_class(c) == kind => start = previous,
_ => break,
}
}
let mut end = probe;
while end < self.content.len() {
match self.content[end..].chars().next() {
Some(c) if char_class(c) == kind => end += c.len_utf8(),
_ => break,
}
}
start..end
}
/// Snap an arbitrary byte offset onto the nearest boundary at or below it.
///
/// Offsets arrive from hit-testing and from platform IME callbacks, neither
/// of which promises to land on a `char` boundary.
pub fn floor_boundary(&self, offset: usize) -> usize {
let mut offset = offset.min(self.content.len());
while offset > 0 && !self.content.is_char_boundary(offset) {
offset -= 1;
}
offset
}
fn previous_boundary(&self, offset: usize) -> usize {
let offset = self.floor_boundary(offset);
self.content[..offset]
.char_indices()
.next_back()
.map(|(index, _)| index)
.unwrap_or(0)
}
fn next_boundary(&self, offset: usize) -> usize {
let offset = self.floor_boundary(offset);
self.content[offset..]
.chars()
.next()
.map(|c| offset + c.len_utf8())
.unwrap_or(offset)
}
/// Skip any whitespace, then one run of a single class.
///
/// Stopping where the class changes is what makes Ctrl+Left walk
/// `user@host.example` part by part instead of jumping the whole thing.
fn word_boundary_before(&self, offset: usize) -> usize {
let mut index = self.floor_boundary(offset);
while index > 0 {
let previous = self.previous_boundary(index);
match self.content[previous..].chars().next() {
Some(c) if char_class(c) == CharClass::Space => index = previous,
_ => break,
}
}
let Some(kind) = self.class_before(index) else {
return index;
};
while index > 0 {
let previous = self.previous_boundary(index);
match self.content[previous..].chars().next() {
Some(c) if char_class(c) == kind => index = previous,
_ => break,
}
}
index
}
fn word_boundary_after(&self, offset: usize) -> usize {
let mut index = self.floor_boundary(offset);
while index < self.content.len() {
match self.content[index..].chars().next() {
Some(c) if char_class(c) == CharClass::Space => index += c.len_utf8(),
_ => break,
}
}
let Some(kind) = self.class_at(index) else {
return index;
};
while index < self.content.len() {
match self.content[index..].chars().next() {
Some(c) if char_class(c) == kind => index += c.len_utf8(),
_ => break,
}
}
index
}
fn class_at(&self, offset: usize) -> Option<CharClass> {
self.content[offset..].chars().next().map(char_class)
}
fn class_before(&self, offset: usize) -> Option<CharClass> {
if offset == 0 {
return None;
}
self.content[self.previous_boundary(offset)..]
.chars()
.next()
.map(char_class)
}
fn collapse_empty_selection(&mut self) {
if self.anchor == Some(self.cursor) {
self.anchor = None;
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum CharClass {
Space,
Word,
Punctuation,
}
/// Word-motion classes.
///
/// Hosts and paths are what this field mostly holds, so `.`, `-`, `/` and `@`
/// count as punctuation: Ctrl+Left in `user@host.example` stops at each part
/// rather than jumping the whole thing.
fn char_class(c: char) -> CharClass {
if c.is_whitespace() {
CharClass::Space
} else if c.is_alphanumeric() || c == '_' {
CharClass::Word
} else {
CharClass::Punctuation
}
}
#[cfg(test)]
mod tests {
use super::{CursorMotion, TextEdit};
#[test]
fn editing_steps_by_character_not_by_byte() {
let mut edit = TextEdit::new("南京a");
assert_eq!(edit.cursor(), edit.content().len());
assert!(edit.delete_backward());
assert_eq!(edit.content(), "南京");
assert!(edit.delete_backward());
assert_eq!(edit.content(), "南");
edit.move_cursor(CursorMotion::Start, false);
edit.insert("x");
assert_eq!(edit.content(), "x南");
assert_eq!(edit.cursor(), 1);
assert!(edit.delete_forward());
assert_eq!(edit.content(), "x");
}
#[test]
fn shift_extends_and_typing_replaces_the_selection() {
let mut edit = TextEdit::new("abc");
edit.move_cursor(CursorMotion::Left, true);
edit.move_cursor(CursorMotion::Left, true);
assert_eq!(edit.selection(), 1..3);
assert!(edit.selection_is_reversed());
edit.insert("Z");
assert_eq!(edit.content(), "aZ");
assert!(!edit.has_selection());
}
#[test]
fn a_plain_move_collapses_to_the_edge_it_points_at() {
let mut edit = TextEdit::new("abcdef");
edit.set_selection(1..4, false);
edit.move_cursor(CursorMotion::Left, false);
assert_eq!(edit.cursor(), 1);
assert!(!edit.has_selection());
edit.set_selection(1..4, false);
edit.move_cursor(CursorMotion::Right, false);
assert_eq!(edit.cursor(), 4);
}
#[test]
fn word_motion_stops_at_the_parts_of_a_host() {
let mut edit = TextEdit::new("user@host.example");
edit.move_cursor(CursorMotion::Start, false);
edit.move_cursor(CursorMotion::WordRight, false);
assert_eq!(&edit.content()[..edit.cursor()], "user");
edit.move_cursor(CursorMotion::WordRight, false);
assert_eq!(&edit.content()[..edit.cursor()], "user@");
edit.move_cursor(CursorMotion::WordRight, false);
assert_eq!(&edit.content()[..edit.cursor()], "user@host");
edit.move_cursor(CursorMotion::End, false);
edit.move_cursor(CursorMotion::WordLeft, false);
assert_eq!(&edit.content()[..edit.cursor()], "user@host.");
}
#[test]
fn delete_word_backward_takes_one_part_at_a_time() {
let mut edit = TextEdit::new("user@host");
assert!(edit.delete_word_backward());
assert_eq!(edit.content(), "user@");
assert!(edit.delete_word_backward());
assert_eq!(edit.content(), "user");
}
#[test]
fn double_click_selects_the_word_under_the_pointer() {
let edit = TextEdit::new("root@192.168.0.1");
assert_eq!(edit.word_range_at(2), 0..4);
// On the separator run itself, not the words either side of it.
assert_eq!(edit.word_range_at(4), 4..5);
// Past the end still resolves to the last word.
assert_eq!(edit.word_range_at(edit.content().len()), 15..16);
}
#[test]
fn offsets_from_hit_testing_are_snapped_onto_boundaries() {
let mut edit = TextEdit::new("南京");
// Mid-codepoint, as a click or an IME callback can produce.
edit.set_cursor(1);
assert_eq!(edit.cursor(), 0);
edit.set_content("南");
assert!(edit.content().is_char_boundary(edit.cursor()));
}
#[test]
fn set_content_clamps_rather_than_resetting_the_caret() {
let mut edit = TextEdit::new("abcdef");
edit.set_cursor(5);
edit.set_content("ab");
assert_eq!(edit.cursor(), 2);
}
}
@@ -274,6 +274,9 @@ impl NyaTermApp {
settings.terminal_scrollback_lines.clamp(100, 100_000) as usize,
);
let connections_filter_placeholder =
crate::i18n::text(&settings.language, "savedConnections.filter");
Self {
stores,
runtime,
@@ -284,7 +287,7 @@ impl NyaTermApp {
connection_state: ConnectionFeatureState::new(
&settings,
ConnectionFeatureFocus {
search: cx.focus_handle(),
filter_placeholder: connections_filter_placeholder.into(),
import: cx.focus_handle(),
editor: cx.focus_handle(),
group_editor: cx.focus_handle(),
@@ -293,6 +296,7 @@ impl NyaTermApp {
network_tunnel_editor: cx.focus_handle(),
network_proxy_editor: cx.focus_handle(),
},
cx,
),
connection_groups,
connection_ssh_keys,
@@ -230,7 +230,7 @@ impl NyaTermApp {
}
/// Drop the keyboard-active row once the filter no longer shows it.
fn sync_connection_keyboard_active(&mut self, cx: &mut Context<Self>) {
pub(in crate::features) fn sync_connection_keyboard_active(&mut self, cx: &mut Context<Self>) {
let Some(active) = self
.connection_state
.list
@@ -251,6 +251,10 @@ impl NyaTermApp {
}
}
/// Keys the filter field deliberately leaves alone.
///
/// The field consumes its own editing keys, so anything arriving here is a
/// list gesture: walk the filtered results, open one, or clear the filter.
pub(in crate::features) fn handle_connection_search_key_down(
&mut self,
event: &KeyDownEvent,
@@ -259,52 +263,47 @@ impl NyaTermApp {
) {
self.mark_user_activity();
let keystroke = &event.keystroke;
if keystroke.modifiers.alt || keystroke.modifiers.function {
if keystroke.modifiers.alt
|| keystroke.modifiers.function
|| keystroke.modifiers.platform
|| keystroke.modifiers.control
{
return;
}
let key = keystroke.key.as_str();
// While a filter is active the box also drives the results: up/down walk
// them and enter opens the active one, as the old UI did.
if !self.connection_state.list.search_is_empty()
&& matches!(key, "up" | "down" | "enter")
&& !keystroke.modifiers.platform
&& !keystroke.modifiers.control
{
match key {
"up" | "down" => {
if self.step_connection_keyboard_active(key == "down", cx) {
return;
}
}
_ => {
if self.open_connection_keyboard_active(window, cx) {
return;
}
match keystroke.key.as_str() {
"escape" => {
cx.stop_propagation();
self.clear_connection_search(window, cx);
}
"up" | "down" if !self.connection_state.list.search_is_empty() => {
if self.step_connection_keyboard_active(keystroke.key == "down", cx) {
cx.stop_propagation();
}
}
}
let changed = if key == "escape" {
self.connection_state.list.clear_search()
} else if !keystroke.modifiers.platform && !keystroke.modifiers.control {
self.connection_state.list.apply_search_key(
key,
keystroke.key_char.as_deref(),
keystroke.modifiers.shift,
)
} else {
false
};
if changed {
if key == "escape" {
self.terminal.view.status = "connection search cleared".to_string();
"enter" if !self.connection_state.list.search_is_empty() => {
if self.open_connection_keyboard_active(window, cx) {
cx.stop_propagation();
}
}
self.sync_connection_keyboard_active(cx);
cx.notify();
_ => {}
}
}
pub(in crate::features) fn clear_connection_search(
&mut self,
window: &mut Window,
cx: &mut Context<Self>,
) {
let field = self.connection_state.list.search_field();
field.update(cx, |field, cx| field.set_content(String::new(), cx));
self.connection_state.list.set_search_text(String::new());
window.focus(&field.read(cx).focus_handle());
self.terminal.view.status = "connection search cleared".to_string();
self.sync_connection_keyboard_active(cx);
cx.notify();
}
pub(in crate::features) fn delete_selected_connections(&mut self, cx: &mut Context<Self>) {
let selected = self.selected_connections();
if selected.is_empty() {
@@ -1,10 +1,13 @@
use std::collections::HashSet;
use gpui::{FocusHandle, Pixels, WindowHandle};
use gpui::{
App, AppContext as _, Context, Entity, FocusHandle, Pixels, SharedString, Subscription,
WindowHandle,
};
use nyaterm_core::AppSettingsSummary;
use super::connections::{ConnectionDragKind, ConnectionDropPosition, ConnectionDropTarget};
use crate::features::{ConnectionEditorToggle, ConnectionEditorWindow};
use crate::features::{ConnectionEditorToggle, ConnectionEditorWindow, NyaTermApp};
use crate::models::{
ConnectionContextMenuState, ConnectionDeleteConfirmState, ConnectionEditorAdvancedTab,
ConnectionEditorField, ConnectionEditorMenu, ConnectionEditorPasswordSource,
@@ -15,6 +18,7 @@ use crate::models::{
NetworkItemMenuState, NetworkMovePickerState, NetworkProxyEditorField, NetworkProxyEditorState,
NetworkTab, NetworkTunnelEditorField, NetworkTunnelEditorState,
};
use nyaterm_ui::{TextField, TextFieldEvent};
mod editor_logic;
mod list_logic;
@@ -35,12 +39,11 @@ use self::editor_logic::{
toggle_connection_editor_flag,
};
use self::list_logic::{
apply_connection_search_key, clear_connection_list_runtime_state, clear_connection_search,
clear_selected_connection_ids, close_connection_more_menu, connection_drop_position_for_target,
connection_search_selected_range, cycle_connection_sort_mode,
clear_connection_list_runtime_state, clear_selected_connection_ids, close_connection_more_menu,
connection_drop_position_for_target, cycle_connection_sort_mode,
remove_connection_list_references, remove_group_list_references,
replace_connection_search_range, retain_loaded_connection_list_references,
select_connection_ids, set_connection_drop_target_if_changed, set_connection_group_hover,
retain_loaded_connection_list_references, select_connection_ids,
set_connection_drop_target_if_changed, set_connection_group_hover,
sync_connection_search_expansion,
};
#[cfg(test)]
@@ -69,7 +72,9 @@ pub(in crate::features) struct ConnectionFeatureState {
}
pub(in crate::features) struct ConnectionFeatureFocus {
pub search: FocusHandle,
/// Placeholder for the filter box, resolved by the caller so this struct
/// stays free of the i18n lookup.
pub filter_placeholder: SharedString,
pub import: FocusHandle,
pub editor: FocusHandle,
pub group_editor: FocusHandle,
@@ -80,15 +85,12 @@ pub(in crate::features) struct ConnectionFeatureFocus {
}
pub(in crate::features) struct ConnectionListState {
/// The editable field. It owns the caret, selection and composition; this
/// struct only caches what it last reported so filtering stays synchronous.
search_field: Entity<TextField>,
search_draft: String,
/// Caret position as a byte offset into `search_draft`.
search_cursor: usize,
/// Other end of the selection while shift-extending; `None` means no selection.
search_anchor: Option<usize>,
/// In-flight IME composition and where it sits in `search_draft`.
search_marked_text: String,
search_marked_range: Option<std::ops::Range<usize>>,
search_focus: FocusHandle,
/// Kept alive for as long as the field is, so edits keep arriving.
_search_subscription: Subscription,
sort_mode: ConnectionSortMode,
more_menu_open: bool,
context_menu: Option<ConnectionContextMenuState>,
@@ -154,15 +156,29 @@ pub(in crate::features) struct NetworkFeatureState {
}
impl ConnectionFeatureState {
pub fn new(settings: &AppSettingsSummary, focus: ConnectionFeatureFocus) -> Self {
pub fn new(
settings: &AppSettingsSummary,
focus: ConnectionFeatureFocus,
cx: &mut Context<NyaTermApp>,
) -> Self {
let filter_placeholder = focus.filter_placeholder;
let search_field =
cx.new(|cx| TextField::new(cx, String::new()).placeholder(filter_placeholder));
// The field owns the text; the panel only needs to know when it changed.
let search_subscription = cx.subscribe(
&search_field,
|app: &mut NyaTermApp, _, event: &TextFieldEvent, cx| {
let TextFieldEvent::Changed(text) = event;
app.connection_state.list.set_search_text(text.clone());
app.sync_connection_keyboard_active(cx);
cx.notify();
},
);
Self {
list: ConnectionListState {
search_field,
search_draft: String::new(),
search_cursor: 0,
search_anchor: None,
search_marked_text: String::new(),
search_marked_range: None,
search_focus: focus.search,
_search_subscription: search_subscription,
sort_mode: ConnectionSortMode::from_setting(
&settings.ui_saved_connections_sort_mode,
),
@@ -252,8 +268,18 @@ impl ConnectionListState {
self.search_draft.is_empty()
}
pub fn search_focus_handle(&self) -> FocusHandle {
self.search_focus.clone()
pub fn search_field(&self) -> Entity<TextField> {
self.search_field.clone()
}
pub fn search_focus_handle(&self, cx: &App) -> FocusHandle {
self.search_field.read(cx).focus_handle()
}
/// Cache what the field just reported. Filtering runs on every keystroke and
/// from paths without an `App`, so it reads this rather than the entity.
pub fn set_search_text(&mut self, text: String) {
self.search_draft = text;
}
pub fn sort_mode(&self) -> ConnectionSortMode {
@@ -352,81 +378,7 @@ impl ConnectionListState {
close_connection_more_menu(&mut self.more_menu_open)
}
pub fn apply_search_key(&mut self, key: &str, input: Option<&str>, shift: bool) -> bool {
apply_connection_search_key(
&mut self.search_draft,
&mut self.search_cursor,
&mut self.search_anchor,
key,
input,
shift,
)
}
pub fn clear_search(&mut self) -> bool {
self.search_marked_text.clear();
self.search_marked_range = None;
clear_connection_search(
&mut self.search_draft,
&mut self.search_cursor,
&mut self.search_anchor,
)
}
pub fn search_cursor(&self) -> usize {
self.search_cursor.min(self.search_draft.len())
}
pub fn search_selection_is_reversed(&self) -> bool {
self.search_anchor
.is_some_and(|anchor| anchor > self.search_cursor)
}
pub fn search_selected_byte_range(&self) -> std::ops::Range<usize> {
connection_search_selected_range(&self.search_draft, self.search_cursor, self.search_anchor)
}
pub fn search_marked_text(&self) -> &str {
&self.search_marked_text
}
pub fn search_marked_range(&self) -> Option<std::ops::Range<usize>> {
self.search_marked_range.clone()
}
pub fn clear_search_marked(&mut self) {
self.search_marked_text.clear();
self.search_marked_range = None;
}
pub fn replace_search_range(&mut self, range: std::ops::Range<usize>, text: &str) {
replace_connection_search_range(
&mut self.search_draft,
&mut self.search_cursor,
&mut self.search_anchor,
range,
text,
);
self.search_marked_text.clear();
self.search_marked_range = None;
}
/// Record an in-flight IME composition and where its selection sits.
pub fn set_search_marked(
&mut self,
start: usize,
marked_text: &str,
selected: Option<std::ops::Range<usize>>,
) {
self.search_marked_text = marked_text.to_string();
self.search_marked_range =
(!marked_text.is_empty()).then_some(start..start + marked_text.len());
if let Some(selected) = selected {
self.search_anchor = (selected.start != selected.end).then_some(start + selected.start);
self.search_cursor = start + selected.end;
}
}
pub fn cycle_sort_mode(&mut self) -> ConnectionSortMode {
cycle_connection_sort_mode(&mut self.sort_mode)
}
@@ -1214,16 +1166,15 @@ mod tests {
advance_network_proxy_editor_focus, advance_network_tunnel_editor_focus,
apply_connection_editor_shell_path, apply_connection_editor_text_key,
apply_connection_editor_working_dir, apply_connection_group_editor_name_key,
apply_connection_search_key, apply_network_group_editor_name_key,
apply_network_proxy_editor_key, apply_network_tunnel_editor_key,
clear_connection_editor_group_menu_draft, clear_connection_editor_runtime_state,
clear_connection_list_runtime_state, clear_connection_search, clear_network_proxy_editor,
clear_network_tunnel_editor, clear_selected_connection_ids, close_connection_more_menu,
commit_connection_editor_new_group, connection_drop_position_for_target,
connection_editor_inline_panel_draft, connection_editor_window_open_or_pending,
connection_search_selected_range, cycle_connection_sort_mode, cycle_network_proxy_group,
cycle_network_proxy_protocol, cycle_network_tunnel_connection, cycle_network_tunnel_group,
cycle_network_tunnel_type, finish_connection_editor_save_state,
apply_network_group_editor_name_key, apply_network_proxy_editor_key,
apply_network_tunnel_editor_key, clear_connection_editor_group_menu_draft,
clear_connection_editor_runtime_state, clear_connection_list_runtime_state,
clear_network_proxy_editor, clear_network_tunnel_editor, clear_selected_connection_ids,
close_connection_more_menu, commit_connection_editor_new_group,
connection_drop_position_for_target, connection_editor_inline_panel_draft,
connection_editor_window_open_or_pending, cycle_connection_sort_mode,
cycle_network_proxy_group, cycle_network_proxy_protocol, cycle_network_tunnel_connection,
cycle_network_tunnel_group, cycle_network_tunnel_type, finish_connection_editor_save_state,
focus_connection_editor_field, focus_network_proxy_editor_field,
focus_network_tunnel_editor_field, insert_connection_editor_description_newline,
insert_network_proxy_command_newline, remove_connection_list_references,
@@ -1253,86 +1204,6 @@ mod tests {
NetworkTunnelEditorField, NetworkTunnelEditorState,
};
#[test]
fn search_editing_is_relative_to_the_caret_and_respects_char_boundaries() {
let mut draft = String::new();
let mut cursor = 0usize;
let mut anchor = None;
for input in ["南", "京", "a"] {
apply_connection_search_key(
&mut draft,
&mut cursor,
&mut anchor,
"",
Some(input),
false,
);
}
assert_eq!(draft, "南京a");
assert_eq!(cursor, draft.len());
// Backspace must drop a whole character, not one byte of a multi-byte one.
apply_connection_search_key(
&mut draft,
&mut cursor,
&mut anchor,
"backspace",
None,
false,
);
apply_connection_search_key(
&mut draft,
&mut cursor,
&mut anchor,
"backspace",
None,
false,
);
assert_eq!(draft, "南");
// Insert at the caret rather than always appending.
apply_connection_search_key(&mut draft, &mut cursor, &mut anchor, "home", None, false);
apply_connection_search_key(&mut draft, &mut cursor, &mut anchor, "", Some("x"), false);
assert_eq!(draft, "x南");
assert_eq!(cursor, 1);
// Delete removes forward.
apply_connection_search_key(&mut draft, &mut cursor, &mut anchor, "delete", None, false);
assert_eq!(draft, "x");
}
#[test]
fn shift_extends_a_selection_that_typing_then_replaces() {
let mut draft = "abc".to_string();
let mut cursor = 3usize;
let mut anchor = None;
apply_connection_search_key(&mut draft, &mut cursor, &mut anchor, "left", None, true);
apply_connection_search_key(&mut draft, &mut cursor, &mut anchor, "left", None, true);
assert_eq!(
connection_search_selected_range(&draft, cursor, anchor),
1..3
);
apply_connection_search_key(&mut draft, &mut cursor, &mut anchor, "", Some("Z"), false);
assert_eq!(draft, "aZ");
assert_eq!(anchor, None);
}
#[test]
fn caret_moves_without_shift_collapse_the_selection() {
let mut draft = "abc".to_string();
let mut cursor = 3usize;
let mut anchor = None;
apply_connection_search_key(&mut draft, &mut cursor, &mut anchor, "left", None, true);
assert!(anchor.is_some());
apply_connection_search_key(&mut draft, &mut cursor, &mut anchor, "left", None, false);
assert_eq!(anchor, None);
assert_eq!(cursor, 1);
}
#[test]
fn search_expansion_opens_matches_and_restores_the_prior_tree() {
let mut expanded = HashSet::from(["kept".to_string()]);
@@ -1419,66 +1290,6 @@ mod tests {
assert!(!close_connection_more_menu(&mut more_menu_open));
}
#[test]
fn apply_connection_search_key_handles_escape_backspace_and_text() {
let mut search_draft = "prod".to_string();
let mut cursor = search_draft.len();
let mut anchor = None;
assert!(apply_connection_search_key(
&mut search_draft,
&mut cursor,
&mut anchor,
"x",
Some("x"),
false
));
assert_eq!(search_draft, "prodx");
assert!(apply_connection_search_key(
&mut search_draft,
&mut cursor,
&mut anchor,
"backspace",
None,
false
));
assert_eq!(search_draft, "prod");
assert!(apply_connection_search_key(
&mut search_draft,
&mut cursor,
&mut anchor,
"escape",
None,
false
));
assert!(search_draft.is_empty());
assert!(!apply_connection_search_key(
&mut search_draft,
&mut cursor,
&mut anchor,
"shift",
None,
false
));
}
#[test]
fn clear_connection_search_is_idempotent_for_notify_callers() {
let mut search_draft = String::new();
let mut cursor = 0usize;
let mut anchor = None;
assert!(clear_connection_search(
&mut search_draft,
&mut cursor,
&mut anchor
));
assert!(search_draft.is_empty());
assert_eq!(cursor, 0);
}
#[test]
fn cycle_connection_sort_mode_updates_and_returns_next_mode() {
let mut sort_mode = ConnectionSortMode::Default;
@@ -121,126 +121,6 @@ pub(super) fn close_connection_more_menu(more_menu_open: &mut bool) -> bool {
was_open
}
/// Edit the filter text relative to the caret.
///
/// The previous version only appended and popped, so the caret could not move and
/// backspace cut a byte off multi-byte input. Everything here steps by character
/// boundary.
pub(super) fn apply_connection_search_key(
search_draft: &mut String,
cursor: &mut usize,
anchor: &mut Option<usize>,
key: &str,
input: Option<&str>,
shift: bool,
) -> bool {
match key {
"escape" => clear_connection_search(search_draft, cursor, anchor),
"left" | "right" | "home" | "end" => {
let next = match key {
"left" => previous_char_boundary(search_draft, *cursor),
"right" => next_char_boundary(search_draft, *cursor),
"home" => 0,
_ => search_draft.len(),
};
move_search_caret(cursor, anchor, next, shift);
true
}
"backspace" | "delete" => {
let selection = connection_search_selected_range(search_draft, *cursor, *anchor);
let range = if selection.start != selection.end {
selection
} else if key == "backspace" {
previous_char_boundary(search_draft, *cursor)..*cursor
} else {
*cursor..next_char_boundary(search_draft, *cursor)
};
if range.start == range.end {
return false;
}
replace_connection_search_range(search_draft, cursor, anchor, range, "");
true
}
_ => {
let Some(input) = input.filter(|input| !input.is_empty()) else {
return false;
};
let range = connection_search_selected_range(search_draft, *cursor, *anchor);
replace_connection_search_range(search_draft, cursor, anchor, range, input);
true
}
}
}
fn move_search_caret(cursor: &mut usize, anchor: &mut Option<usize>, next: usize, shift: bool) {
if shift {
// Start the selection from wherever the caret was.
anchor.get_or_insert(*cursor);
} else {
*anchor = None;
}
*cursor = next;
if anchor.is_some_and(|anchor| anchor == *cursor) {
*anchor = None;
}
}
fn previous_char_boundary(text: &str, offset: usize) -> usize {
text[..offset.min(text.len())]
.char_indices()
.next_back()
.map(|(index, _)| index)
.unwrap_or(0)
}
fn next_char_boundary(text: &str, offset: usize) -> usize {
let offset = offset.min(text.len());
text[offset..]
.chars()
.next()
.map(|c| offset + c.len_utf8())
.unwrap_or(offset)
}
pub(super) fn connection_search_selected_range(
search_draft: &str,
cursor: usize,
anchor: Option<usize>,
) -> std::ops::Range<usize> {
let cursor = cursor.min(search_draft.len());
let anchor = anchor.unwrap_or(cursor).min(search_draft.len());
if anchor <= cursor {
anchor..cursor
} else {
cursor..anchor
}
}
pub(super) fn replace_connection_search_range(
search_draft: &mut String,
cursor: &mut usize,
anchor: &mut Option<usize>,
range: std::ops::Range<usize>,
text: &str,
) {
let start = range.start.min(search_draft.len());
let end = range.end.clamp(start, search_draft.len());
search_draft.replace_range(start..end, text);
*cursor = start + text.len();
*anchor = None;
}
pub(super) fn clear_connection_search(
search_draft: &mut String,
cursor: &mut usize,
anchor: &mut Option<usize>,
) -> bool {
search_draft.clear();
*cursor = 0;
*anchor = None;
true
}
pub(super) fn cycle_connection_sort_mode(sort_mode: &mut ConnectionSortMode) -> ConnectionSortMode {
*sort_mode = sort_mode.next();
*sort_mode
@@ -4,7 +4,7 @@ use gpui::{
prelude::{
FluentBuilder, InteractiveElement, ParentElement, StatefulInteractiveElement, Styled,
},
px, relative, rgb, svg, uniform_list,
px, rgb, svg, uniform_list,
};
use crate::features::{ConnectionDragKind, ConnectionDragPayload, NyaTermApp};
@@ -260,22 +260,9 @@ impl NyaTermApp {
) -> impl IntoElement {
let palette = self.theme_palette();
let search_empty = self.connection_state.list.search_is_empty();
let search_focus = self.connection_state.list.search_focus_handle();
let search_field = self.connection_state.list.search_field();
let search_focus = search_field.read(cx).focus_handle();
let search_focused = search_focus.is_focused(window);
let search_value = if search_empty && !search_focused {
self.tr("savedConnections.filter").to_string()
} else {
// The app has no caret-drawing widget; the multi-line paste overlay
// marks the insertion point with a literal bar, so do the same here
// rather than leaving the field with no sign of where typing lands.
let mut display = self.connection_state.list.search_text().to_string();
if search_focused {
let cursor = self.connection_state.list.search_cursor();
display.insert(cursor, '|');
}
display
};
let input_entity = cx.entity();
// Tauri swaps the glyph, flips it for Z-A and tints it while a name sort is
// active, so the current mode is readable without hovering for the tooltip.
let sort_mode = self.connection_state.list.sort_mode();
@@ -342,14 +329,14 @@ impl NyaTermApp {
.items_center()
.gap_2()
.cursor_text()
.track_focus(&search_focus)
.on_click(cx.listener(|this, _, window, cx| {
let search_focus = this.connection_state.list.search_focus_handle();
window.focus(&search_focus);
let field = this.connection_state.list.search_field();
window.focus(&field.read(cx).focus_handle());
cx.notify();
}))
// Result navigation stays here: the field leaves the arrows
// and enter unconsumed precisely so the list can claim them.
.on_key_down(cx.listener(|this, event: &KeyDownEvent, window, cx| {
cx.stop_propagation();
this.handle_connection_search_key_down(event, window, cx);
}))
.child(
@@ -364,31 +351,7 @@ impl NyaTermApp {
.min_w_0()
.flex_1()
.text_size(px(12.))
.text_color(if search_empty {
rgb(palette.text_dimmed)
} else {
rgb(palette.text)
})
.child(search_value),
)
.child(
gpui::canvas(
|_bounds, _window, _cx| {},
move |bounds, _state, window, cx| {
let focus = input_entity
.read(cx)
.connection_state
.list
.search_focus_handle();
window.handle_input(
&focus,
gpui::ElementInputHandler::new(bounds, input_entity.clone()),
cx,
);
},
)
.absolute()
.inset_0(),
.child(search_field.clone()),
)
.when(!search_empty, |this| {
this.child(
@@ -407,11 +370,7 @@ impl NyaTermApp {
.text_color(rgb(palette.text))
})
.on_click(cx.listener(|this, _, window, cx| {
this.connection_state.list.clear_search();
let search_focus =
this.connection_state.list.search_focus_handle();
window.focus(&search_focus);
cx.notify();
this.clear_connection_search(window, cx);
}))
.child(
svg()
@@ -80,17 +80,6 @@ impl EntityInputHandler for NyaTermApp {
window: &mut Window,
cx: &mut Context<Self>,
) -> Option<String> {
if self
.connection_state
.list
.search_focus_handle()
.is_focused(window)
{
let text = self.connection_state.list.search_text().to_string();
let byte_range = byte_range_from_utf16(&text, &range);
*adjusted_range = Some(utf16_range_from_bytes(&text, &byte_range));
return Some(text[byte_range].to_string());
}
if self.multi_line_paste.is_some() && self.multi_line_paste_focus.is_focused(window) {
let text = self.multi_line_paste_text();
let byte_range = byte_range_from_utf16(text, &range);
@@ -239,19 +228,6 @@ impl EntityInputHandler for NyaTermApp {
reversed: false,
});
}
if self
.connection_state
.list
.search_focus_handle()
.is_focused(window)
{
let text = self.connection_state.list.search_text().to_string();
let range = self.connection_state.list.search_selected_byte_range();
return Some(UTF16Selection {
range: utf16_range_from_bytes(&text, &range),
reversed: self.connection_state.list.search_selection_is_reversed(),
});
}
if self.multi_line_paste_focus.is_focused(window) {
let text = self.multi_line_paste_text();
let range = self.multi_line_paste_selected_byte_range();
@@ -309,19 +285,6 @@ impl EntityInputHandler for NyaTermApp {
let len = self.sync_groups_name_marked_text.encode_utf16().count();
return (len > 0).then_some(0..len);
}
if self
.connection_state
.list
.search_focus_handle()
.is_focused(window)
{
let text = self.connection_state.list.search_text().to_string();
return self
.connection_state
.list
.search_marked_range()
.map(|range| utf16_range_from_bytes(&text, &range));
}
if self.multi_line_paste.is_some() && self.multi_line_paste_focus.is_focused(window) {
return self
.multi_line_paste_marked_range
@@ -365,15 +328,6 @@ impl EntityInputHandler for NyaTermApp {
self.sync_groups_name_marked_text.clear();
return;
}
if self
.connection_state
.list
.search_focus_handle()
.is_focused(window)
{
self.connection_state.list.clear_search_marked();
return;
}
if self.multi_line_paste.is_some() && self.multi_line_paste_focus.is_focused(window) {
self.multi_line_paste_marked_text.clear();
self.multi_line_paste_marked_range = None;
@@ -442,22 +396,6 @@ impl EntityInputHandler for NyaTermApp {
}
return;
}
if self
.connection_state
.list
.search_focus_handle()
.is_focused(window)
{
let draft = self.connection_state.list.search_text().to_string();
let range = range
.as_ref()
.map(|range| byte_range_from_utf16(&draft, range))
.or_else(|| self.connection_state.list.search_marked_range())
.unwrap_or_else(|| self.connection_state.list.search_selected_byte_range());
self.connection_state.list.replace_search_range(range, text);
cx.notify();
return;
}
if self.multi_line_paste.is_some() && self.multi_line_paste_focus.is_focused(window) {
let range = range
.as_ref()
@@ -546,32 +484,6 @@ impl EntityInputHandler for NyaTermApp {
cx.notify();
return;
}
if self
.connection_state
.list
.search_focus_handle()
.is_focused(window)
{
let draft = self.connection_state.list.search_text().to_string();
let range = range
.as_ref()
.map(|range| byte_range_from_utf16(&draft, range))
.or_else(|| self.connection_state.list.search_marked_range())
.unwrap_or_else(|| self.connection_state.list.search_selected_byte_range());
let start = range.start;
self.connection_state
.list
.replace_search_range(range, new_text);
self.connection_state.list.set_search_marked(
start,
new_text,
new_selected_range
.as_ref()
.map(|selected| byte_range_from_utf16(new_text, selected)),
);
cx.notify();
return;
}
if self.multi_line_paste.is_some() && self.multi_line_paste_focus.is_focused(window) {
let range = range
.as_ref()
@@ -699,18 +611,6 @@ impl EntityInputHandler for NyaTermApp {
.unwrap_or_default(),
);
}
if self
.connection_state
.list
.search_focus_handle()
.is_focused(window)
{
let text = self.connection_state.list.search_text().to_string();
return Some(utf16_offset_for_byte(
&text,
self.connection_state.list.search_cursor(),
));
}
if self.multi_line_paste.is_some() && self.multi_line_paste_focus.is_focused(window) {
return Some(utf16_offset_for_byte(
self.multi_line_paste_text(),
+1
View File
@@ -8,4 +8,5 @@ repository.workspace = true
[dependencies]
gpui.workspace = true
nyaterm-core.workspace = true
nyaterm-terminal.workspace = true
+2
View File
@@ -1,8 +1,10 @@
//! Shared GPUI theme tokens and reusable presentation widgets for NyaTerm.
mod text_field;
mod theme;
mod widgets;
pub use text_field::{TextField, TextFieldEvent, text_field_box};
pub use theme::{APPEARANCE_THEME_IDS, ThemePalette, appearance_theme_label, theme_palette};
pub use widgets::{
capability_line, empty_panel, mode_button, section_header, session_info_row, small_button,
+762
View File
@@ -0,0 +1,762 @@
//! A focusable single-line text field.
//!
//! The app previously drew "inputs" as label divs: a `div` that took focus,
//! swallowed key events, and printed the draft string. That has no caret, no
//! selection, no pointer positioning and no IME, so it reads as broken the
//! moment anyone tries to edit rather than append.
//!
//! This is a real GPUI widget instead — an [`Entity`] that owns its buffer and
//! focus, implements [`EntityInputHandler`] so the platform routes composition
//! and clipboard through it, and paints itself with a custom [`Element`] that
//! shapes the line once and reuses that shaping for hit-testing, the selection
//! quads and the caret. Owners react to edits by subscribing to
//! [`TextFieldEvent`] rather than polling the string.
use std::ops::Range;
use std::time::Duration;
use gpui::{
App, Bounds, ClickEvent, Context, CursorStyle, Element, ElementId, ElementInputHandler, Entity,
EntityInputHandler, EventEmitter, FocusHandle, Focusable, GlobalElementId, InspectorElementId,
IntoElement, LayoutId, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels,
Point, Render, ShapedLine, SharedString, Style, TextRun, UTF16Selection, Window, div, fill,
point, prelude::*, px, relative, rgb, size,
};
use nyaterm_core::{CursorMotion, TextEdit};
use crate::theme::ThemePalette;
/// Half of the caret's on/off period.
const CARET_BLINK_INTERVAL: Duration = Duration::from_millis(530);
/// What a [`TextField`] tells its owner.
///
/// Deliberately only the edit: Enter, Escape, Tab and the arrows are dialog and
/// list concerns, so the field leaves them unconsumed for the owner's own key
/// handler to see rather than guessing what they should mean here.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TextFieldEvent {
/// The buffer changed. Carries the new content so the owner does not have
/// to read back through the entity.
Changed(String),
}
/// A single-line editable field.
pub struct TextField {
edit: TextEdit,
focus: FocusHandle,
placeholder: SharedString,
/// Render every character as a bullet. The buffer itself stays in the
/// clear — masking is presentation, and the owner still needs the secret.
masked: bool,
/// In-flight IME composition, as a range into the buffer.
marked: Option<Range<usize>>,
/// Horizontal scroll, so a caret past the right edge stays visible.
scroll_x: Pixels,
caret_visible: bool,
blink: Option<gpui::Task<()>>,
selecting: bool,
}
impl TextField {
pub fn new(cx: &mut Context<Self>, content: impl Into<String>) -> Self {
Self {
edit: TextEdit::new(content),
focus: cx.focus_handle(),
placeholder: SharedString::default(),
masked: false,
marked: None,
scroll_x: px(0.),
caret_visible: true,
blink: None,
selecting: false,
}
}
pub fn placeholder(mut self, placeholder: impl Into<SharedString>) -> Self {
self.placeholder = placeholder.into();
self
}
pub fn masked(mut self, masked: bool) -> Self {
self.masked = masked;
self
}
pub fn content(&self) -> &str {
self.edit.content()
}
pub fn is_empty(&self) -> bool {
self.edit.is_empty()
}
pub fn focus_handle(&self) -> FocusHandle {
self.focus.clone()
}
/// Replace the buffer from the owner's state without emitting a change.
pub fn set_content(&mut self, content: impl Into<String>, cx: &mut Context<Self>) {
let content = content.into();
if self.edit.content() == content {
return;
}
self.edit.set_content(content);
self.marked = None;
cx.notify();
}
/// Put the caret at the end and select everything, the usual "focus for
/// replacement" behaviour when a dialog opens.
pub fn select_all(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.edit.select_all();
self.restart_blink(window, cx);
cx.notify();
}
fn emit_changed(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.marked = None;
self.restart_blink(window, cx);
cx.emit(TextFieldEvent::Changed(self.edit.content().to_string()));
cx.notify();
}
/// Keep the caret solid for a moment after every edit, so typing does not
/// flicker, then resume blinking.
fn restart_blink(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.caret_visible = true;
self.blink = Some(cx.spawn_in(window, async move |this, cx| {
loop {
cx.background_executor().timer(CARET_BLINK_INTERVAL).await;
// The task ends with focus, so an unfocused field costs nothing.
let Ok(still_focused) = this.update_in(cx, |this, window, cx| {
let focused = this.focus.is_focused(window);
if focused {
this.caret_visible = !this.caret_visible;
cx.notify();
}
focused
}) else {
return;
};
if !still_focused {
return;
}
}
}));
}
/// Returns whether the key was an edit, so the caller only stops
/// propagation for keys this field actually claimed.
fn handle_key(
&mut self,
event: &gpui::KeyDownEvent,
window: &mut Window,
cx: &mut Context<Self>,
) -> bool {
let keystroke = &event.keystroke;
let shift = keystroke.modifiers.shift;
let word = keystroke.modifiers.control || keystroke.modifiers.alt;
let accel = keystroke.modifiers.platform || keystroke.modifiers.control;
match keystroke.key.as_str() {
"a" if accel => {
self.edit.select_all();
self.restart_blink(window, cx);
cx.notify();
return true;
}
"c" | "x" if accel => {
let selection = self.edit.selection();
if !selection.is_empty() && !self.masked {
let text = self.edit.content()[selection.clone()].to_string();
cx.write_to_clipboard(gpui::ClipboardItem::new_string(text));
if keystroke.key == "x" {
self.edit.replace(selection, "");
self.emit_changed(window, cx);
}
}
return true;
}
"v" if accel => {
if let Some(text) = cx.read_from_clipboard().and_then(|item| item.text()) {
// A single-line field: paste the first line only, rather
// than silently storing a newline nothing can display.
let text = text.replace(['\n', '\r'], " ");
self.edit.insert(&text);
self.emit_changed(window, cx);
}
return true;
}
"left" => self
.edit
.move_cursor(motion(CursorMotion::Left, word), shift),
"right" => self
.edit
.move_cursor(motion(CursorMotion::Right, word), shift),
"home" => self.edit.move_cursor(CursorMotion::Start, shift),
"end" => self.edit.move_cursor(CursorMotion::End, shift),
"backspace" => {
let changed = if word {
self.edit.delete_word_backward()
} else {
self.edit.delete_backward()
};
if changed {
self.emit_changed(window, cx);
}
return true;
}
"delete" => {
if self.edit.delete_forward() {
self.emit_changed(window, cx);
}
return true;
}
_ => {
// Everything else is text, unless a modifier claims it or the
// key produces none — Enter, Tab and the arrows land here and
// are left for the owner.
if accel || keystroke.modifiers.function {
return false;
}
let Some(input) = keystroke.key_char.as_deref().filter(|s| !s.is_empty()) else {
return false;
};
self.edit.insert(input);
self.emit_changed(window, cx);
return true;
}
}
self.restart_blink(window, cx);
cx.notify();
true
}
}
fn motion(base: CursorMotion, word: bool) -> CursorMotion {
match (base, word) {
(CursorMotion::Left, true) => CursorMotion::WordLeft,
(CursorMotion::Right, true) => CursorMotion::WordRight,
(base, _) => base,
}
}
impl EventEmitter<TextFieldEvent> for TextField {}
impl Focusable for TextField {
fn focus_handle(&self, _: &App) -> FocusHandle {
self.focus.clone()
}
}
/// The text the field actually paints, which is bullets when masked.
fn display_text(edit: &TextEdit, masked: bool) -> String {
if masked {
"•".repeat(edit.content().chars().count())
} else {
edit.content().to_string()
}
}
/// Map a buffer offset onto an offset into the displayed text.
///
/// They differ only while masked, where one character can be several bytes in
/// the buffer but always three in the bullet string.
fn display_offset(edit: &TextEdit, masked: bool, offset: usize) -> usize {
if !masked {
return offset;
}
edit.content()[..offset.min(edit.content().len())]
.chars()
.count()
* '•'.len_utf8()
}
fn buffer_offset(edit: &TextEdit, masked: bool, display: usize) -> usize {
if !masked {
return edit.floor_boundary(display);
}
let chars = display / '•'.len_utf8();
edit.content()
.char_indices()
.nth(chars)
.map(|(index, _)| index)
.unwrap_or(edit.content().len())
}
impl Render for TextField {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let focused = self.focus.is_focused(window);
div()
.id("text-field")
.track_focus(&self.focus)
.size_full()
.cursor(CursorStyle::IBeam)
.on_key_down(cx.listener(|this, event: &gpui::KeyDownEvent, window, cx| {
if this.handle_key(event, window, cx) {
cx.stop_propagation();
}
}))
.on_mouse_down(
MouseButton::Left,
cx.listener(|this, _: &MouseDownEvent, window, cx| {
window.focus(&this.focus);
this.selecting = true;
this.restart_blink(window, cx);
}),
)
.on_mouse_up(
MouseButton::Left,
cx.listener(|this, _: &MouseUpEvent, _, _| {
this.selecting = false;
}),
)
.on_click(cx.listener(|this, event: &ClickEvent, _, cx| {
if event.click_count() >= 2 {
let range = this.edit.word_range_at(this.edit.cursor());
this.edit.set_selection(range, false);
cx.notify();
}
if event.click_count() >= 3 {
this.edit.select_all();
cx.notify();
}
}))
.child(TextFieldElement {
field: cx.entity(),
focused,
})
}
}
impl EntityInputHandler for TextField {
fn text_for_range(
&mut self,
range: Range<usize>,
adjusted: &mut Option<Range<usize>>,
_: &mut Window,
_: &mut Context<Self>,
) -> Option<String> {
let text = self.edit.content();
let byte_range = byte_range_from_utf16(text, &range);
*adjusted = Some(utf16_range_from_bytes(text, &byte_range));
Some(text[byte_range].to_string())
}
fn selected_text_range(
&mut self,
_: bool,
_: &mut Window,
_: &mut Context<Self>,
) -> Option<UTF16Selection> {
let text = self.edit.content();
Some(UTF16Selection {
range: utf16_range_from_bytes(text, &self.edit.selection()),
reversed: self.edit.selection_is_reversed(),
})
}
fn marked_text_range(&self, _: &mut Window, _: &mut Context<Self>) -> Option<Range<usize>> {
self.marked
.as_ref()
.map(|range| utf16_range_from_bytes(self.edit.content(), range))
}
fn unmark_text(&mut self, _: &mut Window, cx: &mut Context<Self>) {
self.marked = None;
cx.notify();
}
fn replace_text_in_range(
&mut self,
range: Option<Range<usize>>,
text: &str,
window: &mut Window,
cx: &mut Context<Self>,
) {
let range = range
.map(|range| byte_range_from_utf16(self.edit.content(), &range))
.or_else(|| self.marked.clone())
.unwrap_or_else(|| self.edit.selection());
self.edit.replace(range, text);
self.emit_changed(window, cx);
}
fn replace_and_mark_text_in_range(
&mut self,
range: Option<Range<usize>>,
new_text: &str,
new_selected: Option<Range<usize>>,
_: &mut Window,
cx: &mut Context<Self>,
) {
let range = range
.map(|range| byte_range_from_utf16(self.edit.content(), &range))
.or_else(|| self.marked.clone())
.unwrap_or_else(|| self.edit.selection());
let start = range.start;
self.edit.replace(range, new_text);
self.marked = (!new_text.is_empty()).then_some(start..start + new_text.len());
if let Some(selected) = new_selected {
let selected = byte_range_from_utf16(new_text, &selected);
self.edit
.set_selection(start + selected.start..start + selected.end, false);
}
self.caret_visible = true;
cx.emit(TextFieldEvent::Changed(self.edit.content().to_string()));
cx.notify();
}
fn bounds_for_range(
&mut self,
_: Range<usize>,
element_bounds: Bounds<Pixels>,
_: &mut Window,
_: &mut Context<Self>,
) -> Option<Bounds<Pixels>> {
// Good enough to place a candidate window against the field.
Some(element_bounds)
}
fn character_index_for_point(
&mut self,
_: Point<Pixels>,
_: &mut Window,
_: &mut Context<Self>,
) -> Option<usize> {
Some(utf16_offset_for_byte(
self.edit.content(),
self.edit.cursor(),
))
}
}
/// Paints the field and owns the shaping its hit-testing depends on.
struct TextFieldElement {
field: Entity<TextField>,
focused: bool,
}
pub struct TextFieldLayout {
line: ShapedLine,
/// `None` while the placeholder is showing, so the caret is not drawn
/// against text the buffer does not contain.
content_line: bool,
scroll_x: Pixels,
selection: Range<usize>,
caret: usize,
}
impl IntoElement for TextFieldElement {
type Element = Self;
fn into_element(self) -> Self::Element {
self
}
}
impl Element for TextFieldElement {
type RequestLayoutState = ();
type PrepaintState = TextFieldLayout;
fn id(&self) -> Option<ElementId> {
None
}
fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
None
}
fn request_layout(
&mut self,
_: Option<&GlobalElementId>,
_: Option<&InspectorElementId>,
window: &mut Window,
cx: &mut App,
) -> (LayoutId, ()) {
let mut style = Style::default();
style.size.width = relative(1.).into();
style.size.height = window.line_height().into();
(window.request_layout(style, [], cx), ())
}
fn prepaint(
&mut self,
_: Option<&GlobalElementId>,
_: Option<&InspectorElementId>,
bounds: Bounds<Pixels>,
_: &mut (),
window: &mut Window,
cx: &mut App,
) -> TextFieldLayout {
let field = self.field.read(cx);
let style = window.text_style();
let font_size = style.font_size.to_pixels(window.rem_size());
let content = display_text(&field.edit, field.masked);
let showing_placeholder = content.is_empty() && !field.placeholder.is_empty();
let text: SharedString = if showing_placeholder {
field.placeholder.clone()
} else {
content.into()
};
let color = if showing_placeholder {
style.color.opacity(0.55)
} else {
style.color
};
let run = TextRun {
len: text.len(),
font: style.font(),
color,
background_color: None,
underline: None,
strikethrough: None,
};
let line = window
.text_system()
.shape_line(text, font_size, &[run], None);
let caret = display_offset(&field.edit, field.masked, field.edit.cursor());
let selection = field.edit.selection();
let selection = display_offset(&field.edit, field.masked, selection.start)
..display_offset(&field.edit, field.masked, selection.end);
// Keep the caret inside the viewport; the field never wraps.
let caret_x = line.x_for_index(caret);
let mut scroll_x = field.scroll_x;
if caret_x - scroll_x > bounds.size.width {
scroll_x = caret_x - bounds.size.width;
}
if caret_x < scroll_x {
scroll_x = caret_x;
}
let overflow = (line.width - bounds.size.width).max(px(0.));
scroll_x = scroll_x.clamp(px(0.), overflow);
if scroll_x != field.scroll_x {
self.field.update(cx, |field, _| field.scroll_x = scroll_x);
}
TextFieldLayout {
line,
content_line: !showing_placeholder,
scroll_x,
selection,
caret,
}
}
fn paint(
&mut self,
_: Option<&GlobalElementId>,
_: Option<&InspectorElementId>,
bounds: Bounds<Pixels>,
_: &mut (),
layout: &mut TextFieldLayout,
window: &mut Window,
cx: &mut App,
) {
let (blinking_on, focus) = {
let field = self.field.read(cx);
(field.caret_visible, field.focus.clone())
};
let selection_color = window.text_style().color.opacity(0.25);
let caret_visible = self.focused && blinking_on && layout.content_line;
let line_height = window.line_height();
let origin = point(bounds.origin.x - layout.scroll_x, bounds.origin.y);
window.with_content_mask(Some(gpui::ContentMask { bounds }), |window| {
if !layout.selection.is_empty() {
let start = layout.line.x_for_index(layout.selection.start);
let end = layout.line.x_for_index(layout.selection.end);
window.paint_quad(fill(
Bounds::new(
point(origin.x + start, bounds.origin.y),
size(end - start, line_height),
),
selection_color,
));
}
layout.line.paint(origin, line_height, window, cx).ok();
if caret_visible {
let x = origin.x + layout.line.x_for_index(layout.caret);
window.paint_quad(fill(
Bounds::new(point(x, bounds.origin.y), size(px(1.5), line_height)),
window.text_style().color,
));
}
});
// Route platform composition and clipboard at the field's own bounds.
if self.focused {
window.handle_input(
&focus,
ElementInputHandler::new(bounds, self.field.clone()),
cx,
);
}
// Pointer positioning reuses the shaping above rather than re-measuring.
let entity = self.field.clone();
let line = layout.line.clone();
let scroll_x = layout.scroll_x;
let left = bounds.origin.x;
window.on_mouse_event(move |event: &MouseDownEvent, phase, window, cx| {
if phase != gpui::DispatchPhase::Bubble
|| event.button != MouseButton::Left
|| !bounds.contains(&event.position)
{
return;
}
entity.update(cx, |field, cx| {
let display = line.closest_index_for_x(event.position.x - left + scroll_x);
let offset = buffer_offset(&field.edit, field.masked, display);
if event.modifiers.shift {
let anchor = field.edit.selection();
let anchor = if offset < anchor.start {
anchor.end
} else {
anchor.start
};
field
.edit
.set_selection(anchor.min(offset)..anchor.max(offset), offset < anchor);
} else {
field.edit.set_cursor(offset);
}
field.restart_blink(window, cx);
cx.notify();
});
window.refresh();
});
let entity = self.field.clone();
let line = layout.line.clone();
window.on_mouse_event(move |event: &MouseMoveEvent, phase, _, cx| {
if phase != gpui::DispatchPhase::Bubble {
return;
}
if !entity.read(cx).selecting {
return;
}
entity.update(cx, |field, cx| {
let display = line.closest_index_for_x(event.position.x - left + scroll_x);
let offset = buffer_offset(&field.edit, field.masked, display);
let anchor = field.edit.selection();
let anchor = if field.edit.selection_is_reversed() {
anchor.end
} else {
anchor.start
};
field
.edit
.set_selection(anchor.min(offset)..anchor.max(offset), offset < anchor);
cx.notify();
});
});
let entity = self.field.clone();
window.on_mouse_event(move |_: &MouseUpEvent, phase, _, cx| {
if phase == gpui::DispatchPhase::Bubble {
entity.update(cx, |field, _| field.selecting = false);
}
});
}
}
/// The chrome around a [`TextField`]: border, background, focus ring.
///
/// Kept separate from the widget so callers can place the field inside their own
/// row without inheriting a box they did not want.
pub fn text_field_box(
id: impl Into<ElementId>,
field: &Entity<TextField>,
palette: ThemePalette,
focused: bool,
) -> gpui::Stateful<gpui::Div> {
div()
.id(id)
.h(px(30.))
.px_2()
.flex()
.items_center()
.rounded_md()
.border_1()
.border_color(rgb(if focused {
palette.primary
} else {
palette.border
}))
.bg(rgb(palette.input))
.text_color(rgb(palette.text))
.child(div().flex_1().min_w_0().child(field.clone()))
}
fn byte_range_from_utf16(text: &str, range: &Range<usize>) -> Range<usize> {
let start = byte_for_utf16_offset(text, range.start);
let end = byte_for_utf16_offset(text, range.end.max(range.start));
start..end
}
fn byte_for_utf16_offset(text: &str, offset: usize) -> usize {
let mut utf16 = 0;
for (index, c) in text.char_indices() {
if utf16 >= offset {
return index;
}
utf16 += c.len_utf16();
}
text.len()
}
fn utf16_range_from_bytes(text: &str, range: &Range<usize>) -> Range<usize> {
utf16_offset_for_byte(text, range.start)..utf16_offset_for_byte(text, range.end)
}
fn utf16_offset_for_byte(text: &str, offset: usize) -> usize {
text[..offset.min(text.len())]
.chars()
.map(char::len_utf16)
.sum()
}
#[cfg(test)]
mod tests {
use super::{buffer_offset, display_offset, display_text, utf16_offset_for_byte};
use nyaterm_core::TextEdit;
#[test]
fn masking_maps_offsets_between_the_buffer_and_the_bullets() {
let edit = TextEdit::new("aé中");
assert_eq!(display_text(&edit, true), "•••");
// Buffer offsets are 0,1,3,6; bullet offsets step by three.
assert_eq!(display_offset(&edit, true, 0), 0);
assert_eq!(display_offset(&edit, true, 1), 3);
assert_eq!(display_offset(&edit, true, 3), 6);
assert_eq!(display_offset(&edit, true, 6), 9);
assert_eq!(buffer_offset(&edit, true, 0), 0);
assert_eq!(buffer_offset(&edit, true, 3), 1);
assert_eq!(buffer_offset(&edit, true, 6), 3);
assert_eq!(buffer_offset(&edit, true, 9), 6);
}
#[test]
fn unmasked_offsets_are_snapped_onto_char_boundaries() {
let edit = TextEdit::new("中文");
assert_eq!(buffer_offset(&edit, false, 1), 0);
assert_eq!(buffer_offset(&edit, false, 3), 3);
}
#[test]
fn utf16_offsets_account_for_surrogate_pairs() {
// An emoji is one char but two UTF-16 units, which is what the IME counts.
assert_eq!(utf16_offset_for_byte("a🙂b", 5), 3);
assert_eq!(utf16_offset_for_byte("a🙂b", 1), 1);
}
}