From 9f81f1f704bc4a415e83e2ed39c999625ae87d35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Wed, 14 Oct 2026 15:32:17 -0400 Subject: [PATCH] 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 --- AGENTS.md | 8 +- Cargo.lock | 1 + Cargo.toml | 3 +- PRD.md | 1 + TASKS.md | 4 +- examples/shot.rs | 116 ++++++++++++++-- src/term/frame.rs | 42 +++++- src/term/ink.rs | 5 + src/term/input.rs | 27 +++- src/term/keys.rs | 10 ++ src/term/keys/tests.rs | 9 ++ src/term/links.rs | 86 ++++++++++++ src/term/mod.rs | 3 + src/term/mouse.rs | 240 +++++++++++++++++++++++++++++++++ src/term/paint.rs | 1 + src/term/pointer.rs | 296 +++++++++++++++++++++++++++++++++++++++++ src/term/pty.rs | 6 +- src/term/tap.rs | 103 +++++++++++++- src/term/view.rs | 37 +++++- 19 files changed, 971 insertions(+), 27 deletions(-) create mode 100644 src/term/links.rs create mode 100644 src/term/mouse.rs create mode 100644 src/term/pointer.rs diff --git a/AGENTS.md b/AGENTS.md index af7b2b7..f2b194c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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:é"] [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:é"] [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. diff --git a/Cargo.lock b/Cargo.lock index 150f768..c69c4df 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1755,6 +1755,7 @@ dependencies = [ "dirs 7.0.0", "ely-gpui-component", "env_logger", + "foreign-types", "futures", "gethostname", "gpui-pre", diff --git a/Cargo.toml b/Cargo.toml index d375224..2e18d4f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/PRD.md b/PRD.md index 57db425..a08f1b8 100644 --- a/PRD.md +++ b/PRD.md @@ -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 diff --git a/TASKS.md b/TASKS.md index 7d801a0..333d8a9 100644 --- a/TASKS.md +++ b/TASKS.md @@ -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 diff --git a/examples/shot.rs b/examples/shot.rs index 3707722..3ae7749 100644 --- a/examples/shot.rs +++ b/examples/shot.rs @@ -1,4 +1,4 @@ -//! `cargo run --example shot -- [--keys "a ctrl-c lopt-f repeat-up mark:é commit:é"] [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"] [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 = fields + .flat_map(|field| field.split(',')) + .map(str::parse) + .collect::>() + .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::() + 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() diff --git a/src/term/frame.rs b/src/term/frame.rs index d51eddf..193fcb6 100644 --- a/src/term/frame.rs +++ b/src/term/frame.rs @@ -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(term: &Term, 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()); diff --git a/src/term/ink.rs b/src/term/ink.rs index ea0410f..22005bc 100644 --- a/src/term/ink.rs +++ b/src/term/ink.rs @@ -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, } 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, } } diff --git a/src/term/input.rs b/src/term/input.rs index 2680f58..3b4a09b 100644 --- a/src/term/input.rs +++ b/src/term/input.rs @@ -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, cx: &mut Context) { - 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) { + if !self.held.remove(&event.keystroke.key) { + return; + } let facts = self.facts(); if self.send(&event.keystroke, Action::Release, facts, cx) { cx.stop_propagation(); diff --git a/src/term/keys.rs b/src/term/keys.rs index e1d92ab..edc84b6 100644 --- a/src/term/keys.rs +++ b/src/term/keys.rs @@ -51,6 +51,16 @@ pub fn encode(key: &Key, app_cursor: bool, kitty: Kitty) -> Option> { } } +/// 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 { + 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) diff --git a/src/term/keys/tests.rs b/src/term/keys/tests.rs index 50260b7..ed15763 100644 --- a/src/term/keys/tests.rs +++ b/src/term/keys/tests.rs @@ -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~" + ); +} diff --git a/src/term/links.rs b/src/term/links.rs new file mode 100644 index 0000000..436f71a --- /dev/null +++ b/src/term/links.rs @@ -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(term: &Term, mut point: Point) -> Option { + 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 { + 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" + ); + } +} diff --git a/src/term/mod.rs b/src/term/mod.rs index 593866c..21a11db 100644 --- a/src/term/mod.rs +++ b/src/term/mod.rs @@ -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; diff --git a/src/term/mouse.rs b/src/term/mouse.rs new file mode 100644 index 0000000..4e11c11 --- /dev/null +++ b/src/term/mouse.rs @@ -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