mirror of
https://github.com/nyakang/nyaterm.git
synced 2026-10-05 00:01:00 +00:00
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:
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user