Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 24 additions & 3 deletions crates/warp_tui/src/editor_element.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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, ..
Expand Down
56 changes: 55 additions & 1 deletion crates/warp_tui/src/editor_element_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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));
Comment thread
harryalbert marked this conversation as resolved.
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 {
Expand Down
25 changes: 24 additions & 1 deletion crates/warp_tui/src/input/view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -629,6 +636,7 @@ impl TuiInputView {
kill_buffer: KillBuffer::default(),
max_visible_rows: 6,
prefix_mouse_state: MouseStateHandle::default(),
focused: false,
}
}

Expand Down Expand Up @@ -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| {
Expand Down Expand Up @@ -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<Self>) {
if focus_ctx.is_self_focused() {
self.focused = true;
ctx.notify();
}
}

fn on_blur(&mut self, blur_ctx: &BlurContext, ctx: &mut ViewContext<Self>) {
if blur_ctx.is_self_blurred() {
self.focused = false;
ctx.notify();
}
}
}

impl From<TuiEditorAction> for TuiInputAction {
Expand Down
Loading