T09: mouse: selection by cell, word and line, Shift extends; reports for X10, normal, button and any tracking in X10, UTF-8 and SGR bytes, mode 9 followed by the tap; the wheel reports, sends arrows or scrolls history; copy, bracketed paste, copy on select, OSC 52; Cmd-press opens OSC 8 targets and URLs; a release goes out only for a press the terminal saw; shot posts mouse gestures and never activates
CI / platforms (windows-latest) (push) Failing after 7s
CI / macos (push) Failing after 8m50s
CI / platforms (ubuntu-24.04) (push) Canceled after 0s

This commit is contained in:
ZacharyZhang-NY committed 2026-10-14 15:32:17 -04:00
1 parent fdb1caa26e
commit 9f81f1f704
19 files changed
+971 -27

No files matched your search

+6 -2
View File
@@ -12,7 +12,7 @@ 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). Run it with `HOME` pointing at a temp folder to try settings without touching your own.
- `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.
- `scripts/check.sh`: fmt, clippy `-D warnings`, tests, the 500-line rule.
## Layout
@@ -20,7 +20,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 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.
- `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.
- `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,4 +49,8 @@ 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: 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.
- 2026-10-14: settings refuse unknown fields; a bad settings file stops the start and is never overwritten.
Generated
+1
View File
@@ -1755,6 +1755,7 @@ dependencies = [
"dirs 7.0.0",
"ely-gpui-component",
"env_logger",
"foreign-types",
"futures",
"gethostname",
"gpui-pre",
+2 -1
View File
@@ -34,7 +34,8 @@ block = "0.1"
objc = "0.2"
[target.'cfg(target_os = "macos")'.dev-dependencies]
core-graphics = "0.24"
core-graphics = { version = "0.24", features = ["highsierra"] }
foreign-types = "0.5"
image = { version = "0.25", default-features = false, features = ["png"] }
raw-window-handle = "0.6"
+1
View File
@@ -60,6 +60,7 @@ Ghostty is the bar for what the terminal does. ELYTerminal owns its engine on `a
- Ely's `ColumnShell`: sessions column, terminal column, right column. The window opens with blur when the setting is on.
- The sessions column and the right column toggle and keep their state in settings.
- The title shows the active session's title. Nothing else in the title bar.
- The title bar is transparent, as Ghostty's: the window's background, opacity and blur run under it, so it wears the terminal's color.
## 7. Sessions panel
+2 -2
View File
@@ -26,7 +26,7 @@ Progress truth. Top to bottom, one at a time. A task is ticked only after its co
## 6. Main window and layout
- [ ] T11 `ColumnShell` with sessions, terminal and right columns; toggles kept in settings; window title.
- [ ] T11 `ColumnShell` with sessions, terminal and right columns; toggles kept in settings; window title; a transparent title bar over the window's background, as Ghostty's.
## 7. Sessions panel
@@ -55,7 +55,7 @@ Progress truth. Top to bottom, one at a time. A task is ticked only after its co
## 12. Menus and shortcuts
- [ ] T22 macOS menu bar; key bindings on all platforms; a key whose press a binding takes sends no release, as in kitty.
- [ ] T22 macOS menu bar; key bindings on all platforms.
## 13. Acceptance
+107 -9
View File
@@ -1,4 +1,4 @@
//! `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 AppKit events, then photographs the window.
//! `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.
#[cfg(target_os = "macos")]
fn main() {
@@ -30,7 +30,6 @@ fn main() {
ely_gpui_component::init(cx).expect("Ely starts");
app::start(cx).expect("the app starts");
let window = app::open(launch, cx).expect("the window opens");
cx.activate(true);
cx.spawn(async move |cx| {
cx.background_executor().timer(Duration::from_secs(1)).await;
let number = window
@@ -117,7 +116,12 @@ mod capture {
#[cfg(target_os = "macos")]
mod typing {
use anyhow::Context as _;
use core_graphics::geometry::CGPoint;
use core_graphics::{
event::{CGEvent, CGEventFlags, ScrollEventUnit},
event_source::{CGEventSource, CGEventSourceStateID},
geometry::{CGPoint, CGRect},
};
use foreign_types::ForeignType;
use objc::{
class, msg_send,
runtime::{NO, Object, YES},
@@ -125,13 +129,14 @@ mod typing {
};
/// US layout key codes and what each key types.
const KEYS: [(&str, u16, &str); 18] = [
const KEYS: [(&str, u16, &str); 19] = [
("a", 0, "a"),
("c", 8, "c"),
("e", 14, "e"),
("f", 3, "f"),
("i", 34, "i"),
("s", 1, "s"),
("v", 9, "v"),
("[", 33, "["),
("space", 49, " "),
("tab", 48, "\t"),
@@ -187,17 +192,103 @@ mod typing {
Ok(())
}
/// Posts `spec`, such as `ctrl-c`, `lopt-f` or `repeat-up`, as a key going down and up on window `number`.
unsafe extern "C" {
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.
fn mouse(name: &str, flags: u64, number: u32) -> anyhow::Result<()> {
let mut fields = name.split('@');
let gesture = fields.next().context("a gesture")?;
let numbers: Vec<f64> = fields
.flat_map(|field| field.split(','))
.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");
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 content: *mut Object = msg_send![window, contentView];
let height = {
let frame: CGRect = msg_send![content, frame];
frame.size.height
};
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];
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 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`.
pub fn post(spec: &str, number: u32) -> anyhow::Result<()> {
if let Some((call, text)) = spec.split_once(':') {
return compose(call, text, number);
}
let mut parts: Vec<&str> = spec.split('-').collect();
let name = parts.pop().context("an empty key")?;
let (_, code, plain) = KEYS
.iter()
.find(|(key, ..)| *key == name)
.with_context(|| format!("no key code for {name}"))?;
let mut flags: u64 = 0;
let mut repeat = NO;
for modifier in &parts {
@@ -214,6 +305,13 @@ mod typing {
other => anyhow::bail!("no modifier {other}"),
};
}
if name.contains('@') {
return mouse(name, flags, number);
}
let (_, code, plain) = KEYS
.iter()
.find(|(key, ..)| *key == name)
.with_context(|| format!("no key code for {name}"))?;
if plain
.chars()
.next()
+41 -1
View File
@@ -1,6 +1,7 @@
use alacritty_terminal::{
event::EventListener,
grid::Dimensions,
index::Point,
term::{Term, cell::Cell, cell::Flags, color::Colors},
vte::ansi::CursorShape,
};
@@ -92,7 +93,19 @@ pub fn frame<T: EventListener>(term: &Term<T>, ink: &Ink) -> Frame {
for indexed in content.display_iter {
let (point, cell) = (indexed.point, indexed.cell);
let row = (point.line.0 + offset) as usize;
let look = style(cell, ink, content.colors);
let mut look = style(cell, ink, content.colors);
let selected = content.selection.as_ref().is_some_and(|range| {
let (line, column) = (point.line, point.column);
range.contains(point)
|| (cell.flags.contains(Flags::WIDE_CHAR)
&& range.contains(Point::new(line, column + 1)))
|| (cell.flags.contains(Flags::WIDE_CHAR_SPACER)
&& range.contains(Point::new(line, column - 1)))
});
if selected {
look.bg = Some(ink.selection);
look.fg = ink.selection_text.unwrap_or(look.fg);
}
let spacer = cell
.flags
.intersects(Flags::WIDE_CHAR_SPACER | Flags::LEADING_WIDE_CHAR_SPACER);
@@ -292,6 +305,33 @@ mod tests {
assert!(hidden.rows[0].alone.is_empty(), "hidden lines draw nothing");
}
#[test]
fn selected_cells_wear_the_selection_colors() {
use alacritty_terminal::{
index::{Column, Line, Side},
selection::{Selection, SelectionType},
};
let mut term = Term::new(Config::default(), &TermSize::new(12, 3), VoidListener);
let mut parser: Processor = Processor::new();
parser.advance(&mut term, "ab\u{4e2d}d".as_bytes());
let start = Point::new(Line(0), Column(1));
let mut selection = Selection::new(SelectionType::Simple, start, Side::Left);
selection.update(Point::new(Line(0), Column(3)), Side::Left);
term.selection = Some(selection);
let ink = Ink::new(themes::find("dracula").expect("dracula"), term.colors());
let frame = frame(&term, &ink);
let grounds: Vec<_> = frame.rows[0]
.runs
.iter()
.map(|(bytes, style)| (*bytes, style.bg))
.collect();
assert_eq!(
&grounds[..3],
[(1, None), (5, Some(ink.selection)), (8, None)],
"b and both halves of the wide cell, whose spacer is picked"
);
}
#[test]
fn hidden_cells_show_nothing_but_their_ground() {
let (frame, _) = shown("\x1b[8;4;9;41mSe\u{301}\x1b[0m\x1b[2D".as_bytes());
+5
View File
@@ -14,6 +14,9 @@ pub struct Ink {
pub bg: Hsla,
pub cursor: Hsla,
pub cursor_text: Hsla,
pub selection: Hsla,
/// None keeps each cell's own color.
pub selection_text: Option<Hsla>,
}
impl Ink {
@@ -31,6 +34,8 @@ impl Ink {
bg: named(NamedColor::Background).unwrap_or(theme.background),
cursor: named(NamedColor::Cursor).unwrap_or(theme.cursor),
cursor_text: theme.cursor_text,
selection: theme.selection_background,
selection_text: theme.selection_foreground,
}
}
+23 -4
View File
@@ -3,7 +3,7 @@ use std::ops::Range;
use alacritty_terminal::{grid::Scroll, term::TermMode};
use gpui::{
App, Bounds, Context, EntityInputHandler, KeyBinding, KeyDownEvent, KeyUpEvent, Keystroke,
NoAction, Pixels, Point, UTF16Selection, Window,
NoAction, Pixels, Point, UTF16Selection, Window, actions,
};
use super::{
@@ -11,11 +11,22 @@ use super::{
view::TerminalView,
};
actions!(elyterminal, [Copy, Paste]);
/// The key context a terminal's root sets.
pub const CONTEXT: &str = "Terminal";
/// Ely's focus keys go to the terminal's listeners instead.
/// Copy and paste; Ely's focus keys go to the terminal's listeners instead.
pub fn bind_keys(cx: &mut App) {
let (copy, paste) = if cfg!(target_os = "macos") {
("cmd-c", "cmd-v")
} else {
("ctrl-shift-c", "ctrl-shift-v")
};
cx.bind_keys([
KeyBinding::new(copy, Copy, Some(CONTEXT)),
KeyBinding::new(paste, Paste, Some(CONTEXT)),
]);
cx.bind_keys(
["tab", "shift-tab", "f6", "shift-f6"]
.map(|key| KeyBinding::new(key, NoAction, Some(CONTEXT))),
@@ -23,9 +34,12 @@ pub fn bind_keys(cx: &mut App) {
}
impl TerminalView {
/// Bytes from the keyboard: back to the newest line, then to the program.
/// Bytes from the keyboard: back to the newest line, no selection, then to the program.
pub(super) fn input(&mut self, bytes: Vec<u8>, cx: &mut Context<Self>) {
self.pty.term.lock().scroll_display(Scroll::Bottom);
let mut term = self.pty.term.lock();
term.scroll_display(Scroll::Bottom);
term.selection = None;
drop(term);
self.pty.write(bytes);
cx.notify();
}
@@ -39,6 +53,7 @@ impl TerminalView {
if event.prefer_character_input {
return;
}
self.held.insert(event.keystroke.key.clone());
let facts = self.facts();
let action = if event.is_held || facts.repeat {
Action::Repeat
@@ -50,7 +65,11 @@ impl TerminalView {
}
}
/// A release goes out only for a press this terminal saw: none for one a binding took.
pub(super) fn key_up(&mut self, event: &KeyUpEvent, _: &mut Window, cx: &mut Context<Self>) {
if !self.held.remove(&event.keystroke.key) {
return;
}
let facts = self.facts();
if self.send(&event.keystroke, Action::Release, facts, cx) {
cx.stop_propagation();
+10
View File
@@ -51,6 +51,16 @@ 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.
pub fn paste(text: &str, bracketed: bool) -> Vec<u8> {
if bracketed {
let text = text.replace(['\x1b', '\x03'], "");
format!("\x1b[200~{text}\x1b[201~").into_bytes()
} else {
text.replace("\r\n", "\r").replace('\n', "\r").into_bytes()
}
}
/// xterm's modifier code: one plus shift 1, alt 2, ctrl 4, super 8.
fn code(key: &Key) -> u32 {
1 + u32::from(key.shift)
+9
View File
@@ -321,3 +321,12 @@ fn super_is_kittys_eighth_bit() {
Some("\x1b[27;9;13~")
);
}
#[test]
fn pastes_end_lines_with_cr_or_go_bracketed_without_escapes() {
assert_eq!(paste("a\r\nb\nc", false), b"a\rb\rc");
assert_eq!(
paste("x\n\x1b[201~rm\x03", true),
b"\x1b[200~x\n[201~rm\x1b[201~"
);
}
+86
View File
@@ -0,0 +1,86 @@
use alacritty_terminal::{
index::{Boundary, Point},
term::{Term, cell::Flags},
};
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.
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) {
point.column -= 1;
}
if let Some(link) = grid[point].hyperlink() {
return Some(link.uri().to_string());
}
let end = term.line_search_right(point);
let mut cell = term.line_search_left(point);
let (mut text, mut at) = (String::new(), 0);
loop {
if cell == point {
at = text.len();
}
let spacer = Flags::WIDE_CHAR_SPACER | Flags::LEADING_WIDE_CHAR_SPACER;
if !grid[cell].flags.intersects(spacer) {
text.push(grid[cell].c);
}
if cell == end {
break;
}
cell = cell.add(term, Boundary::Grid, 1);
}
links(&text)
.into_iter()
.find_map(|(bytes, target)| match target {
Target::Url(url) if bytes.contains(&at) => Some(url.to_string()),
_ => None,
})
}
#[cfg(test)]
mod tests {
use alacritty_terminal::{
event::VoidListener,
index::{Column, Line},
term::{Config, test::TermSize},
vte::ansi::Processor,
};
use super::*;
fn link(bytes: &str, line: i32, column: usize) -> Option<String> {
let mut term = Term::new(Config::default(), &TermSize::new(12, 4), VoidListener);
let mut parser: Processor = Processor::new();
parser.advance(&mut term, bytes.as_bytes());
link_at(&term, Point::new(Line(line), Column(column)))
}
#[test]
fn an_osc_8_link_names_its_target() {
let text = "a \x1b]8;;https://ely.dev/x\x1b\\ely\x1b]8;;\x1b\\ b";
assert_eq!(link(text, 0, 3).as_deref(), Some("https://ely.dev/x"));
assert_eq!(link(text, 0, 0), None);
}
#[test]
fn a_url_ends_before_its_sentence_does() {
assert_eq!(link("(a.io/x).", 0, 2), None, "no scheme, no link");
let text = "(http://a.b)";
assert_eq!(link(text, 0, 4).as_deref(), Some("http://a.b"));
assert_eq!(link(text, 0, 11), None);
}
#[test]
fn a_url_runs_across_wrapped_rows_and_wide_cells() {
let text = "\u{4e2d} https://ely.dev/abc";
assert_eq!(link(text, 1, 3).as_deref(), Some("https://ely.dev/abc"));
assert_eq!(link(text, 0, 3).as_deref(), Some("https://ely.dev/abc"));
assert_eq!(link(text, 0, 1), None);
let wide = "file://a/\u{4e2d} x";
assert_eq!(
link(wide, 0, 10).as_deref(),
Some("file://a/\u{4e2d}"),
"a wide cell's spacer is its char"
);
}
}
+3
View File
@@ -3,9 +3,12 @@ pub mod frame;
pub mod ink;
pub mod input;
pub mod keys;
mod links;
#[cfg(target_os = "macos")]
pub mod mac;
pub mod mouse;
mod paint;
mod pointer;
mod pty;
pub mod shell;
mod tap;
+240
View File
@@ -0,0 +1,240 @@
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 {
Left,
Middle,
Right,
WheelUp,
WheelDown,
}
/// What the mouse did.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Gesture {
Press,
Release,
Motion,
}
/// A mouse event a program may ask for.
#[derive(Clone, Copy, Debug)]
pub struct Report {
/// None for motion with no button down.
pub button: Option<Button>,
pub gesture: Gesture,
/// Column and row on screen, from 0.
pub cell: (usize, usize),
pub shift: bool,
pub alt: bool,
pub ctrl: bool,
}
/// The mouse events a program asked for.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Tracking {
Off,
/// Mode 9: presses alone, no modifiers.
X10,
/// Mode 1000: presses and releases.
Normal,
/// Mode 1002: and motion with a button down.
Button,
/// Mode 1003: and all motion.
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 `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>> {
let clicked = matches!(
report.button,
Some(Button::Left | Button::Middle | Button::Right)
);
let wanted = match tracking {
Tracking::Off => false,
Tracking::X10 => report.gesture == Gesture::Press && clicked,
Tracking::Normal => report.gesture != Gesture::Motion,
Tracking::Button => report.gesture != Gesture::Motion || report.button.is_some(),
Tracking::Any => true,
};
if !wanted {
return None;
}
let sgr = mode.contains(TermMode::SGR_MOUSE);
let mut code = match report.button {
None => 3,
Some(_) if report.gesture == Gesture::Release && !sgr => 3,
Some(Button::Left) => 0,
Some(Button::Middle) => 1,
Some(Button::Right) => 2,
Some(Button::WheelUp) => 64,
Some(Button::WheelDown) => 65,
};
if tracking != Tracking::X10 {
code +=
4 * u32::from(report.shift) + 8 * u32::from(report.alt) + 16 * u32::from(report.ctrl);
}
if report.gesture == Gesture::Motion {
code += 32;
}
let (column, row) = (report.cell.0 + 1, report.cell.1 + 1);
if sgr {
let end = if report.gesture == Gesture::Release {
'm'
} else {
'M'
};
return Some(format!("\x1b[<{code};{column};{row}{end}").into_bytes());
}
let utf8 = mode.contains(TermMode::UTF8_MOUSE);
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");
return None;
}
let mut bytes = b"\x1b[M".to_vec();
bytes.push(32 + code as u8);
for value in [column, row] {
let value = 32 + value as u32;
if utf8 {
let ch = char::from_u32(value).expect("a code point under U+0800");
bytes.extend(ch.to_string().as_bytes());
} else {
bytes.push(value as u8);
}
}
Some(bytes)
}
#[cfg(test)]
mod tests {
use super::*;
fn report(button: Option<Button>, gesture: Gesture, cell: (usize, usize)) -> Report {
Report {
button,
gesture,
cell,
shift: false,
alt: false,
ctrl: false,
}
}
fn sent(report: Report, tracking: Tracking, mode: TermMode) -> Option<Vec<u8>> {
encode(&report, tracking, mode)
}
const LEFT: Option<Button> = Some(Button::Left);
#[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 press = report(LEFT, Gesture::Press, (0, 0));
assert_eq!(sent(press, tracking, mode), 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()));
let motion = report(LEFT, Gesture::Motion, (1, 1));
assert_eq!(sent(motion, tracking, mode), None);
}
#[test]
fn sgr_names_the_released_button_and_the_modifiers() {
let mode = TermMode::MOUSE_REPORT_CLICK | TermMode::SGR_MOUSE;
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())
);
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())
);
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())
);
}
#[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())
);
}
#[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 press = Report {
shift: true,
..report(LEFT, Gesture::Press, (0, 0))
};
assert_eq!(sent(press, tracking, mode), Some(b"\x1b[M !!".to_vec()));
let release = report(LEFT, Gesture::Release, (0, 0));
assert_eq!(sent(release, tracking, mode), 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);
}
#[test]
fn cells_past_the_format_go_utf8_or_nowhere() {
let mode = TermMode::MOUSE_REPORT_CLICK;
let edge = report(LEFT, Gesture::Press, (222, 0));
assert_eq!(
sent(edge, Tracking::Normal, mode),
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;
let mut wide = b"\x1b[M ".to_vec();
wide.extend("\u{100}!".as_bytes());
assert_eq!(sent(past, Tracking::Normal, utf8), Some(wide));
let far = report(LEFT, Gesture::Press, (2015, 0));
assert_eq!(sent(far, Tracking::Normal, utf8), None);
}
}
+1
View File
@@ -50,6 +50,7 @@ pub fn grid(
let lines = (bounds.size.height / cell.height).floor() as usize;
let origin = snapped(bounds.origin, window);
fit.update(cx, |view, cx| {
(view.pointer.origin, view.pointer.cell) = (origin, cell);
if let Some((row, column)) = at {
let corner =
origin + point(cell.width * column as f32, cell.height * row as f32);
+296
View File
@@ -0,0 +1,296 @@
use alacritty_terminal::{
grid::Scroll,
index::{Column, Line, Point as Cell, Side},
selection::{Selection, SelectionType},
term::TermMode,
};
use gpui::{
ClipboardItem, Context, Modifiers, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent,
Pixels, Point, ScrollDelta, ScrollWheelEvent, Size, Window,
};
use super::{
keys,
links::link_at,
mouse::{self, Button, Gesture, Report, Tracking},
view::TerminalView,
};
use crate::settings::SettingsStore;
/// Lines one wheel notch scrolls, as Ghostty's discrete wheel.
const NOTCH: f32 = 3.;
/// Where the grid sits, and what the mouse is doing to it.
#[derive(Default)]
pub struct Pointer {
/// The grid's top-left in the window, from the last layout.
pub origin: Point<Pixels>,
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>,
/// The cell of the last report; motion inside it sends nothing.
last: Option<(usize, usize)>,
/// Wheel travel short of a line.
rest: Pixels,
}
fn button(button: MouseButton) -> Option<Button> {
match button {
MouseButton::Left => Some(Button::Left),
MouseButton::Middle => Some(Button::Middle),
MouseButton::Right => Some(Button::Right),
MouseButton::Navigate(_) => None,
}
}
impl TerminalView {
/// The screen cell under a window position, clamped to the grid; its grid point; its half.
fn cell_at(&self, position: Point<Pixels>) -> ((usize, usize), Cell, Side) {
let Pointer { origin, cell, .. } = self.pointer;
let x = ((position.x - origin.x) / cell.width).max(0.);
let y = ((position.y - origin.y) / cell.height).max(0.);
let (columns, lines) = self.size;
let (column, row) = ((x as usize).min(columns - 1), (y as usize).min(lines - 1));
let side = if x as usize >= columns || x.fract() >= 0.5 {
Side::Right
} else {
Side::Left
};
let offset = self.pty.term.lock().grid().display_offset() as i32;
let point = Cell::new(Line(row as i32 - offset), Column(column));
((column, row), point, side)
}
/// 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::new(*self.pty.term.lock().mode(), self.x10)
}
fn report(
&mut self,
button: Option<Button>,
gesture: Gesture,
cell: (usize, usize),
modifiers: &Modifiers,
tracking: Tracking,
) -> bool {
let report = Report {
button,
gesture,
cell,
shift: modifiers.shift,
alt: modifiers.alt,
ctrl: modifiers.control,
};
let mode = *self.pty.term.lock().mode();
let Some(bytes) = mouse::encode(&report, tracking, mode) else {
return false;
};
log::trace!("terminal {}: {report:?} sends {bytes:?}", self.pty.pid);
self.pty.write(bytes);
self.pointer.last = Some(cell);
true
}
pub(super) fn mouse_down(
&mut self,
event: &MouseDownEvent,
window: &mut Window,
cx: &mut Context<Self>,
) {
window.focus(&self.focus, cx);
let (cell, point, side) = self.cell_at(event.position);
let opens = if cfg!(target_os = "macos") {
event.modifiers.platform
} else {
event.modifiers.control
};
if event.button == MouseButton::Left && opens {
let link = link_at(&self.pty.term.lock(), point);
if let Some(link) = link {
log::info!("terminal {}: opens {link}", self.pty.pid);
cx.open_url(&link);
return;
}
}
let tracking = self.tracking(&event.modifiers);
if tracking != Tracking::Off {
let Some(pressed) = button(event.button) else {
return;
};
if self.report(
Some(pressed),
Gesture::Press,
cell,
&event.modifiers,
tracking,
) {
self.pointer.pressed = Some(pressed);
}
return;
}
if event.button != MouseButton::Left {
return;
}
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),
_ => {
let kind = match event.click_count {
2 => SelectionType::Semantic,
3.. => SelectionType::Lines,
_ => SelectionType::Simple,
};
term.selection = Some(Selection::new(kind, point, side));
}
}
drop(term);
self.pointer.selecting = true;
cx.notify();
}
pub(super) fn mouse_move(
&mut self,
event: &MouseMoveEvent,
_: &mut Window,
cx: &mut Context<Self>,
) {
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() {
selection.update(point, side);
}
cx.notify();
return;
}
let tracking = self.tracking(&event.modifiers);
if tracking != Tracking::Off && self.pointer.last != Some(cell) {
let held = self.pointer.pressed;
self.report(held, Gesture::Motion, cell, &event.modifiers, tracking);
}
}
pub(super) fn mouse_up(
&mut self,
event: &MouseUpEvent,
_: &mut Window,
cx: &mut Context<Self>,
) {
if event.button == MouseButton::Left && std::mem::take(&mut self.pointer.selecting) {
let selected = self.selected();
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 (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);
}
}
pub(super) fn wheel(
&mut self,
event: &ScrollWheelEvent,
_: &mut Window,
cx: &mut Context<Self>,
) {
let height = self.pointer.cell.height;
if height <= Pixels::ZERO {
return;
}
cx.stop_propagation();
self.pointer.rest += match event.delta {
ScrollDelta::Pixels(delta) => delta.y,
ScrollDelta::Lines(delta) => height * delta.y * NOTCH,
};
let lines = (self.pointer.rest / height).trunc() as i32;
if lines == 0 {
return;
}
self.pointer.rest -= height * lines as f32;
log::trace!("terminal {}: wheel {lines} lines", self.pty.pid);
let (cell, ..) = self.cell_at(event.position);
let tracking = self.tracking(&event.modifiers);
let mode = *self.pty.term.lock().mode();
if tracking != Tracking::Off {
let turn = if lines > 0 {
Button::WheelUp
} else {
Button::WheelDown
};
for _ in 0..lines.unsigned_abs() {
self.report(Some(turn), Gesture::Press, cell, &event.modifiers, tracking);
}
} else if mode.contains(TermMode::ALT_SCREEN | TermMode::ALTERNATE_SCROLL) {
let arrow: &[u8] = match (lines > 0, mode.contains(TermMode::APP_CURSOR)) {
(true, true) => b"\x1bOA",
(true, false) => b"\x1b[A",
(false, true) => b"\x1bOB",
(false, false) => b"\x1b[B",
};
self.pty.write(arrow.repeat(lines.unsigned_abs() as usize));
} else {
self.pty.term.lock().scroll_display(Scroll::Delta(lines));
cx.notify();
}
}
/// The selected text, without the newline alacritty ends a whole line with, as Ghostty copies it.
fn selected(&self) -> Option<String> {
let mut text = self.pty.term.lock().selection_to_string()?;
if text.ends_with('\n') {
text.pop();
}
(!text.is_empty()).then_some(text)
}
/// The selection to the clipboard.
pub(super) fn copy(&mut self, cx: &mut Context<Self>) {
match self.selected() {
Some(text) => self.copy_text(text, cx),
None => log::debug!("terminal {}: nothing selected to copy", self.pty.pid),
}
}
fn copy_text(&self, text: String, cx: &mut Context<Self>) {
log::info!(
"terminal {}: copied {} characters",
self.pty.pid,
text.chars().count()
);
cx.write_to_clipboard(ClipboardItem::new_string(text));
}
/// The clipboard's text to the program, bracketed when it asked.
pub(super) fn paste(&mut self, cx: &mut Context<Self>) {
let Some(text) = cx.read_from_clipboard().and_then(|item| item.text()) else {
log::debug!("terminal {}: no text to paste", self.pty.pid);
return;
};
let bracketed = self
.pty
.term
.lock()
.mode()
.contains(TermMode::BRACKETED_PASTE);
log::info!(
"terminal {}: pastes {} characters{}",
self.pty.pid,
text.chars().count(),
if bracketed { ", bracketed" } else { "" }
);
self.input(keys::paste(&text, bracketed), cx);
}
}
+4 -2
View File
@@ -14,11 +14,13 @@ use futures::channel::mpsc::{UnboundedReceiver, UnboundedSender, unbounded};
use super::tap::Tapped;
use crate::settings::{CursorStyle, Settings};
/// What a session hears from its pty: the grid's events and the folder the shell reports.
/// What a session hears from its pty: the grid's events, the folder the shell reports, and mode 9.
#[derive(Debug)]
pub enum PtyEvent {
Term(Event),
Folder(PathBuf),
/// X10 mouse reporting turned on or off.
X10(bool),
}
/// Hands the grid's events to the session; `pid` names the program in logs.
@@ -248,7 +250,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::Term(_) | PtyEvent::X10(_) => {}
}
}
(folders, success)
+102 -1
View File
@@ -12,10 +12,11 @@ use super::pty::PtyEvent;
/// The longest OSC body kept; longer ones are dropped whole.
const MOST: usize = 4096;
/// A pty whose reader hands each byte to an OSC 7 scanner before the parser sees it.
/// A pty whose reader hands each byte to the OSC 7 and mode 9 scanners before the parser sees it.
pub(crate) struct Tapped<P> {
pub inner: P,
scanner: Osc7,
x10: Mode9,
events: UnboundedSender<PtyEvent>,
pid: u32,
last: Option<PathBuf>,
@@ -26,6 +27,7 @@ impl<P> Tapped<P> {
Self {
inner,
scanner: Osc7::default(),
x10: Mode9::default(),
events,
pid,
last: None,
@@ -51,6 +53,15 @@ 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() {
log::debug!("pty {pid}: a mouse mode after its session closed");
}
}
Ok(count)
}
}
@@ -174,6 +185,82 @@ impl Osc7 {
}
}
/// The most parameters one private mode sequence keeps, as vte's.
const PARAMS: usize = 32;
#[derive(Clone, Copy, Debug, Default, PartialEq)]
enum Csi {
#[default]
Ground,
Escape,
Bracket,
Private,
}
/// Follows `CSI ? 9 h` and `l`, X10 mouse reporting, across reads: alacritty's parser drops mode 9.
#[derive(Default)]
pub(crate) struct Mode9 {
state: Csi,
params: Vec<u32>,
on: bool,
}
impl Mode9 {
/// The mode after `bytes`, when they changed it.
pub fn feed(&mut self, bytes: &[u8]) -> Option<bool> {
let was = self.on;
let mut rest = bytes;
while !rest.is_empty() {
if self.state == Csi::Ground {
let Some(at) = rest.iter().position(|&byte| byte == 0x1b) else {
break;
};
self.state = Csi::Escape;
rest = &rest[at + 1..];
continue;
}
let byte = rest[0];
rest = &rest[1..];
self.state = match (self.state, byte) {
(_, 0x1b) => Csi::Escape,
(Csi::Escape, b'[') => Csi::Bracket,
(Csi::Escape, b'c') => {
self.on = false;
Csi::Ground
}
(Csi::Bracket, b'?') => {
self.params = vec![0];
Csi::Private
}
(Csi::Private, b'0'..=b'9') => {
let last = self.params.last_mut().expect("a parameter in progress");
*last = last
.saturating_mul(10)
.saturating_add(u32::from(byte - b'0'));
Csi::Private
}
(Csi::Private, b';') if self.params.len() < PARAMS => {
self.params.push(0);
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,
_ => {}
}
}
Csi::Ground
}
_ => Csi::Ground,
};
}
(self.on != was).then_some(self.on)
}
}
/// The local folder an OSC 7 `file://host/path` names; another host's folder is none.
pub(crate) fn folder(body: &[u8]) -> Option<PathBuf> {
let Some(rest) = body.strip_prefix(b"file://") else {
@@ -271,6 +358,20 @@ mod tests {
assert_eq!(scan(&[&long, b"\x1b]7;b\x07"]), ["b"]);
}
#[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));
}
#[test]
fn a_local_uri_gives_its_decoded_folder() {
assert_eq!(
+32 -5
View File
@@ -1,18 +1,19 @@
use std::{path::PathBuf, process::ExitStatus, rc::Rc, time::Duration};
use std::{collections::HashSet, path::PathBuf, process::ExitStatus, rc::Rc, time::Duration};
use alacritty_terminal::{event::Event, grid::Dimensions, vte::ansi::CursorShape};
use futures::StreamExt;
use gpui::{
Bounds, ClipboardItem, Context, EventEmitter, FocusHandle, Focusable, Font, FontFeatures,
FontStyle, FontWeight, IntoElement, ParentElement, Pixels, Render, Styled, Subscription, Task,
Window, div, prelude::*, px, size,
FontStyle, FontWeight, IntoElement, MouseButton, ParentElement, Pixels, Render, Styled,
Subscription, Task, Window, div, prelude::*, px, size,
};
use super::{
frame::frame,
ink::Ink,
input,
input::{self, Copy, Paste},
paint::{self, Look},
pointer::Pointer,
pty::{Events, Pty, PtyEvent, config, fit},
};
use crate::{
@@ -46,7 +47,12 @@ pub struct TerminalView {
pub(super) marked: Option<String>,
/// The cursor's cell in the window, from the last layout.
pub(super) caret: Bounds<Pixels>,
size: (usize, usize),
pub(super) pointer: Pointer,
/// X10 mouse reporting, mode 9, as the tap follows it.
pub(super) x10: bool,
/// Keys whose press this terminal saw, so their releases may go out.
pub(super) held: HashSet<String>,
pub(super) size: (usize, usize),
cell: (u16, u16),
blinking: bool,
shown: bool,
@@ -76,6 +82,9 @@ impl TerminalView {
focus: cx.focus_handle().tab_stop(true),
marked: None,
caret: Bounds::default(),
pointer: Pointer::default(),
x10: false,
held: HashSet::new(),
size,
cell: (8, 16),
blinking: false,
@@ -146,6 +155,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,
}
}
cx.notify();
@@ -159,6 +169,11 @@ impl TerminalView {
Event::ResetTitle => cx.emit(TerminalEvent::Title(String::new())),
Event::Bell => cx.emit(TerminalEvent::Bell),
Event::ClipboardStore(_, text) => {
log::info!(
"terminal {}: OSC 52 copied {} characters",
self.pty.pid,
text.chars().count()
);
cx.write_to_clipboard(ClipboardItem::new_string(text))
}
Event::ClipboardLoad(..) => log::error!(
@@ -261,10 +276,22 @@ impl Render for TerminalView {
.track_focus(&self.focus)
.on_key_down(cx.listener(Self::key_down))
.on_key_up(cx.listener(Self::key_up))
.on_action(cx.listener(|view, _: &Copy, _, cx| view.copy(cx)))
.on_action(cx.listener(|view, _: &Paste, _, cx| view.paste(cx)))
.on_any_mouse_down(cx.listener(Self::mouse_down))
.on_mouse_move(cx.listener(Self::mouse_move))
.on_scroll_wheel(cx.listener(Self::wheel))
.cursor_text()
.size_full()
.px(px(settings.padding_x))
.py(px(settings.padding_y))
.bg(ink.bg.alpha(settings.background_opacity));
let root = [MouseButton::Left, MouseButton::Middle, MouseButton::Right]
.into_iter()
.fold(root, |root, button| {
root.on_mouse_up(button, cx.listener(Self::mouse_up))
.on_mouse_up_out(button, cx.listener(Self::mouse_up))
});
match look(&settings, &ink, window) {
Ok(look) => {
self.cell = (