T09: the tap holds one mouse tracking and one format as xterm does, any reset ends it; every reported button keeps its release, motion reports the first held; URLs keep combining marks; selections log start, run and end; shot hands real buttons to the view and shows its window without activating
This commit is contained in:
10 files changed
+311
-206
No files matched your search
@@ -12,7 +12,10 @@ ELYTerminal. A terminal on GPUI and Ely components. `PRD.md` says what it does;
|
||||
## Commands
|
||||
|
||||
- `cargo run` opens the app. `RUST_LOG=elyterminal=debug cargo run` logs more; `trace` logs each paint.
|
||||
- `cargo run --example shot -- [--keys "a ctrl-c lopt-f repeat-up mark:é commit:é"] <png> <program> [args…]` runs a program in the main window, types the keys as real AppKit events (`lopt`/`ropt` are the left and right Option keys, `repeat-` sets AppKit's repeat flag, `mark:` and `commit:` hand text to the window's input client as an input method does), and writes a PNG of the window (macOS; no permission needed for the app's own window). It never activates its window, so it takes no keystrokes or clicks meant for other apps. Run it with `HOME` pointing at a temp folder to try settings without touching your own.
|
||||
- `cargo run --example shot -- [--keys "SPECS"] <png> <program> [args…]` runs a program in the main window, plays SPECS on it, and writes a PNG of the window (macOS; no permission needed for the app's own window). It shows its window without activating the app, so gpui keeps drawing it and keystrokes or clicks meant for other apps stay there. Run it with `HOME` pointing at a temp folder to try settings without touching your own. SPECS, space-separated:
|
||||
- keys as real AppKit events: `a`, `ctrl-c`, `lopt-f` and `ropt-f` (left and right Option), `cmd-v`, `repeat-up` (AppKit's repeat flag);
|
||||
- input method calls: `mark:é` composes, `commit:é` types;
|
||||
- mouse gestures handed to gpui's view, at points from the content's top-left, modifiers as for keys: `click@x,y`, `click2@`, `click3@`, `rclick@`, `mclick@`, `drag@x,y@x,y`, `move@x,y`, the steps `ldown@`, `lup@`, `ldrag@`, `rdown@`, `rup@`, `mdown@`, `mup@`, and `scrollup@x,y@lines`, `scrolldown@x,y@lines`.
|
||||
- `scripts/check.sh`: fmt, clippy `-D warnings`, tests, the 500-line rule.
|
||||
|
||||
## Layout
|
||||
@@ -20,7 +23,7 @@ ELYTerminal. A terminal on GPUI and Ely components. `PRD.md` says what it does;
|
||||
- `src/main.rs`: start. `src/lib.rs`: the modules, public so tests and examples reach them. `src/app.rs`: fonts, settings, integration, then the main window.
|
||||
- `src/settings`: the model, its ranges, and `SettingsStore`, the global that loads, checks and saves `settings.json`.
|
||||
- `src/themes`: built-in terminal themes, the chrome palette each maps to, and `apply`, which dresses the app for the system's appearance.
|
||||
- `src/term`: the terminal engine. `pty` starts a program through alacritty's event loop; `tap` wraps the pty so its reader scans OSC 7 and mode 9 before the parser. `unix` starts the program itself, so a failed `chdir` fails the start; Windows uses alacritty's ConPTY. `shell` writes the integration scripts at start and builds each shell's launch. `frame` reads the grid into styled rows, `boxes` turns box drawing, block elements and powerline arrows into rects and polygons (`boxes/outline` strokes curves and diagonals into polygons), `ink` resolves colors, `paint` draws rows, decorations and the cursor, `view` is the entity: pty events, metrics, blink, resize. `keys` turns key events into bytes, the kitty protocol and, in `keys/legacy`, xterm's encoding, with no gpui types; `input` is the view's key listeners, the bindings that keep Tab and F6 from Ely's focus moves, and its IME handler; `mac` reads the Option side and repeat flag of AppKit's current event, keeps held keys repeating, and hands Command key-ups to the focused view. `mouse` encodes mouse reports, with no gpui types; `links` finds the link at a cell; `pointer` is the view's mouse listeners: selection, reports, wheel, links, copy and paste.
|
||||
- `src/term`: the terminal engine. `pty` starts a program through alacritty's event loop; `tap` wraps the pty so its reader scans OSC 7 and the mouse modes before the parser. `unix` starts the program itself, so a failed `chdir` fails the start; Windows uses alacritty's ConPTY. `shell` writes the integration scripts at start and builds each shell's launch. `frame` reads the grid into styled rows, `boxes` turns box drawing, block elements and powerline arrows into rects and polygons (`boxes/outline` strokes curves and diagonals into polygons), `ink` resolves colors, `paint` draws rows, decorations and the cursor, `view` is the entity: pty events, metrics, blink, resize. `keys` turns key events into bytes, the kitty protocol and, in `keys/legacy`, xterm's encoding, with no gpui types; `input` is the view's key listeners, the bindings that keep Tab and F6 from Ely's focus moves, and its IME handler; `mac` reads the Option side and repeat flag of AppKit's current event, keeps held keys repeating, and hands Command key-ups to the focused view. `mouse` encodes mouse reports, with no gpui types; `links` finds the link at a cell; `pointer` is the view's mouse listeners: selection, reports, wheel, links, copy and paste.
|
||||
- `assets/shell-integration`: ELYTerminal's own scripts (not Ghostty's, which are GPL): zsh through `ZDOTDIR`, bash through `--rcfile`, fish through `XDG_DATA_DIRS`, PowerShell through `-NoExit -Command`. Each prompt sends OSC 7 and, except in fish, whose `fish_title` stays, a title.
|
||||
- `assets/fonts`: JetBrains Mono 2.304's bold and italic faces (OFL; Ely bundles regular), and Nerd Fonts 3.5.1's JetBrains Mono NF Mono Regular, which draws icons a font lacks.
|
||||
- `assets/themes`: Ghostty theme files copied verbatim from iTerm2-Color-Schemes (MIT, its `LICENSE` beside them).
|
||||
@@ -49,7 +52,7 @@ ELYTerminal. A terminal on GPUI and Ely components. `PRD.md` says what it does;
|
||||
- 2026-10-14: an input method's commit goes to the program as UTF-8 under every kitty flag, as kitty (`keys.c`, `GLFW_IME_COMMIT_TEXT`) and Ghostty (test `kitty: composed text with report all`) send it.
|
||||
- 2026-10-14: what gpui hides from the kitty protocol: a key alone on a modifier sends nothing (gpui reports no key event for it), and shifted punctuation reports the character it types as its key, since gpui clears Shift for it.
|
||||
- 2026-10-14: on macOS, which Option key is down and whether a key repeats come from AppKit's current event: gpui rebuilds keys AppKit routes through its commands as fresh presses. As in Ghostty, `ApplePressAndHoldEnabled` is registered off, so held keys repeat instead of opening the accent menu, and a local event monitor hands Command key-ups to the focused view, since AppKit never routes them to the window.
|
||||
- 2026-10-14: mouse reports follow Ghostty's rules: X10 tracking sends presses alone without modifiers, SGR names the released button, motion inside one cell sends nothing, Shift takes the mouse back for selecting. X10 tracking is mode 9, which vte drops; the tap follows `CSI ? 9 h` and `l`, and 1000, 1002, 1003 and a reset end it.
|
||||
- 2026-10-14: mouse reports follow Ghostty's rules: X10 tracking sends presses alone without modifiers, SGR names the released button, motion inside one cell sends nothing, Shift takes the mouse back for selecting. The tap follows modes 9, 1000, 1002, 1003, 1005 and 1006 as xterm holds them: one tracking and one format at a time, the last set wins, any reset ends it, and RIS clears both. alacritty drops 9 and keeps each mode apart, so its mouse bits go unread. Motion reports the first held button: left, right, middle.
|
||||
- 2026-10-14: a wheel notch scrolls 3 lines, as Ghostty's discrete wheel; a trackpad scrolls its pixels. The wheel reports to a program that tracks the mouse, sends arrows on the alternate screen under mode 1007, and scrolls history otherwise.
|
||||
- 2026-10-14: Cmd-C and Cmd-V copy and paste on macOS, Ctrl-Shift-C and Ctrl-Shift-V elsewhere; Cmd-press, Ctrl-press off macOS, opens an OSC 8 target or a URL Ely's `links` finds in the wrapped line. Copies drop the newline alacritty ends a whole line with, as Ghostty's do. Bracketed pastes lose ESC and Ctrl-C, as alacritty's do; plain pastes end lines with CR. Typing and pasting clear the selection.
|
||||
- 2026-10-14: a key's release goes out only when the terminal saw its press, so a key a binding takes sends no release, as in kitty.
|
||||
|
||||
+119
-69
@@ -1,4 +1,4 @@
|
||||
//! `cargo run --example shot -- [--keys "a ctrl-c lopt-f repeat-up mark:é commit:é shift-click@40,20"] <png> <program> [args…]`: runs a program in the main window, types the keys and mouse gestures as AppKit events, then photographs the window.
|
||||
//! Runs a program in the main window, plays keys and mouse gestures on it, and photographs it.
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn main() {
|
||||
@@ -31,11 +31,12 @@ fn main() {
|
||||
app::start(cx).expect("the app starts");
|
||||
let window = app::open(launch, cx).expect("the window opens");
|
||||
cx.spawn(async move |cx| {
|
||||
cx.background_executor().timer(Duration::from_secs(1)).await;
|
||||
let number = window
|
||||
.update(cx, |_, window, _| capture::number(window))
|
||||
.expect("the window lives")
|
||||
.expect("a window number");
|
||||
capture::front(number).expect("the window on screen");
|
||||
cx.background_executor().timer(Duration::from_secs(1)).await;
|
||||
for spec in &keys {
|
||||
typing::post(spec, number).expect("a key event");
|
||||
cx.background_executor()
|
||||
@@ -60,7 +61,7 @@ mod capture {
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::{Context as _, bail};
|
||||
use objc::{msg_send, runtime::Object, sel, sel_impl};
|
||||
use objc::{class, msg_send, runtime::Object, sel, sel_impl};
|
||||
use raw_window_handle::{HasWindowHandle, RawWindowHandle};
|
||||
|
||||
/// A gpui window's number, as CoreGraphics knows it.
|
||||
@@ -76,6 +77,17 @@ mod capture {
|
||||
u32::try_from(number).context("window number out of range")
|
||||
}
|
||||
|
||||
/// Shows the window without activating the app: gpui draws it, real input stays put.
|
||||
pub fn front(number: u32) -> anyhow::Result<()> {
|
||||
unsafe {
|
||||
let app: *mut Object = msg_send![class!(NSApplication), sharedApplication];
|
||||
let window: *mut Object = msg_send![app, windowWithWindowNumber: i64::from(number)];
|
||||
anyhow::ensure!(!window.is_null(), "no window {number}");
|
||||
let _: () = msg_send![window, orderFrontRegardless];
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Writes the window `number` as a PNG; the app's own window needs no permission.
|
||||
pub fn snapshot(number: u32, path: &Path) -> anyhow::Result<()> {
|
||||
use core_graphics::{
|
||||
@@ -117,14 +129,14 @@ mod capture {
|
||||
mod typing {
|
||||
use anyhow::Context as _;
|
||||
use core_graphics::{
|
||||
event::{CGEvent, CGEventFlags, ScrollEventUnit},
|
||||
event::{CGEvent, CGEventFlags, CGEventType, CGMouseButton, EventField, ScrollEventUnit},
|
||||
event_source::{CGEventSource, CGEventSourceStateID},
|
||||
geometry::{CGPoint, CGRect},
|
||||
};
|
||||
use foreign_types::ForeignType;
|
||||
use objc::{
|
||||
class, msg_send,
|
||||
runtime::{NO, Object, YES},
|
||||
runtime::{NO, Object, Sel, YES},
|
||||
sel, sel_impl,
|
||||
};
|
||||
|
||||
@@ -196,7 +208,68 @@ mod typing {
|
||||
fn CGEventSetLocation(event: *mut core_graphics::sys::CGEvent, location: CGPoint);
|
||||
}
|
||||
|
||||
/// `click@x,y` (`click2`, `click3`, `rclick`), `drag@x,y@x,y`, `move@x,y`, `scrollup@x,y@lines` or `scrolldown`, at points from the content's top-left; AppKit routes no window-less scroll, so it goes to the view.
|
||||
/// A gesture's steps: each one's name, point and click count.
|
||||
fn steps(gesture: &str, points: &[CGPoint]) -> anyhow::Result<Vec<(String, CGPoint, i64)>> {
|
||||
let step = |name: &str, at: CGPoint, clicks| (name.to_string(), at, clicks);
|
||||
let (at, to) = (points[0], points.get(1).copied());
|
||||
Ok(match gesture {
|
||||
"click" | "click2" | "click3" => (1..=gesture[5..].parse().unwrap_or(1))
|
||||
.flat_map(|clicks| [step("ldown", at, clicks), step("lup", at, clicks)])
|
||||
.collect(),
|
||||
"rclick" => vec![step("rdown", at, 1), step("rup", at, 1)],
|
||||
"mclick" => vec![step("mdown", at, 1), step("mup", at, 1)],
|
||||
"drag" => {
|
||||
let to = to.context("drag needs a second point")?;
|
||||
vec![
|
||||
step("ldown", at, 1),
|
||||
step("ldrag", to, 1),
|
||||
step("lup", to, 1),
|
||||
]
|
||||
}
|
||||
name => vec![step(name, at, 1)],
|
||||
})
|
||||
}
|
||||
|
||||
/// A step's event type, button and the selector gpui's view takes it by.
|
||||
fn kind(step: &str) -> anyhow::Result<(CGEventType, CGMouseButton, &'static str)> {
|
||||
Ok(match step {
|
||||
"ldown" => (
|
||||
CGEventType::LeftMouseDown,
|
||||
CGMouseButton::Left,
|
||||
"mouseDown:",
|
||||
),
|
||||
"lup" => (CGEventType::LeftMouseUp, CGMouseButton::Left, "mouseUp:"),
|
||||
"ldrag" => (
|
||||
CGEventType::LeftMouseDragged,
|
||||
CGMouseButton::Left,
|
||||
"mouseDragged:",
|
||||
),
|
||||
"rdown" => (
|
||||
CGEventType::RightMouseDown,
|
||||
CGMouseButton::Right,
|
||||
"rightMouseDown:",
|
||||
),
|
||||
"rup" => (
|
||||
CGEventType::RightMouseUp,
|
||||
CGMouseButton::Right,
|
||||
"rightMouseUp:",
|
||||
),
|
||||
"mdown" => (
|
||||
CGEventType::OtherMouseDown,
|
||||
CGMouseButton::Center,
|
||||
"otherMouseDown:",
|
||||
),
|
||||
"mup" => (
|
||||
CGEventType::OtherMouseUp,
|
||||
CGMouseButton::Center,
|
||||
"otherMouseUp:",
|
||||
),
|
||||
"move" => (CGEventType::MouseMoved, CGMouseButton::Left, "mouseMoved:"),
|
||||
other => anyhow::bail!("no mouse gesture {other}"),
|
||||
})
|
||||
}
|
||||
|
||||
/// Hands gpui's view a mouse gesture as AppKit events, even while the window is inactive.
|
||||
fn mouse(name: &str, flags: u64, number: u32) -> anyhow::Result<()> {
|
||||
let mut fields = name.split('@');
|
||||
let gesture = fields.next().context("a gesture")?;
|
||||
@@ -205,84 +278,61 @@ mod typing {
|
||||
.map(str::parse)
|
||||
.collect::<Result<_, _>>()
|
||||
.with_context(|| format!("numbers in {name}"))?;
|
||||
let needed = match gesture {
|
||||
"drag" => 4,
|
||||
"scrollup" | "scrolldown" => 3,
|
||||
_ => 2,
|
||||
};
|
||||
anyhow::ensure!(numbers.len() == needed, "{name} needs {needed} numbers");
|
||||
anyhow::ensure!(numbers.len() >= 2, "{name} needs a point");
|
||||
unsafe {
|
||||
let app: *mut Object = msg_send![class!(NSApplication), sharedApplication];
|
||||
let window: *mut Object = msg_send![app, windowWithWindowNumber: i64::from(number)];
|
||||
anyhow::ensure!(!window.is_null(), "no window {number}");
|
||||
let view: *mut Object = msg_send![window, firstResponder];
|
||||
let content: *mut Object = msg_send![window, contentView];
|
||||
let height = {
|
||||
let frame: CGRect = msg_send![content, frame];
|
||||
frame.size.height
|
||||
let frame: CGRect = msg_send![content, frame];
|
||||
let height = frame.size.height;
|
||||
let points: Vec<CGPoint> = numbers
|
||||
.chunks_exact(2)
|
||||
.map(|pair| CGPoint::new(pair[0], height - pair[1]))
|
||||
.collect();
|
||||
let screens: *mut Object = msg_send![class!(NSScreen), screens];
|
||||
let main: *mut Object = msg_send![screens, objectAtIndex: 0u64];
|
||||
let screen: CGRect = msg_send![main, frame];
|
||||
let source = || {
|
||||
CGEventSource::new(CGEventSourceStateID::Private)
|
||||
.map_err(|_| anyhow::anyhow!("no event source"))
|
||||
};
|
||||
let at = |index: usize| CGPoint::new(numbers[index], height - numbers[index + 1]);
|
||||
let post = |kind: u64, point: CGPoint, clicks: i64| -> anyhow::Result<()> {
|
||||
let event: *mut Object = msg_send![
|
||||
class!(NSEvent),
|
||||
mouseEventWithType: kind
|
||||
location: point
|
||||
modifierFlags: flags
|
||||
timestamp: 0.0f64
|
||||
windowNumber: i64::from(number)
|
||||
context: std::ptr::null_mut::<Object>()
|
||||
eventNumber: 0i64
|
||||
clickCount: clicks
|
||||
pressure: 1.0f32
|
||||
];
|
||||
anyhow::ensure!(!event.is_null(), "AppKit refused the mouse event {name}");
|
||||
let _: () = msg_send![app, postEvent: event atStart: NO];
|
||||
let deliver = |event: CGEvent, at: CGPoint, selector: &str| -> anyhow::Result<()> {
|
||||
CGEventSetLocation(
|
||||
event.as_ptr(),
|
||||
CGPoint::new(at.x, screen.size.height - at.y),
|
||||
);
|
||||
event.set_flags(CGEventFlags::from_bits_truncate(flags));
|
||||
let native: *mut Object =
|
||||
msg_send![class!(NSEvent), eventWithCGEvent: event.as_ptr()];
|
||||
anyhow::ensure!(!native.is_null(), "AppKit refused {name}");
|
||||
let selector = Sel::register(selector);
|
||||
let _: *mut Object = msg_send![view, performSelector: selector withObject: native];
|
||||
Ok(())
|
||||
};
|
||||
match gesture {
|
||||
"click" | "click2" | "click3" => {
|
||||
for clicks in 1..=gesture[5..].parse().unwrap_or(1) {
|
||||
post(1, at(0), clicks)?;
|
||||
post(2, at(0), clicks)?;
|
||||
}
|
||||
if let Some(up) = gesture.strip_prefix("scroll") {
|
||||
let lines = *numbers.get(2).context("a scroll needs lines")? as i32;
|
||||
let lines = if up == "up" { lines } else { -lines };
|
||||
let event =
|
||||
CGEvent::new_scroll_event(source()?, ScrollEventUnit::LINE, 1, lines, 0, 0)
|
||||
.map_err(|_| anyhow::anyhow!("no scroll event"))?;
|
||||
deliver(event, points[0], "scrollWheel:")?;
|
||||
} else {
|
||||
for (step, at, clicks) in steps(gesture, &points)? {
|
||||
let (kind, button, selector) = kind(&step)?;
|
||||
let event = CGEvent::new_mouse_event(source()?, kind, at, button)
|
||||
.map_err(|_| anyhow::anyhow!("no mouse event"))?;
|
||||
event.set_integer_value_field(EventField::MOUSE_EVENT_CLICK_STATE, clicks);
|
||||
deliver(event, at, selector)?;
|
||||
}
|
||||
"rclick" => {
|
||||
post(3, at(0), 1)?;
|
||||
post(4, at(0), 1)?;
|
||||
}
|
||||
"drag" => {
|
||||
post(1, at(0), 1)?;
|
||||
post(6, at(2), 1)?;
|
||||
post(2, at(2), 1)?;
|
||||
}
|
||||
"move" => post(5, at(0), 0)?,
|
||||
"scrollup" | "scrolldown" => {
|
||||
let lines = numbers[2] as i32 * if gesture == "scrollup" { 1 } else { -1 };
|
||||
let screens: *mut Object = msg_send![class!(NSScreen), screens];
|
||||
let main: *mut Object = msg_send![screens, objectAtIndex: 0u64];
|
||||
let screen: CGRect = msg_send![main, frame];
|
||||
let point = at(0);
|
||||
let source = CGEventSource::new(CGEventSourceStateID::Private)
|
||||
.map_err(|_| anyhow::anyhow!("no event source"))?;
|
||||
let event =
|
||||
CGEvent::new_scroll_event(source, ScrollEventUnit::LINE, 1, lines, 0, 0)
|
||||
.map_err(|_| anyhow::anyhow!("no scroll event"))?;
|
||||
let place = CGPoint::new(point.x, screen.size.height - point.y);
|
||||
CGEventSetLocation(event.as_ptr(), place);
|
||||
event.set_flags(CGEventFlags::from_bits_truncate(flags));
|
||||
let native: *mut Object =
|
||||
msg_send![class!(NSEvent), eventWithCGEvent: event.as_ptr()];
|
||||
anyhow::ensure!(!native.is_null(), "AppKit refused the scroll {name}");
|
||||
let view: *mut Object = msg_send![window, firstResponder];
|
||||
let _: () = msg_send![view, scrollWheel: native];
|
||||
}
|
||||
other => anyhow::bail!("no mouse gesture {other}"),
|
||||
}
|
||||
}
|
||||
log::info!("shot: mouse {name}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Posts `spec`, such as `ctrl-c`, `lopt-f`, `repeat-up` or `shift-drag@0,0@40,20`, as AppKit events on window `number`.
|
||||
/// Posts `spec`, a key, an input method call or a mouse gesture, on window `number`.
|
||||
pub fn post(spec: &str, number: u32) -> anyhow::Result<()> {
|
||||
if let Some((call, text)) = spec.split_once(':') {
|
||||
return compose(call, text, number);
|
||||
|
||||
+3
-1
@@ -38,7 +38,9 @@ impl TerminalView {
|
||||
pub(super) fn input(&mut self, bytes: Vec<u8>, cx: &mut Context<Self>) {
|
||||
let mut term = self.pty.term.lock();
|
||||
term.scroll_display(Scroll::Bottom);
|
||||
term.selection = None;
|
||||
if term.selection.take().is_some() {
|
||||
log::debug!("terminal {}: input clears the selection", self.pty.pid);
|
||||
}
|
||||
drop(term);
|
||||
self.pty.write(bytes);
|
||||
cx.notify();
|
||||
|
||||
+1
-1
@@ -51,7 +51,7 @@ pub fn encode(key: &Key, app_cursor: bool, kitty: Kitty) -> Option<Vec<u8>> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Pasted text as the program gets it; bracketed text loses ESC and Ctrl-C, as alacritty's does.
|
||||
/// Pasted text for the program; bracketed text loses ESC and Ctrl-C, as in alacritty.
|
||||
pub fn paste(text: &str, bracketed: bool) -> Vec<u8> {
|
||||
if bracketed {
|
||||
let text = text.replace(['\x1b', '\x03'], "");
|
||||
|
||||
+8
-1
@@ -4,7 +4,7 @@ use alacritty_terminal::{
|
||||
};
|
||||
use ely_gpui_component::terminal::{Target, links};
|
||||
|
||||
/// The link at `point`: the program's OSC 8 target, else a URL in its line, wrapped rows joined.
|
||||
/// The OSC 8 target at `point`, else a URL in its wrapped line.
|
||||
pub fn link_at<T>(term: &Term<T>, mut point: Point) -> Option<String> {
|
||||
let grid = term.grid();
|
||||
if grid[point].flags.contains(Flags::WIDE_CHAR_SPACER) {
|
||||
@@ -23,6 +23,7 @@ pub fn link_at<T>(term: &Term<T>, mut point: Point) -> Option<String> {
|
||||
let spacer = Flags::WIDE_CHAR_SPACER | Flags::LEADING_WIDE_CHAR_SPACER;
|
||||
if !grid[cell].flags.intersects(spacer) {
|
||||
text.push(grid[cell].c);
|
||||
text.extend(grid[cell].zerowidth().into_iter().flatten());
|
||||
}
|
||||
if cell == end {
|
||||
break;
|
||||
@@ -70,6 +71,12 @@ mod tests {
|
||||
assert_eq!(link(text, 0, 11), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_url_keeps_its_combining_marks() {
|
||||
let text = "https://e.test/cafe\u{301}/x";
|
||||
assert_eq!(link(text, 1, 1).as_deref(), Some(text));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_url_runs_across_wrapped_rows_and_wide_cells() {
|
||||
let text = "\u{4e2d} https://ely.dev/abc";
|
||||
|
||||
+91
-69
@@ -1,5 +1,3 @@
|
||||
use alacritty_terminal::term::TermMode;
|
||||
|
||||
/// A button as reports name it; the wheel's two turns count as buttons.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Button {
|
||||
@@ -32,8 +30,9 @@ pub struct Report {
|
||||
}
|
||||
|
||||
/// The mouse events a program asked for.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum Tracking {
|
||||
#[default]
|
||||
Off,
|
||||
/// Mode 9: presses alone, no modifiers.
|
||||
X10,
|
||||
@@ -45,30 +44,51 @@ pub enum Tracking {
|
||||
Any,
|
||||
}
|
||||
|
||||
impl Tracking {
|
||||
/// The tracking `mode` sets; `x10` is mode 9, which the tap follows.
|
||||
pub fn new(mode: TermMode, x10: bool) -> Self {
|
||||
if x10 {
|
||||
Self::X10
|
||||
} else if mode.contains(TermMode::MOUSE_MOTION) {
|
||||
Self::Any
|
||||
} else if mode.contains(TermMode::MOUSE_DRAG) {
|
||||
Self::Button
|
||||
} else if mode.contains(TermMode::MOUSE_REPORT_CLICK) {
|
||||
Self::Normal
|
||||
} else {
|
||||
Self::Off
|
||||
}
|
||||
/// The bytes reports go in: xterm's default, mode 1005 or mode 1006.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum Format {
|
||||
#[default]
|
||||
X10,
|
||||
Utf8,
|
||||
Sgr,
|
||||
}
|
||||
|
||||
/// How a program asked for the mouse.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct MouseMode {
|
||||
pub tracking: Tracking,
|
||||
pub format: Format,
|
||||
}
|
||||
|
||||
impl MouseMode {
|
||||
/// Follows `CSI ? mode h` or `l`; as in xterm, a reset ends the tracking or format.
|
||||
pub fn apply(&mut self, mode: u32, set: bool) {
|
||||
let tracking = match mode {
|
||||
9 => Tracking::X10,
|
||||
1000 => Tracking::Normal,
|
||||
1002 => Tracking::Button,
|
||||
1003 => Tracking::Any,
|
||||
1005 | 1006 => {
|
||||
self.format = match (set, mode) {
|
||||
(false, _) => Format::X10,
|
||||
(true, 1005) => Format::Utf8,
|
||||
(true, _) => Format::Sgr,
|
||||
};
|
||||
return;
|
||||
}
|
||||
_ => return,
|
||||
};
|
||||
self.tracking = if set { tracking } else { Tracking::Off };
|
||||
}
|
||||
}
|
||||
|
||||
/// The bytes `report` sends; none when the program did not ask for it or its format cannot hold the cell.
|
||||
pub fn encode(report: &Report, tracking: Tracking, mode: TermMode) -> Option<Vec<u8>> {
|
||||
/// The bytes `report` sends; none when unasked for or past what the format holds.
|
||||
pub fn encode(report: &Report, mode: MouseMode) -> Option<Vec<u8>> {
|
||||
let clicked = matches!(
|
||||
report.button,
|
||||
Some(Button::Left | Button::Middle | Button::Right)
|
||||
);
|
||||
let wanted = match tracking {
|
||||
let wanted = match mode.tracking {
|
||||
Tracking::Off => false,
|
||||
Tracking::X10 => report.gesture == Gesture::Press && clicked,
|
||||
Tracking::Normal => report.gesture != Gesture::Motion,
|
||||
@@ -78,7 +98,7 @@ pub fn encode(report: &Report, tracking: Tracking, mode: TermMode) -> Option<Vec
|
||||
if !wanted {
|
||||
return None;
|
||||
}
|
||||
let sgr = mode.contains(TermMode::SGR_MOUSE);
|
||||
let sgr = mode.format == Format::Sgr;
|
||||
let mut code = match report.button {
|
||||
None => 3,
|
||||
Some(_) if report.gesture == Gesture::Release && !sgr => 3,
|
||||
@@ -88,7 +108,7 @@ pub fn encode(report: &Report, tracking: Tracking, mode: TermMode) -> Option<Vec
|
||||
Some(Button::WheelUp) => 64,
|
||||
Some(Button::WheelDown) => 65,
|
||||
};
|
||||
if tracking != Tracking::X10 {
|
||||
if mode.tracking != Tracking::X10 {
|
||||
code +=
|
||||
4 * u32::from(report.shift) + 8 * u32::from(report.alt) + 16 * u32::from(report.ctrl);
|
||||
}
|
||||
@@ -104,7 +124,7 @@ pub fn encode(report: &Report, tracking: Tracking, mode: TermMode) -> Option<Vec
|
||||
};
|
||||
return Some(format!("\x1b[<{code};{column};{row}{end}").into_bytes());
|
||||
}
|
||||
let utf8 = mode.contains(TermMode::UTF8_MOUSE);
|
||||
let utf8 = mode.format == Format::Utf8;
|
||||
let most = if utf8 { 0x7FF - 32 } else { 0xFF - 32 };
|
||||
if column > most || row > most {
|
||||
log::debug!("mouse: cell {column},{row} is past what the format holds");
|
||||
@@ -139,102 +159,104 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn sent(report: Report, tracking: Tracking, mode: TermMode) -> Option<Vec<u8>> {
|
||||
encode(&report, tracking, mode)
|
||||
fn mode(tracking: Tracking, format: Format) -> MouseMode {
|
||||
MouseMode { tracking, format }
|
||||
}
|
||||
|
||||
const LEFT: Option<Button> = Some(Button::Left);
|
||||
|
||||
#[test]
|
||||
fn one_tracking_and_one_format_hold_and_a_reset_ends_them() {
|
||||
let mut held = MouseMode::default();
|
||||
for (code, set) in [(1000, true), (9, true), (9, false), (1006, true)] {
|
||||
held.apply(code, set);
|
||||
}
|
||||
assert_eq!(held, mode(Tracking::Off, Format::Sgr), "9 replaced 1000");
|
||||
held.apply(1002, true);
|
||||
held.apply(1003, false);
|
||||
assert_eq!(held.tracking, Tracking::Off, "any reset ends tracking");
|
||||
held.apply(1003, true);
|
||||
held.apply(1005, true);
|
||||
assert_eq!(
|
||||
held,
|
||||
mode(Tracking::Any, Format::Utf8),
|
||||
"the last format holds"
|
||||
);
|
||||
held.apply(1006, false);
|
||||
held.apply(25, true);
|
||||
assert_eq!(held, mode(Tracking::Any, Format::X10));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normal_tracking_sends_presses_and_releases_as_x10_bytes() {
|
||||
let mode = TermMode::MOUSE_REPORT_CLICK;
|
||||
let tracking = Tracking::new(mode, false);
|
||||
assert_eq!(tracking, Tracking::Normal);
|
||||
let normal = mode(Tracking::Normal, Format::X10);
|
||||
let press = report(LEFT, Gesture::Press, (0, 0));
|
||||
assert_eq!(sent(press, tracking, mode), Some(b"\x1b[M !!".to_vec()));
|
||||
assert_eq!(encode(&press, normal), Some(b"\x1b[M !!".to_vec()));
|
||||
let release = report(LEFT, Gesture::Release, (3, 2));
|
||||
assert_eq!(sent(release, tracking, mode), Some(b"\x1b[M#$#".to_vec()));
|
||||
assert_eq!(encode(&release, normal), Some(b"\x1b[M#$#".to_vec()));
|
||||
let motion = report(LEFT, Gesture::Motion, (1, 1));
|
||||
assert_eq!(sent(motion, tracking, mode), None);
|
||||
assert_eq!(encode(&motion, normal), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sgr_names_the_released_button_and_the_modifiers() {
|
||||
let mode = TermMode::MOUSE_REPORT_CLICK | TermMode::SGR_MOUSE;
|
||||
let sgr = mode(Tracking::Normal, Format::Sgr);
|
||||
let press = Report {
|
||||
shift: true,
|
||||
ctrl: true,
|
||||
..report(Some(Button::Right), Gesture::Press, (4, 2))
|
||||
};
|
||||
assert_eq!(
|
||||
sent(press, Tracking::Normal, mode),
|
||||
Some(b"\x1b[<22;5;3M".to_vec())
|
||||
);
|
||||
assert_eq!(encode(&press, sgr), Some(b"\x1b[<22;5;3M".to_vec()));
|
||||
let release = report(Some(Button::Middle), Gesture::Release, (300, 9));
|
||||
assert_eq!(
|
||||
sent(release, Tracking::Normal, mode),
|
||||
Some(b"\x1b[<1;301;10m".to_vec())
|
||||
);
|
||||
assert_eq!(encode(&release, sgr), Some(b"\x1b[<1;301;10m".to_vec()));
|
||||
let alt = Report {
|
||||
alt: true,
|
||||
..report(Some(Button::WheelDown), Gesture::Press, (0, 0))
|
||||
};
|
||||
assert_eq!(
|
||||
sent(alt, Tracking::Normal, mode),
|
||||
Some(b"\x1b[<73;1;1M".to_vec())
|
||||
);
|
||||
assert_eq!(encode(&alt, sgr), Some(b"\x1b[<73;1;1M".to_vec()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn motion_needs_button_or_any_tracking() {
|
||||
let mode = TermMode::SGR_MOUSE;
|
||||
let held = report(LEFT, Gesture::Motion, (1, 0));
|
||||
let free = report(None, Gesture::Motion, (1, 0));
|
||||
assert_eq!(Tracking::new(TermMode::MOUSE_DRAG, false), Tracking::Button);
|
||||
assert_eq!(
|
||||
sent(held, Tracking::Button, mode),
|
||||
Some(b"\x1b[<32;2;1M".to_vec())
|
||||
);
|
||||
assert_eq!(sent(free, Tracking::Button, mode), None);
|
||||
assert_eq!(Tracking::new(TermMode::MOUSE_MOTION, false), Tracking::Any);
|
||||
assert_eq!(
|
||||
sent(free, Tracking::Any, mode),
|
||||
Some(b"\x1b[<35;2;1M".to_vec())
|
||||
);
|
||||
let button = mode(Tracking::Button, Format::Sgr);
|
||||
assert_eq!(encode(&held, button), Some(b"\x1b[<32;2;1M".to_vec()));
|
||||
assert_eq!(encode(&free, button), None);
|
||||
let any = mode(Tracking::Any, Format::Sgr);
|
||||
assert_eq!(encode(&free, any), Some(b"\x1b[<35;2;1M".to_vec()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn x10_tracking_sends_presses_alone_without_modifiers() {
|
||||
let mode = TermMode::MOUSE_REPORT_CLICK;
|
||||
let tracking = Tracking::new(mode, true);
|
||||
assert_eq!(tracking, Tracking::X10);
|
||||
let x10 = mode(Tracking::X10, Format::X10);
|
||||
let press = Report {
|
||||
shift: true,
|
||||
..report(LEFT, Gesture::Press, (0, 0))
|
||||
};
|
||||
assert_eq!(sent(press, tracking, mode), Some(b"\x1b[M !!".to_vec()));
|
||||
assert_eq!(encode(&press, x10), Some(b"\x1b[M !!".to_vec()));
|
||||
let release = report(LEFT, Gesture::Release, (0, 0));
|
||||
assert_eq!(sent(release, tracking, mode), None);
|
||||
assert_eq!(encode(&release, x10), None);
|
||||
let wheel = report(Some(Button::WheelUp), Gesture::Press, (0, 0));
|
||||
assert_eq!(sent(wheel, tracking, mode), None);
|
||||
assert_eq!(Tracking::new(TermMode::empty(), false), Tracking::Off);
|
||||
assert_eq!(encode(&wheel, x10), None);
|
||||
assert_eq!(encode(&press, MouseMode::default()), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cells_past_the_format_go_utf8_or_nowhere() {
|
||||
let mode = TermMode::MOUSE_REPORT_CLICK;
|
||||
let normal = mode(Tracking::Normal, Format::X10);
|
||||
let edge = report(LEFT, Gesture::Press, (222, 0));
|
||||
assert_eq!(
|
||||
sent(edge, Tracking::Normal, mode),
|
||||
encode(&edge, normal),
|
||||
Some(vec![0x1b, b'[', b'M', 32, 255, 33])
|
||||
);
|
||||
let past = report(LEFT, Gesture::Press, (223, 0));
|
||||
assert_eq!(sent(past, Tracking::Normal, mode), None);
|
||||
let utf8 = mode | TermMode::UTF8_MOUSE;
|
||||
assert_eq!(encode(&past, normal), None);
|
||||
let utf8 = mode(Tracking::Normal, Format::Utf8);
|
||||
let mut wide = b"\x1b[M ".to_vec();
|
||||
wide.extend("\u{100}!".as_bytes());
|
||||
assert_eq!(sent(past, Tracking::Normal, utf8), Some(wide));
|
||||
assert_eq!(encode(&past, utf8), Some(wide));
|
||||
let far = report(LEFT, Gesture::Press, (2015, 0));
|
||||
assert_eq!(sent(far, Tracking::Normal, utf8), None);
|
||||
assert_eq!(encode(&far, utf8), None);
|
||||
}
|
||||
}
|
||||
+40
-17
@@ -12,7 +12,7 @@ use gpui::{
|
||||
use super::{
|
||||
keys,
|
||||
links::link_at,
|
||||
mouse::{self, Button, Gesture, Report, Tracking},
|
||||
mouse::{self, Button, Gesture, MouseMode, Report, Tracking},
|
||||
view::TerminalView,
|
||||
};
|
||||
use crate::settings::SettingsStore;
|
||||
@@ -28,8 +28,8 @@ pub struct Pointer {
|
||||
pub cell: Size<Pixels>,
|
||||
/// The left button is down and drawing a selection.
|
||||
selecting: bool,
|
||||
/// The button whose press went to the program.
|
||||
pressed: Option<Button>,
|
||||
/// Buttons whose press went to the program.
|
||||
pressed: Vec<Button>,
|
||||
/// The cell of the last report; motion inside it sends nothing.
|
||||
last: Option<(usize, usize)>,
|
||||
/// Wheel travel short of a line.
|
||||
@@ -66,9 +66,10 @@ impl TerminalView {
|
||||
/// What the program tracks; none while Shift holds the mouse for selecting.
|
||||
fn tracking(&self, modifiers: &Modifiers) -> Tracking {
|
||||
if modifiers.shift {
|
||||
return Tracking::Off;
|
||||
Tracking::Off
|
||||
} else {
|
||||
self.mouse.tracking
|
||||
}
|
||||
Tracking::new(*self.pty.term.lock().mode(), self.x10)
|
||||
}
|
||||
|
||||
fn report(
|
||||
@@ -87,8 +88,8 @@ impl TerminalView {
|
||||
alt: modifiers.alt,
|
||||
ctrl: modifiers.control,
|
||||
};
|
||||
let mode = *self.pty.term.lock().mode();
|
||||
let Some(bytes) = mouse::encode(&report, tracking, mode) else {
|
||||
let format = self.mouse.format;
|
||||
let Some(bytes) = mouse::encode(&report, MouseMode { tracking, format }) else {
|
||||
return false;
|
||||
};
|
||||
log::trace!("terminal {}: {report:?} sends {bytes:?}", self.pty.pid);
|
||||
@@ -129,27 +130,33 @@ impl TerminalView {
|
||||
cell,
|
||||
&event.modifiers,
|
||||
tracking,
|
||||
) {
|
||||
self.pointer.pressed = Some(pressed);
|
||||
) && !self.pointer.pressed.contains(&pressed)
|
||||
{
|
||||
self.pointer.pressed.push(pressed);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if event.button != MouseButton::Left {
|
||||
return;
|
||||
}
|
||||
let pid = self.pty.pid;
|
||||
let mut term = self.pty.term.lock();
|
||||
match (
|
||||
term.selection.as_mut(),
|
||||
event.modifiers.shift,
|
||||
event.click_count,
|
||||
) {
|
||||
(Some(selection), true, 1) => selection.update(point, side),
|
||||
(Some(selection), true, 1) => {
|
||||
log::debug!("terminal {pid}: the selection extends to {point:?}");
|
||||
selection.update(point, side);
|
||||
}
|
||||
_ => {
|
||||
let kind = match event.click_count {
|
||||
2 => SelectionType::Semantic,
|
||||
3.. => SelectionType::Lines,
|
||||
_ => SelectionType::Simple,
|
||||
};
|
||||
log::debug!("terminal {pid}: a {kind:?} selection starts at {point:?}");
|
||||
term.selection = Some(Selection::new(kind, point, side));
|
||||
}
|
||||
}
|
||||
@@ -167,6 +174,7 @@ impl TerminalView {
|
||||
let (cell, point, side) = self.cell_at(event.position);
|
||||
if self.pointer.selecting && event.pressed_button == Some(MouseButton::Left) {
|
||||
if let Some(selection) = self.pty.term.lock().selection.as_mut() {
|
||||
log::trace!("terminal {}: the selection runs to {point:?}", self.pty.pid);
|
||||
selection.update(point, side);
|
||||
}
|
||||
cx.notify();
|
||||
@@ -174,7 +182,9 @@ impl TerminalView {
|
||||
}
|
||||
let tracking = self.tracking(&event.modifiers);
|
||||
if tracking != Tracking::Off && self.pointer.last != Some(cell) {
|
||||
let held = self.pointer.pressed;
|
||||
let held = [Button::Left, Button::Right, Button::Middle]
|
||||
.into_iter()
|
||||
.find(|button| self.pointer.pressed.contains(button));
|
||||
self.report(held, Gesture::Motion, cell, &event.modifiers, tracking);
|
||||
}
|
||||
}
|
||||
@@ -187,16 +197,29 @@ impl TerminalView {
|
||||
) {
|
||||
if event.button == MouseButton::Left && std::mem::take(&mut self.pointer.selecting) {
|
||||
let selected = self.selected();
|
||||
let count = selected.as_ref().map_or(0, |text| text.chars().count());
|
||||
log::debug!(
|
||||
"terminal {}: the selection ends on {count} characters",
|
||||
self.pty.pid
|
||||
);
|
||||
if let Some(text) = selected.filter(|_| SettingsStore::get(cx).copy_on_select) {
|
||||
self.copy_text(text, cx);
|
||||
}
|
||||
}
|
||||
let released = button(event.button);
|
||||
if released.is_some() && self.pointer.pressed == released {
|
||||
self.pointer.pressed = None;
|
||||
let Some(released) = button(event.button) else {
|
||||
return;
|
||||
};
|
||||
if self.pointer.pressed.contains(&released) {
|
||||
self.pointer.pressed.retain(|held| *held != released);
|
||||
let (cell, ..) = self.cell_at(event.position);
|
||||
let tracking = Tracking::new(*self.pty.term.lock().mode(), self.x10);
|
||||
self.report(released, Gesture::Release, cell, &event.modifiers, tracking);
|
||||
let tracking = self.mouse.tracking;
|
||||
self.report(
|
||||
Some(released),
|
||||
Gesture::Release,
|
||||
cell,
|
||||
&event.modifiers,
|
||||
tracking,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -247,7 +270,7 @@ impl TerminalView {
|
||||
}
|
||||
}
|
||||
|
||||
/// The selected text, without the newline alacritty ends a whole line with, as Ghostty copies it.
|
||||
/// The selected text without alacritty's final newline, as Ghostty copies it.
|
||||
fn selected(&self) -> Option<String> {
|
||||
let mut text = self.pty.term.lock().selection_to_string()?;
|
||||
if text.ends_with('\n') {
|
||||
|
||||
+4
-5
@@ -11,16 +11,15 @@ use alacritty_terminal::{
|
||||
};
|
||||
use futures::channel::mpsc::{UnboundedReceiver, UnboundedSender, unbounded};
|
||||
|
||||
use super::tap::Tapped;
|
||||
use super::{mouse::MouseMode, tap::Tapped};
|
||||
use crate::settings::{CursorStyle, Settings};
|
||||
|
||||
/// What a session hears from its pty: the grid's events, the folder the shell reports, and mode 9.
|
||||
/// What a session hears from its pty.
|
||||
#[derive(Debug)]
|
||||
pub enum PtyEvent {
|
||||
Term(Event),
|
||||
Folder(PathBuf),
|
||||
/// X10 mouse reporting turned on or off.
|
||||
X10(bool),
|
||||
Mouse(MouseMode),
|
||||
}
|
||||
|
||||
/// Hands the grid's events to the session; `pid` names the program in logs.
|
||||
@@ -250,7 +249,7 @@ mod tests {
|
||||
PtyEvent::Folder(folder) => folders.push(folder),
|
||||
PtyEvent::Term(Event::ChildExit(status)) => success = status.success(),
|
||||
PtyEvent::Term(Event::Exit) => break,
|
||||
PtyEvent::Term(_) | PtyEvent::X10(_) => {}
|
||||
PtyEvent::Term(_) | PtyEvent::Mouse(_) => {}
|
||||
}
|
||||
}
|
||||
(folders, success)
|
||||
|
||||
+34
-36
@@ -7,16 +7,16 @@ use alacritty_terminal::{
|
||||
use futures::channel::mpsc::UnboundedSender;
|
||||
use polling::{Event, PollMode, Poller};
|
||||
|
||||
use super::pty::PtyEvent;
|
||||
use super::{mouse::MouseMode, pty::PtyEvent};
|
||||
|
||||
/// The longest OSC body kept; longer ones are dropped whole.
|
||||
const MOST: usize = 4096;
|
||||
|
||||
/// A pty whose reader hands each byte to the OSC 7 and mode 9 scanners before the parser sees it.
|
||||
/// A pty whose reader shows each byte to its scanners before the parser.
|
||||
pub(crate) struct Tapped<P> {
|
||||
pub inner: P,
|
||||
scanner: Osc7,
|
||||
x10: Mode9,
|
||||
mouse: MouseModes,
|
||||
events: UnboundedSender<PtyEvent>,
|
||||
pid: u32,
|
||||
last: Option<PathBuf>,
|
||||
@@ -27,7 +27,7 @@ impl<P> Tapped<P> {
|
||||
Self {
|
||||
inner,
|
||||
scanner: Osc7::default(),
|
||||
x10: Mode9::default(),
|
||||
mouse: MouseModes::default(),
|
||||
events,
|
||||
pid,
|
||||
last: None,
|
||||
@@ -53,12 +53,9 @@ impl<P: EventedReadWrite> io::Read for Tapped<P> {
|
||||
log::debug!("pty {pid}: a folder report after its session closed");
|
||||
}
|
||||
});
|
||||
if let Some(on) = self.x10.feed(&buf[..count]) {
|
||||
log::info!(
|
||||
"pty {pid}: X10 mouse reporting {}",
|
||||
if on { "on" } else { "off" }
|
||||
);
|
||||
if events.unbounded_send(PtyEvent::X10(on)).is_err() {
|
||||
if let Some(mode) = self.mouse.feed(&buf[..count]) {
|
||||
log::info!("pty {pid}: mouse {mode:?}");
|
||||
if events.unbounded_send(PtyEvent::Mouse(mode)).is_err() {
|
||||
log::debug!("pty {pid}: a mouse mode after its session closed");
|
||||
}
|
||||
}
|
||||
@@ -197,18 +194,18 @@ enum Csi {
|
||||
Private,
|
||||
}
|
||||
|
||||
/// Follows `CSI ? 9 h` and `l`, X10 mouse reporting, across reads: alacritty's parser drops mode 9.
|
||||
/// The mouse modes as xterm holds them; alacritty drops 9 and splits the rest.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct Mode9 {
|
||||
pub(crate) struct MouseModes {
|
||||
state: Csi,
|
||||
params: Vec<u32>,
|
||||
on: bool,
|
||||
mode: MouseMode,
|
||||
}
|
||||
|
||||
impl Mode9 {
|
||||
impl MouseModes {
|
||||
/// The mode after `bytes`, when they changed it.
|
||||
pub fn feed(&mut self, bytes: &[u8]) -> Option<bool> {
|
||||
let was = self.on;
|
||||
pub fn feed(&mut self, bytes: &[u8]) -> Option<MouseMode> {
|
||||
let was = self.mode;
|
||||
let mut rest = bytes;
|
||||
while !rest.is_empty() {
|
||||
if self.state == Csi::Ground {
|
||||
@@ -225,7 +222,7 @@ impl Mode9 {
|
||||
(_, 0x1b) => Csi::Escape,
|
||||
(Csi::Escape, b'[') => Csi::Bracket,
|
||||
(Csi::Escape, b'c') => {
|
||||
self.on = false;
|
||||
self.mode = MouseMode::default();
|
||||
Csi::Ground
|
||||
}
|
||||
(Csi::Bracket, b'?') => {
|
||||
@@ -244,20 +241,15 @@ impl Mode9 {
|
||||
Csi::Private
|
||||
}
|
||||
(Csi::Private, b'h' | b'l') => {
|
||||
let set = byte == b'h';
|
||||
for mode in &self.params {
|
||||
match mode {
|
||||
9 => self.on = set,
|
||||
1000 | 1002 | 1003 if set => self.on = false,
|
||||
_ => {}
|
||||
}
|
||||
for &mode in &self.params {
|
||||
self.mode.apply(mode, byte == b'h');
|
||||
}
|
||||
Csi::Ground
|
||||
}
|
||||
_ => Csi::Ground,
|
||||
};
|
||||
}
|
||||
(self.on != was).then_some(self.on)
|
||||
(self.mode != was).then_some(self.mode)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -359,17 +351,23 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mode_9_follows_its_sets_resets_and_the_modes_that_replace_it() {
|
||||
let mut mode = Mode9::default();
|
||||
assert_eq!(mode.feed(b"a\x1b[?"), None);
|
||||
assert_eq!(mode.feed(b"1006;9h"), Some(true));
|
||||
assert_eq!(mode.feed(b"\x1b[9m\x1b[?25l\x1b[?9"), None);
|
||||
assert_eq!(mode.feed(b"l"), Some(false));
|
||||
assert_eq!(mode.feed(b"\x1b[?9h\x1b[?1000h"), None, "1000 replaced it");
|
||||
assert_eq!(mode.feed(b"\x1b[?9h"), Some(true));
|
||||
assert_eq!(mode.feed(b"\x1b[?1000l"), None);
|
||||
assert_eq!(mode.feed(b"\x1bc"), Some(false), "a reset ends it");
|
||||
assert_eq!(mode.feed(b"\x1b[?09;\x1b[?9h"), Some(true));
|
||||
fn mouse_modes_follow_private_modes_in_order_across_reads() {
|
||||
use crate::term::mouse::{Format, Tracking};
|
||||
let mut modes = MouseModes::default();
|
||||
assert_eq!(modes.feed(b"a\x1b[?"), None);
|
||||
assert!(modes.feed(b"1006;9h").is_some());
|
||||
assert_eq!(modes.mode.format, Format::Sgr);
|
||||
assert_eq!(modes.mode.tracking, Tracking::X10);
|
||||
assert_eq!(modes.feed(b"\x1b[9m\x1b[?25l\x1b[?9"), None);
|
||||
assert!(modes.feed(b"l").is_some());
|
||||
assert_eq!(modes.mode.tracking, Tracking::Off);
|
||||
modes.feed(b"\x1b[?1000h\x1b[?9h\x1b[?9l");
|
||||
assert_eq!(modes.mode.tracking, Tracking::Off, "9 replaced 1000");
|
||||
modes.feed(b"\x1b[?1002h");
|
||||
let reset = modes.feed(b"\x1bc");
|
||||
assert_eq!(reset, Some(MouseMode::default()), "a reset ends it");
|
||||
modes.feed(b"\x1b[?09;\x1b[?1003h");
|
||||
assert_eq!(modes.mode.tracking, Tracking::Any);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+5
-4
@@ -12,6 +12,7 @@ use super::{
|
||||
frame::frame,
|
||||
ink::Ink,
|
||||
input::{self, Copy, Paste},
|
||||
mouse::MouseMode,
|
||||
paint::{self, Look},
|
||||
pointer::Pointer,
|
||||
pty::{Events, Pty, PtyEvent, config, fit},
|
||||
@@ -48,8 +49,8 @@ pub struct TerminalView {
|
||||
/// The cursor's cell in the window, from the last layout.
|
||||
pub(super) caret: Bounds<Pixels>,
|
||||
pub(super) pointer: Pointer,
|
||||
/// X10 mouse reporting, mode 9, as the tap follows it.
|
||||
pub(super) x10: bool,
|
||||
/// The mouse mode, as the tap follows it.
|
||||
pub(super) mouse: MouseMode,
|
||||
/// Keys whose press this terminal saw, so their releases may go out.
|
||||
pub(super) held: HashSet<String>,
|
||||
pub(super) size: (usize, usize),
|
||||
@@ -83,7 +84,7 @@ impl TerminalView {
|
||||
marked: None,
|
||||
caret: Bounds::default(),
|
||||
pointer: Pointer::default(),
|
||||
x10: false,
|
||||
mouse: MouseMode::default(),
|
||||
held: HashSet::new(),
|
||||
size,
|
||||
cell: (8, 16),
|
||||
@@ -155,7 +156,7 @@ impl TerminalView {
|
||||
match event {
|
||||
PtyEvent::Folder(folder) => cx.emit(TerminalEvent::Folder(folder)),
|
||||
PtyEvent::Term(event) => self.term_event(event, cx),
|
||||
PtyEvent::X10(on) => self.x10 = on,
|
||||
PtyEvent::Mouse(mode) => self.mouse = mode,
|
||||
}
|
||||
}
|
||||
cx.notify();
|
||||
|
||||
Reference in new issue
Block a user