Merge remote-tracking branch 'origin/main' into themes
This commit is contained in:
@@ -31,6 +31,10 @@ pub enum Action {
|
||||
FocusSidebar,
|
||||
FocusPane(Direction),
|
||||
|
||||
// Session scrollback
|
||||
ScrollPanePageUp,
|
||||
ScrollPanePageDown,
|
||||
|
||||
// Sidebar highlight movement
|
||||
SidebarNext,
|
||||
SidebarPrev,
|
||||
@@ -78,6 +82,8 @@ impl Action {
|
||||
format!("{direction:?}").to_lowercase()
|
||||
)
|
||||
}
|
||||
Action::ScrollPanePageUp => "Scroll session up one page".into(),
|
||||
Action::ScrollPanePageDown => "Scroll session down one page".into(),
|
||||
Action::SidebarNext => "Select next sidebar item".into(),
|
||||
Action::SidebarPrev => "Select previous sidebar item".into(),
|
||||
Action::ToggleSidebarGroup => "Expand or collapse directory".into(),
|
||||
@@ -102,6 +108,8 @@ impl Action {
|
||||
"EnterInsert" => return Ok(Action::EnterInsert),
|
||||
"LeaveInsert" => return Ok(Action::LeaveInsert),
|
||||
"FocusSidebar" => return Ok(Action::FocusSidebar),
|
||||
"ScrollPanePageUp" => return Ok(Action::ScrollPanePageUp),
|
||||
"ScrollPanePageDown" => return Ok(Action::ScrollPanePageDown),
|
||||
"SidebarNext" => return Ok(Action::SidebarNext),
|
||||
"SidebarPrev" => return Ok(Action::SidebarPrev),
|
||||
"ToggleSidebarGroup" => return Ok(Action::ToggleSidebarGroup),
|
||||
@@ -221,6 +229,7 @@ mod tests {
|
||||
Action::EnterInsert,
|
||||
Action::Quit,
|
||||
Action::NewDraftPane,
|
||||
Action::ScrollPanePageDown,
|
||||
Action::FocusPane(Direction::Right),
|
||||
] {
|
||||
let s = a.to_string();
|
||||
|
||||
@@ -207,6 +207,11 @@ impl App {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
if let Some(effect) = self.handle_pane_scroll(&key) {
|
||||
self.perform(effect.into_iter().collect()).await;
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let known = self.cfg.keymap.known_bindings(self.state.mode);
|
||||
match self.chord.feed(&key, &known) {
|
||||
ChordResult::Pending => Ok(false),
|
||||
@@ -370,6 +375,10 @@ impl App {
|
||||
// either in the sidebar or back in the pane.
|
||||
self.state.focus = Focus::Pane;
|
||||
}
|
||||
Action::ScrollPanePageUp => self.scroll_active_pane(self.pane_page_rows() as isize),
|
||||
Action::ScrollPanePageDown => {
|
||||
self.scroll_active_pane(-(self.pane_page_rows() as isize))
|
||||
}
|
||||
Action::SidebarNext => {
|
||||
let order = visible_sidebar_order(
|
||||
&self.catalog.snapshot(),
|
||||
@@ -501,11 +510,79 @@ impl App {
|
||||
/// attached to send it to.
|
||||
fn forward_to_session(&mut self, key: &InputKey) -> Option<Effect> {
|
||||
let sid = self.state.active_session()?;
|
||||
self.emulators.get(sid)?;
|
||||
let handle = self.emulators.get(sid)?;
|
||||
let bytes = encode_key(key)?;
|
||||
handle
|
||||
.screen
|
||||
.lock()
|
||||
.expect("vt100 parser mutex")
|
||||
.set_scrollback(0);
|
||||
Some(Effect::Forward { id: sid, bytes })
|
||||
}
|
||||
|
||||
fn pane_page_rows(&self) -> usize {
|
||||
self.region
|
||||
.pane
|
||||
.map_or(1, |area| usize::from(area.height.saturating_sub(1).max(1)))
|
||||
}
|
||||
|
||||
/// Positive rows move into older history; negative rows return toward the
|
||||
/// live bottom. vt100 clamps both ends to the retained buffer.
|
||||
fn scroll_active_pane(&mut self, rows: isize) {
|
||||
let Some(sid) = self.state.active_session() else {
|
||||
return;
|
||||
};
|
||||
let Some(handle) = self.emulators.get(sid) else {
|
||||
return;
|
||||
};
|
||||
let mut parser = handle.screen.lock().expect("vt100 parser mutex");
|
||||
let current = parser.screen().scrollback();
|
||||
let next = if rows >= 0 {
|
||||
current.saturating_add(rows as usize)
|
||||
} else {
|
||||
current.saturating_sub(rows.unsigned_abs())
|
||||
};
|
||||
parser.set_scrollback(next);
|
||||
}
|
||||
|
||||
/// Wheel input belongs to the terminal surface in every mode. Normal
|
||||
/// shells move through Maestro's retained history; full-screen programs
|
||||
/// that enabled terminal mouse reporting receive a real wheel event and
|
||||
/// scroll their own alternate-screen content.
|
||||
fn handle_pane_scroll(&mut self, key: &InputKey) -> Option<Option<Effect>> {
|
||||
let InputKey::Mouse {
|
||||
kind,
|
||||
region: Region::Pane,
|
||||
col,
|
||||
row,
|
||||
} = key
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
let lines = match kind {
|
||||
MouseKind::ScrollUp => 3,
|
||||
MouseKind::ScrollDown => -3,
|
||||
_ => return None,
|
||||
};
|
||||
let sid = self.state.active_session()?;
|
||||
let handle = self.emulators.get(sid)?;
|
||||
let pane = self.region.pane?;
|
||||
let parser = handle.screen.lock().expect("vt100 parser mutex");
|
||||
if parser.screen().mouse_protocol_mode() != vt100::MouseProtocolMode::None {
|
||||
let bytes = encode_mouse_wheel(
|
||||
*kind,
|
||||
col.saturating_sub(pane.x).saturating_add(1),
|
||||
row.saturating_sub(pane.y).saturating_add(1),
|
||||
parser.screen().mouse_protocol_encoding(),
|
||||
);
|
||||
drop(parser);
|
||||
return Some(Some(Effect::Forward { id: sid, bytes }));
|
||||
}
|
||||
drop(parser);
|
||||
self.scroll_active_pane(lines);
|
||||
Some(None)
|
||||
}
|
||||
|
||||
/// Feed one keystroke into the active tab's draft form. Returns the spawn
|
||||
/// effect when the user accepted a valid form; an invalid one names the
|
||||
/// missing field inside the form (#64) instead of posting.
|
||||
@@ -1016,6 +1093,8 @@ fn encode_key(key: &InputKey) -> Option<Vec<u8>> {
|
||||
InputKey::Left => Some(b"\x1b[D".to_vec()),
|
||||
InputKey::Home => Some(b"\x1b[H".to_vec()),
|
||||
InputKey::End => Some(b"\x1b[F".to_vec()),
|
||||
InputKey::PageUp => Some(b"\x1b[5~".to_vec()),
|
||||
InputKey::PageDown => Some(b"\x1b[6~".to_vec()),
|
||||
InputKey::Delete => Some(b"\x1b[3~".to_vec()),
|
||||
InputKey::Paste(text) => {
|
||||
// Wrap in the DECSET 2004 markers. Raw bytes look like typed keys
|
||||
@@ -1031,6 +1110,39 @@ fn encode_key(key: &InputKey) -> Option<Vec<u8>> {
|
||||
}
|
||||
}
|
||||
|
||||
fn encode_mouse_wheel(
|
||||
kind: MouseKind,
|
||||
col: u16,
|
||||
row: u16,
|
||||
encoding: vt100::MouseProtocolEncoding,
|
||||
) -> Vec<u8> {
|
||||
let button = match kind {
|
||||
MouseKind::ScrollUp => 64,
|
||||
MouseKind::ScrollDown => 65,
|
||||
_ => return Vec::new(),
|
||||
};
|
||||
match encoding {
|
||||
vt100::MouseProtocolEncoding::Sgr => format!("\x1b[<{button};{col};{row}M").into_bytes(),
|
||||
vt100::MouseProtocolEncoding::Default => vec![
|
||||
0x1b,
|
||||
b'[',
|
||||
b'M',
|
||||
(button + 32) as u8,
|
||||
col.min(223).saturating_add(32) as u8,
|
||||
row.min(223).saturating_add(32) as u8,
|
||||
],
|
||||
vt100::MouseProtocolEncoding::Utf8 => {
|
||||
let mut out = b"\x1b[M".to_vec();
|
||||
for value in [button + 32, u32::from(col) + 32, u32::from(row) + 32] {
|
||||
let ch = char::from_u32(value).expect("mouse coordinate is a valid codepoint");
|
||||
let mut bytes = [0; 4];
|
||||
out.extend_from_slice(ch.encode_utf8(&mut bytes).as_bytes());
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the lines shown by the help overlay from the live keymap, so the
|
||||
/// user always sees the bindings that are actually active (defaults + their
|
||||
/// own `[tui.keymap.*]` overrides).
|
||||
@@ -1825,6 +1937,49 @@ mod tests {
|
||||
assert!(app.forward_to_session(&InputKey::Enter).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pane_scrollback_moves_and_user_input_returns_to_live_output() {
|
||||
use crate::session_task::{SessionHandle, SessionState};
|
||||
use std::sync::atomic::AtomicU8;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
let sid = Uuid::from_u128(91);
|
||||
let mut parser = vt100::Parser::new(2, 20, 20);
|
||||
parser.process(b"one\r\ntwo\r\nthree\r\nfour\r\nfive");
|
||||
let screen = Arc::new(Mutex::new(parser));
|
||||
let handle = SessionHandle {
|
||||
tx: mpsc::channel(1).0,
|
||||
screen: Arc::clone(&screen),
|
||||
state: Arc::new(AtomicU8::new(SessionState::Live as u8)),
|
||||
};
|
||||
let mut app = make_app();
|
||||
app.state.active_tab_mut().pane = Some(PaneRef::Session(sid));
|
||||
app.emulators.insert(sid, handle);
|
||||
|
||||
app.scroll_active_pane(3);
|
||||
assert!(screen.lock().unwrap().screen().scrollback() > 0);
|
||||
assert!(app.forward_to_session(&InputKey::Enter).is_some());
|
||||
assert_eq!(screen.lock().unwrap().screen().scrollback(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mouse_wheel_encoding_supports_sgr_terminals() {
|
||||
assert_eq!(
|
||||
encode_mouse_wheel(MouseKind::ScrollUp, 7, 4, vt100::MouseProtocolEncoding::Sgr,),
|
||||
b"\x1b[<64;7;4M"
|
||||
);
|
||||
assert_eq!(
|
||||
encode_mouse_wheel(
|
||||
MouseKind::ScrollDown,
|
||||
7,
|
||||
4,
|
||||
vt100::MouseProtocolEncoding::Sgr,
|
||||
),
|
||||
b"\x1b[<65;7;4M"
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression review fix: `Paste(_)` used to be silently dropped, so a
|
||||
/// paste into an attached session vanished once bracketed paste was
|
||||
/// enabled. It also has to keep its DECSET 2004 markers — without them a
|
||||
|
||||
@@ -56,6 +56,9 @@ const DEFAULT_NORMAL: &[(&str, &str)] = &[
|
||||
("w", "ShowWorktrees"),
|
||||
// Focus
|
||||
("ctrl-w h", "FocusSidebar"),
|
||||
// Session scrollback
|
||||
("pageup", "ScrollPanePageUp"),
|
||||
("pagedown", "ScrollPanePageDown"),
|
||||
// Chrome
|
||||
("ctrl-w b", "ToggleSidebar"),
|
||||
("?", "ShowHelp"),
|
||||
|
||||
@@ -36,6 +36,8 @@ pub enum InputKey {
|
||||
Right,
|
||||
Home,
|
||||
End,
|
||||
PageUp,
|
||||
PageDown,
|
||||
Delete,
|
||||
/// Bracketed paste: crossterm delivers the whole payload as one event, so
|
||||
/// text fields can insert it as a single operation.
|
||||
@@ -135,6 +137,8 @@ impl InputKey {
|
||||
InputKey::Right => "right".into(),
|
||||
InputKey::Home => "home".into(),
|
||||
InputKey::End => "end".into(),
|
||||
InputKey::PageUp => "pageup".into(),
|
||||
InputKey::PageDown => "pagedown".into(),
|
||||
InputKey::Delete => "delete".into(),
|
||||
// Pastes are not bindable; the canonical form exists only to
|
||||
// satisfy exhaustiveness and never round-trips through `parse`.
|
||||
@@ -204,6 +208,8 @@ impl InputKey {
|
||||
"right" => return Ok(InputKey::Right),
|
||||
"home" => return Ok(InputKey::Home),
|
||||
"end" => return Ok(InputKey::End),
|
||||
"pageup" => return Ok(InputKey::PageUp),
|
||||
"pagedown" => return Ok(InputKey::PageDown),
|
||||
"delete" => return Ok(InputKey::Delete),
|
||||
_ => {}
|
||||
}
|
||||
@@ -278,6 +284,8 @@ where
|
||||
KeyCode::Right => InputKey::Right,
|
||||
KeyCode::Home => InputKey::Home,
|
||||
KeyCode::End => InputKey::End,
|
||||
KeyCode::PageUp => InputKey::PageUp,
|
||||
KeyCode::PageDown => InputKey::PageDown,
|
||||
KeyCode::Delete => InputKey::Delete,
|
||||
_ => return None,
|
||||
})
|
||||
@@ -614,6 +622,8 @@ mod tests {
|
||||
"right",
|
||||
"home",
|
||||
"end",
|
||||
"pageup",
|
||||
"pagedown",
|
||||
"delete",
|
||||
] {
|
||||
let k = InputKey::parse(s).unwrap();
|
||||
|
||||
@@ -74,7 +74,7 @@ impl Pane {
|
||||
|
||||
// Place the terminal cursor at vt100's cursor position when we own
|
||||
// focus and the cursor is on-screen. Terminals blink it on their own.
|
||||
if self.focused && !screen.hide_cursor() {
|
||||
if self.focused && screen.scrollback() == 0 && !screen.hide_cursor() {
|
||||
let (cr, cc) = screen.cursor_position();
|
||||
if cr < rows && cc < cols {
|
||||
f.set_cursor_position((area.x + cc, area.y + cr));
|
||||
|
||||
@@ -23,6 +23,10 @@ use maestro_protocol::ClientControl;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
|
||||
/// Match the browser terminal's retained history without letting long-running
|
||||
/// sessions grow the client without bound.
|
||||
pub const SCROLLBACK_ROWS: usize = 5_000;
|
||||
|
||||
/// Input the dispatcher (or the resize tracker) pushes to a session task.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum SessionInput {
|
||||
@@ -103,7 +107,7 @@ impl SessionTask {
|
||||
/// up with `SessionInput::Resize` on every frame whose allocation changes.
|
||||
pub fn spawn(ws_url: String, rows: u16, cols: u16) -> SessionHandle {
|
||||
let (tx, mut rx) = mpsc::channel::<SessionInput>(64);
|
||||
let parser = Arc::new(Mutex::new(vt100::Parser::new(rows, cols, 0)));
|
||||
let parser = Arc::new(Mutex::new(vt100::Parser::new(rows, cols, SCROLLBACK_ROWS)));
|
||||
let state = Arc::new(AtomicU8::new(SessionState::Connecting as u8));
|
||||
|
||||
let parser_bg = parser.clone();
|
||||
|
||||
@@ -145,6 +145,8 @@ chord_timeout_ms = 1000 # how long to hold a partial chord like `ctrl-w`
|
||||
"?" = "ShowHelp"
|
||||
"ctrl-w h" = "FocusSidebar"
|
||||
"enter" = "AttachHighlighted"
|
||||
"pageup" = "ScrollPanePageUp"
|
||||
"pagedown" = "ScrollPanePageDown"
|
||||
|
||||
[tui.keymap.insert]
|
||||
# (leader is added automatically as LeaveInsert; only override here when you
|
||||
|
||||
+2
-1
@@ -123,7 +123,8 @@ export function createAttachConnection(
|
||||
// wheel events so xterm encodes them as mouse-wheel reports the app reads,
|
||||
// scrolling *its* content (the only scroll that exists on the alt screen).
|
||||
// • alt screen with no mouse tracking → nothing to do; the app owns input.
|
||||
// `touch-action: pan-y` (CSS) tells the browser we handle the Y gesture.
|
||||
// `touch-action: none` (CSS) keeps the browser from cancelling the custom
|
||||
// gesture stream; only the terminal surface opts out of native page panning.
|
||||
{
|
||||
const vp = el.querySelector(".xterm-viewport") as HTMLElement | null;
|
||||
const screen = el.querySelector(".xterm-screen") as HTMLElement | null;
|
||||
|
||||
+5
-3
@@ -1142,9 +1142,11 @@ textarea {
|
||||
.attach-body .xterm .xterm-viewport {
|
||||
overflow-y: auto;
|
||||
scrollbar-width: none;
|
||||
/* We drive scrollback from touchmove (see attach.ts); claim vertical pans so
|
||||
the browser doesn't fight the handler or trigger gestures mid-scroll. */
|
||||
touch-action: pan-y;
|
||||
/* Maestro drives scrollback from touchmove (see attach.ts). `pan-y` hands
|
||||
the gesture to the browser, which may cancel the event stream before the
|
||||
terminal sees it; `none` keeps a drag inside the terminal under our
|
||||
control while the rest of the page retains native scrolling. */
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.attach-body .xterm .xterm-viewport::-webkit-scrollbar {
|
||||
|
||||
Reference in New Issue
Block a user