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
This commit is contained in:
19 files changed
+971
-27
No files matched your search
+41
-1
@@ -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());
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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~"
|
||||
);
|
||||
}
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 = (
|
||||
|
||||
Reference in new issue
Block a user