From 71a3e79abe08e521215ff3c5740c5cd2aa88f3d7 Mon Sep 17 00:00:00 2001 From: harryalbert Date: Wed, 15 Jul 2026 10:51:04 -0400 Subject: [PATCH] Route TUI raw text input by focus, GUI-style --- crates/warp_tui/src/editor_element.rs | 27 ++++++++-- crates/warp_tui/src/editor_element_tests.rs | 56 ++++++++++++++++++++- crates/warp_tui/src/input/view.rs | 25 ++++++++- 3 files changed, 103 insertions(+), 5 deletions(-) diff --git a/crates/warp_tui/src/editor_element.rs b/crates/warp_tui/src/editor_element.rs index edd8ef799be..39e534b59f1 100644 --- a/crates/warp_tui/src/editor_element.rs +++ b/crates/warp_tui/src/editor_element.rs @@ -109,6 +109,11 @@ pub(crate) struct TuiEditorElement { // ── Config ────────────────────────────────────────────────────────────── editable: bool, + /// Whether the owning view is focused, snapshotted at construction like + /// the GUI editor element's `view_snapshot.is_focused`. Editable elements + /// only consume typed text while focused, so several editable elements + /// can be rendered at once without contending for keystrokes. + is_focused: bool, /// Maximum visible rows for a scroll-windowed consumer; the first visible /// row comes from the render state's char-cell scroll offset. `None` /// renders full height. @@ -179,6 +184,7 @@ impl TuiEditorElement { sel_char_range, hidden_line_ranges, editable: false, + is_focused: false, viewport_rows: None, line_number_gutter: false, hide_trailing_empty_line: false, @@ -205,6 +211,14 @@ impl TuiEditorElement { self } + /// Records the owning view's focus state (tracked by the view via + /// `on_focus`/`on_blur`, like the GUI's `EditorView::focused`). Editable + /// consumers must pass this: typed text is only consumed while focused. + pub(crate) fn with_view_focused(mut self, is_focused: bool) -> Self { + self.is_focused = is_focused; + self + } + /// Window the rows to `max_visible_rows`, starting at the render state's /// char-cell scroll offset (owned model-side; consumers drive it via /// `CharCellState::follow_cursor` / `scroll_by`). Omitted = render all @@ -385,8 +399,10 @@ impl TuiEditorElement { if let Some(cursor) = cursor { self.cursor_col = cursor.col + self.gutter_cols; self.cursor_row_in_view = cursor.row.saturating_sub(first_visible_row) as u16; - self.cursor_visible = - self.editable && cursor.row >= first_visible_row && cursor.row < visible_end.max(1); + self.cursor_visible = self.editable + && self.is_focused + && cursor.row >= first_visible_row + && cursor.row < visible_end.max(1); } else { self.cursor_col = 0; self.cursor_row_in_view = 0; @@ -750,7 +766,12 @@ impl TuiElement for TuiEditorElement { return true; } - if self.editable { + // Raw text only flows into the focused view's editor: with several + // editable elements rendered at once (e.g. a prompt input plus a + // search field), only the focused one may consume typed characters, + // mirroring the GUI editor element's `is_focused` gate + // (`typed_characters` in `app/src/editor/view/element.rs`). + if self.editable && self.is_focused { match event { TuiEvent::KeyDown { keystroke, chars, .. diff --git a/crates/warp_tui/src/editor_element_tests.rs b/crates/warp_tui/src/editor_element_tests.rs index a70a4355e0f..935f4cfbfd5 100644 --- a/crates/warp_tui/src/editor_element_tests.rs +++ b/crates/warp_tui/src/editor_element_tests.rs @@ -12,6 +12,8 @@ use warpui_core::elements::tui::{ TuiLayoutContext, TuiPaintContext, TuiPaintSurface, TuiRect, TuiScreenPosition, TuiSize, TuiStyle, }; +use warpui_core::event::KeyEventDetails; +use warpui_core::keymap::Keystroke; use warpui_core::{App, AppContext, ModelHandle}; use super::{TuiEditorAction, TuiEditorElement, TuiEditorStyles}; @@ -109,7 +111,19 @@ fn render_lines( .map(|line| line.trim_end().to_string()) .collect() } -fn dispatch_event(ctx: &AppContext, mut element: TuiEditorElement, event: &TuiEvent) -> bool { +fn dispatch_event(ctx: &AppContext, element: TuiEditorElement, event: &TuiEvent) -> bool { + dispatch_event_with_view_focus(ctx, element, event, true) +} + +/// Like [`dispatch_event`], but supplies the owning view's focus snapshot, +/// mirroring the GUI's `EditorView::focused` → `EditorElement` path. +fn dispatch_event_with_view_focus( + ctx: &AppContext, + mut element: TuiEditorElement, + event: &TuiEvent, + view_focused: bool, +) -> bool { + element = element.with_view_focused(view_focused); let mut rendered_views = EntityIdMap::default(); let mut layout_ctx = TuiLayoutContext { rendered_views: &mut rendered_views, @@ -166,6 +180,46 @@ fn editable_paste_emits_one_complete_text_action() { }); } +#[test] +fn editable_editor_ignores_text_when_another_view_is_focused() { + App::test((), |mut app| async move { + app.update(|ctx| { + ctx.add_singleton_model(|_| Appearance::mock()); + let actions = Rc::new(RefCell::new(Vec::new())); + + // Focus elsewhere: the editable editor declines typed text. + let actions_for_handler = actions.clone(); + let model_unfocused = model(ctx, ""); + let element = TuiEditorElement::new(&model_unfocused, ctx) + .editable() + .on_action(move |action, _| actions_for_handler.borrow_mut().push(action)); + let key = TuiEvent::KeyDown { + keystroke: Keystroke { + key: "a".to_owned(), + ..Default::default() + }, + chars: "a".to_owned(), + details: KeyEventDetails::default(), + is_composing: false, + }; + assert!(!dispatch_event_with_view_focus(ctx, element, &key, false)); + assert!(actions.borrow().is_empty()); + + // Focus on the owning view: typed text is consumed. + let actions_for_handler = actions.clone(); + let model_focused = model(ctx, ""); + let element = TuiEditorElement::new(&model_focused, ctx) + .editable() + .on_action(move |action, _| actions_for_handler.borrow_mut().push(action)); + assert!(dispatch_event_with_view_focus(ctx, element, &key, true)); + assert!(matches!( + actions.borrow().as_slice(), + [TuiEditorAction::InsertChar('a')] + )); + }); + }); +} + #[test] fn read_only_editor_ignores_paste() { App::test((), |mut app| async move { diff --git a/crates/warp_tui/src/input/view.rs b/crates/warp_tui/src/input/view.rs index 427664ccac1..13fe398f346 100644 --- a/crates/warp_tui/src/input/view.rs +++ b/crates/warp_tui/src/input/view.rs @@ -36,7 +36,10 @@ use warpui_core::elements::MouseStateHandle; use warpui_core::keymap::macros::*; use warpui_core::keymap::{self, EditableBinding}; use warpui_core::text::word_boundaries::WordBoundariesPolicy; -use warpui_core::{AppContext, Entity, ModelHandle, TuiView, TypedActionView, ViewContext}; +use warpui_core::{ + AppContext, BlurContext, Entity, FocusContext, ModelHandle, TuiView, TypedActionView, + ViewContext, +}; use super::kill_buffer::KillBuffer; use crate::editor_element::{TuiEditorAction, TuiEditorElement, TuiEditorStyles}; @@ -585,6 +588,10 @@ pub struct TuiInputView { /// Mouse state for the shell-mode `!` gutter; created once here (not inline /// during render) so mouse tracking survives per-frame element rebuilds. prefix_mouse_state: MouseStateHandle, + /// Whether this view is focused, tracked via `on_focus`/`on_blur` like + /// the GUI's `EditorView::focused`. Snapshotted into the editor element + /// so it only consumes typed text while the input is focused. + focused: bool, } impl Entity for TuiInputView { @@ -629,6 +636,7 @@ impl TuiInputView { kill_buffer: KillBuffer::default(), max_visible_rows: 6, prefix_mouse_state: MouseStateHandle::default(), + focused: false, } } @@ -674,6 +682,7 @@ impl TuiInputView { } let mut element = TuiEditorElement::new(&self.model, ctx) .editable() + .with_view_focused(self.focused) .with_viewport_rows(self.max_visible_rows) .with_styles(styles) .on_action(|action, event_ctx| { @@ -756,6 +765,20 @@ impl TuiView for TuiInputView { fn keymap_context(&self, ctx: &AppContext) -> keymap::Context { input_keymap_context(self.active_inline_menu(ctx).is_some() || self.is_shell_mode(ctx)) } + + fn on_focus(&mut self, focus_ctx: &FocusContext, ctx: &mut ViewContext) { + if focus_ctx.is_self_focused() { + self.focused = true; + ctx.notify(); + } + } + + fn on_blur(&mut self, blur_ctx: &BlurContext, ctx: &mut ViewContext) { + if blur_ctx.is_self_blurred() { + self.focused = false; + ctx.notify(); + } + } } impl From for TuiInputAction {