From ce1c140f6553fbde930400a2d76fc31b4081acdd Mon Sep 17 00:00:00 2001 From: Kang Date: Mon, 27 Jul 2026 00:08:37 +0800 Subject: [PATCH] feat(connections): give the connection editor real input fields Every input in the new/edit-connection dialog was a label div sharing one focus handle, with a `focused_field` enum routing keystrokes to whichever draft string was "active". Clicking a field only moved that enum, so there was no caret, no selection, no pointer positioning, no composition, and password masking was a string of bullets rebuilt on each render. Each text input is now a `TextField` entity, built when the editor opens and cleared when it closes, so the widgets live exactly as long as the draft they mirror. `ConnectionEditorState` stays a plain value the runtime can clone and save from; the fields write back through their subscriptions. The password field masks itself while the buffer keeps the secret in the clear, which is what the save path needs. The folder-name editor gets the same treatment. Two focus fixes fall out of this. The editor surface's blanket `on_click` focus grab is gone: it existed to keep the old fake inputs "focused", and would now steal focus back from whichever field the pointer just landed on, since click follows mouse-down. And because focus is per-window, the detached editor window claims the name field itself on its first frame rather than trusting what the main window focused. The labelled row is the hit target, not just the one text line inside it, and it takes its ring from the widget's own focus rather than from an enum nothing updates any more. --- .../src/features/connection_editor_window.rs | 19 ++++ .../connections/connection_runtime/editor.rs | 32 ++++++- .../connections/connection_runtime/groups.rs | 7 +- .../src/features/connections/state.rs | 90 +++++++++++++++++- .../connections/state/editor_logic.rs | 93 +++++++++++++++++++ .../connections/editor/connection/local.rs | 42 +++------ .../connections/editor/connection/mod.rs | 36 +++---- .../connections/editor/connection/serial.rs | 18 ++-- .../connections/editor/connection/ssh.rs | 73 +++++---------- .../connections/editor/connection/telnet.rs | 23 ++--- .../pages/connections/editor/group_delete.rs | 34 ++++--- .../src/features/pages/connections/list.rs | 65 +++++++++++-- .../nyaterm-desktop/src/models/connections.rs | 2 +- crates/nyaterm-ui/src/text_field.rs | 9 ++ 14 files changed, 384 insertions(+), 159 deletions(-) diff --git a/crates/nyaterm-desktop/src/features/connection_editor_window.rs b/crates/nyaterm-desktop/src/features/connection_editor_window.rs index 8eb651198..66258d4fb 100644 --- a/crates/nyaterm-desktop/src/features/connection_editor_window.rs +++ b/crates/nyaterm-desktop/src/features/connection_editor_window.rs @@ -4,10 +4,14 @@ use gpui::{ }; use super::{NyaTermApp, child_window_header, child_window_titlebar}; +use crate::models::ConnectionEditorField; pub(in crate::features) struct ConnectionEditorWindow { app: Entity, _app_subscription: Subscription, + /// Focus is per-window, so the field the main window focused means nothing + /// here; this window has to claim it on its own first frame. + focused_initial_field: bool, } impl ConnectionEditorWindow { @@ -16,6 +20,7 @@ impl ConnectionEditorWindow { Self { app, _app_subscription: app_subscription, + focused_initial_field: false, } } } @@ -40,6 +45,20 @@ impl Render for ConnectionEditorWindow { ) }); window.set_window_title(&title); + if !self.focused_initial_field { + self.focused_initial_field = true; + let field = self + .app + .read(cx) + .connection_state + .editor_fields() + .get(&ConnectionEditorField::Name) + .cloned(); + if let Some(field) = field { + window.focus(&field.read(cx).focus_handle()); + field.update(cx, |field, cx| field.select_all(window, cx)); + } + } let content = self .app .update(cx, |app, cx| app.connection_editor_window_view(editor, cx)); diff --git a/crates/nyaterm-desktop/src/features/connections/connection_runtime/editor.rs b/crates/nyaterm-desktop/src/features/connections/connection_runtime/editor.rs index 1ff1bdea0..0139d1104 100644 --- a/crates/nyaterm-desktop/src/features/connections/connection_runtime/editor.rs +++ b/crates/nyaterm-desktop/src/features/connections/connection_runtime/editor.rs @@ -102,16 +102,33 @@ impl NyaTermApp { }; self.connection_state.editor.begin_edit(editor); + // Fields mirror the draft, so they are rebuilt with it. + self.connection_state.build_editor_fields(cx); self.terminal.view.status = "connection editor opened".to_string(); if !self.open_connection_editor_window(cx) { - let editor_focus = self.connection_state.editor.focus_handle(); - window.focus(&editor_focus); + // Land on the name and select it, so an edit can start by typing. + match self + .connection_state + .editor_fields() + .get(&ConnectionEditorField::Name) + .cloned() + { + Some(field) => { + window.focus(&field.read(cx).focus_handle()); + field.update(cx, |field, cx| field.select_all(window, cx)); + } + None => { + let editor_focus = self.connection_state.editor.focus_handle(); + window.focus(&editor_focus); + } + } } cx.notify(); } pub(in crate::features) fn close_connection_editor(&mut self, cx: &mut Context) { self.connection_state.editor.close(); + self.connection_state.clear_editor_fields(); self.terminal.view.status = "connection editor closed".to_string(); cx.notify(); } @@ -188,6 +205,17 @@ impl NyaTermApp { cx.notify(); } + /// Take an edit from a field widget into the draft. + pub(in crate::features) fn apply_connection_editor_field_text( + &mut self, + field: ConnectionEditorField, + text: String, + cx: &mut Context, + ) { + self.connection_state.set_editor_field_text(field, text); + cx.notify(); + } + pub(in crate::features) fn focus_connection_editor_field( &mut self, field: ConnectionEditorField, diff --git a/crates/nyaterm-desktop/src/features/connections/connection_runtime/groups.rs b/crates/nyaterm-desktop/src/features/connections/connection_runtime/groups.rs index b765045f8..59af98297 100644 --- a/crates/nyaterm-desktop/src/features/connections/connection_runtime/groups.rs +++ b/crates/nyaterm-desktop/src/features/connections/connection_runtime/groups.rs @@ -43,14 +43,17 @@ impl NyaTermApp { parent_id, error: None, }); + self.connection_state.build_group_editor_field(cx); self.terminal.view.status = "connection group editor opened".to_string(); - let group_editor_focus = self.connection_state.group_editor.focus_handle(); - window.focus(&group_editor_focus); + if let Some(field) = self.connection_state.group_editor_field() { + window.focus(&field.read(cx).focus_handle()); + } cx.notify(); } pub(in crate::features) fn close_connection_group_editor(&mut self, cx: &mut Context) { self.connection_state.group_editor.close(); + self.connection_state.clear_group_editor_field(); self.terminal.view.status = "connection group editor closed".to_string(); cx.notify(); } diff --git a/crates/nyaterm-desktop/src/features/connections/state.rs b/crates/nyaterm-desktop/src/features/connections/state.rs index bd05064e0..5858fd768 100644 --- a/crates/nyaterm-desktop/src/features/connections/state.rs +++ b/crates/nyaterm-desktop/src/features/connections/state.rs @@ -1,4 +1,4 @@ -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use gpui::{ App, AppContext as _, Context, Entity, FocusHandle, Pixels, SharedString, Subscription, @@ -30,9 +30,9 @@ use self::editor_logic::{ apply_connection_group_editor_name_key, clear_connection_editor_group_menu_draft, clear_connection_editor_runtime_state, commit_connection_editor_new_group, connection_editor_inline_panel_draft, connection_editor_window_open_or_pending, - finish_connection_editor_save_state, focus_connection_editor_field, + editor_field_seeds, finish_connection_editor_save_state, focus_connection_editor_field, insert_connection_editor_description_newline, set_connection_editor_advanced_tab, - set_connection_editor_error, set_connection_editor_icon, + set_connection_editor_error, set_connection_editor_field_text, set_connection_editor_icon, set_connection_editor_icon_auto_detect, set_connection_editor_kind, set_connection_editor_menu_value, set_connection_editor_password_source, set_connection_editor_telnet_tab, set_connection_group_editor_error, @@ -120,6 +120,14 @@ pub(in crate::features) struct ConnectionImportState { pub(in crate::features) struct ConnectionEditorFeatureState { draft: Option, + /// One editable field per text input, built when the editor opens. + /// + /// The draft above stays the source of truth for saving; these own the + /// caret, selection and composition, and write back through their + /// subscriptions. Keeping them out of `ConnectionEditorState` keeps that + /// model a plain value the runtime can clone. + fields: HashMap>, + field_subscriptions: Vec, window: Option>, window_open_pending: bool, focus: FocusHandle, @@ -129,6 +137,9 @@ pub(in crate::features) struct ConnectionEditorFeatureState { pub(in crate::features) struct ConnectionGroupEditorFeatureState { draft: Option, + /// The folder-name input, built with the draft it mirrors. + field: Option>, + field_subscription: Option, focus: FocusHandle, } @@ -205,6 +216,8 @@ impl ConnectionFeatureState { }, editor: ConnectionEditorFeatureState { draft: None, + fields: HashMap::new(), + field_subscriptions: Vec::new(), window: None, window_open_pending: false, focus: focus.editor, @@ -213,6 +226,8 @@ impl ConnectionFeatureState { }, group_editor: ConnectionGroupEditorFeatureState { draft: None, + field: None, + field_subscription: None, focus: focus.group_editor, }, confirmations: ConnectionConfirmationState { @@ -239,6 +254,75 @@ impl ConnectionFeatureState { } } + /// The editor's fields, for handing to the render sections. + pub fn editor_fields(&self) -> &HashMap> { + &self.editor.fields + } + + /// Build a field per input and wire each back into the draft. + /// + /// Called when the editor opens, so the entities live exactly as long as the + /// draft they mirror and never leak between edits. + pub fn build_editor_fields(&mut self, cx: &mut Context) { + self.editor.fields.clear(); + self.editor.field_subscriptions.clear(); + let Some(draft) = self.editor.draft.as_ref() else { + return; + }; + for (field, value, masked) in editor_field_seeds(draft) { + let entity = cx.new(|cx| TextField::new(cx, value).masked(masked)); + let subscription = cx.subscribe(&entity, move |app: &mut NyaTermApp, _, event, cx| { + let TextFieldEvent::Changed(text) = event; + app.apply_connection_editor_field_text(field, text.clone(), cx); + }); + self.editor.fields.insert(field, entity); + self.editor.field_subscriptions.push(subscription); + } + } + + pub fn set_editor_field_text(&mut self, field: ConnectionEditorField, text: String) { + if let Some(draft) = self.editor.draft.as_mut() { + set_connection_editor_field_text(draft, field, text); + } + } + + pub fn group_editor_field(&self) -> Option> { + self.group_editor.field.clone() + } + + pub fn build_group_editor_field(&mut self, cx: &mut Context) { + let Some(draft) = self.group_editor.draft.as_ref() else { + self.group_editor.field = None; + self.group_editor.field_subscription = None; + return; + }; + let entity = cx.new(|cx| TextField::new(cx, draft.name.clone())); + let subscription = cx.subscribe(&entity, |app: &mut NyaTermApp, _, event, cx| { + let TextFieldEvent::Changed(text) = event; + app.connection_state.set_group_editor_name(text.clone()); + cx.notify(); + }); + self.group_editor.field = Some(entity); + self.group_editor.field_subscription = Some(subscription); + } + + pub fn clear_group_editor_field(&mut self) { + self.group_editor.field = None; + self.group_editor.field_subscription = None; + } + + pub fn set_group_editor_name(&mut self, name: String) { + if let Some(draft) = self.group_editor.draft.as_mut() { + draft.name = name; + draft.error = None; + } + } + + pub fn clear_editor_fields(&mut self) { + self.editor.fields.clear(); + self.editor.field_subscriptions.clear(); + } + pub fn finish_editor_save(&mut self, connection_id: String, group_id: Option) { finish_connection_editor_save_state( &mut self.editor.draft, diff --git a/crates/nyaterm-desktop/src/features/connections/state/editor_logic.rs b/crates/nyaterm-desktop/src/features/connections/state/editor_logic.rs index 24c40bd38..40ed476e0 100644 --- a/crates/nyaterm-desktop/src/features/connections/state/editor_logic.rs +++ b/crates/nyaterm-desktop/src/features/connections/state/editor_logic.rs @@ -511,3 +511,96 @@ pub(super) fn set_connection_group_editor_error( editor.error = Some(error); true } + +/// Which draft strings become editable fields, and which are secrets. +/// +/// Driven off the draft rather than a fixed list so a field that does not apply +/// to the current kind is simply never built. +pub(super) fn editor_field_seeds( + draft: &ConnectionEditorState, +) -> Vec<(ConnectionEditorField, String, bool)> { + vec![ + (ConnectionEditorField::Name, draft.name.clone(), false), + ( + ConnectionEditorField::Description, + draft.description.clone(), + false, + ), + ( + ConnectionEditorField::NewGroupName, + draft.new_group_name.clone(), + false, + ), + (ConnectionEditorField::Host, draft.host.clone(), false), + (ConnectionEditorField::Port, draft.port.clone(), false), + ( + ConnectionEditorField::Username, + draft.username.clone(), + false, + ), + ( + ConnectionEditorField::Password, + draft.password.clone(), + true, + ), + ( + ConnectionEditorField::ShellPath, + draft.shell_path.clone(), + false, + ), + ( + ConnectionEditorField::ShellArgs, + draft.shell_args.clone(), + false, + ), + ( + ConnectionEditorField::WorkingDir, + draft.working_dir.clone(), + false, + ), + ( + ConnectionEditorField::SerialPort, + draft.serial_port.clone(), + false, + ), + ( + ConnectionEditorField::BaudRate, + draft.baud_rate.clone(), + false, + ), + ( + ConnectionEditorField::PostLoginCommand, + draft.post_login_command.clone(), + false, + ), + ( + ConnectionEditorField::PostLoginDelay, + draft.post_login_delay_ms.clone(), + false, + ), + ] +} + +/// Write an edited field back into the draft. +pub(super) fn set_connection_editor_field_text( + draft: &mut ConnectionEditorState, + field: ConnectionEditorField, + text: String, +) { + match field { + ConnectionEditorField::Name => draft.name = text, + ConnectionEditorField::Description => draft.description = text, + ConnectionEditorField::NewGroupName => draft.new_group_name = text, + ConnectionEditorField::Host => draft.host = text, + ConnectionEditorField::Port => draft.port = text, + ConnectionEditorField::Username => draft.username = text, + ConnectionEditorField::Password => draft.password = text, + ConnectionEditorField::ShellPath => draft.shell_path = text, + ConnectionEditorField::ShellArgs => draft.shell_args = text, + ConnectionEditorField::WorkingDir => draft.working_dir = text, + ConnectionEditorField::SerialPort => draft.serial_port = text, + ConnectionEditorField::BaudRate => draft.baud_rate = text, + ConnectionEditorField::PostLoginCommand => draft.post_login_command = text, + ConnectionEditorField::PostLoginDelay => draft.post_login_delay_ms = text, + } +} diff --git a/crates/nyaterm-desktop/src/features/pages/connections/editor/connection/local.rs b/crates/nyaterm-desktop/src/features/pages/connections/editor/connection/local.rs index e1da16159..be1ccc025 100644 --- a/crates/nyaterm-desktop/src/features/pages/connections/editor/connection/local.rs +++ b/crates/nyaterm-desktop/src/features/pages/connections/editor/connection/local.rs @@ -7,15 +7,18 @@ use gpui::{ use crate::features::{NyaTermApp, small_button}; use crate::models::{ConnectionEditorField, ConnectionEditorMenu, ConnectionEditorState}; -use super::super::super::list::{ConnectionEditorChoice, connection_editor_select, editor_field}; +use super::super::super::list::{ + ConnectionEditorChoice, ConnectionEditorFields, connection_editor_select, editor_field, +}; pub(super) fn connection_editor_local_section( palette: crate::theme::ThemePalette, - editor: &ConnectionEditorState, + _editor: &ConnectionEditorState, shell_label: &'static str, shell_options: Vec, open_menu: Option, language: &str, + fields: &ConnectionEditorFields, cx: &mut Context, ) -> gpui::Div { let tr = |key: &'static str| crate::i18n::text(language, key); @@ -56,17 +59,10 @@ pub(super) fn connection_editor_local_section( ) .child(div().min_w_0().flex_1().child(editor_field( palette, - "connection-editor-shell", "", - editor.shell_path.clone(), - editor.focused_field == ConnectionEditorField::ShellPath, - cx.listener(|this, _, window, cx| { - this.focus_connection_editor_field( - ConnectionEditorField::ShellPath, - window, - cx, - ); - }), + ConnectionEditorField::ShellPath, + fields, + cx, ))) .child( div() @@ -96,13 +92,10 @@ pub(super) fn connection_editor_local_section( ) .child(editor_field( palette, - "connection-editor-args", tr("dialog.shellArgs"), - editor.shell_args.clone(), - editor.focused_field == ConnectionEditorField::ShellArgs, - cx.listener(|this, _, window, cx| { - this.focus_connection_editor_field(ConnectionEditorField::ShellArgs, window, cx); - }), + ConnectionEditorField::ShellArgs, + fields, + cx, )) .child( div() @@ -111,17 +104,10 @@ pub(super) fn connection_editor_local_section( .gap_2() .child(div().min_w_0().flex_1().child(editor_field( palette, - "connection-editor-cwd", tr("dialog.workingDir"), - editor.working_dir.clone(), - editor.focused_field == ConnectionEditorField::WorkingDir, - cx.listener(|this, _, window, cx| { - this.focus_connection_editor_field( - ConnectionEditorField::WorkingDir, - window, - cx, - ); - }), + ConnectionEditorField::WorkingDir, + fields, + cx, ))) .child(small_button( palette, diff --git a/crates/nyaterm-desktop/src/features/pages/connections/editor/connection/mod.rs b/crates/nyaterm-desktop/src/features/pages/connections/editor/connection/mod.rs index e5dac62e7..ecb54ac7e 100644 --- a/crates/nyaterm-desktop/src/features/pages/connections/editor/connection/mod.rs +++ b/crates/nyaterm-desktop/src/features/pages/connections/editor/connection/mod.rs @@ -54,6 +54,7 @@ impl NyaTermApp { ) -> AnyElement { let palette = self.theme_palette(); let language = self.settings.language.clone(); + let fields = self.connection_state.editor_fields().clone(); let title = if editor.id.is_some() { self.tr("dialog.editConnection") } else { @@ -373,15 +374,6 @@ impl NyaTermApp { selected: editor.shell_path == value, }) .collect::>(); - let password_display = if editor.password.is_empty() { - if editor.existing_password.is_some() { - self.tr("dialog.passwordAlreadySet").to_string() - } else { - String::new() - } - } else { - "•".repeat(editor.password.chars().count().min(24)) - }; let icon_key = editor.icon.as_deref(); let icon_def = resolve_connection_icon(icon_key, editor.kind.label()); let icon_picker_open = self.connection_state.editor.icon_picker_is_open(); @@ -538,12 +530,10 @@ impl NyaTermApp { .flex_col() .gap_3() .overflow_hidden() + // No blanket focus grab here: it existed to keep the old label-div + // inputs "focused", and would now steal focus back from whichever + // field the pointer just landed on, since click follows mouse-down. .track_focus(&editor_focus) - .on_click(cx.listener(|this, _, window, cx| { - let editor_focus = this.connection_state.editor.focus_handle(); - window.focus(&editor_focus); - cx.notify(); - })) .on_key_down(cx.listener(|this, event: &KeyDownEvent, window, cx| { cx.stop_propagation(); this.handle_connection_editor_key_down(event, window, cx); @@ -618,17 +608,10 @@ impl NyaTermApp { .child(icon_picker) .child(div().min_w(px(192.)).flex_1().child(editor_field( palette, - "connection-editor-name", name_label, - editor.name.clone(), - editor.focused_field == ConnectionEditorField::Name, - cx.listener(|this, _, window, cx| { - this.focus_connection_editor_field( - ConnectionEditorField::Name, - window, - cx, - ); - }), + ConnectionEditorField::Name, + &fields, + cx, ))) .child(div().min_w(px(192.)).max_w(px(288.)).flex_1().child( connection_editor_group_select( @@ -649,7 +632,6 @@ impl NyaTermApp { this.child(connection_editor_ssh_section( palette, &editor, - password_display.clone(), password_label.clone(), key_label.clone(), otp_label.clone(), @@ -664,6 +646,7 @@ impl NyaTermApp { backspace_options.clone(), active_menu, &language, + &fields, cx, )) }) @@ -675,6 +658,7 @@ impl NyaTermApp { shell_options, active_menu, &language, + &fields, cx, )) }) @@ -685,6 +669,7 @@ impl NyaTermApp { backspace_options.clone(), active_menu, &language, + &fields, cx, )) }) @@ -700,6 +685,7 @@ impl NyaTermApp { backspace_options, active_menu, &language, + &fields, cx, )) }) diff --git a/crates/nyaterm-desktop/src/features/pages/connections/editor/connection/serial.rs b/crates/nyaterm-desktop/src/features/pages/connections/editor/connection/serial.rs index aa76ee03a..1b238f307 100644 --- a/crates/nyaterm-desktop/src/features/pages/connections/editor/connection/serial.rs +++ b/crates/nyaterm-desktop/src/features/pages/connections/editor/connection/serial.rs @@ -7,7 +7,9 @@ use gpui::{ use crate::features::NyaTermApp; use crate::models::{ConnectionEditorField, ConnectionEditorMenu, ConnectionEditorState}; -use super::super::super::list::{ConnectionEditorChoice, connection_editor_select, editor_field}; +use super::super::super::list::{ + ConnectionEditorChoice, ConnectionEditorFields, connection_editor_select, editor_field, +}; pub(super) fn connection_editor_serial_section( palette: crate::theme::ThemePalette, @@ -20,6 +22,7 @@ pub(super) fn connection_editor_serial_section( backspace_options: Vec, open_menu: Option, language: &str, + fields: &ConnectionEditorFields, cx: &mut Context, ) -> gpui::Div { let tr = |key: &'static str| crate::i18n::text(language, key); @@ -66,17 +69,10 @@ pub(super) fn connection_editor_serial_section( .gap_1() .child(div().min_w_0().flex_1().child(editor_field( palette, - "connection-editor-baud", tr("dialog.baudRate"), - editor.baud_rate.clone(), - editor.focused_field == ConnectionEditorField::BaudRate, - cx.listener(|this, _, window, cx| { - this.focus_connection_editor_field( - ConnectionEditorField::BaudRate, - window, - cx, - ); - }), + ConnectionEditorField::BaudRate, + fields, + cx, ))) .child(div().w(px(72.)).flex_none().child(connection_editor_select( palette, diff --git a/crates/nyaterm-desktop/src/features/pages/connections/editor/connection/ssh.rs b/crates/nyaterm-desktop/src/features/pages/connections/editor/connection/ssh.rs index d6447dfe1..48ff423a7 100644 --- a/crates/nyaterm-desktop/src/features/pages/connections/editor/connection/ssh.rs +++ b/crates/nyaterm-desktop/src/features/pages/connections/editor/connection/ssh.rs @@ -15,7 +15,8 @@ use crate::models::{ }; use super::super::super::list::{ - ConnectionEditorChoice, connection_editor_select, editor_field, toggle_chip, + ConnectionEditorChoice, ConnectionEditorFields, connection_editor_select, editor_field, + toggle_chip, }; fn ssh_segment_tab( @@ -99,7 +100,6 @@ fn ssh_advanced_content( pub(super) fn connection_editor_ssh_section( palette: crate::theme::ThemePalette, editor: &ConnectionEditorState, - password_display: String, password_label: String, key_label: String, otp_label: String, @@ -114,6 +114,7 @@ pub(super) fn connection_editor_ssh_section( backspace_options: Vec, open_menu: Option, language: &str, + fields: &ConnectionEditorFields, cx: &mut Context, ) -> gpui::Div { let tr = |key: &'static str| crate::i18n::text(language, key); @@ -295,34 +296,25 @@ pub(super) fn connection_editor_ssh_section( .gap_2() .child(editor_field( palette, - "connection-editor-host", tr("dialog.host"), - editor.host.clone(), - editor.focused_field == ConnectionEditorField::Host, - cx.listener(|this, _, window, cx| { - this.focus_connection_editor_field(ConnectionEditorField::Host, window, cx); - }), + ConnectionEditorField::Host, + fields, + cx, )) .child(editor_field( palette, - "connection-editor-port", tr("dialog.port"), - editor.port.clone(), - editor.focused_field == ConnectionEditorField::Port, - cx.listener(|this, _, window, cx| { - this.focus_connection_editor_field(ConnectionEditorField::Port, window, cx); - }), + ConnectionEditorField::Port, + fields, + cx, )), ) .child(editor_field( palette, - "connection-editor-username", tr("dialog.username"), - editor.username.clone(), - editor.focused_field == ConnectionEditorField::Username, - cx.listener(|this, _, window, cx| { - this.focus_connection_editor_field(ConnectionEditorField::Username, window, cx); - }), + ConnectionEditorField::Username, + fields, + cx, )) .child( div() @@ -382,17 +374,10 @@ pub(super) fn connection_editor_ssh_section( |this| { this.child(editor_field( palette, - "connection-editor-password", tr("dialog.password"), - password_display.clone(), - editor.focused_field == ConnectionEditorField::Password, - cx.listener(|this, _, window, cx| { - this.focus_connection_editor_field( - ConnectionEditorField::Password, - window, - cx, - ); - }), + ConnectionEditorField::Password, + fields, + cx, )) }, ) @@ -563,34 +548,18 @@ pub(super) fn connection_editor_ssh_section( .when(editor.post_login_enabled, |this| { this.child(editor_field( palette, - "connection-editor-post-login-command", tr("dialog.postLoginCommandContent"), - editor.post_login_command.clone(), - editor.focused_field - == ConnectionEditorField::PostLoginCommand, - cx.listener(|this, _, window, cx| { - this.focus_connection_editor_field( - ConnectionEditorField::PostLoginCommand, - window, - cx, - ); - }), + ConnectionEditorField::PostLoginCommand, + fields, + cx, )) .child( editor_field( palette, - "connection-editor-post-login-delay", tr("dialog.postLoginDelay"), - editor.post_login_delay_ms.clone(), - editor.focused_field - == ConnectionEditorField::PostLoginDelay, - cx.listener(|this, _, window, cx| { - this.focus_connection_editor_field( - ConnectionEditorField::PostLoginDelay, - window, - cx, - ); - }), + ConnectionEditorField::PostLoginDelay, + fields, + cx, ), ) }), diff --git a/crates/nyaterm-desktop/src/features/pages/connections/editor/connection/telnet.rs b/crates/nyaterm-desktop/src/features/pages/connections/editor/connection/telnet.rs index 33b6b2e83..d6c856f53 100644 --- a/crates/nyaterm-desktop/src/features/pages/connections/editor/connection/telnet.rs +++ b/crates/nyaterm-desktop/src/features/pages/connections/editor/connection/telnet.rs @@ -11,7 +11,9 @@ use crate::models::{ ConnectionEditorField, ConnectionEditorMenu, ConnectionEditorState, ConnectionEditorTelnetTab, }; -use super::super::super::list::{ConnectionEditorChoice, connection_editor_select, editor_field}; +use super::super::super::list::{ + ConnectionEditorChoice, ConnectionEditorFields, connection_editor_select, editor_field, +}; fn telnet_segment_tab( palette: crate::theme::ThemePalette, @@ -132,6 +134,7 @@ pub(super) fn connection_editor_telnet_section( backspace_options: Vec, open_menu: Option, language: &str, + fields: &ConnectionEditorFields, cx: &mut Context, ) -> gpui::Div { let tr = |key: &'static str| crate::i18n::text(language, key); @@ -194,23 +197,17 @@ pub(super) fn connection_editor_telnet_section( .gap_2() .child(editor_field( palette, - "connection-editor-telnet-host", tr("dialog.host"), - editor.host.clone(), - editor.focused_field == ConnectionEditorField::Host, - cx.listener(|this, _, window, cx| { - this.focus_connection_editor_field(ConnectionEditorField::Host, window, cx); - }), + ConnectionEditorField::Host, + fields, + cx, )) .child(editor_field( palette, - "connection-editor-telnet-port", tr("dialog.port"), - editor.port.clone(), - editor.focused_field == ConnectionEditorField::Port, - cx.listener(|this, _, window, cx| { - this.focus_connection_editor_field(ConnectionEditorField::Port, window, cx); - }), + ConnectionEditorField::Port, + fields, + cx, )), ) .child( diff --git a/crates/nyaterm-desktop/src/features/pages/connections/editor/group_delete.rs b/crates/nyaterm-desktop/src/features/pages/connections/editor/group_delete.rs index 6a2142ad3..4b4c3a3ab 100644 --- a/crates/nyaterm-desktop/src/features/pages/connections/editor/group_delete.rs +++ b/crates/nyaterm-desktop/src/features/pages/connections/editor/group_delete.rs @@ -15,8 +15,6 @@ use crate::models::{ ConnectionGroupOpenConfirmState, }; -use super::super::list::editor_field; - impl NyaTermApp { pub(in crate::features) fn connection_group_editor_panel( &mut self, @@ -52,18 +50,26 @@ impl NyaTermApp { .text_color(rgb(palette.text)) .child(title), ) - .child(editor_field( - palette, - "connection-group-name", - self.tr("savedConnections.folderName"), - editor.name.clone(), - true, - cx.listener(|this, _, window, cx| { - let group_editor_focus = this.connection_state.group_editor.focus_handle(); - window.focus(&group_editor_focus); - cx.notify(); - }), - )) + .children(self.connection_state.group_editor_field().map(|field| { + div() + .h(px(36.)) + .px_3() + .py_1() + .flex() + .flex_col() + .justify_center() + .rounded_sm() + .border_1() + .border_color(rgb(palette.primary)) + .bg(rgb(palette.input)) + .child( + div() + .text_xs() + .text_color(rgb(palette.text_muted)) + .child(self.tr("savedConnections.folderName")), + ) + .child(div().min_w_0().flex_1().text_xs().child(field)) + })) .when_some(editor.error.clone(), |this, error| { this.child( div() diff --git a/crates/nyaterm-desktop/src/features/pages/connections/list.rs b/crates/nyaterm-desktop/src/features/pages/connections/list.rs index 6d228e5bf..31b73ab72 100644 --- a/crates/nyaterm-desktop/src/features/pages/connections/list.rs +++ b/crates/nyaterm-desktop/src/features/pages/connections/list.rs @@ -1,7 +1,8 @@ use std::collections::HashMap; use gpui::{ - App, AppContext as _, ClickEvent, Context, FontWeight, IntoElement, SharedString, Window, div, + App, AppContext as _, ClickEvent, Context, Entity, FontWeight, IntoElement, SharedString, + Window, div, prelude::{ FluentBuilder, InteractiveElement, ParentElement, StatefulInteractiveElement, Styled, }, @@ -9,8 +10,9 @@ use gpui::{ }; use nyaterm_core::{Group, ProxyConfig, SavedConnection, natural_compare, truncate_preview}; -use crate::features::{NyaTermApp, format_last_used_ms, transfer_input}; -use crate::models::{ConnectionEditorMenu, ConnectionSortMode}; +use crate::features::{NyaTermApp, format_last_used_ms}; +use crate::models::{ConnectionEditorField, ConnectionEditorMenu, ConnectionSortMode}; +use nyaterm_ui::TextField; #[derive(Clone)] pub(super) enum ConnectionListRow { @@ -921,15 +923,62 @@ mod tests { } } +/// The editor's text fields, resolved once per render. +/// +/// Sections receive this rather than the app, because they are free functions +/// that already take a `Context` and cannot borrow `NyaTermApp` again. +pub(super) type ConnectionEditorFields = HashMap>; + +/// A labelled row wrapping one editable field. pub(super) fn editor_field( palette: crate::theme::ThemePalette, - id: impl Into, label: &'static str, - value: String, - active: bool, - on_click: impl Fn(&gpui::ClickEvent, &mut gpui::Window, &mut gpui::App) + 'static, + field: ConnectionEditorField, + fields: &ConnectionEditorFields, + cx: &App, ) -> impl IntoElement { - transfer_input(id, label, value, active, palette).on_click(on_click) + // The whole labelled row is the hit target: the field itself is only one + // text line tall, so clicking the label or the padding would otherwise miss. + let entity = fields.get(&field); + let handle = entity.map(|field| field.read(cx).focus_handle()); + let focused = entity.is_some_and(|field| field.read(cx).has_focus()); + let mut row = div() + .h(px(36.)) + .px_3() + .py_1() + .flex() + .flex_col() + .justify_center() + .gap_0() + .rounded_sm() + .border_1() + .border_color(rgb(if focused { + palette.primary + } else { + palette.border + })) + .bg(rgb(palette.input)); + if !label.is_empty() { + row = row.child( + div() + .text_xs() + .text_color(rgb(palette.text_muted)) + .child(label), + ); + } + row.when_some(handle, |row, handle| { + row.on_mouse_down(gpui::MouseButton::Left, move |_, window, _| { + window.focus(&handle); + }) + }) + .children(fields.get(&field).map(|field| { + div() + .min_w_0() + .flex_1() + .text_xs() + .text_color(rgb(palette.text)) + .child(field.clone()) + })) } pub(super) fn icon_action_button( diff --git a/crates/nyaterm-desktop/src/models/connections.rs b/crates/nyaterm-desktop/src/models/connections.rs index 8a42de3f2..dcfe6222c 100644 --- a/crates/nyaterm-desktop/src/models/connections.rs +++ b/crates/nyaterm-desktop/src/models/connections.rs @@ -79,7 +79,7 @@ pub(crate) enum ConnectionEditorTelnetTab { Compatibility, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub(crate) enum ConnectionEditorField { Name, NewGroupName, diff --git a/crates/nyaterm-ui/src/text_field.rs b/crates/nyaterm-ui/src/text_field.rs index 82799ea12..6116a4348 100644 --- a/crates/nyaterm-ui/src/text_field.rs +++ b/crates/nyaterm-ui/src/text_field.rs @@ -54,6 +54,9 @@ pub struct TextField { /// Horizontal scroll, so a caret past the right edge stays visible. scroll_x: Pixels, caret_visible: bool, + /// Focus as of the last render, so owners can style their own chrome with + /// only an `&App` — focus itself is a window-scoped question. + focused: bool, blink: Option>, selecting: bool, } @@ -68,6 +71,7 @@ impl TextField { marked: None, scroll_x: px(0.), caret_visible: true, + focused: false, blink: None, selecting: false, } @@ -95,6 +99,10 @@ impl TextField { self.focus.clone() } + pub fn has_focus(&self) -> bool { + self.focused + } + /// 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(); @@ -289,6 +297,7 @@ fn buffer_offset(edit: &TextEdit, masked: bool, display: usize) -> usize { impl Render for TextField { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let focused = self.focus.is_focused(window); + self.focused = focused; div() .id("text-field") .track_focus(&self.focus)