feat(settings): give the panels a real text input to share

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.
This commit is contained in:
Kang
2026-07-27 02:30:31 +08:00
parent 41d8e1c314
commit f8fe4ef16b
6 changed files with 276 additions and 163 deletions
@@ -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,
@@ -33,6 +33,9 @@ pub struct NyaTermApp {
pub(in crate::features) pending_saved_connection_queue: VecDeque<PendingSavedConnectionStart>,
pub(in crate::features) connection_groups: Vec<Group>,
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<SshKey>,
pub(in crate::features) connection_otp_entries: Vec<OtpEntry>,
pub(in crate::features) connection_saved_passwords: Vec<SavedPassword>,
@@ -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,
@@ -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.))
@@ -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:
/// `<index>.name` or `<index>.url`.
pub(in crate::features) fn apply_search_engine_input(
&mut self,
rest: &str,
text: String,
cx: &mut Context<Self>,
) {
let Some((index, field)) = rest.split_once('.') else {
return;
};
let Ok(index) = index.parse::<usize>() 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>) {
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<Self>,
) {
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>,
) {
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);
}
@@ -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<SharedString>) -> Self {
Self {
placeholder: placeholder.into(),
..Default::default()
}
}
pub fn masked() -> Self {
Self {
masked: true,
..Default::default()
}
}
pub fn multi_line(placeholder: impl Into<SharedString>) -> Self {
Self {
placeholder: placeholder.into(),
masked: false,
multi_line: true,
}
}
}
#[derive(Default)]
pub(in crate::features) struct TextInputRegistry {
fields: HashMap<SharedString, Entity<TextField>>,
/// Kept alive alongside its field, so edits keep arriving.
subscriptions: HashMap<SharedString, Subscription>,
}
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<SharedString>,
seed: &str,
setup: TextInputSetup,
cx: &mut Context<Self>,
) -> Entity<TextField> {
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<SharedString>,
seed: &str,
setup: TextInputSetup,
cx: &mut Context<Self>,
) -> 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<String> {
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.<index>.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<Self>) {
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));
}
}