From f8fe4ef16b9c331ee27958bb772bbc8aeb27c422 Mon Sep 17 00:00:00 2001 From: Kang Date: Mon, 27 Jul 2026 02:30:31 +0800 Subject: [PATCH] feat(settings): give the panels a real text input to share MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Outside the connection editor every "input" in the app is a label div over a draft string, with a focus handle per box and a hand-written key handler — no caret, no selection, no IME, no clipboard, and a little of it reimplemented in each panel. The search-engine editor showed the failure plainly: the placeholder and the value were stacked inside one box that only had room for one of them. The connection editor solved this by owning a TextField entity per field. Threading that map through every panel's state would be a lot of churn, so the fields live in one registry keyed by a string id, created the first time a panel renders one. A panel keeps no state beyond the value it already had, and edits arrive as one event with the id attached, which the registry routes by prefix. The search-engine editor is the first caller: its two boxes are real inputs now, and the key handler and focus-steering they needed are gone. Structural changes forget the ids they invalidate — adding an engine shifts every row's index. --- .../src/features/app_state/construct.rs | 1 + .../src/features/app_state/mod.rs | 3 + crates/nyaterm-desktop/src/features/mod.rs | 2 + .../pages/settings/terminal/search.rs | 119 +++-------- .../settings_runtime/search_engines.rs | 115 ++++------ .../src/features/text_inputs.rs | 199 ++++++++++++++++++ 6 files changed, 276 insertions(+), 163 deletions(-) create mode 100644 crates/nyaterm-desktop/src/features/text_inputs.rs diff --git a/crates/nyaterm-desktop/src/features/app_state/construct.rs b/crates/nyaterm-desktop/src/features/app_state/construct.rs index 711bbec6c..09bc913bf 100644 --- a/crates/nyaterm-desktop/src/features/app_state/construct.rs +++ b/crates/nyaterm-desktop/src/features/app_state/construct.rs @@ -438,6 +438,7 @@ impl NyaTermApp { search_engine_actions_index: None, search_engine_edit_field: SearchEngineEditorField::Name, search_engine_focus: cx.focus_handle(), + text_inputs: TextInputRegistry::default(), action_link_menu: None, action_link_tooltip: None, action_link_hover_pending: None, diff --git a/crates/nyaterm-desktop/src/features/app_state/mod.rs b/crates/nyaterm-desktop/src/features/app_state/mod.rs index c7bdf9391..eb67e39b7 100644 --- a/crates/nyaterm-desktop/src/features/app_state/mod.rs +++ b/crates/nyaterm-desktop/src/features/app_state/mod.rs @@ -33,6 +33,9 @@ pub struct NyaTermApp { pub(in crate::features) pending_saved_connection_queue: VecDeque, pub(in crate::features) connection_groups: Vec, pub(in crate::features) connection_state: ConnectionFeatureState, + /// Real text inputs for the panels that have not been given their own, + /// keyed by an id the panel picks. See `features::text_inputs`. + pub(in crate::features) text_inputs: TextInputRegistry, pub(in crate::features) connection_ssh_keys: Vec, pub(in crate::features) connection_otp_entries: Vec, pub(in crate::features) connection_saved_passwords: Vec, diff --git a/crates/nyaterm-desktop/src/features/mod.rs b/crates/nyaterm-desktop/src/features/mod.rs index 0a5e09be6..feb149ae5 100644 --- a/crates/nyaterm-desktop/src/features/mod.rs +++ b/crates/nyaterm-desktop/src/features/mod.rs @@ -25,6 +25,7 @@ mod shell; mod sync; mod sync_input; mod terminal; +mod text_inputs; mod transfer_external_sync_window; mod transfers; mod translation; @@ -95,6 +96,7 @@ pub(in crate::features) use terminal::{ FULL_SHELL_PAINT_COUNT, TerminalFeatureFocus, TerminalFeatureState, full_shell_paint_count, terminal_surface_paint_count, }; +pub(in crate::features) use text_inputs::{TextInputRegistry, TextInputSetup}; pub(in crate::features) use transfer_external_sync_window::TransferExternalSyncWindow; pub(in crate::features) use transfers::{ TransferFeatureFocus, TransferFeatureState, duplicate_decision_label, duplicate_policy_label, diff --git a/crates/nyaterm-desktop/src/features/pages/settings/terminal/search.rs b/crates/nyaterm-desktop/src/features/pages/settings/terminal/search.rs index 130f78f0c..76b991b99 100644 --- a/crates/nyaterm-desktop/src/features/pages/settings/terminal/search.rs +++ b/crates/nyaterm-desktop/src/features/pages/settings/terminal/search.rs @@ -1,5 +1,5 @@ use super::*; -use crate::features::{SEARCH_ENGINE_ICON_IDS, mono_icon, search_engine_icon}; +use crate::features::{SEARCH_ENGINE_ICON_IDS, TextInputSetup, mono_icon, search_engine_icon}; impl NyaTermApp { pub(in crate::features) fn terminal_search_settings_section( @@ -20,6 +20,29 @@ impl NyaTermApp { let icon_picker_index = self.search_engine_icon_picker_index; let actions_index = self.search_engine_actions_index; let edit_field = self.search_engine_edit_field; + // Built before the row closure, which only has `&self`: the inputs are + // entities the app has to create, and only the expanded row shows them. + let mut editor_inputs = expanded_index.and_then(|index| { + let engine = engines.get(index)?.clone(); + let name_placeholder = self.tr("settings.engineName"); + let name = self + .text_input_box( + format!("settings.search-engine.{index}.name"), + &engine.name, + TextInputSetup::placeholder(name_placeholder), + cx, + ) + .into_any_element(); + let url = self + .text_input_box( + format!("settings.search-engine.{index}.url"), + &engine.url_template, + TextInputSetup::placeholder("https://google.com/search?q=%s"), + cx, + ) + .into_any_element(); + Some((name, url)) + }); let add_action = search_engine_text_button( palette, "settings-search-engine-add", @@ -70,22 +93,16 @@ impl NyaTermApp { .overflow_hidden() .children(engines.into_iter().enumerate().map(|(index, engine)| { let is_open = expanded_index == Some(index); + let (name_input, url_input) = if is_open { + match editor_inputs.take() { + Some((name, url)) => (Some(name), Some(url)), + None => (None, None), + } + } else { + (None, None) + }; let icon_picker_open = icon_picker_index == Some(index); let actions_open = actions_index == Some(index); - let name_active = edit_index == Some(index) - && edit_field == SearchEngineEditorField::Name; - let url_active = edit_index == Some(index) - && edit_field == SearchEngineEditorField::Url; - let name_value = if engine.name.is_empty() { - " ".to_string() - } else { - engine.name.clone() - }; - let url_value = if engine.url_template.is_empty() { - " ".to_string() - } else { - engine.url_template.clone() - }; let has_placeholder = engine.url_template.contains("%s"); let icon_def = engine .icon @@ -364,41 +381,7 @@ impl NyaTermApp { "settings.engineName", )), ) - .child( - transfer_input( - format!( - "settings-search-engine-name-{index}" - ), - self.tr("settings.engineName"), - name_value, - name_active, - palette, - ) - .track_focus( - &self.search_engine_focus, - ) - .on_click(cx.listener( - move |this, _, window, cx| { - this.focus_search_engine_field( - index, - SearchEngineEditorField::Name, - window, - cx, - ); - }, - )) - .on_key_down(cx.listener( - |this, - event: &KeyDownEvent, - _, - cx| { - cx.stop_propagation(); - this.handle_search_engine_key_down( - event, cx, - ); - }, - )), - ), + .children(name_input), ) .child( div() @@ -416,41 +399,7 @@ impl NyaTermApp { "settings.engineUrl", )), ) - .child( - transfer_input( - format!( - "settings-search-engine-url-{index}" - ), - "https://google.com/search?q=%s", - url_value, - url_active, - palette, - ) - .track_focus( - &self.search_engine_focus, - ) - .on_click(cx.listener( - move |this, _, window, cx| { - this.focus_search_engine_field( - index, - SearchEngineEditorField::Url, - window, - cx, - ); - }, - )) - .on_key_down(cx.listener( - |this, - event: &KeyDownEvent, - _, - cx| { - cx.stop_propagation(); - this.handle_search_engine_key_down( - event, cx, - ); - }, - )), - ) + .children(url_input) .child( div() .text_size(px(10.)) diff --git a/crates/nyaterm-desktop/src/features/settings/settings_runtime/search_engines.rs b/crates/nyaterm-desktop/src/features/settings/settings_runtime/search_engines.rs index 8242ba880..2176152f4 100644 --- a/crates/nyaterm-desktop/src/features/settings/settings_runtime/search_engines.rs +++ b/crates/nyaterm-desktop/src/features/settings/settings_runtime/search_engines.rs @@ -4,6 +4,37 @@ use crate::models::SearchEngineEditorField; use nyaterm_core::SearchEngineConfig; impl NyaTermApp { + /// Apply an edit from one of the engine editor's inputs. + /// + /// `rest` is what follows `settings.search-engine.` in the field id: + /// `.name` or `.url`. + pub(in crate::features) fn apply_search_engine_input( + &mut self, + rest: &str, + text: String, + cx: &mut Context, + ) { + let Some((index, field)) = rest.split_once('.') else { + return; + }; + let Ok(index) = index.parse::() else { + return; + }; + let Some(engine) = self.settings.search_custom_engines.get_mut(index) else { + return; + }; + match field { + "name" => engine.name = text, + "url" => engine.url_template = text, + _ => return, + } + // Persist as it is typed, the way every other settings control does. + // Trimming waits for the row to close, so a space mid-word survives. + self.save_terminal_settings(cx); + self.terminal.view.status = "search engine edited".to_string(); + cx.notify(); + } + pub(in crate::features) fn add_search_engine(&mut self, cx: &mut Context) { self.settings.search_custom_engines.insert( 0, @@ -19,6 +50,9 @@ impl NyaTermApp { self.search_engine_icon_picker_index = None; self.search_engine_actions_index = None; self.search_engine_edit_field = SearchEngineEditorField::Name; + // The inputs are keyed by row index and every row just shifted down, so + // they have to be rebuilt from the engines they now stand for. + self.forget_text_inputs("settings.search-engine."); self.save_terminal_settings(cx); self.terminal.view.status = "search engine added".to_string(); } @@ -48,6 +82,7 @@ impl NyaTermApp { self.search_engine_edit_index = Some(edit - 1); } } + self.forget_text_inputs("settings.search-engine."); self.save_terminal_settings(cx); self.terminal.view.status = "search engine removed".to_string(); } @@ -79,84 +114,6 @@ impl NyaTermApp { self.save_terminal_settings(cx); } - pub(in crate::features) fn focus_search_engine_field( - &mut self, - index: usize, - field: SearchEngineEditorField, - window: &mut Window, - cx: &mut Context, - ) { - if index >= self.settings.search_custom_engines.len() { - return; - } - self.search_engine_edit_index = Some(index); - self.search_engine_edit_field = field; - window.focus(&self.search_engine_focus); - cx.notify(); - } - - pub(in crate::features) fn handle_search_engine_key_down( - &mut self, - event: &KeyDownEvent, - cx: &mut Context, - ) { - self.mark_user_activity(); - let Some(index) = self.search_engine_edit_index else { - return; - }; - if index >= self.settings.search_custom_engines.len() { - return; - } - let keystroke = &event.keystroke; - if keystroke.modifiers.platform || keystroke.modifiers.alt || keystroke.modifiers.control { - return; - } - match keystroke.key.as_str() { - "backspace" => { - let engine = &mut self.settings.search_custom_engines[index]; - match self.search_engine_edit_field { - SearchEngineEditorField::Name => { - engine.name.pop(); - } - SearchEngineEditorField::Url => { - engine.url_template.pop(); - } - } - self.terminal.view.status = "search engine edited".to_string(); - cx.notify(); - } - "tab" => { - self.search_engine_edit_field = self.search_engine_edit_field.next(); - cx.notify(); - } - "enter" => { - self.normalize_search_engines(); - self.save_terminal_settings(cx); - self.terminal.view.status = "search engines saved".to_string(); - } - "escape" => { - self.search_engine_edit_index = None; - self.terminal.view.status = "search engine input blurred".to_string(); - cx.notify(); - } - _ => { - if let Some(input) = keystroke - .key_char - .as_deref() - .filter(|input| !input.is_empty()) - { - let engine = &mut self.settings.search_custom_engines[index]; - match self.search_engine_edit_field { - SearchEngineEditorField::Name => engine.name.push_str(input), - SearchEngineEditorField::Url => engine.url_template.push_str(input), - } - self.terminal.view.status = "search engine edited".to_string(); - cx.notify(); - } - } - } - } - pub(in crate::features) fn expand_search_engine( &mut self, index: usize, @@ -165,6 +122,8 @@ impl NyaTermApp { if self.search_engine_expanded_index == Some(index) { self.search_engine_expanded_index = None; self.search_engine_edit_index = None; + self.normalize_search_engines(); + self.save_terminal_settings(cx); } else { self.search_engine_expanded_index = Some(index); } diff --git a/crates/nyaterm-desktop/src/features/text_inputs.rs b/crates/nyaterm-desktop/src/features/text_inputs.rs new file mode 100644 index 000000000..2105ccb5e --- /dev/null +++ b/crates/nyaterm-desktop/src/features/text_inputs.rs @@ -0,0 +1,199 @@ +//! A registry of real text inputs, keyed by an id the caller picks. +//! +//! Most panels still draw their "inputs" as a label div over a draft string, +//! with a focus handle per box and a hand-written key handler. That pattern has +//! no caret, no selection, no IME and no clipboard, and every panel reimplements +//! a little of it. +//! +//! The connection editor solved this by owning one [`TextField`] entity per +//! field. Doing the same everywhere would mean threading a map through every +//! panel's state. Instead the fields live here, keyed by a string id, and are +//! created the first time a panel renders one. A panel needs no state of its +//! own beyond the value it already keeps, and edits arrive as one event with the +//! id attached. + +use std::collections::HashMap; + +use gpui::{ + App, AppContext as _, Context, Entity, InteractiveElement as _, IntoElement, MouseButton, + ParentElement as _, SharedString, Styled as _, Subscription, div, prelude::FluentBuilder as _, + px, rgb, +}; +use nyaterm_ui::{TextField, TextFieldEvent}; + +use super::NyaTermApp; + +/// How a field should behave, for the one call that creates it. +/// +/// Only read when the field is first seen; later renders reuse the entity, so +/// changing this for an existing id has no effect until the id is forgotten. +#[derive(Default, Clone)] +pub(in crate::features) struct TextInputSetup { + pub placeholder: SharedString, + pub masked: bool, + pub multi_line: bool, +} + +impl TextInputSetup { + pub fn placeholder(placeholder: impl Into) -> Self { + Self { + placeholder: placeholder.into(), + ..Default::default() + } + } + + pub fn masked() -> Self { + Self { + masked: true, + ..Default::default() + } + } + + pub fn multi_line(placeholder: impl Into) -> Self { + Self { + placeholder: placeholder.into(), + masked: false, + multi_line: true, + } + } +} + +#[derive(Default)] +pub(in crate::features) struct TextInputRegistry { + fields: HashMap>, + /// Kept alive alongside its field, so edits keep arriving. + subscriptions: HashMap, +} + +impl NyaTermApp { + /// The input for `id`, created on first use and seeded with `seed`. + /// + /// After that the field owns its own text: `seed` is ignored, because the + /// field is the source of truth for what is being typed. Use + /// [`Self::reset_text_input`] to push a value back down, and + /// [`Self::forget_text_inputs`] when the thing being edited goes away. + pub(in crate::features) fn text_input( + &mut self, + id: impl Into, + seed: &str, + setup: TextInputSetup, + cx: &mut Context, + ) -> Entity { + let id = id.into(); + if let Some(field) = self.text_inputs.fields.get(&id) { + return field.clone(); + } + + let entity = cx.new(|cx| { + TextField::new(cx, seed) + .placeholder(setup.placeholder) + .masked(setup.masked) + .multi_line(setup.multi_line) + }); + let subscription_id = id.clone(); + let subscription = cx.subscribe(&entity, move |app: &mut NyaTermApp, _, event, cx| { + let TextFieldEvent::Changed(text) = event; + app.on_text_input_changed(subscription_id.clone(), text.clone(), cx); + }); + self.text_inputs.fields.insert(id.clone(), entity.clone()); + self.text_inputs.subscriptions.insert(id, subscription); + entity + } + + /// A bordered box hosting the input for `id`. + /// + /// The box is the hit target, so clicking anywhere in it takes the caret — + /// the text itself is only one line tall inside it. + pub(in crate::features) fn text_input_box( + &mut self, + id: impl Into, + seed: &str, + setup: TextInputSetup, + cx: &mut Context, + ) -> impl IntoElement { + let id = id.into(); + let palette = self.theme_palette(); + let multi_line = setup.multi_line; + let field = self.text_input(id.clone(), seed, setup, cx); + let handle = field.read(cx).focus_handle(); + let focused = field.read(cx).has_focus(); + div() + .id(id) + .when_else( + multi_line, + |this| this.min_h(px(72.)).py_2().items_start(), + |this| this.h(px(30.)).items_center(), + ) + .min_w_0() + .px_2() + .flex() + .rounded_sm() + .border_1() + .border_color(rgb(if focused { + palette.primary + } else { + palette.border + })) + .bg(rgb(palette.input)) + .cursor_text() + .on_mouse_down(MouseButton::Left, move |_, window, _| { + window.focus(&handle); + }) + .child( + div() + .min_w_0() + .flex_1() + .text_xs() + .text_color(rgb(palette.text)) + .child(field), + ) + } + + /// What the input for `id` currently holds, if it exists. + pub(in crate::features) fn text_input_value(&self, id: &str, cx: &App) -> Option { + self.text_inputs + .fields + .get(id) + .map(|field| field.read(cx).content().to_string()) + } + + /// Whether the input for `id` has the caret, as of the last frame. + pub(in crate::features) fn text_input_focused(&self, id: &str, cx: &App) -> bool { + self.text_inputs + .fields + .get(id) + .is_some_and(|field| field.read(cx).has_focus()) + } + + /// Push a value the runtime changed back into its input. + pub(in crate::features) fn reset_text_input(&mut self, id: &str, text: &str, cx: &mut App) { + if let Some(field) = self.text_inputs.fields.get(id) { + field.update(cx, |field, cx| field.set_content(text, cx)); + } + } + + /// Route an edit to the panel that owns the id. + /// + /// Ids are dotted and start with the panel, so a panel claims a whole + /// prefix: `settings.search-engine..name`. Anything unclaimed is + /// ignored rather than panicking — a field can outlive one frame of the + /// panel that made it. + fn on_text_input_changed(&mut self, id: SharedString, text: String, cx: &mut Context) { + if let Some(rest) = id.strip_prefix("settings.search-engine.") { + self.apply_search_engine_input(rest, text, cx); + } + } + + /// Drop every input whose id starts with `prefix`. + /// + /// Called when the thing being edited closes, so reopening it seeds fresh + /// values rather than showing what was typed into the previous one. + pub(in crate::features) fn forget_text_inputs(&mut self, prefix: &str) { + self.text_inputs + .fields + .retain(|id, _| !id.starts_with(prefix)); + self.text_inputs + .subscriptions + .retain(|id, _| !id.starts_with(prefix)); + } +}