mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-22 00:02:23 +00:00
feat(clipboard): add copy-on-select (#40)
Selecting with the mouse — drag, double-click word, triple-click line, over terminal output or the prompt's command editor — copies the selection the moment the gesture ends, no Cmd+C needed. Opt-in via Settings -> Terminal -> Clipboard (config key copy_on_select), off by default so a stray selection never overwrites the clipboard. Closes #34 Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
This commit is contained in:
@@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- Copy on select: an opt-in Settings → Terminal → Clipboard toggle (config
|
||||
key `copy_on_select`) that copies a mouse selection — drag, double-click
|
||||
word, or triple-click line, over terminal output or the prompt's command
|
||||
editor — to the clipboard the moment the gesture ends, no ⌘C needed. Off
|
||||
by default so a stray selection never overwrites the clipboard. (#34)
|
||||
|
||||
### Fixed
|
||||
|
||||
- The held-⌘ tab-number badges no longer stick on after the window loses key
|
||||
|
||||
@@ -106,6 +106,11 @@ pub struct Config {
|
||||
pub mouse_scroll_multiplier: f32,
|
||||
/// Drop trailing whitespace from each copied line. Off by default.
|
||||
pub clipboard_trim_trailing_spaces: bool,
|
||||
/// Copy a mouse selection to the clipboard as soon as the gesture ends,
|
||||
/// without ⌘C (à la Ghostty/iTerm2's copy-on-select). Off by default —
|
||||
/// the clipboard is never overwritten by a stray selection unless opted
|
||||
/// into.
|
||||
pub copy_on_select: bool,
|
||||
/// Window state at launch: normal / maximized / fullscreen.
|
||||
#[serde(default, deserialize_with = "de_lenient")]
|
||||
pub startup_mode: StartupMode,
|
||||
@@ -271,6 +276,7 @@ impl Default for Config {
|
||||
focus_follows_mouse: false,
|
||||
mouse_scroll_multiplier: 1.0,
|
||||
clipboard_trim_trailing_spaces: false,
|
||||
copy_on_select: false,
|
||||
startup_mode: StartupMode::Normal,
|
||||
working_directory: WorkingDirectory::default(),
|
||||
env: HashMap::new(),
|
||||
|
||||
+133
-5
@@ -213,6 +213,12 @@ pub struct TerminalView {
|
||||
/// True while a left-drag that began on the command-editor line is in progress,
|
||||
/// so mouse-move extends the editor selection rather than the terminal's.
|
||||
editor_selecting: bool,
|
||||
/// True from a left press on the command-editor line until its release —
|
||||
/// unlike [`Self::editor_selecting`] it also covers the double/triple-click
|
||||
/// word/line selections, which don't arm drag-extend. Tells the mouse-up
|
||||
/// that the ended gesture selected in the editor, so copy-on-select copies
|
||||
/// the editor's selection rather than the terminal's.
|
||||
editor_select_gesture: bool,
|
||||
/// The URL currently under the mouse (an OSC 8 hyperlink or a bare URL found
|
||||
/// in the row text), if any. Drives the hover underline and the pointing-hand
|
||||
/// cursor that mark a link as clickable. Stored in scroll-stable grid
|
||||
@@ -659,6 +665,7 @@ impl TerminalView {
|
||||
completion: None,
|
||||
reverse_search: None,
|
||||
editor_selecting: false,
|
||||
editor_select_gesture: false,
|
||||
hovered_link: None,
|
||||
_focus_subs: focus_subs,
|
||||
}
|
||||
@@ -1869,6 +1876,7 @@ impl TerminalView {
|
||||
2 => self.cmd.select_word_at(idx),
|
||||
_ => self.cmd.select_all(),
|
||||
}
|
||||
self.editor_select_gesture = true;
|
||||
self.completion = None;
|
||||
self.cursor_visible = true;
|
||||
cx.notify();
|
||||
@@ -2533,10 +2541,30 @@ impl TerminalView {
|
||||
true
|
||||
}
|
||||
|
||||
pub fn on_select_end(&mut self, _cx: &mut Context<Self>) {
|
||||
/// Mouse-up: the selection gesture (if any) is over. With copy-on-select
|
||||
/// enabled, the selection the gesture drove goes straight to the clipboard
|
||||
/// — [`select_end_copy`] picks the buffer, and empty selections (a plain
|
||||
/// click repositioning the caret / collapsing the old selection) write
|
||||
/// nothing because both copy paths drop empty text.
|
||||
pub fn on_select_end(&mut self, cx: &mut Context<Self>) {
|
||||
let copy = select_end_copy(
|
||||
cx.global::<Config>().copy_on_select,
|
||||
self.selecting,
|
||||
self.editor_select_gesture,
|
||||
);
|
||||
self.selecting = false;
|
||||
self.editor_selecting = false;
|
||||
self.editor_select_gesture = false;
|
||||
self.drag_scroll = None;
|
||||
match copy {
|
||||
SelectEndCopy::None => {}
|
||||
SelectEndCopy::Grid => self.copy_selection(cx),
|
||||
SelectEndCopy::Editor => {
|
||||
if let Some(text) = self.cmd.selected_text() {
|
||||
cx.write_to_clipboard(ClipboardItem::new_string(text));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn on_scroll(&mut self, ev: &ScrollWheelEvent, _window: &mut Window, cx: &mut Context<Self>) {
|
||||
@@ -3330,6 +3358,30 @@ fn wheel_route(mode: TermMode, shift: bool, up: bool) -> WheelRoute {
|
||||
WheelRoute::Scrollback
|
||||
}
|
||||
|
||||
/// What a finished mouse-selection gesture should auto-copy when
|
||||
/// copy-on-select is enabled (see `Config::copy_on_select`).
|
||||
#[derive(Debug, PartialEq)]
|
||||
enum SelectEndCopy {
|
||||
/// Feature off, or the mouse-up ended no selection gesture (a plain
|
||||
/// click, a right/middle release): leave the clipboard alone.
|
||||
None,
|
||||
/// The gesture drove the terminal grid selection (drag / double / triple
|
||||
/// click over output): copy `term.selection`.
|
||||
Grid,
|
||||
/// The gesture landed on the command editor's line: copy the editor's
|
||||
/// own selection.
|
||||
Editor,
|
||||
}
|
||||
|
||||
fn select_end_copy(enabled: bool, grid: bool, editor: bool) -> SelectEndCopy {
|
||||
match (enabled, grid, editor) {
|
||||
(false, ..) => SelectEndCopy::None,
|
||||
(true, true, _) => SelectEndCopy::Grid,
|
||||
(true, false, true) => SelectEndCopy::Editor,
|
||||
(true, false, false) => SelectEndCopy::None,
|
||||
}
|
||||
}
|
||||
|
||||
/// One mouse report, encoded for the protocol the app negotiated. SGR (1006)
|
||||
/// prints decimal 1-based coordinates and keeps the button in the final
|
||||
/// letter (`M` press / `m` release); X10 packs everything into three bytes,
|
||||
@@ -3612,10 +3664,10 @@ fn drag_scroll_step(overshoot: f32) -> i32 {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
WheelRoute, clipboard_paste_text, display_width, drag_scroll_step, encode_mouse,
|
||||
fallback_chain, fig_icon_emoji, fig_icon_glyph, focus_report_bytes, menu_layout,
|
||||
paste_bytes, shell_escape_path, smooth_scroll_step, trim_trailing_spaces, wheel_route,
|
||||
wrapped_click_index,
|
||||
SelectEndCopy, WheelRoute, clipboard_paste_text, display_width, drag_scroll_step,
|
||||
encode_mouse, fallback_chain, fig_icon_emoji, fig_icon_glyph, focus_report_bytes,
|
||||
menu_layout, paste_bytes, select_end_copy, shell_escape_path, smooth_scroll_step,
|
||||
trim_trailing_spaces, wheel_route, wrapped_click_index,
|
||||
};
|
||||
use alacritty_terminal::term::TermMode;
|
||||
use gpui::{ClipboardEntry, ClipboardItem, ExternalPaths, Modifiers};
|
||||
@@ -3724,6 +3776,29 @@ mod tests {
|
||||
assert_eq!(wheel_route(everything, true, false), WheelRoute::Scrollback);
|
||||
}
|
||||
|
||||
/// Copy-on-select fires only when the released gesture actually drove a
|
||||
/// selection, and copies the buffer that gesture touched — the terminal
|
||||
/// grid or the command editor's line. Off, or a mouse-up that ended no
|
||||
/// gesture (a plain click, a right-click), must leave the clipboard alone.
|
||||
#[test]
|
||||
fn copy_on_select_copies_the_buffer_the_gesture_touched() {
|
||||
// Disabled: never copy, whatever kind of gesture just ended.
|
||||
assert_eq!(select_end_copy(false, true, false), SelectEndCopy::None);
|
||||
assert_eq!(select_end_copy(false, false, true), SelectEndCopy::None);
|
||||
|
||||
// A grid gesture (drag / double / triple click over output) copies
|
||||
// the terminal selection; one on the editor line copies the editor's.
|
||||
assert_eq!(select_end_copy(true, true, false), SelectEndCopy::Grid);
|
||||
assert_eq!(select_end_copy(true, false, true), SelectEndCopy::Editor);
|
||||
|
||||
// No gesture ended → nothing to copy.
|
||||
assert_eq!(select_end_copy(true, false, false), SelectEndCopy::None);
|
||||
|
||||
// The press routes to exactly one buffer, but if both flags ever
|
||||
// read set, the grid selection (the visible one) wins.
|
||||
assert_eq!(select_end_copy(true, true, true), SelectEndCopy::Grid);
|
||||
}
|
||||
|
||||
/// SGR (1006) reports print 1-based decimal coordinates, stack the
|
||||
/// modifier bits onto the button code, and carry press/release in the
|
||||
/// final letter. A drift in any of these lands clicks one cell off in
|
||||
@@ -4381,6 +4456,59 @@ mod gpui_tests {
|
||||
assert_eq!(wait_for(cx, "hello again"), "hello again");
|
||||
}
|
||||
|
||||
/// Copy-on-select, end to end: real output through the pump, the same
|
||||
/// start/update/end calls the mouse handlers make, then the clipboard.
|
||||
/// Off (the default) the release must leave the clipboard alone; on, the
|
||||
/// selected text lands at mouse-up with no ⌘C.
|
||||
#[gpui::test]
|
||||
fn copy_on_select_writes_the_clipboard_at_mouse_up(cx: &mut TestAppContext) {
|
||||
let (window, mut daemon) = harness(cx);
|
||||
|
||||
DaemonMsg::Output(b"hello world".to_vec())
|
||||
.encode(&mut daemon)
|
||||
.unwrap();
|
||||
// Bounded poll for the reader thread, as in the pump test above.
|
||||
for _ in 0..400 {
|
||||
cx.run_until_parked();
|
||||
let row: String = window
|
||||
.update(cx, |view, _, _| {
|
||||
let term = view.terminal.term.clone();
|
||||
let term = term.lock();
|
||||
(0..11)
|
||||
.map(|c| term.grid()[alacritty_terminal::index::Line(0)][Column(c)].c)
|
||||
.collect()
|
||||
})
|
||||
.unwrap();
|
||||
if row == "hello world" {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
|
||||
// Drag across "hello" and release with the feature off: no copy.
|
||||
let drag_hello = |cx: &mut TestAppContext| {
|
||||
window
|
||||
.update(cx, |view, _, cx| {
|
||||
view.on_select_start(0, 0, true, 1, cx);
|
||||
view.on_select_update(4, 0, false, cx);
|
||||
view.on_select_end(cx);
|
||||
})
|
||||
.unwrap();
|
||||
};
|
||||
drag_hello(cx);
|
||||
assert_eq!(
|
||||
cx.update(|cx| cx.read_from_clipboard()),
|
||||
None,
|
||||
"default-off must never write the clipboard"
|
||||
);
|
||||
|
||||
// Same gesture with the feature on: "hello" is on the clipboard.
|
||||
cx.update(|cx| cx.update_global::<Config, _>(|cfg, _| cfg.copy_on_select = true));
|
||||
drag_hello(cx);
|
||||
let text = cx.update(|cx| cx.read_from_clipboard().and_then(|item| item.text()));
|
||||
assert_eq!(text.as_deref(), Some("hello"));
|
||||
}
|
||||
|
||||
/// Reproduces the "orange caret jumps to the top-left corner after Claude
|
||||
/// Code exits" bug at the state level, driving the two conditions that must
|
||||
/// co-occur to trigger it:
|
||||
|
||||
@@ -766,6 +766,10 @@ impl Tty7App {
|
||||
self.update_config(cx, |cfg| cfg.clipboard_trim_trailing_spaces = on);
|
||||
}
|
||||
|
||||
pub(crate) fn set_copy_on_select(&mut self, on: bool, cx: &mut Context<Self>) {
|
||||
self.update_config(cx, |cfg| cfg.copy_on_select = on);
|
||||
}
|
||||
|
||||
pub(crate) fn set_startup_mode(
|
||||
&mut self,
|
||||
mode: crate::core::config::StartupMode,
|
||||
|
||||
@@ -1014,6 +1014,7 @@ impl Tty7App {
|
||||
let option_as_alt = cfg.macos_option_as_alt;
|
||||
let scroll_mult = cfg.mouse_scroll_multiplier;
|
||||
let clip_trim = cfg.clipboard_trim_trailing_spaces;
|
||||
let copy_on_select = cfg.copy_on_select;
|
||||
// Map the persisted scrollback depth onto its preset radio index (default
|
||||
// to 10k's slot for any off-preset value a hand-edit might leave).
|
||||
let scrollback_idx = match cfg.scrollback_limit {
|
||||
@@ -1078,6 +1079,10 @@ impl Tty7App {
|
||||
.checked(clip_trim)
|
||||
.on_click(cx.listener(|this, on: &bool, _w, cx| this.set_clipboard_trim(*on, cx)))
|
||||
.into_any_element();
|
||||
let copy_on_select_switch = Switch::new("term-copy-on-select")
|
||||
.checked(copy_on_select)
|
||||
.on_click(cx.listener(|this, on: &bool, _w, cx| this.set_copy_on_select(*on, cx)))
|
||||
.into_any_element();
|
||||
// macOS only: the Option/special-character split this toggle resolves
|
||||
// doesn't exist on other platforms, where Alt always carries Meta.
|
||||
let option_alt_row = cfg!(target_os = "macos").then(|| {
|
||||
@@ -1153,6 +1158,12 @@ impl Tty7App {
|
||||
))
|
||||
.child(self.section_rule(cx))
|
||||
.child(self.section_header("Clipboard", cx))
|
||||
.child(self.settings_row(
|
||||
"Copy on select",
|
||||
"Selecting text with the mouse copies it to the clipboard right away, no ⌘C needed.",
|
||||
copy_on_select_switch,
|
||||
cx,
|
||||
))
|
||||
.child(self.settings_row(
|
||||
"Trim trailing spaces on copy",
|
||||
"Strip trailing whitespace from each copied line.",
|
||||
|
||||
Reference in New Issue
Block a user