diff --git a/crates/warp_terminal/src/model/escape_sequences.rs b/crates/warp_terminal/src/model/escape_sequences.rs index dc5f4994951..69d29935128 100644 --- a/crates/warp_terminal/src/model/escape_sequences.rs +++ b/crates/warp_terminal/src/model/escape_sequences.rs @@ -169,11 +169,17 @@ pub mod EscCodes { // Mouse-related escape codes pub const MOUSE_LEFT: u8 = 0; + pub const MOUSE_MIDDLE: u8 = 1; pub const MOUSE_RIGHT: u8 = 2; pub const MOUSE_DRAG: u8 = 32; + pub const MOUSE_MIDDLE_DRAG: u8 = 33; + pub const MOUSE_RIGHT_DRAG: u8 = 34; pub const MOUSE_MOVE: u8 = 35; pub const MOUSE_WHEEL_UP: u8 = 64; pub const MOUSE_WHEEL_DOWN: u8 = 65; + pub const MOUSE_SHIFT: u8 = 4; + pub const MOUSE_ALT: u8 = 8; + pub const MOUSE_CONTROL: u8 = 16; pub const FOCUS_IN: &[u8] = &[C0::ESC, b'[', b'I']; pub const FOCUS_OUT: &[u8] = &[C0::ESC, b'[', b'O']; @@ -257,10 +263,13 @@ impl ToEscapeSequence for MouseState { MouseAction::Released => 'm', _ => 'M', }; - let (button, repeats) = match self.button() { + let (mut button, repeats) = match self.button() { MouseButton::Left => (EscCodes::MOUSE_LEFT, 1), + MouseButton::Middle => (EscCodes::MOUSE_MIDDLE, 1), MouseButton::Right => (EscCodes::MOUSE_RIGHT, 1), MouseButton::LeftDrag => (EscCodes::MOUSE_DRAG, 1), + MouseButton::MiddleDrag => (EscCodes::MOUSE_MIDDLE_DRAG, 1), + MouseButton::RightDrag => (EscCodes::MOUSE_RIGHT_DRAG, 1), MouseButton::Move => (EscCodes::MOUSE_MOVE, 1), MouseButton::Wheel => { if let MouseAction::Scrolled { delta } = self.action() { @@ -275,6 +284,16 @@ impl ToEscapeSequence for MouseState { } } }; + let modifiers = self.modifiers(); + if modifiers.shift { + button += EscCodes::MOUSE_SHIFT; + } + if modifiers.alt { + button += EscCodes::MOUSE_ALT; + } + if modifiers.ctrl { + button += EscCodes::MOUSE_CONTROL; + } let point = self.maybe_point()?; let msg = format!( @@ -494,7 +513,11 @@ fn keystroke_to_c0_control_code( if KEYSTROKE_TO_C0_CODE.contains_key(keystroke.key.as_str()) { return Some(vec![KEYSTROKE_TO_C0_CODE[keystroke.key.as_str()]]); } - None + let [byte] = keystroke.key.as_bytes() else { + return None; + }; + byte.is_ascii_alphabetic() + .then(|| vec![byte.to_ascii_uppercase() - b'@']) } /// Returns the appropriate escape sequence for the given "cursor movement" keystroke. diff --git a/crates/warp_terminal/src/model/escape_sequences_tests.rs b/crates/warp_terminal/src/model/escape_sequences_tests.rs index e33def700c5..701ed7b0998 100644 --- a/crates/warp_terminal/src/model/escape_sequences_tests.rs +++ b/crates/warp_terminal/src/model/escape_sequences_tests.rs @@ -1,3 +1,4 @@ +use warpui_core::event::ModifiersState; use warpui_core::keymap::Keystroke; use warpui_core::platform::OperatingSystem; @@ -43,6 +44,9 @@ fn test_keystroke_to_c0_control_code() { // Expected mapping taken from the VT220 spec // [here](https://vt100.net/docs/vt220-rm/chapter3.html#S3.2.5), table 3.2.5. let test_cases: &[(Keystroke, Vec)] = &[ + (Keystroke::parse("ctrl-a").unwrap(), vec![C0::SOH]), + (Keystroke::parse("ctrl-c").unwrap(), vec![C0::ETX]), + (Keystroke::parse("ctrl-z").unwrap(), vec![C0::SUB]), (Keystroke::parse("ctrl- ").unwrap(), vec![C0::NUL]), (Keystroke::parse("ctrl-2").unwrap(), vec![C0::NUL]), (Keystroke::parse("ctrl-3").unwrap(), vec![C0::ESC]), @@ -74,6 +78,31 @@ fn test_shift_backspace_emits_del_sequence() { fn test_mouse_actions_to_escape_sequence() { // Validating we produce the correct escape sequences. let test_cases: &[(MouseState, Vec)] = &[ + ( + MouseState::new( + MouseButton::Middle, + MouseAction::Pressed, + ModifiersState { + shift: true, + ctrl: true, + ..Default::default() + }, + ) + .set_point(Point::new(2, 3)), + // [<21;4;3M (middle + shift + control) + vec![ + C0::ESC, + b'[', + b'<', + b'2', + b'1', + b';', + b'4', + b';', + b'3', + b'M', + ], + ), ( MouseState::new(MouseButton::Right, MouseAction::Pressed, Default::default()) .set_point(Point::new(10, 10)), diff --git a/crates/warp_terminal/src/model/mouse.rs b/crates/warp_terminal/src/model/mouse.rs index 8afdb97cf4c..ea229fc714f 100644 --- a/crates/warp_terminal/src/model/mouse.rs +++ b/crates/warp_terminal/src/model/mouse.rs @@ -5,9 +5,12 @@ use super::indexing::Point; #[derive(Debug, Copy, Clone)] pub enum MouseButton { Left, + Middle, Right, Wheel, LeftDrag, + MiddleDrag, + RightDrag, Move, // Used for mouse hover events (when cursor is moving) } #[derive(Debug, Copy, Clone)] diff --git a/crates/warp_tui/src/alt_screen.rs b/crates/warp_tui/src/alt_screen.rs new file mode 100644 index 00000000000..17d7ebcc525 --- /dev/null +++ b/crates/warp_tui/src/alt_screen.rs @@ -0,0 +1,242 @@ +//! Full-frame alternate-screen rendering and input forwarding for the TUI. + +use std::sync::Arc; + +use parking_lot::FairMutex; +use warp::tui_export::TerminalModel; +use warp_terminal::model::escape_sequences::{ + EscCodes, KeystrokeWithDetails, ToEscapeSequence as _, C0, C1, +}; +use warp_terminal::model::grid::Dimensions as _; +use warp_terminal::model::mouse::{MouseAction, MouseButton, MouseState}; +use warp_terminal::model::{Point, TermMode}; +use warpui_core::elements::tui::{ + TuiBuffer, TuiConstraint, TuiElement, TuiEvent, TuiEventContext, TuiLayoutContext, + TuiPaintContext, TuiRect, TuiRectExt as _, TuiSize, +}; +use warpui_core::AppContext; + +use crate::terminal_block::{cell_to_style, sanitized_symbol}; +use crate::terminal_session_view::TuiTerminalSessionAction; + +/// Paints and owns input for the active terminal alternate screen. +pub(super) struct TuiAltScreenElement { + model: Arc>, +} + +impl TuiAltScreenElement { + pub(super) fn new(model: Arc>) -> Self { + Self { model } + } + + fn event_bytes(&self, event: &TuiEvent, area: TuiRect) -> Option> { + let model = self.model.lock(); + if !model.is_alt_screen_active() { + return None; + } + + match event { + TuiEvent::KeyDown { + keystroke, + chars, + details, + .. + } => KeystrokeWithDetails { + keystroke, + key_without_modifiers: details.key_without_modifiers.as_deref(), + chars: Some(chars), + } + .to_escape_sequence(&*model) + .or_else(|| named_key_bytes(keystroke)) + .or_else(|| (!chars.is_empty()).then(|| chars.as_bytes().to_vec())), + _ => mouse_event_bytes(event, area, &model), + } + } +} + +impl TuiElement for TuiAltScreenElement { + fn layout( + &mut self, + constraint: TuiConstraint, + _ctx: &mut TuiLayoutContext, + _app: &AppContext, + ) -> TuiSize { + constraint.max + } + + fn render(&self, area: TuiRect, buffer: &mut TuiBuffer, _ctx: &mut TuiPaintContext) { + let model = self.model.lock(); + if !model.is_alt_screen_active() { + return; + } + + let colors = model.colors(); + let grid = model.alt_screen().grid_handler(); + let row_count = grid.visible_rows().min(usize::from(area.height)); + let column_count = grid.columns().min(usize::from(area.width)); + for row_index in 0..row_count { + let Some(row) = grid.row(row_index) else { + continue; + }; + let y = area.y.saturating_add(row_index as u16); + for column_index in 0..column_count { + let cell = &row[column_index]; + let x = area.x.saturating_add(column_index as u16); + if let Some(buffer_cell) = buffer.cell_mut((x, y)) { + buffer_cell + .set_symbol(&sanitized_symbol(cell)) + .set_style(cell_to_style(cell, &colors)); + } + } + } + } + + fn cursor_position(&self, area: TuiRect, _ctx: &mut TuiPaintContext) -> Option<(u16, u16)> { + let model = self.model.lock(); + if !model.is_alt_screen_active() || !model.is_term_mode_set(TermMode::SHOW_CURSOR) { + return None; + } + let cursor = model.alt_screen().grid_handler().cursor_render_point(); + let column = u16::try_from(cursor.col).ok()?; + let row = u16::try_from(cursor.row).ok()?; + (column < area.width && row < area.height).then_some((column, row)) + } + + fn dispatch_event( + &mut self, + event: &TuiEvent, + area: TuiRect, + event_ctx: &mut TuiEventContext, + _ctx: &mut TuiLayoutContext, + _app: &AppContext, + ) -> bool { + let Some(bytes) = self.event_bytes(event, area) else { + return false; + }; + event_ctx.dispatch_typed_action(TuiTerminalSessionAction::ForwardAltScreenInput(bytes)); + true + } +} + +fn named_key_bytes(keystroke: &warpui_core::keymap::Keystroke) -> Option> { + match keystroke.key.as_str() { + "enter" => Some(vec![C0::CR]), + "tab" | "\t" if keystroke.shift => Some([C1::CSI, b"Z"].concat()), + "tab" | "\t" => Some(vec![C0::HT]), + "escape" => Some(vec![C0::ESC]), + "insert" => Some(b"\x1b[2~".to_vec()), + "delete" => Some(b"\x1b[3~".to_vec()), + "pageup" => Some(b"\x1b[5~".to_vec()), + "pagedown" => Some(b"\x1b[6~".to_vec()), + _ => None, + } +} +fn mouse_event_bytes(event: &TuiEvent, area: TuiRect, model: &TerminalModel) -> Option> { + let position = event.position()?; + if !area.contains_point(position) { + return None; + } + + if let TuiEvent::ScrollWheel { delta, .. } = event { + if !mouse_reporting_enabled(model) { + return model + .is_term_mode_set(TermMode::ALTERNATE_SCROLL) + .then(|| alternate_scroll_bytes(delta.1)) + .filter(|bytes| !bytes.is_empty()); + } + } else if !mouse_reporting_enabled(model) { + return None; + } + + let point = terminal_point(position, area, model)?; + let mouse = match event { + TuiEvent::ScrollWheel { + delta, modifiers, .. + } => MouseState::new( + MouseButton::Wheel, + MouseAction::Scrolled { + delta: saturating_i32(delta.1), + }, + *modifiers, + ), + TuiEvent::LeftMouseDown { modifiers, .. } => { + MouseState::new(MouseButton::Left, MouseAction::Pressed, *modifiers) + } + TuiEvent::LeftMouseUp { modifiers, .. } => { + MouseState::new(MouseButton::Left, MouseAction::Released, *modifiers) + } + TuiEvent::LeftMouseDragged { modifiers, .. } if drag_reporting_enabled(model) => { + MouseState::new(MouseButton::LeftDrag, MouseAction::Pressed, *modifiers) + } + TuiEvent::MiddleMouseDown { modifiers, .. } => { + MouseState::new(MouseButton::Middle, MouseAction::Pressed, *modifiers) + } + TuiEvent::MiddleMouseUp { modifiers, .. } => { + MouseState::new(MouseButton::Middle, MouseAction::Released, *modifiers) + } + TuiEvent::MiddleMouseDragged { modifiers, .. } if drag_reporting_enabled(model) => { + MouseState::new(MouseButton::MiddleDrag, MouseAction::Pressed, *modifiers) + } + TuiEvent::RightMouseDown { modifiers, .. } => { + MouseState::new(MouseButton::Right, MouseAction::Pressed, *modifiers) + } + TuiEvent::RightMouseUp { modifiers, .. } => { + MouseState::new(MouseButton::Right, MouseAction::Released, *modifiers) + } + TuiEvent::RightMouseDragged { modifiers, .. } if drag_reporting_enabled(model) => { + MouseState::new(MouseButton::RightDrag, MouseAction::Pressed, *modifiers) + } + TuiEvent::MouseMoved { modifiers, .. } + if model.is_term_mode_set(TermMode::MOUSE_MOTION) => + { + MouseState::new(MouseButton::Move, MouseAction::Pressed, *modifiers) + } + _ => return None, + } + .set_point(point); + mouse.to_escape_sequence(model) +} + +fn mouse_reporting_enabled(model: &TerminalModel) -> bool { + model.is_term_mode_set(TermMode::SGR_MOUSE) && model.is_term_mode_set(TermMode::MOUSE_MODE) +} + +fn drag_reporting_enabled(model: &TerminalModel) -> bool { + model.is_term_mode_set(TermMode::MOUSE_DRAG) || model.is_term_mode_set(TermMode::MOUSE_MOTION) +} + +fn terminal_point( + position: warpui_core::elements::tui::TuiPoint, + area: TuiRect, + model: &TerminalModel, +) -> Option { + let grid = model.alt_screen().grid_handler(); + let max_column = grid.columns().checked_sub(1)?; + let max_row = grid.visible_rows().checked_sub(1)?; + Some(Point::new( + usize::from(position.y.saturating_sub(area.y)).min(max_row), + usize::from(position.x.saturating_sub(area.x)).min(max_column), + )) +} + +fn alternate_scroll_bytes(rows: isize) -> Vec { + let command = if rows > 0 { + EscCodes::ARROW_UP + } else { + EscCodes::ARROW_DOWN + }; + let sequence = EscCodes::build_escape_sequence_with_c1(C1::SS3, &[command]); + sequence.repeat(rows.unsigned_abs()) +} + +fn saturating_i32(value: isize) -> i32 { + i32::try_from(value).unwrap_or(if value.is_negative() { + i32::MIN + } else { + i32::MAX + }) +} + +#[cfg(test)] +#[path = "alt_screen_tests.rs"] +mod tests; diff --git a/crates/warp_tui/src/alt_screen_tests.rs b/crates/warp_tui/src/alt_screen_tests.rs new file mode 100644 index 00000000000..60814453566 --- /dev/null +++ b/crates/warp_tui/src/alt_screen_tests.rs @@ -0,0 +1,119 @@ +use std::sync::Arc; + +use parking_lot::FairMutex; +use warp::terminal::model::ansi::{Handler as _, Mode}; +use warp::tui_export::TerminalModel; +use warpui::event::{KeyEventDetails, ModifiersState}; +use warpui::{App, EntityIdMap}; +use warpui_core::elements::tui::{ + TuiBuffer, TuiBufferExt, TuiConstraint, TuiElement, TuiEvent, TuiLayoutContext, + TuiPaintContext, TuiPoint, TuiRect, TuiSize, +}; +use warpui_core::keymap::Keystroke; + +use super::TuiAltScreenElement; + +fn model_with_text(text: &str) -> Arc> { + let mut model = TerminalModel::mock(None, None); + model.set_mode(Mode::SwapScreen { + save_cursor_and_clear_screen: true, + }); + for character in text.chars() { + model.input(character); + } + Arc::new(FairMutex::new(model)) +} + +#[test] +fn paints_alt_screen_grid_and_places_cursor() { + App::test((), |app| async move { + app.read(|app| { + let mut element = TuiAltScreenElement::new(model_with_text("hello")); + let mut rendered_views = EntityIdMap::default(); + let mut layout_ctx = TuiLayoutContext { + rendered_views: &mut rendered_views, + }; + let area = TuiRect::new(0, 0, 10, 3); + assert_eq!( + element.layout( + TuiConstraint::tight(TuiSize::new(area.width, area.height)), + &mut layout_ctx, + app, + ), + TuiSize::new(10, 3) + ); + + let mut buffer = TuiBuffer::empty(area); + let mut paint_ctx = TuiPaintContext::new(&mut rendered_views); + element.render(area, &mut buffer, &mut paint_ctx); + + assert_eq!(buffer.to_lines()[0], "hello "); + assert_eq!(element.cursor_position(area, &mut paint_ctx), Some((5, 0))); + }); + }); +} + +#[test] +fn key_events_are_encoded_for_the_pty() { + let element = TuiAltScreenElement::new(model_with_text("")); + for (keystroke, expected) in [ + ("ctrl-c", vec![0x03]), + ("enter", vec![b'\r']), + ("escape", vec![0x1b]), + ("tab", vec![b'\t']), + ("shift-tab", b"\x1b[Z".to_vec()), + ("delete", b"\x1b[3~".to_vec()), + ] { + let event = TuiEvent::KeyDown { + keystroke: Keystroke::parse(keystroke).unwrap(), + chars: String::new(), + details: KeyEventDetails::default(), + is_composing: false, + }; + assert_eq!( + element.event_bytes(&event, TuiRect::new(0, 0, 10, 3)), + Some(expected), + "wrong PTY bytes for {keystroke}" + ); + } +} + +#[test] +fn wheel_events_use_alternate_scroll_without_mouse_reporting() { + let element = TuiAltScreenElement::new(model_with_text("")); + let event = TuiEvent::ScrollWheel { + position: TuiPoint::new(2, 1), + delta: (0, 2), + precise: false, + modifiers: ModifiersState::default(), + }; + + assert_eq!( + element.event_bytes(&event, TuiRect::new(0, 0, 10, 3)), + Some(b"\x1bOA\x1bOA".to_vec()) + ); +} + +#[test] +fn mouse_reporting_preserves_drag_button_position_and_modifiers() { + let model = model_with_text(""); + { + let mut model = model.lock(); + model.set_mode(Mode::SgrMouse); + model.set_mode(Mode::ReportCellMouseMotion); + } + let element = TuiAltScreenElement::new(model); + let event = TuiEvent::MiddleMouseDragged { + position: TuiPoint::new(2, 1), + modifiers: ModifiersState { + shift: true, + ctrl: true, + ..Default::default() + }, + }; + + assert_eq!( + element.event_bytes(&event, TuiRect::new(0, 0, 10, 3)), + Some(b"\x1b[<53;3;2M".to_vec()) + ); +} diff --git a/crates/warp_tui/src/lib.rs b/crates/warp_tui/src/lib.rs index bd8a43b6079..8d42b853226 100644 --- a/crates/warp_tui/src/lib.rs +++ b/crates/warp_tui/src/lib.rs @@ -9,6 +9,7 @@ mod agent_block; mod agent_block_sections; +mod alt_screen; mod autoupdate; mod clipboard; pub mod input; diff --git a/crates/warp_tui/src/session.rs b/crates/warp_tui/src/session.rs index 74cde8a5e91..a4b945d7c8b 100644 --- a/crates/warp_tui/src/session.rs +++ b/crates/warp_tui/src/session.rs @@ -122,6 +122,10 @@ fn create_terminal_session_after_login( return; } + let initial_terminal_size = ctx + .window_bounds(&root.window_id(ctx)) + .map(|bounds| bounds.size()) + .unwrap_or_else(|| Vector2F::new(120., 24.)); let root = root.clone(); let manager = LocalTtyTerminalManager::::create_tui_model( std::env::current_dir().ok(), @@ -129,7 +133,7 @@ fn create_terminal_session_after_login( IsSharedSessionCreator::No, None, banner.clone(), - Vector2F::new(120., 24.), + initial_terminal_size, None, None, TRANSCRIPT_BLOCK_SPACING, diff --git a/crates/warp_tui/src/terminal_block.rs b/crates/warp_tui/src/terminal_block.rs index 47ab3ab1268..2de4006f464 100644 --- a/crates/warp_tui/src/terminal_block.rs +++ b/crates/warp_tui/src/terminal_block.rs @@ -279,7 +279,7 @@ fn cell_to_color(color: &Color, colors: &TerminalColorList) -> TuiColor { } } -fn cell_to_style(cell: &Cell, colors: &TerminalColorList) -> TuiStyle { +pub(super) fn cell_to_style(cell: &Cell, colors: &TerminalColorList) -> TuiStyle { let mut style = TuiStyle::default().fg(cell_to_color(&cell.fg, colors)); // Cells with the default background are left bg-unset so they inherit the // TUI's own background instead of painting the theme's background color; @@ -312,7 +312,7 @@ fn cell_to_style(cell: &Cell, colors: &TerminalColorList) -> TuiStyle { style } -fn sanitized_symbol(cell: &Cell) -> String { +pub(super) fn sanitized_symbol(cell: &Cell) -> String { let content = cell.content_for_display().to_string(); if content.is_empty() || content.chars().any(char::is_control) { " ".to_owned() diff --git a/crates/warp_tui/src/terminal_session_view.rs b/crates/warp_tui/src/terminal_session_view.rs index 130f1148dcb..c58de2f282a 100644 --- a/crates/warp_tui/src/terminal_session_view.rs +++ b/crates/warp_tui/src/terminal_session_view.rs @@ -43,6 +43,7 @@ use warpui_core::{ AppContext, Entity, EntityId, ModelHandle, TuiView, TypedActionView, ViewContext, ViewHandle, }; +use crate::alt_screen::TuiAltScreenElement; use crate::autoupdate::{TuiAutoupdater, TuiAutoupdaterEvent}; use crate::clipboard::copy_to_clipboard; use crate::conversation_selection::TuiConversationSelection; @@ -71,6 +72,7 @@ const CTRL_C_EXIT_HINT: &str = "ctrl-c again to exit"; /// Events emitted by the TUI terminal session surface. pub(crate) enum TuiTerminalSessionEvent { ExecuteCommand(Box), + WriteBytes(Cow<'static, [u8]>), WriteAgentInput { bytes: Cow<'static, [u8]>, mode: AIAgentPtyWriteMode, @@ -81,6 +83,7 @@ impl PtyIntentEvent for TuiTerminalSessionEvent { fn pty_intent(&self) -> Option { match self { Self::ExecuteCommand(event) => Some(PtyIntent::ExecuteCommand((**event).clone())), + Self::WriteBytes(bytes) => Some(PtyIntent::WriteBytes(bytes.clone())), Self::WriteAgentInput { bytes, mode } => Some(PtyIntent::WriteAgentInput { bytes: bytes.clone(), mode: *mode, @@ -128,6 +131,8 @@ pub(crate) enum TuiTerminalSessionAction { /// conversation, else clear the input; a second press within /// [`CTRL_C_EXIT_WINDOW`] exits the TUI. Interrupt, + /// Raw input encoded by the alternate-screen element for the PTY. + ForwardAltScreenInput(Vec), /// Click on the footer's usage entry: flips the persisted credits⇄cost /// display-mode setting. ToggleUsageDisplay, @@ -1140,6 +1145,9 @@ impl TuiView for TuiTerminalSessionView { } fn render(&self, ctx: &AppContext) -> Box { + if self.terminal_model.lock().is_alt_screen_active() { + return TuiAltScreenElement::new(self.terminal_model.clone()).finish(); + } let inline_menu = self.inline_menu.render(ctx); // The border takes the shell-mode accent while in shell mode. let builder = TuiUiBuilder::from_app(ctx); @@ -1238,6 +1246,10 @@ impl TuiView for TuiTerminalSessionView { .with_padding_top(2) .finish() } + + fn should_dispatch_keybindings(&self, _ctx: &AppContext) -> bool { + !self.terminal_model.lock().is_alt_screen_active() + } } impl TypedActionView for TuiTerminalSessionView { @@ -1246,6 +1258,13 @@ impl TypedActionView for TuiTerminalSessionView { fn handle_action(&mut self, action: &TuiTerminalSessionAction, ctx: &mut ViewContext) { match action { TuiTerminalSessionAction::Interrupt => self.handle_interrupt(ctx), + TuiTerminalSessionAction::ForwardAltScreenInput(bytes) => { + if self.terminal_model.lock().is_alt_screen_active() { + ctx.emit(TuiTerminalSessionEvent::WriteBytes(Cow::Owned( + bytes.clone(), + ))); + } + } TuiTerminalSessionAction::ToggleUsageDisplay => self.toggle_usage_display(ctx), } } diff --git a/crates/warpui_core/src/core/app/tui.rs b/crates/warpui_core/src/core/app/tui.rs index 55d9c9691f0..8d885c8536c 100644 --- a/crates/warpui_core/src/core/app/tui.rs +++ b/crates/warpui_core/src/core/app/tui.rs @@ -11,6 +11,23 @@ use crate::keymap::{BindingLens, IsBindingValid}; use crate::{EntityId, WindowId}; impl AppContext { + /// Returns whether every TUI view in `responder_chain` allows the shared + /// keymap to run before element dispatch. + pub(crate) fn should_dispatch_tui_keybindings( + &self, + window_id: WindowId, + responder_chain: &[EntityId], + ) -> bool { + responder_chain.iter().all(|view_id| { + self.windows + .get(&window_id) + .and_then(|window| window.views.get(view_id)) + .is_none_or(|view| match view { + StoredView::Tui(view) => view.should_dispatch_keybindings(self), + StoredView::Gui(_) => true, + }) + }) + } /// Registers a validator that validates every binding that matches the /// given TUI view's default [`Context`](crate::keymap::Context). The TUI /// counterpart of [`Self::register_binding_validator`]: after the app is diff --git a/crates/warpui_core/src/core/view/tui.rs b/crates/warpui_core/src/core/view/tui.rs index fd586474037..513e926ee8d 100644 --- a/crates/warpui_core/src/core/view/tui.rs +++ b/crates/warpui_core/src/core/view/tui.rs @@ -40,6 +40,16 @@ pub trait TuiView: Entity { fn keymap_context(&self, _: &AppContext) -> keymap::Context { Self::default_keymap_context() } + /// Whether key-down events should be offered to the shared keymap before + /// reaching this view's rendered element tree. + /// + /// Views that represent a raw terminal input surface can return `false` so + /// every keystroke, including keys normally bound by an ancestor, reaches + /// the terminal element unchanged. If any view in the focused responder + /// chain returns `false`, the runtime skips the keymap for that event. + fn should_dispatch_keybindings(&self, _: &AppContext) -> bool { + true + } /// Returns the default context for a view. fn default_keymap_context() -> keymap::Context { @@ -82,6 +92,7 @@ pub trait AnyTuiView { view_id: EntityId, ); fn keymap_context(&self, app: &AppContext) -> keymap::Context; + fn should_dispatch_keybindings(&self, app: &AppContext) -> bool; fn child_view_ids(&self, app: &AppContext) -> Vec; } @@ -130,6 +141,9 @@ where fn keymap_context(&self, app: &AppContext) -> keymap::Context { TuiView::keymap_context(self, app) } + fn should_dispatch_keybindings(&self, app: &AppContext) -> bool { + TuiView::should_dispatch_keybindings(self, app) + } fn child_view_ids(&self, app: &AppContext) -> Vec { TuiView::child_view_ids(self, app) diff --git a/crates/warpui_core/src/elements/tui/event.rs b/crates/warpui_core/src/elements/tui/event.rs index 4d42e313b33..fcb87a374a2 100644 --- a/crates/warpui_core/src/elements/tui/event.rs +++ b/crates/warpui_core/src/elements/tui/event.rs @@ -53,11 +53,27 @@ pub enum TuiEvent { modifiers: ModifiersState, click_count: u32, }, + MiddleMouseUp { + position: TuiPoint, + modifiers: ModifiersState, + }, + MiddleMouseDragged { + position: TuiPoint, + modifiers: ModifiersState, + }, RightMouseDown { position: TuiPoint, modifiers: ModifiersState, click_count: u32, }, + RightMouseUp { + position: TuiPoint, + modifiers: ModifiersState, + }, + RightMouseDragged { + position: TuiPoint, + modifiers: ModifiersState, + }, MouseMoved { position: TuiPoint, modifiers: ModifiersState, @@ -74,7 +90,11 @@ impl TuiEvent { | Self::LeftMouseUp { position, .. } | Self::LeftMouseDragged { position, .. } | Self::MiddleMouseDown { position, .. } + | Self::MiddleMouseUp { position, .. } + | Self::MiddleMouseDragged { position, .. } | Self::RightMouseDown { position, .. } + | Self::RightMouseUp { position, .. } + | Self::RightMouseDragged { position, .. } | Self::MouseMoved { position, .. } => Some(*position), Self::KeyDown { .. } => None, } diff --git a/crates/warpui_core/src/elements/tui/selectable.rs b/crates/warpui_core/src/elements/tui/selectable.rs index 35c90e5b4ef..2bcc2d3c5d7 100644 --- a/crates/warpui_core/src/elements/tui/selectable.rs +++ b/crates/warpui_core/src/elements/tui/selectable.rs @@ -325,7 +325,11 @@ where | TuiEvent::ScrollWheel { .. } | TuiEvent::KeyDown { .. } | TuiEvent::MiddleMouseDown { .. } + | TuiEvent::MiddleMouseUp { .. } + | TuiEvent::MiddleMouseDragged { .. } | TuiEvent::RightMouseDown { .. } + | TuiEvent::RightMouseUp { .. } + | TuiEvent::RightMouseDragged { .. } | TuiEvent::MouseMoved { .. } => false, } } diff --git a/crates/warpui_core/src/runtime/event_conversion.rs b/crates/warpui_core/src/runtime/event_conversion.rs index f02291cc4e7..22d16ecd173 100644 --- a/crates/warpui_core/src/runtime/event_conversion.rs +++ b/crates/warpui_core/src/runtime/event_conversion.rs @@ -60,18 +60,31 @@ impl TryFrom for TuiEvent { position, modifiers, }), + MouseEventKind::Up(MouseButton::Middle) => Ok(TuiEvent::MiddleMouseUp { + position, + modifiers, + }), + MouseEventKind::Up(MouseButton::Right) => Ok(TuiEvent::RightMouseUp { + position, + modifiers, + }), MouseEventKind::Drag(MouseButton::Left) => Ok(TuiEvent::LeftMouseDragged { position, modifiers, }), + MouseEventKind::Drag(MouseButton::Middle) => Ok(TuiEvent::MiddleMouseDragged { + position, + modifiers, + }), + MouseEventKind::Drag(MouseButton::Right) => Ok(TuiEvent::RightMouseDragged { + position, + modifiers, + }), MouseEventKind::Moved => Ok(TuiEvent::MouseMoved { position, modifiers, is_synthetic: false, }), - // Add these variants when a concrete TUI consumer needs them. - MouseEventKind::Up(MouseButton::Middle | MouseButton::Right) - | MouseEventKind::Drag(MouseButton::Middle | MouseButton::Right) => Err(()), } } } diff --git a/crates/warpui_core/src/runtime/event_conversion_tests.rs b/crates/warpui_core/src/runtime/event_conversion_tests.rs index 41fc975caca..ae97e723117 100644 --- a/crates/warpui_core/src/runtime/event_conversion_tests.rs +++ b/crates/warpui_core/src/runtime/event_conversion_tests.rs @@ -232,27 +232,35 @@ fn mouse_moved_maps_to_tui_mouse_moved_event() { } #[test] -fn unsupported_mouse_up_and_drag_buttons_are_ignored() { - assert!(mouse( - MouseEventKind::Up(MouseButton::Right), - KeyModifiers::empty() - ) - .is_none()); - assert!(mouse( - MouseEventKind::Up(MouseButton::Middle), - KeyModifiers::empty() - ) - .is_none()); - assert!(mouse( - MouseEventKind::Drag(MouseButton::Right), - KeyModifiers::empty() - ) - .is_none()); - assert!(mouse( - MouseEventKind::Drag(MouseButton::Middle), - KeyModifiers::empty() - ) - .is_none()); +fn middle_and_right_mouse_up_and_drag_map_to_tui_mouse_events() { + assert!(matches!( + mouse( + MouseEventKind::Up(MouseButton::Middle), + KeyModifiers::empty() + ), + Some(TuiEvent::MiddleMouseUp { .. }) + )); + assert!(matches!( + mouse( + MouseEventKind::Drag(MouseButton::Middle), + KeyModifiers::empty() + ), + Some(TuiEvent::MiddleMouseDragged { .. }) + )); + assert!(matches!( + mouse( + MouseEventKind::Up(MouseButton::Right), + KeyModifiers::empty() + ), + Some(TuiEvent::RightMouseUp { .. }) + )); + assert!(matches!( + mouse( + MouseEventKind::Drag(MouseButton::Right), + KeyModifiers::empty() + ), + Some(TuiEvent::RightMouseDragged { .. }) + )); } /// Builds a `button` mouse-down at `(x, y)` via the real conversion (so it diff --git a/crates/warpui_core/src/runtime/mod.rs b/crates/warpui_core/src/runtime/mod.rs index dc00397693c..d3c64f51572 100644 --- a/crates/warpui_core/src/runtime/mod.rs +++ b/crates/warpui_core/src/runtime/mod.rs @@ -28,6 +28,8 @@ use std::thread; use std::time::Duration; use instant::Instant; +use pathfinder_geometry::rect::RectF; +use pathfinder_geometry::vector::Vector2F; use ratatui::crossterm::cursor::{Hide, Show}; use ratatui::crossterm::event::{ self, DisableMouseCapture, EnableMouseCapture, Event as CrosstermEvent, @@ -106,6 +108,13 @@ impl TuiScreen { /// timed redraw. fn draw(&mut self, ctx: &mut AppContext) -> io::Result> { let size = self.terminal.size()?; + ctx.update_window_bounds( + self.window_id, + RectF::new( + Vector2F::zero(), + Vector2F::new(f32::from(size.width), f32::from(size.height)), + ), + ); let area = TuiRect::new(0, 0, size.width, size.height); let invalidation = ctx.take_all_invalidations_for_window(self.window_id); self.presenter @@ -135,11 +144,19 @@ impl TuiScreen { // responder chain first, exactly like the GUI window event path. if let Some((keystroke, is_composing)) = event.key_down() { let responder_chain = ctx.get_responder_chain(self.window_id); - match ctx.dispatch_keystroke(self.window_id, &responder_chain, keystroke, is_composing) + if ctx.key_bindings_enabled(self.window_id) + && ctx.should_dispatch_tui_keybindings(self.window_id, &responder_chain) { - Ok(true) => return true, - Ok(false) => {} - Err(error) => report_error!(error.context("error dispatching keystroke")), + match ctx.dispatch_keystroke( + self.window_id, + &responder_chain, + keystroke, + is_composing, + ) { + Ok(true) => return true, + Ok(false) => {} + Err(error) => report_error!(error.context("error dispatching keystroke")), + } } } diff --git a/crates/warpui_core/src/runtime/mod_tests.rs b/crates/warpui_core/src/runtime/mod_tests.rs index 2a84ad11f5e..7a20a10a64c 100644 --- a/crates/warpui_core/src/runtime/mod_tests.rs +++ b/crates/warpui_core/src/runtime/mod_tests.rs @@ -225,6 +225,76 @@ fn keymap_binding_dispatches_typed_action_to_tui_view() { }); } +/// A raw-input view must receive bound keys at the element layer without the +/// shared keymap swallowing them first. +#[test] +fn raw_input_view_bypasses_keymap_bindings() { + struct RawInputView { + element_hits: Rc>, + binding_hits: usize, + } + + impl Entity for RawInputView { + type Event = (); + } + + impl TuiView for RawInputView { + fn ui_name() -> &'static str { + "RawInputView" + } + + fn render(&self, _: &AppContext) -> Box { + let hits = self.element_hits.clone(); + Box::new( + TuiEventHandler::new(().finish()).on_key("c", move |_, _, _| { + hits.set(hits.get() + 1); + }), + ) + } + + fn should_dispatch_keybindings(&self, _: &AppContext) -> bool { + false + } + } + + impl TypedActionView for RawInputView { + type Action = Bump; + + fn handle_action(&mut self, _: &Bump, _: &mut ViewContext) { + self.binding_hits += 1; + } + } + + App::test((), |mut app| async move { + let element_hits = Rc::new(Cell::new(0)); + let hits_for_view = element_hits.clone(); + let (window_id, root) = app.update(|ctx| { + ctx.register_fixed_bindings([FixedBinding::new("ctrl-c", Bump, id!("RawInputView"))]); + ctx.add_tui_window(window_options(), |_| RawInputView { + element_hits: hits_for_view, + binding_hits: 0, + }) + }); + + let mut terminal = TestTerminal::new(TuiSize::new(20, 3)); + terminal.events.push_back(CrosstermEvent::Key(KeyEvent::new( + KeyCode::Char('c'), + KeyModifiers::CONTROL, + ))); + let mut runtime = TuiRuntime::with_terminal(&app, window_id, root.clone(), terminal); + + let mut iterations = 0; + runtime + .run_until(&mut app, |_| { + iterations += 1; + iterations > 1 + }) + .unwrap(); + + assert_eq!(root.read(&app, |view, _| view.binding_hits), 0); + assert_eq!(element_hits.get(), 1); + }); +} /// A binding with a permissive (always-true) context predicate whose action /// type has no handler on any view in the TUI responder chain must not swallow /// the keystroke: the keymap pass reports it unhandled and the element pass