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
+306
-201
No files matched your search
@@ -12,7 +12,10 @@ ELYTerminal. A terminal on GPUI and Ely components. `PRD.md` says what it does;
|
|||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
- `cargo run` opens the app. `RUST_LOG=elyterminal=debug cargo run` logs more; `trace` logs each paint.
|
- `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.
|
- `scripts/check.sh`: fmt, clippy `-D warnings`, tests, the 500-line rule.
|
||||||
|
|
||||||
## Layout
|
## 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/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/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/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/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/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).
|
- `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: 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: 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: 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: 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: 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.
|
- 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.
|
||||||
|
|||||||
+115
-65
@@ -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")]
|
#[cfg(target_os = "macos")]
|
||||||
fn main() {
|
fn main() {
|
||||||
@@ -31,11 +31,12 @@ fn main() {
|
|||||||
app::start(cx).expect("the app starts");
|
app::start(cx).expect("the app starts");
|
||||||
let window = app::open(launch, cx).expect("the window opens");
|
let window = app::open(launch, cx).expect("the window opens");
|
||||||
cx.spawn(async move |cx| {
|
cx.spawn(async move |cx| {
|
||||||
cx.background_executor().timer(Duration::from_secs(1)).await;
|
|
||||||
let number = window
|
let number = window
|
||||||
.update(cx, |_, window, _| capture::number(window))
|
.update(cx, |_, window, _| capture::number(window))
|
||||||
.expect("the window lives")
|
.expect("the window lives")
|
||||||
.expect("a window number");
|
.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 {
|
for spec in &keys {
|
||||||
typing::post(spec, number).expect("a key event");
|
typing::post(spec, number).expect("a key event");
|
||||||
cx.background_executor()
|
cx.background_executor()
|
||||||
@@ -60,7 +61,7 @@ mod capture {
|
|||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
use anyhow::{Context as _, bail};
|
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};
|
use raw_window_handle::{HasWindowHandle, RawWindowHandle};
|
||||||
|
|
||||||
/// A gpui window's number, as CoreGraphics knows it.
|
/// 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")
|
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.
|
/// Writes the window `number` as a PNG; the app's own window needs no permission.
|
||||||
pub fn snapshot(number: u32, path: &Path) -> anyhow::Result<()> {
|
pub fn snapshot(number: u32, path: &Path) -> anyhow::Result<()> {
|
||||||
use core_graphics::{
|
use core_graphics::{
|
||||||
@@ -117,14 +129,14 @@ mod capture {
|
|||||||
mod typing {
|
mod typing {
|
||||||
use anyhow::Context as _;
|
use anyhow::Context as _;
|
||||||
use core_graphics::{
|
use core_graphics::{
|
||||||
event::{CGEvent, CGEventFlags, ScrollEventUnit},
|
event::{CGEvent, CGEventFlags, CGEventType, CGMouseButton, EventField, ScrollEventUnit},
|
||||||
event_source::{CGEventSource, CGEventSourceStateID},
|
event_source::{CGEventSource, CGEventSourceStateID},
|
||||||
geometry::{CGPoint, CGRect},
|
geometry::{CGPoint, CGRect},
|
||||||
};
|
};
|
||||||
use foreign_types::ForeignType;
|
use foreign_types::ForeignType;
|
||||||
use objc::{
|
use objc::{
|
||||||
class, msg_send,
|
class, msg_send,
|
||||||
runtime::{NO, Object, YES},
|
runtime::{NO, Object, Sel, YES},
|
||||||
sel, sel_impl,
|
sel, sel_impl,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -196,7 +208,68 @@ mod typing {
|
|||||||
fn CGEventSetLocation(event: *mut core_graphics::sys::CGEvent, location: CGPoint);
|
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<()> {
|
fn mouse(name: &str, flags: u64, number: u32) -> anyhow::Result<()> {
|
||||||
let mut fields = name.split('@');
|
let mut fields = name.split('@');
|
||||||
let gesture = fields.next().context("a gesture")?;
|
let gesture = fields.next().context("a gesture")?;
|
||||||
@@ -205,84 +278,61 @@ mod typing {
|
|||||||
.map(str::parse)
|
.map(str::parse)
|
||||||
.collect::<Result<_, _>>()
|
.collect::<Result<_, _>>()
|
||||||
.with_context(|| format!("numbers in {name}"))?;
|
.with_context(|| format!("numbers in {name}"))?;
|
||||||
let needed = match gesture {
|
anyhow::ensure!(numbers.len() >= 2, "{name} needs a point");
|
||||||
"drag" => 4,
|
|
||||||
"scrollup" | "scrolldown" => 3,
|
|
||||||
_ => 2,
|
|
||||||
};
|
|
||||||
anyhow::ensure!(numbers.len() == needed, "{name} needs {needed} numbers");
|
|
||||||
unsafe {
|
unsafe {
|
||||||
let app: *mut Object = msg_send![class!(NSApplication), sharedApplication];
|
let app: *mut Object = msg_send![class!(NSApplication), sharedApplication];
|
||||||
let window: *mut Object = msg_send![app, windowWithWindowNumber: i64::from(number)];
|
let window: *mut Object = msg_send![app, windowWithWindowNumber: i64::from(number)];
|
||||||
anyhow::ensure!(!window.is_null(), "no window {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 content: *mut Object = msg_send![window, contentView];
|
||||||
let height = {
|
|
||||||
let frame: CGRect = msg_send![content, frame];
|
let frame: CGRect = msg_send![content, frame];
|
||||||
frame.size.height
|
let height = frame.size.height;
|
||||||
};
|
let points: Vec<CGPoint> = numbers
|
||||||
let at = |index: usize| CGPoint::new(numbers[index], height - numbers[index + 1]);
|
.chunks_exact(2)
|
||||||
let post = |kind: u64, point: CGPoint, clicks: i64| -> anyhow::Result<()> {
|
.map(|pair| CGPoint::new(pair[0], height - pair[1]))
|
||||||
let event: *mut Object = msg_send![
|
.collect();
|
||||||
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];
|
|
||||||
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)?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"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 screens: *mut Object = msg_send![class!(NSScreen), screens];
|
||||||
let main: *mut Object = msg_send![screens, objectAtIndex: 0u64];
|
let main: *mut Object = msg_send![screens, objectAtIndex: 0u64];
|
||||||
let screen: CGRect = msg_send![main, frame];
|
let screen: CGRect = msg_send![main, frame];
|
||||||
let point = at(0);
|
let source = || {
|
||||||
let source = CGEventSource::new(CGEventSourceStateID::Private)
|
CGEventSource::new(CGEventSourceStateID::Private)
|
||||||
.map_err(|_| anyhow::anyhow!("no event source"))?;
|
.map_err(|_| anyhow::anyhow!("no event source"))
|
||||||
let event =
|
};
|
||||||
CGEvent::new_scroll_event(source, ScrollEventUnit::LINE, 1, lines, 0, 0)
|
let deliver = |event: CGEvent, at: CGPoint, selector: &str| -> anyhow::Result<()> {
|
||||||
.map_err(|_| anyhow::anyhow!("no scroll event"))?;
|
CGEventSetLocation(
|
||||||
let place = CGPoint::new(point.x, screen.size.height - point.y);
|
event.as_ptr(),
|
||||||
CGEventSetLocation(event.as_ptr(), place);
|
CGPoint::new(at.x, screen.size.height - at.y),
|
||||||
|
);
|
||||||
event.set_flags(CGEventFlags::from_bits_truncate(flags));
|
event.set_flags(CGEventFlags::from_bits_truncate(flags));
|
||||||
let native: *mut Object =
|
let native: *mut Object =
|
||||||
msg_send![class!(NSEvent), eventWithCGEvent: event.as_ptr()];
|
msg_send![class!(NSEvent), eventWithCGEvent: event.as_ptr()];
|
||||||
anyhow::ensure!(!native.is_null(), "AppKit refused the scroll {name}");
|
anyhow::ensure!(!native.is_null(), "AppKit refused {name}");
|
||||||
let view: *mut Object = msg_send![window, firstResponder];
|
let selector = Sel::register(selector);
|
||||||
let _: () = msg_send![view, scrollWheel: native];
|
let _: *mut Object = msg_send![view, performSelector: selector withObject: native];
|
||||||
|
Ok(())
|
||||||
|
};
|
||||||
|
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)?;
|
||||||
}
|
}
|
||||||
other => anyhow::bail!("no mouse gesture {other}"),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
log::info!("shot: mouse {name}");
|
log::info!("shot: mouse {name}");
|
||||||
Ok(())
|
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<()> {
|
pub fn post(spec: &str, number: u32) -> anyhow::Result<()> {
|
||||||
if let Some((call, text)) = spec.split_once(':') {
|
if let Some((call, text)) = spec.split_once(':') {
|
||||||
return compose(call, text, number);
|
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>) {
|
pub(super) fn input(&mut self, bytes: Vec<u8>, cx: &mut Context<Self>) {
|
||||||
let mut term = self.pty.term.lock();
|
let mut term = self.pty.term.lock();
|
||||||
term.scroll_display(Scroll::Bottom);
|
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);
|
drop(term);
|
||||||
self.pty.write(bytes);
|
self.pty.write(bytes);
|
||||||
cx.notify();
|
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> {
|
pub fn paste(text: &str, bracketed: bool) -> Vec<u8> {
|
||||||
if bracketed {
|
if bracketed {
|
||||||
let text = text.replace(['\x1b', '\x03'], "");
|
let text = text.replace(['\x1b', '\x03'], "");
|
||||||
|
|||||||
+8
-1
@@ -4,7 +4,7 @@ use alacritty_terminal::{
|
|||||||
};
|
};
|
||||||
use ely_gpui_component::terminal::{Target, links};
|
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> {
|
pub fn link_at<T>(term: &Term<T>, mut point: Point) -> Option<String> {
|
||||||
let grid = term.grid();
|
let grid = term.grid();
|
||||||
if grid[point].flags.contains(Flags::WIDE_CHAR_SPACER) {
|
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;
|
let spacer = Flags::WIDE_CHAR_SPACER | Flags::LEADING_WIDE_CHAR_SPACER;
|
||||||
if !grid[cell].flags.intersects(spacer) {
|
if !grid[cell].flags.intersects(spacer) {
|
||||||
text.push(grid[cell].c);
|
text.push(grid[cell].c);
|
||||||
|
text.extend(grid[cell].zerowidth().into_iter().flatten());
|
||||||
}
|
}
|
||||||
if cell == end {
|
if cell == end {
|
||||||
break;
|
break;
|
||||||
@@ -70,6 +71,12 @@ mod tests {
|
|||||||
assert_eq!(link(text, 0, 11), None);
|
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]
|
#[test]
|
||||||
fn a_url_runs_across_wrapped_rows_and_wide_cells() {
|
fn a_url_runs_across_wrapped_rows_and_wide_cells() {
|
||||||
let text = "\u{4e2d} https://ely.dev/abc";
|
let text = "\u{4e2d} https://ely.dev/abc";
|
||||||
|
|||||||
+90
-68
@@ -1,5 +1,3 @@
|
|||||||
use alacritty_terminal::term::TermMode;
|
|
||||||
|
|
||||||
/// A button as reports name it; the wheel's two turns count as buttons.
|
/// A button as reports name it; the wheel's two turns count as buttons.
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
pub enum Button {
|
pub enum Button {
|
||||||
@@ -32,8 +30,9 @@ pub struct Report {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The mouse events a program asked for.
|
/// The mouse events a program asked for.
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||||
pub enum Tracking {
|
pub enum Tracking {
|
||||||
|
#[default]
|
||||||
Off,
|
Off,
|
||||||
/// Mode 9: presses alone, no modifiers.
|
/// Mode 9: presses alone, no modifiers.
|
||||||
X10,
|
X10,
|
||||||
@@ -45,30 +44,51 @@ pub enum Tracking {
|
|||||||
Any,
|
Any,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Tracking {
|
/// The bytes reports go in: xterm's default, mode 1005 or mode 1006.
|
||||||
/// The tracking `mode` sets; `x10` is mode 9, which the tap follows.
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||||
pub fn new(mode: TermMode, x10: bool) -> Self {
|
pub enum Format {
|
||||||
if x10 {
|
#[default]
|
||||||
Self::X10
|
X10,
|
||||||
} else if mode.contains(TermMode::MOUSE_MOTION) {
|
Utf8,
|
||||||
Self::Any
|
Sgr,
|
||||||
} else if mode.contains(TermMode::MOUSE_DRAG) {
|
|
||||||
Self::Button
|
|
||||||
} else if mode.contains(TermMode::MOUSE_REPORT_CLICK) {
|
|
||||||
Self::Normal
|
|
||||||
} else {
|
|
||||||
Self::Off
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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.
|
/// The bytes `report` sends; none when unasked for or past what the format holds.
|
||||||
pub fn encode(report: &Report, tracking: Tracking, mode: TermMode) -> Option<Vec<u8>> {
|
pub fn encode(report: &Report, mode: MouseMode) -> Option<Vec<u8>> {
|
||||||
let clicked = matches!(
|
let clicked = matches!(
|
||||||
report.button,
|
report.button,
|
||||||
Some(Button::Left | Button::Middle | Button::Right)
|
Some(Button::Left | Button::Middle | Button::Right)
|
||||||
);
|
);
|
||||||
let wanted = match tracking {
|
let wanted = match mode.tracking {
|
||||||
Tracking::Off => false,
|
Tracking::Off => false,
|
||||||
Tracking::X10 => report.gesture == Gesture::Press && clicked,
|
Tracking::X10 => report.gesture == Gesture::Press && clicked,
|
||||||
Tracking::Normal => report.gesture != Gesture::Motion,
|
Tracking::Normal => report.gesture != Gesture::Motion,
|
||||||
@@ -78,7 +98,7 @@ pub fn encode(report: &Report, tracking: Tracking, mode: TermMode) -> Option<Vec
|
|||||||
if !wanted {
|
if !wanted {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let sgr = mode.contains(TermMode::SGR_MOUSE);
|
let sgr = mode.format == Format::Sgr;
|
||||||
let mut code = match report.button {
|
let mut code = match report.button {
|
||||||
None => 3,
|
None => 3,
|
||||||
Some(_) if report.gesture == Gesture::Release && !sgr => 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::WheelUp) => 64,
|
||||||
Some(Button::WheelDown) => 65,
|
Some(Button::WheelDown) => 65,
|
||||||
};
|
};
|
||||||
if tracking != Tracking::X10 {
|
if mode.tracking != Tracking::X10 {
|
||||||
code +=
|
code +=
|
||||||
4 * u32::from(report.shift) + 8 * u32::from(report.alt) + 16 * u32::from(report.ctrl);
|
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());
|
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 };
|
let most = if utf8 { 0x7FF - 32 } else { 0xFF - 32 };
|
||||||
if column > most || row > most {
|
if column > most || row > most {
|
||||||
log::debug!("mouse: cell {column},{row} is past what the format holds");
|
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>> {
|
fn mode(tracking: Tracking, format: Format) -> MouseMode {
|
||||||
encode(&report, tracking, mode)
|
MouseMode { tracking, format }
|
||||||
}
|
}
|
||||||
|
|
||||||
const LEFT: Option<Button> = Some(Button::Left);
|
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]
|
#[test]
|
||||||
fn normal_tracking_sends_presses_and_releases_as_x10_bytes() {
|
fn normal_tracking_sends_presses_and_releases_as_x10_bytes() {
|
||||||
let mode = TermMode::MOUSE_REPORT_CLICK;
|
let normal = mode(Tracking::Normal, Format::X10);
|
||||||
let tracking = Tracking::new(mode, false);
|
|
||||||
assert_eq!(tracking, Tracking::Normal);
|
|
||||||
let press = report(LEFT, Gesture::Press, (0, 0));
|
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));
|
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));
|
let motion = report(LEFT, Gesture::Motion, (1, 1));
|
||||||
assert_eq!(sent(motion, tracking, mode), None);
|
assert_eq!(encode(&motion, normal), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn sgr_names_the_released_button_and_the_modifiers() {
|
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 {
|
let press = Report {
|
||||||
shift: true,
|
shift: true,
|
||||||
ctrl: true,
|
ctrl: true,
|
||||||
..report(Some(Button::Right), Gesture::Press, (4, 2))
|
..report(Some(Button::Right), Gesture::Press, (4, 2))
|
||||||
};
|
};
|
||||||
assert_eq!(
|
assert_eq!(encode(&press, sgr), Some(b"\x1b[<22;5;3M".to_vec()));
|
||||||
sent(press, Tracking::Normal, mode),
|
|
||||||
Some(b"\x1b[<22;5;3M".to_vec())
|
|
||||||
);
|
|
||||||
let release = report(Some(Button::Middle), Gesture::Release, (300, 9));
|
let release = report(Some(Button::Middle), Gesture::Release, (300, 9));
|
||||||
assert_eq!(
|
assert_eq!(encode(&release, sgr), Some(b"\x1b[<1;301;10m".to_vec()));
|
||||||
sent(release, Tracking::Normal, mode),
|
|
||||||
Some(b"\x1b[<1;301;10m".to_vec())
|
|
||||||
);
|
|
||||||
let alt = Report {
|
let alt = Report {
|
||||||
alt: true,
|
alt: true,
|
||||||
..report(Some(Button::WheelDown), Gesture::Press, (0, 0))
|
..report(Some(Button::WheelDown), Gesture::Press, (0, 0))
|
||||||
};
|
};
|
||||||
assert_eq!(
|
assert_eq!(encode(&alt, sgr), Some(b"\x1b[<73;1;1M".to_vec()));
|
||||||
sent(alt, Tracking::Normal, mode),
|
|
||||||
Some(b"\x1b[<73;1;1M".to_vec())
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn motion_needs_button_or_any_tracking() {
|
fn motion_needs_button_or_any_tracking() {
|
||||||
let mode = TermMode::SGR_MOUSE;
|
|
||||||
let held = report(LEFT, Gesture::Motion, (1, 0));
|
let held = report(LEFT, Gesture::Motion, (1, 0));
|
||||||
let free = report(None, Gesture::Motion, (1, 0));
|
let free = report(None, Gesture::Motion, (1, 0));
|
||||||
assert_eq!(Tracking::new(TermMode::MOUSE_DRAG, false), Tracking::Button);
|
let button = mode(Tracking::Button, Format::Sgr);
|
||||||
assert_eq!(
|
assert_eq!(encode(&held, button), Some(b"\x1b[<32;2;1M".to_vec()));
|
||||||
sent(held, Tracking::Button, mode),
|
assert_eq!(encode(&free, button), None);
|
||||||
Some(b"\x1b[<32;2;1M".to_vec())
|
let any = mode(Tracking::Any, Format::Sgr);
|
||||||
);
|
assert_eq!(encode(&free, any), Some(b"\x1b[<35;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())
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn x10_tracking_sends_presses_alone_without_modifiers() {
|
fn x10_tracking_sends_presses_alone_without_modifiers() {
|
||||||
let mode = TermMode::MOUSE_REPORT_CLICK;
|
let x10 = mode(Tracking::X10, Format::X10);
|
||||||
let tracking = Tracking::new(mode, true);
|
|
||||||
assert_eq!(tracking, Tracking::X10);
|
|
||||||
let press = Report {
|
let press = Report {
|
||||||
shift: true,
|
shift: true,
|
||||||
..report(LEFT, Gesture::Press, (0, 0))
|
..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));
|
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));
|
let wheel = report(Some(Button::WheelUp), Gesture::Press, (0, 0));
|
||||||
assert_eq!(sent(wheel, tracking, mode), None);
|
assert_eq!(encode(&wheel, x10), None);
|
||||||
assert_eq!(Tracking::new(TermMode::empty(), false), Tracking::Off);
|
assert_eq!(encode(&press, MouseMode::default()), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn cells_past_the_format_go_utf8_or_nowhere() {
|
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));
|
let edge = report(LEFT, Gesture::Press, (222, 0));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
sent(edge, Tracking::Normal, mode),
|
encode(&edge, normal),
|
||||||
Some(vec![0x1b, b'[', b'M', 32, 255, 33])
|
Some(vec![0x1b, b'[', b'M', 32, 255, 33])
|
||||||
);
|
);
|
||||||
let past = report(LEFT, Gesture::Press, (223, 0));
|
let past = report(LEFT, Gesture::Press, (223, 0));
|
||||||
assert_eq!(sent(past, Tracking::Normal, mode), None);
|
assert_eq!(encode(&past, normal), None);
|
||||||
let utf8 = mode | TermMode::UTF8_MOUSE;
|
let utf8 = mode(Tracking::Normal, Format::Utf8);
|
||||||
let mut wide = b"\x1b[M ".to_vec();
|
let mut wide = b"\x1b[M ".to_vec();
|
||||||
wide.extend("\u{100}!".as_bytes());
|
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));
|
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::{
|
use super::{
|
||||||
keys,
|
keys,
|
||||||
links::link_at,
|
links::link_at,
|
||||||
mouse::{self, Button, Gesture, Report, Tracking},
|
mouse::{self, Button, Gesture, MouseMode, Report, Tracking},
|
||||||
view::TerminalView,
|
view::TerminalView,
|
||||||
};
|
};
|
||||||
use crate::settings::SettingsStore;
|
use crate::settings::SettingsStore;
|
||||||
@@ -28,8 +28,8 @@ pub struct Pointer {
|
|||||||
pub cell: Size<Pixels>,
|
pub cell: Size<Pixels>,
|
||||||
/// The left button is down and drawing a selection.
|
/// The left button is down and drawing a selection.
|
||||||
selecting: bool,
|
selecting: bool,
|
||||||
/// The button whose press went to the program.
|
/// Buttons whose press went to the program.
|
||||||
pressed: Option<Button>,
|
pressed: Vec<Button>,
|
||||||
/// The cell of the last report; motion inside it sends nothing.
|
/// The cell of the last report; motion inside it sends nothing.
|
||||||
last: Option<(usize, usize)>,
|
last: Option<(usize, usize)>,
|
||||||
/// Wheel travel short of a line.
|
/// Wheel travel short of a line.
|
||||||
@@ -66,9 +66,10 @@ impl TerminalView {
|
|||||||
/// What the program tracks; none while Shift holds the mouse for selecting.
|
/// What the program tracks; none while Shift holds the mouse for selecting.
|
||||||
fn tracking(&self, modifiers: &Modifiers) -> Tracking {
|
fn tracking(&self, modifiers: &Modifiers) -> Tracking {
|
||||||
if modifiers.shift {
|
if modifiers.shift {
|
||||||
return Tracking::Off;
|
Tracking::Off
|
||||||
|
} else {
|
||||||
|
self.mouse.tracking
|
||||||
}
|
}
|
||||||
Tracking::new(*self.pty.term.lock().mode(), self.x10)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn report(
|
fn report(
|
||||||
@@ -87,8 +88,8 @@ impl TerminalView {
|
|||||||
alt: modifiers.alt,
|
alt: modifiers.alt,
|
||||||
ctrl: modifiers.control,
|
ctrl: modifiers.control,
|
||||||
};
|
};
|
||||||
let mode = *self.pty.term.lock().mode();
|
let format = self.mouse.format;
|
||||||
let Some(bytes) = mouse::encode(&report, tracking, mode) else {
|
let Some(bytes) = mouse::encode(&report, MouseMode { tracking, format }) else {
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
log::trace!("terminal {}: {report:?} sends {bytes:?}", self.pty.pid);
|
log::trace!("terminal {}: {report:?} sends {bytes:?}", self.pty.pid);
|
||||||
@@ -129,27 +130,33 @@ impl TerminalView {
|
|||||||
cell,
|
cell,
|
||||||
&event.modifiers,
|
&event.modifiers,
|
||||||
tracking,
|
tracking,
|
||||||
) {
|
) && !self.pointer.pressed.contains(&pressed)
|
||||||
self.pointer.pressed = Some(pressed);
|
{
|
||||||
|
self.pointer.pressed.push(pressed);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if event.button != MouseButton::Left {
|
if event.button != MouseButton::Left {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
let pid = self.pty.pid;
|
||||||
let mut term = self.pty.term.lock();
|
let mut term = self.pty.term.lock();
|
||||||
match (
|
match (
|
||||||
term.selection.as_mut(),
|
term.selection.as_mut(),
|
||||||
event.modifiers.shift,
|
event.modifiers.shift,
|
||||||
event.click_count,
|
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 {
|
let kind = match event.click_count {
|
||||||
2 => SelectionType::Semantic,
|
2 => SelectionType::Semantic,
|
||||||
3.. => SelectionType::Lines,
|
3.. => SelectionType::Lines,
|
||||||
_ => SelectionType::Simple,
|
_ => SelectionType::Simple,
|
||||||
};
|
};
|
||||||
|
log::debug!("terminal {pid}: a {kind:?} selection starts at {point:?}");
|
||||||
term.selection = Some(Selection::new(kind, point, side));
|
term.selection = Some(Selection::new(kind, point, side));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -167,6 +174,7 @@ impl TerminalView {
|
|||||||
let (cell, point, side) = self.cell_at(event.position);
|
let (cell, point, side) = self.cell_at(event.position);
|
||||||
if self.pointer.selecting && event.pressed_button == Some(MouseButton::Left) {
|
if self.pointer.selecting && event.pressed_button == Some(MouseButton::Left) {
|
||||||
if let Some(selection) = self.pty.term.lock().selection.as_mut() {
|
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);
|
selection.update(point, side);
|
||||||
}
|
}
|
||||||
cx.notify();
|
cx.notify();
|
||||||
@@ -174,7 +182,9 @@ impl TerminalView {
|
|||||||
}
|
}
|
||||||
let tracking = self.tracking(&event.modifiers);
|
let tracking = self.tracking(&event.modifiers);
|
||||||
if tracking != Tracking::Off && self.pointer.last != Some(cell) {
|
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);
|
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) {
|
if event.button == MouseButton::Left && std::mem::take(&mut self.pointer.selecting) {
|
||||||
let selected = self.selected();
|
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) {
|
if let Some(text) = selected.filter(|_| SettingsStore::get(cx).copy_on_select) {
|
||||||
self.copy_text(text, cx);
|
self.copy_text(text, cx);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let released = button(event.button);
|
let Some(released) = button(event.button) else {
|
||||||
if released.is_some() && self.pointer.pressed == released {
|
return;
|
||||||
self.pointer.pressed = None;
|
};
|
||||||
|
if self.pointer.pressed.contains(&released) {
|
||||||
|
self.pointer.pressed.retain(|held| *held != released);
|
||||||
let (cell, ..) = self.cell_at(event.position);
|
let (cell, ..) = self.cell_at(event.position);
|
||||||
let tracking = Tracking::new(*self.pty.term.lock().mode(), self.x10);
|
let tracking = self.mouse.tracking;
|
||||||
self.report(released, Gesture::Release, cell, &event.modifiers, 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> {
|
fn selected(&self) -> Option<String> {
|
||||||
let mut text = self.pty.term.lock().selection_to_string()?;
|
let mut text = self.pty.term.lock().selection_to_string()?;
|
||||||
if text.ends_with('\n') {
|
if text.ends_with('\n') {
|
||||||
|
|||||||
+4
-5
@@ -11,16 +11,15 @@ use alacritty_terminal::{
|
|||||||
};
|
};
|
||||||
use futures::channel::mpsc::{UnboundedReceiver, UnboundedSender, unbounded};
|
use futures::channel::mpsc::{UnboundedReceiver, UnboundedSender, unbounded};
|
||||||
|
|
||||||
use super::tap::Tapped;
|
use super::{mouse::MouseMode, tap::Tapped};
|
||||||
use crate::settings::{CursorStyle, Settings};
|
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)]
|
#[derive(Debug)]
|
||||||
pub enum PtyEvent {
|
pub enum PtyEvent {
|
||||||
Term(Event),
|
Term(Event),
|
||||||
Folder(PathBuf),
|
Folder(PathBuf),
|
||||||
/// X10 mouse reporting turned on or off.
|
Mouse(MouseMode),
|
||||||
X10(bool),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Hands the grid's events to the session; `pid` names the program in logs.
|
/// 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::Folder(folder) => folders.push(folder),
|
||||||
PtyEvent::Term(Event::ChildExit(status)) => success = status.success(),
|
PtyEvent::Term(Event::ChildExit(status)) => success = status.success(),
|
||||||
PtyEvent::Term(Event::Exit) => break,
|
PtyEvent::Term(Event::Exit) => break,
|
||||||
PtyEvent::Term(_) | PtyEvent::X10(_) => {}
|
PtyEvent::Term(_) | PtyEvent::Mouse(_) => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
(folders, success)
|
(folders, success)
|
||||||
|
|||||||
+34
-36
@@ -7,16 +7,16 @@ use alacritty_terminal::{
|
|||||||
use futures::channel::mpsc::UnboundedSender;
|
use futures::channel::mpsc::UnboundedSender;
|
||||||
use polling::{Event, PollMode, Poller};
|
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.
|
/// The longest OSC body kept; longer ones are dropped whole.
|
||||||
const MOST: usize = 4096;
|
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(crate) struct Tapped<P> {
|
||||||
pub inner: P,
|
pub inner: P,
|
||||||
scanner: Osc7,
|
scanner: Osc7,
|
||||||
x10: Mode9,
|
mouse: MouseModes,
|
||||||
events: UnboundedSender<PtyEvent>,
|
events: UnboundedSender<PtyEvent>,
|
||||||
pid: u32,
|
pid: u32,
|
||||||
last: Option<PathBuf>,
|
last: Option<PathBuf>,
|
||||||
@@ -27,7 +27,7 @@ impl<P> Tapped<P> {
|
|||||||
Self {
|
Self {
|
||||||
inner,
|
inner,
|
||||||
scanner: Osc7::default(),
|
scanner: Osc7::default(),
|
||||||
x10: Mode9::default(),
|
mouse: MouseModes::default(),
|
||||||
events,
|
events,
|
||||||
pid,
|
pid,
|
||||||
last: None,
|
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");
|
log::debug!("pty {pid}: a folder report after its session closed");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
if let Some(on) = self.x10.feed(&buf[..count]) {
|
if let Some(mode) = self.mouse.feed(&buf[..count]) {
|
||||||
log::info!(
|
log::info!("pty {pid}: mouse {mode:?}");
|
||||||
"pty {pid}: X10 mouse reporting {}",
|
if events.unbounded_send(PtyEvent::Mouse(mode)).is_err() {
|
||||||
if on { "on" } else { "off" }
|
|
||||||
);
|
|
||||||
if events.unbounded_send(PtyEvent::X10(on)).is_err() {
|
|
||||||
log::debug!("pty {pid}: a mouse mode after its session closed");
|
log::debug!("pty {pid}: a mouse mode after its session closed");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -197,18 +194,18 @@ enum Csi {
|
|||||||
Private,
|
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)]
|
#[derive(Default)]
|
||||||
pub(crate) struct Mode9 {
|
pub(crate) struct MouseModes {
|
||||||
state: Csi,
|
state: Csi,
|
||||||
params: Vec<u32>,
|
params: Vec<u32>,
|
||||||
on: bool,
|
mode: MouseMode,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Mode9 {
|
impl MouseModes {
|
||||||
/// The mode after `bytes`, when they changed it.
|
/// The mode after `bytes`, when they changed it.
|
||||||
pub fn feed(&mut self, bytes: &[u8]) -> Option<bool> {
|
pub fn feed(&mut self, bytes: &[u8]) -> Option<MouseMode> {
|
||||||
let was = self.on;
|
let was = self.mode;
|
||||||
let mut rest = bytes;
|
let mut rest = bytes;
|
||||||
while !rest.is_empty() {
|
while !rest.is_empty() {
|
||||||
if self.state == Csi::Ground {
|
if self.state == Csi::Ground {
|
||||||
@@ -225,7 +222,7 @@ impl Mode9 {
|
|||||||
(_, 0x1b) => Csi::Escape,
|
(_, 0x1b) => Csi::Escape,
|
||||||
(Csi::Escape, b'[') => Csi::Bracket,
|
(Csi::Escape, b'[') => Csi::Bracket,
|
||||||
(Csi::Escape, b'c') => {
|
(Csi::Escape, b'c') => {
|
||||||
self.on = false;
|
self.mode = MouseMode::default();
|
||||||
Csi::Ground
|
Csi::Ground
|
||||||
}
|
}
|
||||||
(Csi::Bracket, b'?') => {
|
(Csi::Bracket, b'?') => {
|
||||||
@@ -244,20 +241,15 @@ impl Mode9 {
|
|||||||
Csi::Private
|
Csi::Private
|
||||||
}
|
}
|
||||||
(Csi::Private, b'h' | b'l') => {
|
(Csi::Private, b'h' | b'l') => {
|
||||||
let set = byte == b'h';
|
for &mode in &self.params {
|
||||||
for mode in &self.params {
|
self.mode.apply(mode, byte == b'h');
|
||||||
match mode {
|
|
||||||
9 => self.on = set,
|
|
||||||
1000 | 1002 | 1003 if set => self.on = false,
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Csi::Ground
|
Csi::Ground
|
||||||
}
|
}
|
||||||
_ => 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]
|
#[test]
|
||||||
fn mode_9_follows_its_sets_resets_and_the_modes_that_replace_it() {
|
fn mouse_modes_follow_private_modes_in_order_across_reads() {
|
||||||
let mut mode = Mode9::default();
|
use crate::term::mouse::{Format, Tracking};
|
||||||
assert_eq!(mode.feed(b"a\x1b[?"), None);
|
let mut modes = MouseModes::default();
|
||||||
assert_eq!(mode.feed(b"1006;9h"), Some(true));
|
assert_eq!(modes.feed(b"a\x1b[?"), None);
|
||||||
assert_eq!(mode.feed(b"\x1b[9m\x1b[?25l\x1b[?9"), None);
|
assert!(modes.feed(b"1006;9h").is_some());
|
||||||
assert_eq!(mode.feed(b"l"), Some(false));
|
assert_eq!(modes.mode.format, Format::Sgr);
|
||||||
assert_eq!(mode.feed(b"\x1b[?9h\x1b[?1000h"), None, "1000 replaced it");
|
assert_eq!(modes.mode.tracking, Tracking::X10);
|
||||||
assert_eq!(mode.feed(b"\x1b[?9h"), Some(true));
|
assert_eq!(modes.feed(b"\x1b[9m\x1b[?25l\x1b[?9"), None);
|
||||||
assert_eq!(mode.feed(b"\x1b[?1000l"), None);
|
assert!(modes.feed(b"l").is_some());
|
||||||
assert_eq!(mode.feed(b"\x1bc"), Some(false), "a reset ends it");
|
assert_eq!(modes.mode.tracking, Tracking::Off);
|
||||||
assert_eq!(mode.feed(b"\x1b[?09;\x1b[?9h"), Some(true));
|
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]
|
#[test]
|
||||||
|
|||||||
+5
-4
@@ -12,6 +12,7 @@ use super::{
|
|||||||
frame::frame,
|
frame::frame,
|
||||||
ink::Ink,
|
ink::Ink,
|
||||||
input::{self, Copy, Paste},
|
input::{self, Copy, Paste},
|
||||||
|
mouse::MouseMode,
|
||||||
paint::{self, Look},
|
paint::{self, Look},
|
||||||
pointer::Pointer,
|
pointer::Pointer,
|
||||||
pty::{Events, Pty, PtyEvent, config, fit},
|
pty::{Events, Pty, PtyEvent, config, fit},
|
||||||
@@ -48,8 +49,8 @@ pub struct TerminalView {
|
|||||||
/// The cursor's cell in the window, from the last layout.
|
/// The cursor's cell in the window, from the last layout.
|
||||||
pub(super) caret: Bounds<Pixels>,
|
pub(super) caret: Bounds<Pixels>,
|
||||||
pub(super) pointer: Pointer,
|
pub(super) pointer: Pointer,
|
||||||
/// X10 mouse reporting, mode 9, as the tap follows it.
|
/// The mouse mode, as the tap follows it.
|
||||||
pub(super) x10: bool,
|
pub(super) mouse: MouseMode,
|
||||||
/// Keys whose press this terminal saw, so their releases may go out.
|
/// Keys whose press this terminal saw, so their releases may go out.
|
||||||
pub(super) held: HashSet<String>,
|
pub(super) held: HashSet<String>,
|
||||||
pub(super) size: (usize, usize),
|
pub(super) size: (usize, usize),
|
||||||
@@ -83,7 +84,7 @@ impl TerminalView {
|
|||||||
marked: None,
|
marked: None,
|
||||||
caret: Bounds::default(),
|
caret: Bounds::default(),
|
||||||
pointer: Pointer::default(),
|
pointer: Pointer::default(),
|
||||||
x10: false,
|
mouse: MouseMode::default(),
|
||||||
held: HashSet::new(),
|
held: HashSet::new(),
|
||||||
size,
|
size,
|
||||||
cell: (8, 16),
|
cell: (8, 16),
|
||||||
@@ -155,7 +156,7 @@ impl TerminalView {
|
|||||||
match event {
|
match event {
|
||||||
PtyEvent::Folder(folder) => cx.emit(TerminalEvent::Folder(folder)),
|
PtyEvent::Folder(folder) => cx.emit(TerminalEvent::Folder(folder)),
|
||||||
PtyEvent::Term(event) => self.term_event(event, cx),
|
PtyEvent::Term(event) => self.term_event(event, cx),
|
||||||
PtyEvent::X10(on) => self.x10 = on,
|
PtyEvent::Mouse(mode) => self.mouse = mode,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
cx.notify();
|
cx.notify();
|
||||||
|
|||||||
Reference in new issue
Block a user