Skip to content
Draft
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: 25 additions & 2 deletions crates/warp_terminal/src/model/escape_sequences.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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'];
Expand Down Expand Up @@ -257,10 +263,13 @@ impl<T: ModeProvider> ToEscapeSequence<T> 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() {
Expand All @@ -275,6 +284,16 @@ impl<T: ModeProvider> ToEscapeSequence<T> 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!(
Expand Down Expand Up @@ -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.
Expand Down
29 changes: 29 additions & 0 deletions crates/warp_terminal/src/model/escape_sequences_tests.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use warpui_core::event::ModifiersState;
use warpui_core::keymap::Keystroke;
use warpui_core::platform::OperatingSystem;

Expand Down Expand Up @@ -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<u8>)] = &[
(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]),
Expand Down Expand Up @@ -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<u8>)] = &[
(
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)),
Expand Down
3 changes: 3 additions & 0 deletions crates/warp_terminal/src/model/mouse.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down
242 changes: 242 additions & 0 deletions crates/warp_tui/src/alt_screen.rs
Original file line number Diff line number Diff line change
@@ -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<FairMutex<TerminalModel>>,
}

impl TuiAltScreenElement {
pub(super) fn new(model: Arc<FairMutex<TerminalModel>>) -> Self {
Self { model }
}

fn event_bytes(&self, event: &TuiEvent, area: TuiRect) -> Option<Vec<u8>> {
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<Vec<u8>> {
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<Vec<u8>> {
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<Point> {
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<u8> {
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;
Loading