diff --git a/Cargo.lock b/Cargo.lock index 450440b21..95b9a1e9c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4348,6 +4348,7 @@ name = "nyaterm-ui" version = "1.1.12" dependencies = [ "gpui", + "nyaterm-core", "nyaterm-terminal", ] diff --git a/crates/nyaterm-core/src/lib.rs b/crates/nyaterm-core/src/lib.rs index b4fa85385..bd4e5248b 100644 --- a/crates/nyaterm-core/src/lib.rs +++ b/crates/nyaterm-core/src/lib.rs @@ -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, diff --git a/crates/nyaterm-core/src/text_edit.rs b/crates/nyaterm-core/src/text_edit.rs new file mode 100644 index 000000000..3e0a7ea3f --- /dev/null +++ b/crates/nyaterm-core/src/text_edit.rs @@ -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, +} + +impl TextEdit { + pub fn new(content: impl Into) -> 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) { + 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 { + 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, 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, 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 { + 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 { + self.content[offset..].chars().next().map(char_class) + } + + fn class_before(&self, offset: usize) -> Option { + 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); + } +} diff --git a/crates/nyaterm-desktop/src/features/app_state/construct.rs b/crates/nyaterm-desktop/src/features/app_state/construct.rs index 8ace0c857..711bbec6c 100644 --- a/crates/nyaterm-desktop/src/features/app_state/construct.rs +++ b/crates/nyaterm-desktop/src/features/app_state/construct.rs @@ -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, diff --git a/crates/nyaterm-desktop/src/features/connections/connection_runtime/actions.rs b/crates/nyaterm-desktop/src/features/connections/connection_runtime/actions.rs index 2765f4558..62d03b230 100644 --- a/crates/nyaterm-desktop/src/features/connections/connection_runtime/actions.rs +++ b/crates/nyaterm-desktop/src/features/connections/connection_runtime/actions.rs @@ -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) { + pub(in crate::features) fn sync_connection_keyboard_active(&mut self, cx: &mut Context) { 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, + ) { + 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) { let selected = self.selected_connections(); if selected.is_empty() { diff --git a/crates/nyaterm-desktop/src/features/connections/state.rs b/crates/nyaterm-desktop/src/features/connections/state.rs index dff0db963..bd05064e0 100644 --- a/crates/nyaterm-desktop/src/features/connections/state.rs +++ b/crates/nyaterm-desktop/src/features/connections/state.rs @@ -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, 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, - /// In-flight IME composition and where it sits in `search_draft`. - search_marked_text: String, - search_marked_range: Option>, - 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, @@ -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, + ) -> 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 { + 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 { - 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> { - 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, 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>, - ) { - 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; diff --git a/crates/nyaterm-desktop/src/features/connections/state/list_logic.rs b/crates/nyaterm-desktop/src/features/connections/state/list_logic.rs index 35ffe6494..89d6600c5 100644 --- a/crates/nyaterm-desktop/src/features/connections/state/list_logic.rs +++ b/crates/nyaterm-desktop/src/features/connections/state/list_logic.rs @@ -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, - 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, 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, -) -> std::ops::Range { - 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, - range: std::ops::Range, - 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, -) -> 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 diff --git a/crates/nyaterm-desktop/src/features/pages/connections/view/page.rs b/crates/nyaterm-desktop/src/features/pages/connections/view/page.rs index 78eb8dde9..15abaf674 100644 --- a/crates/nyaterm-desktop/src/features/pages/connections/view/page.rs +++ b/crates/nyaterm-desktop/src/features/pages/connections/view/page.rs @@ -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() diff --git a/crates/nyaterm-desktop/src/features/terminal/terminal_selection_runtime/helpers.rs b/crates/nyaterm-desktop/src/features/terminal/terminal_selection_runtime/helpers.rs index 221569dd0..0e6b59d83 100644 --- a/crates/nyaterm-desktop/src/features/terminal/terminal_selection_runtime/helpers.rs +++ b/crates/nyaterm-desktop/src/features/terminal/terminal_selection_runtime/helpers.rs @@ -80,17 +80,6 @@ impl EntityInputHandler for NyaTermApp { window: &mut Window, cx: &mut Context, ) -> Option { - 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(), diff --git a/crates/nyaterm-ui/Cargo.toml b/crates/nyaterm-ui/Cargo.toml index f04b69532..35379f17f 100644 --- a/crates/nyaterm-ui/Cargo.toml +++ b/crates/nyaterm-ui/Cargo.toml @@ -8,4 +8,5 @@ repository.workspace = true [dependencies] gpui.workspace = true +nyaterm-core.workspace = true nyaterm-terminal.workspace = true diff --git a/crates/nyaterm-ui/src/lib.rs b/crates/nyaterm-ui/src/lib.rs index c2d16e6e6..6989f0133 100644 --- a/crates/nyaterm-ui/src/lib.rs +++ b/crates/nyaterm-ui/src/lib.rs @@ -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, diff --git a/crates/nyaterm-ui/src/text_field.rs b/crates/nyaterm-ui/src/text_field.rs new file mode 100644 index 000000000..82799ea12 --- /dev/null +++ b/crates/nyaterm-ui/src/text_field.rs @@ -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>, + /// Horizontal scroll, so a caret past the right edge stays visible. + scroll_x: Pixels, + caret_visible: bool, + blink: Option>, + selecting: bool, +} + +impl TextField { + pub fn new(cx: &mut Context, content: impl Into) -> 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) -> 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, cx: &mut Context) { + 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.edit.select_all(); + self.restart_blink(window, cx); + cx.notify(); + } + + fn emit_changed(&mut self, window: &mut Window, cx: &mut Context) { + 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.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, + ) -> 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 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) -> 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, + adjusted: &mut Option>, + _: &mut Window, + _: &mut Context, + ) -> Option { + 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, + ) -> Option { + 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) -> Option> { + 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.marked = None; + cx.notify(); + } + + fn replace_text_in_range( + &mut self, + range: Option>, + text: &str, + window: &mut Window, + cx: &mut Context, + ) { + 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>, + new_text: &str, + new_selected: Option>, + _: &mut Window, + cx: &mut Context, + ) { + 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, + element_bounds: Bounds, + _: &mut Window, + _: &mut Context, + ) -> Option> { + // Good enough to place a candidate window against the field. + Some(element_bounds) + } + + fn character_index_for_point( + &mut self, + _: Point, + _: &mut Window, + _: &mut Context, + ) -> Option { + 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, + 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, + 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 { + 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, + _: &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, + _: &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, + field: &Entity, + palette: ThemePalette, + focused: bool, +) -> gpui::Stateful { + 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) -> Range { + 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) -> Range { + 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); + } +}