mirror of
https://github.com/openai/codex.git
synced 2026-10-19 17:28:46 +00:00
Add X11 primary selection and middle-click paste support (#49112)
## What changed - Publish released mouse selections from both transcript views to X11 `PRIMARY` as plain text, even when copy-on-select is disabled. Preserve configured `CLIPBOARD` copying when it is enabled. - Paste `PRIMARY` into the editable fullscreen composer on middle-click, including while a transcript selection remains active. - Limit support to local Linux X11 sessions, excluding Wayland, SSH, tmux, WSL, and VS Code terminals. - Retain the latest deferred selection publication and order it before a pending `PRIMARY` read, while giving explicit copies priority. Cancel stale deferred work when its selection or paste target is invalidated. - Clarify that scrollback mode uses native terminal copy, paste, and scrollback. ## Testing Add regression tests for selection publication with copy-on-select enabled and disabled, publication-before-read ordering, explicit-copy priority, cancellation of deferred work, and middle-click paste invalidation. GitOrigin-RevId: 6ba7bddffda60183b7f9ad5cd69a97da8a4a8dcd
This commit is contained in:
@@ -839,8 +839,9 @@ impl App {
|
||||
tui.link_hover.observe(&event);
|
||||
self.refresh_link_hover(tui)?;
|
||||
self.invalidate_right_click_paste(&event);
|
||||
self.finish_clipboard(tui);
|
||||
self.finish_clipboard(tui, &event);
|
||||
let event = self.finish_right_click_paste(tui, event);
|
||||
let idle_draw = matches!(event, TuiEvent::Draw);
|
||||
if matches!(&event, TuiEvent::Key(_))
|
||||
&& self.handle_composer_copy_event(tui, &event, |tui, text| {
|
||||
tui.copy_transcript_selection(text, crate::clipboard_copy::CopyFormat::PlainText)
|
||||
@@ -1137,6 +1138,10 @@ impl App {
|
||||
TuiEvent::FocusLost => {}
|
||||
}
|
||||
}
|
||||
// Both transcript owners must consume completions before automatic work advances.
|
||||
if idle_draw {
|
||||
tui.clipboard.advance(tui.frame_requester());
|
||||
}
|
||||
Ok(AppRunControl::Continue)
|
||||
}
|
||||
|
||||
|
||||
@@ -32,23 +32,25 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn finish_clipboard(&mut self, tui: &mut tui::Tui) {
|
||||
pub(super) fn finish_clipboard(&mut self, tui: &mut tui::Tui, event: &TuiEvent) {
|
||||
let current = tui.is_owned_screen() && self.overlay.is_none();
|
||||
let Some(completion) = tui.clipboard.poll() else {
|
||||
return;
|
||||
};
|
||||
if let Some(characters) = self.chat_widget.finish_clipboard(completion, current) {
|
||||
self.transcript_view
|
||||
.show_copy_feedback(&completion.1, characters);
|
||||
}
|
||||
let follow = self
|
||||
.transcript_view
|
||||
.finish_copy(&self.transcript_cells, completion, current);
|
||||
if follow == Some(true) {
|
||||
if self.backtrack.overlay_preview_active {
|
||||
self.close_transcript_overlay(tui);
|
||||
if let Some(completion) = tui.clipboard.poll() {
|
||||
if let Some(characters) = self.chat_widget.finish_clipboard(completion, current) {
|
||||
self.transcript_view
|
||||
.show_copy_feedback(&completion.1, characters);
|
||||
}
|
||||
self.transcript_view.jump_to_latest();
|
||||
let follow =
|
||||
self.transcript_view
|
||||
.finish_copy(&self.transcript_cells, completion, current);
|
||||
if follow == Some(true) {
|
||||
if self.backtrack.overlay_preview_active {
|
||||
self.close_transcript_overlay(tui);
|
||||
}
|
||||
self.transcript_view.jump_to_latest();
|
||||
}
|
||||
}
|
||||
if !current || matches!(event, TuiEvent::FocusLost | TuiEvent::Resume) {
|
||||
self.transcript_view.cancel_primary();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@ impl App {
|
||||
let chat_widget = &self.chat_widget;
|
||||
let transcript_width = chat_widget.history_wrap_width(width);
|
||||
let view = &mut self.transcript_view;
|
||||
view.primary_selection = self.right_click_paste_environment.primary;
|
||||
view.copy_on_select = self
|
||||
.local_settings
|
||||
.copy_on_select(&codex_terminal_detection::terminal_info());
|
||||
@@ -529,14 +530,15 @@ impl App {
|
||||
let copy_on_select = matches!(action, ViewAction::CopyOnSelect(_));
|
||||
match action {
|
||||
ViewAction::Changed => {}
|
||||
ViewAction::PrimarySelection(text) => self.transcript_view.publish_primary(tui, &text),
|
||||
ViewAction::Copy(text)
|
||||
| ViewAction::CopyOnSelect(text)
|
||||
| ViewAction::CopyAndFollow(text) => {
|
||||
let result = self.transcript_view.copy_selected_text_with(
|
||||
let result = self.transcript_view.copy_selected_text(
|
||||
tui,
|
||||
&self.transcript_cells,
|
||||
&text,
|
||||
!copy_on_select,
|
||||
|text, format| tui.copy_transcript_selection(text, format),
|
||||
);
|
||||
self.transcript_view
|
||||
.show_copy_feedback(&result, text.chars().count());
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
//! Right-click fallback for an unchanged, editable fullscreen composer.
|
||||
//! Right-click CLIPBOARD and local X11 middle-click PRIMARY fallback for an editable composer.
|
||||
//! Existing selection handlers run first. Only a draw may deliver a read, so real input
|
||||
//! invalidates pending paste before completion and is never replaced by clipboard text.
|
||||
|
||||
use super::*;
|
||||
use crate::clipboard_copy::worker::PasteSource;
|
||||
use crate::tui::VscodeDetection;
|
||||
use codex_config::types::RightClickPaste;
|
||||
use crossterm::event::MouseButton;
|
||||
@@ -12,9 +13,12 @@ use crossterm::event::MouseEventKind;
|
||||
pub(super) struct PendingPaste {
|
||||
thread: Option<ThreadId>,
|
||||
draft: (String, usize),
|
||||
source: PasteSource,
|
||||
_request: Arc<()>,
|
||||
}
|
||||
|
||||
pub(super) struct PasteEnvironment {
|
||||
pub(super) primary: bool,
|
||||
pub(super) platform_default: bool,
|
||||
pub(super) ssh: bool,
|
||||
pub(super) wsl: bool,
|
||||
@@ -24,6 +28,7 @@ pub(super) struct PasteEnvironment {
|
||||
impl PasteEnvironment {
|
||||
pub(super) fn detect() -> Self {
|
||||
Self {
|
||||
primary: crate::clipboard_copy::primary::available(),
|
||||
platform_default: cfg!(any(target_os = "windows", target_os = "linux")),
|
||||
ssh: crate::clipboard_copy::is_ssh_session(),
|
||||
wsl: crate::clipboard_copy::is_wsl_session(),
|
||||
@@ -46,51 +51,75 @@ impl PasteEnvironment {
|
||||
}
|
||||
|
||||
impl App {
|
||||
fn right_click_paste_target(&self, tui: &tui::Tui) -> Option<PendingPaste> {
|
||||
fn right_click_paste_target(
|
||||
&self,
|
||||
tui: &tui::Tui,
|
||||
source: PasteSource,
|
||||
) -> Option<(Option<ThreadId>, (String, usize))> {
|
||||
let allowed = match source {
|
||||
PasteSource::Clipboard => {
|
||||
!self.transcript_view.has_selection_range()
|
||||
&& self
|
||||
.right_click_paste_environment
|
||||
.allows(self.local_settings.tui.right_click_paste)
|
||||
}
|
||||
PasteSource::Primary => self.right_click_paste_environment.primary,
|
||||
};
|
||||
if !tui.is_owned_screen()
|
||||
|| self.overlay.is_some()
|
||||
|| self.transcript_view.has_selection_range()
|
||||
|| self.transcript_view.is_search_active()
|
||||
|| !self
|
||||
.right_click_paste_environment
|
||||
.allows(self.local_settings.tui.right_click_paste)
|
||||
|| !allowed
|
||||
{
|
||||
return None;
|
||||
}
|
||||
Some(PendingPaste {
|
||||
thread: self.current_displayed_thread_id(),
|
||||
draft: self.chat_widget.right_click_paste_target()?,
|
||||
})
|
||||
Some((
|
||||
self.current_displayed_thread_id(),
|
||||
self.chat_widget.right_click_paste_target()?,
|
||||
))
|
||||
}
|
||||
|
||||
pub(super) fn start_right_click_paste(&mut self, tui: &mut tui::Tui, mouse: MouseEvent) {
|
||||
if mouse.kind != MouseEventKind::Down(MouseButton::Right)
|
||||
|| !mouse.modifiers.is_empty()
|
||||
|| self.pending_right_click_paste.is_some()
|
||||
|| tui.clipboard.is_busy()
|
||||
{
|
||||
let source = match mouse.kind {
|
||||
MouseEventKind::Down(MouseButton::Right) => PasteSource::Clipboard,
|
||||
MouseEventKind::Down(MouseButton::Middle) => PasteSource::Primary,
|
||||
_ => return,
|
||||
};
|
||||
if !mouse.modifiers.is_empty() || self.pending_right_click_paste.is_some() {
|
||||
return;
|
||||
}
|
||||
let Some(target) = self.right_click_paste_target(tui) else {
|
||||
let Some((thread, draft)) = self.right_click_paste_target(tui, source) else {
|
||||
return;
|
||||
};
|
||||
match tui.clipboard.read_text(tui.frame_requester()) {
|
||||
Ok(true) => self.pending_right_click_paste = Some(target),
|
||||
Ok(false) => {}
|
||||
match tui.clipboard.read_text(source, tui.frame_requester()) {
|
||||
Ok(Some(request)) => {
|
||||
self.pending_right_click_paste = Some(PendingPaste {
|
||||
thread,
|
||||
draft,
|
||||
source,
|
||||
_request: request,
|
||||
});
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(error) => self.chat_widget.add_error_message(error),
|
||||
}
|
||||
}
|
||||
|
||||
/// Invalidate before polling, even when the worker has already completed.
|
||||
pub(super) fn invalidate_right_click_paste(&mut self, event: &TuiEvent) {
|
||||
let Some(pending) = &self.pending_right_click_paste else {
|
||||
return;
|
||||
};
|
||||
let button = match pending.source {
|
||||
PasteSource::Clipboard => MouseButton::Right,
|
||||
PasteSource::Primary => MouseButton::Middle,
|
||||
};
|
||||
let keep = match event {
|
||||
TuiEvent::Draw | TuiEvent::Resize(_) | TuiEvent::FocusGained => true,
|
||||
TuiEvent::Mouse(mouse) => {
|
||||
matches!(
|
||||
mouse.kind,
|
||||
MouseEventKind::Moved | MouseEventKind::Up(MouseButton::Right)
|
||||
) || (mouse.kind == MouseEventKind::Down(MouseButton::Right)
|
||||
&& mouse.modifiers.is_empty())
|
||||
mouse.kind == MouseEventKind::Moved
|
||||
|| mouse.kind == MouseEventKind::Up(button)
|
||||
|| (mouse.kind == MouseEventKind::Down(button) && mouse.modifiers.is_empty())
|
||||
}
|
||||
TuiEvent::Key(_) | TuiEvent::Paste(_) | TuiEvent::FocusLost | TuiEvent::Resume => false,
|
||||
};
|
||||
@@ -107,18 +136,22 @@ impl App {
|
||||
if !matches!(event, TuiEvent::Draw) {
|
||||
return event;
|
||||
}
|
||||
let Some(pending) = self.pending_right_click_paste.as_ref() else {
|
||||
tui.clipboard.take_text_result();
|
||||
return event;
|
||||
};
|
||||
let Some((thread, draft)) = self.right_click_paste_target(tui, pending.source) else {
|
||||
self.pending_right_click_paste = None;
|
||||
return event;
|
||||
};
|
||||
if pending.thread != thread || pending.draft != draft {
|
||||
self.pending_right_click_paste = None;
|
||||
return event;
|
||||
}
|
||||
let Some(result) = tui.clipboard.take_text_result() else {
|
||||
return event;
|
||||
};
|
||||
let Some(pending) = self.pending_right_click_paste.take() else {
|
||||
return event;
|
||||
};
|
||||
let Some(current) = self.right_click_paste_target(tui) else {
|
||||
return event;
|
||||
};
|
||||
if pending.thread != current.thread || pending.draft != current.draft {
|
||||
return event;
|
||||
}
|
||||
self.pending_right_click_paste = None;
|
||||
match result {
|
||||
Ok(text) if !text.is_empty() => {
|
||||
tui.frame_requester().schedule_frame();
|
||||
|
||||
@@ -15,6 +15,7 @@ fn policy_preserves_explicit_modes_and_terminal_ownership() {
|
||||
(true, false, true, Other, [false, true, true]),
|
||||
] {
|
||||
let env = PasteEnvironment {
|
||||
primary: false,
|
||||
platform_default,
|
||||
ssh,
|
||||
wsl,
|
||||
@@ -31,3 +32,30 @@ fn policy_preserves_explicit_modes_and_terminal_ownership() {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pending_primary_paste_survives_release_but_not_other_input() {
|
||||
let mut app = crate::app::test_support::make_test_app().await;
|
||||
for (kind, keep) in [
|
||||
(MouseEventKind::Up(MouseButton::Middle), true),
|
||||
(MouseEventKind::Moved, true),
|
||||
(MouseEventKind::Down(MouseButton::Middle), true),
|
||||
(MouseEventKind::Down(MouseButton::Left), false),
|
||||
(MouseEventKind::Up(MouseButton::Right), false),
|
||||
(MouseEventKind::ScrollDown, false),
|
||||
] {
|
||||
app.pending_right_click_paste = Some(PendingPaste {
|
||||
thread: None,
|
||||
draft: (String::new(), 0),
|
||||
source: PasteSource::Primary,
|
||||
_request: Arc::new(()),
|
||||
});
|
||||
app.invalidate_right_click_paste(&TuiEvent::Mouse(MouseEvent {
|
||||
kind,
|
||||
column: 0,
|
||||
row: 0,
|
||||
modifiers: crossterm::event::KeyModifiers::NONE,
|
||||
}));
|
||||
assert_eq!(app.pending_right_click_paste.is_some(), keep);
|
||||
}
|
||||
}
|
||||
|
||||
+10
-6
@@ -6,7 +6,7 @@ fullscreen=false, width=80
|
||||
TUI mode for next launch
|
||||
|
||||
|
||||
› 1. Scrollback (current) Use your terminal's scrollback
|
||||
› 1. Scrollback (current) Native terminal copy, paste and scrollback
|
||||
2. Fullscreen Scroll within Codex's fullscreen view
|
||||
|
||||
Restart to apply. Launch overrides still apply.
|
||||
@@ -16,8 +16,10 @@ fullscreen=false, width=40
|
||||
TUI mode for next launch
|
||||
|
||||
|
||||
› 1. Scrollback (current) Use your
|
||||
terminal's
|
||||
› 1. Scrollback (current) Native
|
||||
terminal
|
||||
copy, paste
|
||||
and
|
||||
scrollback
|
||||
2. Fullscreen Scroll within
|
||||
Codex's
|
||||
@@ -32,7 +34,7 @@ fullscreen=true, width=80
|
||||
TUI mode for next launch
|
||||
|
||||
|
||||
1. Scrollback Use your terminal's scrollback
|
||||
1. Scrollback Native terminal copy, paste and scrollback
|
||||
› 2. Fullscreen (current) Scroll within Codex's fullscreen view
|
||||
|
||||
Restart to apply. Launch overrides still apply.
|
||||
@@ -42,8 +44,10 @@ fullscreen=true, width=40
|
||||
TUI mode for next launch
|
||||
|
||||
|
||||
1. Scrollback Use your
|
||||
terminal's
|
||||
1. Scrollback Native
|
||||
terminal
|
||||
copy, paste
|
||||
and
|
||||
scrollback
|
||||
› 2. Fullscreen (current) Scroll within
|
||||
Codex's
|
||||
|
||||
@@ -67,6 +67,7 @@ pub(crate) async fn make_test_app() -> App {
|
||||
app_server_target: crate::AppServerTarget::Embedded,
|
||||
pending_right_click_paste: None,
|
||||
right_click_paste_environment: super::right_click_paste::PasteEnvironment {
|
||||
primary: false,
|
||||
platform_default: true,
|
||||
ssh: false,
|
||||
wsl: false,
|
||||
|
||||
@@ -6034,6 +6034,7 @@ async fn make_test_app() -> Box<App> {
|
||||
reconnect: Default::default(),
|
||||
pending_right_click_paste: None,
|
||||
right_click_paste_environment: super::right_click_paste::PasteEnvironment {
|
||||
primary: false,
|
||||
platform_default: true,
|
||||
ssh: false,
|
||||
wsl: false,
|
||||
@@ -6149,6 +6150,7 @@ pub(super) async fn make_test_app_with_channels() -> (
|
||||
reconnect: Default::default(),
|
||||
pending_right_click_paste: None,
|
||||
right_click_paste_environment: super::right_click_paste::PasteEnvironment {
|
||||
primary: false,
|
||||
platform_default: true,
|
||||
ssh: false,
|
||||
wsl: false,
|
||||
|
||||
@@ -10,7 +10,11 @@ use crate::bottom_pane::popup_consts::picker_hint_line_for_keymap;
|
||||
impl ChatWidget {
|
||||
pub(crate) fn show_tui_mode_picker(&mut self) {
|
||||
let items = [
|
||||
(false, "Scrollback", "Use your terminal's scrollback"),
|
||||
(
|
||||
false,
|
||||
"Scrollback",
|
||||
"Native terminal copy, paste and scrollback",
|
||||
),
|
||||
(true, "Fullscreen", "Scroll within Codex's fullscreen view"),
|
||||
]
|
||||
.into_iter()
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
//! so callers must distinguish them from confirmed native clipboard writes.
|
||||
//! Image paste lives in `clipboard_paste`.
|
||||
|
||||
pub(crate) mod primary;
|
||||
mod tmux;
|
||||
pub(crate) mod worker;
|
||||
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
//! Local X11 PRIMARY selection. Never forward PRIMARY over SSH or tmux, or
|
||||
//! accidentally target a Wayland compositor or WSL host clipboard.
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
use super::ClipboardLease;
|
||||
use super::CopyOutcome;
|
||||
use std::time::Instant;
|
||||
|
||||
pub(crate) fn available() -> bool {
|
||||
cfg!(target_os = "linux")
|
||||
&& std::env::var_os("DISPLAY").is_some()
|
||||
&& std::env::var_os("WAYLAND_DISPLAY").is_none()
|
||||
&& !super::is_ssh_session()
|
||||
&& !super::is_tmux_session()
|
||||
&& !super::is_wsl_session()
|
||||
&& crate::tui::detect_vscode_terminal() != crate::tui::VscodeDetection::VsCode
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub(super) fn copy(
|
||||
text: &str,
|
||||
begin_delivery: impl FnOnce() -> Result<(), String>,
|
||||
) -> Result<CopyOutcome, String> {
|
||||
use arboard::SetExtLinux;
|
||||
let mut clipboard = arboard::Clipboard::new().map_err(|err| err.to_string())?;
|
||||
begin_delivery()?;
|
||||
clipboard
|
||||
.set()
|
||||
.clipboard(arboard::LinuxClipboardKind::Primary)
|
||||
.text(text)
|
||||
.map_err(|err| format!("X11 primary selection: {err}"))?;
|
||||
Ok(CopyOutcome::Copied(Some(ClipboardLease::native_linux(
|
||||
clipboard,
|
||||
))))
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
pub(super) fn copy(
|
||||
_text: &str,
|
||||
_begin_delivery: impl FnOnce() -> Result<(), String>,
|
||||
) -> Result<CopyOutcome, String> {
|
||||
Err("X11 primary selection unavailable".into())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub(super) fn read(deadline: Instant) -> Result<String, String> {
|
||||
use arboard::GetExtLinux;
|
||||
crate::clipboard_paste::text::native_result(
|
||||
arboard::Clipboard::new().and_then(|mut clipboard| {
|
||||
clipboard
|
||||
.get()
|
||||
.clipboard(arboard::LinuxClipboardKind::Primary)
|
||||
.text()
|
||||
}),
|
||||
deadline,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
pub(super) fn read(_deadline: Instant) -> Result<String, String> {
|
||||
Err("X11 primary selection unavailable".into())
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
//! One session-lived worker owns blocking clipboard operations and native leases.
|
||||
//! Setup has a five-second budget, but abandoning it cannot interrupt an OS call.
|
||||
//! There is no backlog: the worker stays busy until that call returns. Once delivery
|
||||
//! starts it must finish. Text reads have the same budget for the whole operation; late
|
||||
//! results are discarded. Terminal escape sequences are emitted by the UI.
|
||||
//! There is no general backlog: the worker stays busy until that call returns. Only the
|
||||
//! latest selection publication and one PRIMARY read can wait, with publication ordered
|
||||
//! first. Their UI owners cancel by dropping their scopes. Once delivery starts it must
|
||||
//! finish. Text reads have the same budget for the whole operation; late results are
|
||||
//! discarded. Terminal escape sequences are emitted by the UI.
|
||||
|
||||
use super::ClipboardLease;
|
||||
use super::CopyFormat;
|
||||
@@ -11,6 +13,7 @@ use crate::tui::FrameRequester;
|
||||
use std::cell::Cell;
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex;
|
||||
use std::sync::Weak;
|
||||
use std::sync::mpsc;
|
||||
use std::time::Duration;
|
||||
use std::time::Instant;
|
||||
@@ -37,6 +40,7 @@ impl Setup {
|
||||
struct CopySetup {
|
||||
phase: Mutex<Setup>,
|
||||
frames: FrameRequester,
|
||||
owner: Option<Weak<()>>,
|
||||
}
|
||||
|
||||
impl CopySetup {
|
||||
@@ -48,6 +52,14 @@ impl CopySetup {
|
||||
if setup.timed_out(Instant::now()) {
|
||||
return Err(SETUP_TIMEOUT_MESSAGE.into());
|
||||
}
|
||||
if matches!(*setup, Setup::Pending(_))
|
||||
&& self
|
||||
.owner
|
||||
.as_ref()
|
||||
.is_some_and(|owner| owner.strong_count() == 0)
|
||||
{
|
||||
return Err("selection ended before clipboard delivery".into());
|
||||
}
|
||||
*setup = Setup::Delivering;
|
||||
Ok(())
|
||||
}
|
||||
@@ -69,13 +81,29 @@ impl CopySetup {
|
||||
|
||||
pub(crate) type CopyResult = Result<CopyStatus, String>;
|
||||
|
||||
/// A mouse selection may also retain the configured automatic clipboard copy.
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub(crate) enum CopyDestination {
|
||||
Clipboard,
|
||||
Primary,
|
||||
ClipboardAndPrimary(Arc<str>),
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum PasteSource {
|
||||
Clipboard,
|
||||
Primary,
|
||||
}
|
||||
|
||||
enum Request {
|
||||
Copy {
|
||||
text: Arc<str>,
|
||||
format: CopyFormat,
|
||||
destination: CopyDestination,
|
||||
setup: Arc<CopySetup>,
|
||||
},
|
||||
Read {
|
||||
source: PasteSource,
|
||||
deadline: Instant,
|
||||
response: mpsc::Sender<Result<String, String>>,
|
||||
},
|
||||
@@ -84,7 +112,9 @@ enum Request {
|
||||
struct PendingRead {
|
||||
frames: FrameRequester,
|
||||
deadline: Instant,
|
||||
response: mpsc::Receiver<Result<String, String>>,
|
||||
source: PasteSource,
|
||||
owner: Weak<()>,
|
||||
response: Option<mpsc::Receiver<Result<String, String>>>,
|
||||
expired: bool,
|
||||
}
|
||||
|
||||
@@ -100,13 +130,18 @@ pub(crate) struct ClipboardWorker {
|
||||
pending: Option<(u64, Arc<CopySetup>)>,
|
||||
next_id: u64,
|
||||
completed: Option<(u64, CopyResult)>,
|
||||
primary: Option<(Arc<str>, Weak<()>)>,
|
||||
pending_read: Option<PendingRead>,
|
||||
read_result: Option<(Instant, Result<String, String>)>,
|
||||
read_result: Option<(Instant, Weak<()>, Result<String, String>)>,
|
||||
}
|
||||
|
||||
impl ClipboardWorker {
|
||||
pub(crate) fn is_busy(&self) -> bool {
|
||||
self.pending.is_some() || self.pending_read.is_some()
|
||||
self.pending.is_some()
|
||||
|| self
|
||||
.pending_read
|
||||
.as_ref()
|
||||
.is_some_and(|read| read.response.is_some())
|
||||
}
|
||||
|
||||
pub(crate) fn copy(
|
||||
@@ -114,6 +149,46 @@ impl ClipboardWorker {
|
||||
text: Arc<str>,
|
||||
format: CopyFormat,
|
||||
frames: FrameRequester,
|
||||
) -> CopyResult {
|
||||
self.copy_to(
|
||||
text,
|
||||
format,
|
||||
CopyDestination::Clipboard,
|
||||
frames,
|
||||
/*owner*/ None,
|
||||
)
|
||||
}
|
||||
|
||||
/// On a busy automatic copy, retain only PRIMARY, never defer a CLIPBOARD copy.
|
||||
pub(crate) fn select(
|
||||
&mut self,
|
||||
text: Arc<str>,
|
||||
format: CopyFormat,
|
||||
destination: CopyDestination,
|
||||
frames: FrameRequester,
|
||||
) -> (CopyResult, Arc<()>) {
|
||||
let owner = Arc::new(());
|
||||
let primary = match &destination {
|
||||
CopyDestination::Clipboard => None,
|
||||
CopyDestination::Primary => Some(Arc::clone(&text)),
|
||||
CopyDestination::ClipboardAndPrimary(plain_text) => Some(Arc::clone(plain_text)),
|
||||
};
|
||||
self.primary = None;
|
||||
let scope = primary.as_ref().map(|_| Arc::downgrade(&owner));
|
||||
let result = self.copy_to(text, format, destination, frames, scope);
|
||||
if result == Ok(CopyStatus::Busy) {
|
||||
self.primary = primary.map(|text| (text, Arc::downgrade(&owner)));
|
||||
}
|
||||
(result, owner)
|
||||
}
|
||||
|
||||
fn copy_to(
|
||||
&mut self,
|
||||
text: Arc<str>,
|
||||
format: CopyFormat,
|
||||
destination: CopyDestination,
|
||||
frames: FrameRequester,
|
||||
owner: Option<Weak<()>>,
|
||||
) -> CopyResult {
|
||||
if self.is_busy() {
|
||||
return Ok(CopyStatus::Busy);
|
||||
@@ -127,6 +202,7 @@ impl ClipboardWorker {
|
||||
let setup = Arc::new(CopySetup {
|
||||
phase: Mutex::new(Setup::Pending(Instant::now() + SETUP_TIMEOUT)),
|
||||
frames: frames.clone(),
|
||||
owner,
|
||||
});
|
||||
self.requests
|
||||
.as_ref()
|
||||
@@ -134,6 +210,7 @@ impl ClipboardWorker {
|
||||
.send(Request::Copy {
|
||||
text,
|
||||
format,
|
||||
destination,
|
||||
setup: Arc::clone(&setup),
|
||||
})
|
||||
.map_err(|_| "clipboard worker stopped".to_string())?;
|
||||
@@ -169,32 +246,58 @@ impl ClipboardWorker {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Start one text read. A timed-out native call keeps the shared worker occupied.
|
||||
pub(crate) fn read_text(&mut self, frames: FrameRequester) -> Result<bool, String> {
|
||||
if self.is_busy() {
|
||||
return Ok(false);
|
||||
/// Accept one text read. PRIMARY may wait behind its publication; CLIPBOARD never queues.
|
||||
/// The caller must retain the scope until it consumes or cancels the read.
|
||||
pub(crate) fn read_text(
|
||||
&mut self,
|
||||
source: PasteSource,
|
||||
frames: FrameRequester,
|
||||
) -> Result<Option<Arc<()>>, String> {
|
||||
if self.pending_read.is_some() || (source == PasteSource::Clipboard && self.is_busy()) {
|
||||
return Ok(None);
|
||||
}
|
||||
self.ensure_started(frames.clone())?;
|
||||
let deadline = Instant::now() + SETUP_TIMEOUT;
|
||||
let owner = Arc::new(());
|
||||
let wait = self.is_busy() || (source == PasteSource::Primary && self.primary.is_some());
|
||||
self.pending_read = Some(PendingRead {
|
||||
frames: frames.clone(),
|
||||
deadline,
|
||||
source,
|
||||
owner: Arc::downgrade(&owner),
|
||||
response: None,
|
||||
expired: false,
|
||||
});
|
||||
self.read_result = None;
|
||||
if !wait {
|
||||
self.start_read()?;
|
||||
}
|
||||
frames.schedule_frame_in(SETUP_TIMEOUT);
|
||||
Ok(Some(owner))
|
||||
}
|
||||
|
||||
fn start_read(&mut self) -> Result<(), String> {
|
||||
let read = self
|
||||
.pending_read
|
||||
.as_mut()
|
||||
.ok_or("no clipboard read pending")?;
|
||||
let (response, receiver) = mpsc::channel();
|
||||
self.requests
|
||||
.as_ref()
|
||||
.ok_or("clipboard worker stopped")?
|
||||
.send(Request::Read { deadline, response })
|
||||
.send(Request::Read {
|
||||
source: read.source,
|
||||
deadline: read.deadline,
|
||||
response,
|
||||
})
|
||||
.map_err(|_| "clipboard worker stopped".to_string())?;
|
||||
self.pending_read = Some(PendingRead {
|
||||
frames: frames.clone(),
|
||||
deadline,
|
||||
response: receiver,
|
||||
expired: false,
|
||||
});
|
||||
self.read_result = None;
|
||||
frames.schedule_frame_in(SETUP_TIMEOUT);
|
||||
Ok(true)
|
||||
read.response = Some(receiver);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn take_text_result(&mut self) -> Option<Result<String, String>> {
|
||||
self.read_result.take().map(|(deadline, result)| {
|
||||
let (deadline, owner, result) = self.read_result.take()?;
|
||||
(owner.strong_count() > 0).then(|| {
|
||||
if Instant::now() >= deadline {
|
||||
Err("clipboard read timed out".into())
|
||||
} else {
|
||||
@@ -203,6 +306,40 @@ impl ClipboardWorker {
|
||||
})
|
||||
}
|
||||
|
||||
/// Run deferred work only at an idle draw boundary, after the UI validates its owners.
|
||||
/// Polling a completion before a key must never preempt that key with an automatic copy.
|
||||
pub(crate) fn advance(&mut self, frames: FrameRequester) {
|
||||
if self.is_busy() {
|
||||
return;
|
||||
}
|
||||
// Target validation may have dropped the scope after this draw's poll.
|
||||
if self
|
||||
.pending_read
|
||||
.as_ref()
|
||||
.is_some_and(|read| read.owner.strong_count() == 0)
|
||||
{
|
||||
self.pending_read = None;
|
||||
}
|
||||
if let Some((text, owner)) = self.primary.take()
|
||||
&& owner.strong_count() > 0
|
||||
{
|
||||
let _ = self.copy_to(
|
||||
text,
|
||||
CopyFormat::PlainText,
|
||||
CopyDestination::Primary,
|
||||
frames,
|
||||
Some(owner),
|
||||
);
|
||||
}
|
||||
if !self.is_busy()
|
||||
&& self.pending_read.is_some()
|
||||
&& let Err(error) = self.start_read()
|
||||
&& let Some(read) = self.pending_read.take()
|
||||
{
|
||||
self.read_result = Some((read.deadline, read.owner, Err(error)));
|
||||
}
|
||||
}
|
||||
|
||||
fn start(
|
||||
&mut self,
|
||||
frames: FrameRequester,
|
||||
@@ -222,15 +359,45 @@ impl ClipboardWorker {
|
||||
.name("clipboard-copy".into())
|
||||
.spawn(move || {
|
||||
let mut lease: Option<ClipboardLease> = None;
|
||||
// An ordinary copy must not relinquish PRIMARY, or vice versa.
|
||||
let mut primary_lease: Option<ClipboardLease> = None;
|
||||
while let Ok(request) = incoming.recv() {
|
||||
let (outcome, terminal_text) = match request {
|
||||
Request::Copy {
|
||||
text,
|
||||
format,
|
||||
destination,
|
||||
setup,
|
||||
} => copy(&text, format, &setup),
|
||||
Request::Read { deadline, response } => {
|
||||
let _ = response.send(read(deadline));
|
||||
} => {
|
||||
let plain_text = match &destination {
|
||||
CopyDestination::Clipboard => None,
|
||||
CopyDestination::Primary => Some(&text),
|
||||
CopyDestination::ClipboardAndPrimary(plain_text) => {
|
||||
Some(plain_text)
|
||||
}
|
||||
};
|
||||
let primary = plain_text.map(|text| {
|
||||
super::primary::copy(text, || setup.begin_delivery())
|
||||
.map(|outcome| outcome.store(&mut primary_lease))
|
||||
});
|
||||
match primary {
|
||||
Some(result) if destination == CopyDestination::Primary => {
|
||||
(result.map(|_| super::CopyOutcome::Requested), None)
|
||||
}
|
||||
// Unavailable PRIMARY must not break configured CLIPBOARD copy.
|
||||
_ => copy(&text, format, &setup),
|
||||
}
|
||||
}
|
||||
Request::Read {
|
||||
source,
|
||||
deadline,
|
||||
response,
|
||||
} => {
|
||||
let result = match source {
|
||||
PasteSource::Clipboard => read(deadline),
|
||||
PasteSource::Primary => super::primary::read(deadline),
|
||||
};
|
||||
let _ = response.send(result);
|
||||
frames.schedule_frame();
|
||||
continue;
|
||||
}
|
||||
@@ -249,7 +416,7 @@ impl ClipboardWorker {
|
||||
}
|
||||
// Finish Linux clipboard-manager handoff before disconnecting the response channel.
|
||||
#[cfg(target_os = "linux")]
|
||||
drop(lease);
|
||||
drop((lease, primary_lease));
|
||||
drop(outgoing);
|
||||
})
|
||||
.map_err(|error| format!("could not start clipboard worker: {error}"))?;
|
||||
@@ -261,25 +428,36 @@ impl ClipboardWorker {
|
||||
/// Poll only while the UI owns the terminal. Retain one result for its original consumer.
|
||||
pub(crate) fn poll(&mut self) -> Option<&(u64, CopyResult)> {
|
||||
if let Some(read) = &mut self.pending_read {
|
||||
if read.owner.strong_count() == 0 {
|
||||
read.expired = true;
|
||||
}
|
||||
if !read.expired && Instant::now() >= read.deadline {
|
||||
read.expired = true;
|
||||
self.read_result = Some((read.deadline, Err("clipboard read timed out".into())));
|
||||
self.read_result = Some((
|
||||
read.deadline,
|
||||
read.owner.clone(),
|
||||
Err("clipboard read timed out".into()),
|
||||
));
|
||||
}
|
||||
match read.response.try_recv() {
|
||||
Ok(result) => {
|
||||
match read.response.as_ref().map(mpsc::Receiver::try_recv) {
|
||||
Some(Ok(result)) => {
|
||||
if !read.expired {
|
||||
self.read_result = Some((read.deadline, result));
|
||||
self.read_result = Some((read.deadline, read.owner.clone(), result));
|
||||
}
|
||||
self.pending_read = None;
|
||||
}
|
||||
Err(mpsc::TryRecvError::Disconnected) => {
|
||||
Some(Err(mpsc::TryRecvError::Disconnected)) => {
|
||||
if !read.expired {
|
||||
self.read_result =
|
||||
Some((read.deadline, Err("clipboard worker stopped".into())));
|
||||
self.read_result = Some((
|
||||
read.deadline,
|
||||
read.owner.clone(),
|
||||
Err("clipboard worker stopped".into()),
|
||||
));
|
||||
}
|
||||
self.pending_read = None;
|
||||
}
|
||||
Err(mpsc::TryRecvError::Empty) => {
|
||||
None if read.expired => self.pending_read = None,
|
||||
Some(Err(mpsc::TryRecvError::Empty)) | None => {
|
||||
if !read.expired {
|
||||
read.frames.schedule_frame_in(
|
||||
read.deadline.saturating_duration_since(Instant::now()),
|
||||
|
||||
@@ -265,6 +265,7 @@ fn delivery_claim_cannot_be_abandoned() {
|
||||
let setup = CopySetup {
|
||||
phase: Mutex::new(Setup::Pending(Instant::now() + SETUP_TIMEOUT)),
|
||||
frames: FrameRequester::test_dummy(),
|
||||
owner: None,
|
||||
};
|
||||
assert_eq!(setup.begin_delivery(), Ok(()));
|
||||
assert!(
|
||||
@@ -384,14 +385,22 @@ fn expired_text_read_rejects_backlog_and_discards_late_completion() {
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert!(worker.read_text(FrameRequester::test_dummy()).unwrap());
|
||||
let request = worker
|
||||
.read_text(PasteSource::Clipboard, FrameRequester::test_dummy())
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
worker.pending_read.as_mut().unwrap().deadline = Instant::now();
|
||||
worker.poll();
|
||||
assert_eq!(
|
||||
worker.take_text_result(),
|
||||
Some(Err("clipboard read timed out".into()))
|
||||
);
|
||||
assert!(!worker.read_text(FrameRequester::test_dummy()).unwrap());
|
||||
assert!(
|
||||
worker
|
||||
.read_text(PasteSource::Clipboard, FrameRequester::test_dummy())
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
release.send(()).unwrap();
|
||||
let deadline = Instant::now() + SETUP_TIMEOUT;
|
||||
while worker.is_busy() && Instant::now() < deadline {
|
||||
@@ -401,9 +410,167 @@ fn expired_text_read_rejects_backlog_and_discards_late_completion() {
|
||||
assert!(!worker.is_busy());
|
||||
assert_eq!(worker.take_text_result(), None);
|
||||
// A completion harvested by a non-draw event must retain its acceptance deadline.
|
||||
worker.read_result = Some((Instant::now(), Ok("completed but no longer current".into())));
|
||||
worker.read_result = Some((
|
||||
Instant::now(),
|
||||
Arc::downgrade(&request),
|
||||
Ok("completed but no longer current".into()),
|
||||
));
|
||||
assert_eq!(
|
||||
worker.take_text_result(),
|
||||
Some(Err("clipboard read timed out".into()))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn latest_primary_is_published_before_read_but_does_not_preempt_explicit_copy() {
|
||||
let frames = FrameRequester::test_dummy();
|
||||
let (requests, incoming) = mpsc::channel();
|
||||
let (outgoing, responses) = mpsc::channel();
|
||||
let mut worker = ClipboardWorker::default();
|
||||
worker.requests = Some(requests);
|
||||
worker.responses = Some(responses);
|
||||
let complete = || {
|
||||
outgoing
|
||||
.send(Response {
|
||||
result: Ok(CopyStatus::Confirmed),
|
||||
terminal_text: None,
|
||||
})
|
||||
.unwrap();
|
||||
};
|
||||
worker
|
||||
.copy("original".into(), CopyFormat::PlainText, frames.clone())
|
||||
.unwrap();
|
||||
assert!(matches!(incoming.try_recv(), Ok(Request::Copy { .. })));
|
||||
let (result, _old) = worker.select(
|
||||
"old".into(),
|
||||
CopyFormat::PlainText,
|
||||
CopyDestination::Primary,
|
||||
frames.clone(),
|
||||
);
|
||||
assert_eq!(result, Ok(CopyStatus::Busy));
|
||||
let (result, _latest) = worker.select(
|
||||
"**visit the café**".into(),
|
||||
CopyFormat::Markdown,
|
||||
CopyDestination::ClipboardAndPrimary("visit the café".into()),
|
||||
frames.clone(),
|
||||
);
|
||||
assert_eq!(result, Ok(CopyStatus::Busy));
|
||||
let _read = worker
|
||||
.read_text(PasteSource::Primary, frames.clone())
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(
|
||||
worker
|
||||
.read_text(PasteSource::Clipboard, frames.clone())
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
assert!(incoming.try_recv().is_err());
|
||||
complete();
|
||||
assert_eq!(worker.poll(), Some(&(1, Ok(CopyStatus::Confirmed))));
|
||||
// This models a completion arriving with a real copy key, before an idle draw.
|
||||
assert_eq!(
|
||||
worker.copy("explicit".into(), CopyFormat::PlainText, frames.clone()),
|
||||
Ok(CopyStatus::Pending(2))
|
||||
);
|
||||
assert!(
|
||||
matches!(incoming.try_recv(), Ok(Request::Copy { text, destination: CopyDestination::Clipboard, .. })
|
||||
if text.as_ref() == "explicit")
|
||||
);
|
||||
worker.advance(frames.clone());
|
||||
assert!(incoming.try_recv().is_err());
|
||||
complete();
|
||||
worker.poll();
|
||||
worker.advance(frames.clone());
|
||||
assert!(
|
||||
matches!(incoming.try_recv(), Ok(Request::Copy { text, format: CopyFormat::PlainText, destination: CopyDestination::Primary, .. })
|
||||
if text.as_ref() == "visit the café")
|
||||
);
|
||||
assert!(incoming.try_recv().is_err());
|
||||
complete();
|
||||
worker.poll();
|
||||
worker.advance(frames);
|
||||
let Ok(Request::Read {
|
||||
source: PasteSource::Primary,
|
||||
response,
|
||||
..
|
||||
}) = incoming.try_recv()
|
||||
else {
|
||||
panic!("one PRIMARY read must follow the latest publication");
|
||||
};
|
||||
response.send(Ok("visit the café".into())).unwrap();
|
||||
worker.poll();
|
||||
assert_eq!(worker.take_text_result(), Some(Ok("visit the café".into())));
|
||||
assert!(incoming.try_recv().is_err());
|
||||
drop(outgoing);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancelling_owners_after_poll_drops_deferred_work_without_releasing_worker_early() {
|
||||
let frames = FrameRequester::test_dummy();
|
||||
let (requests, incoming) = mpsc::channel();
|
||||
let (outgoing, responses) = mpsc::channel();
|
||||
let mut worker = ClipboardWorker::default();
|
||||
worker.requests = Some(requests);
|
||||
worker.responses = Some(responses);
|
||||
worker
|
||||
.copy("active".into(), CopyFormat::PlainText, frames.clone())
|
||||
.unwrap();
|
||||
assert!(matches!(incoming.try_recv(), Ok(Request::Copy { .. })));
|
||||
let (_, selection) = worker.select(
|
||||
"cancelled".into(),
|
||||
CopyFormat::PlainText,
|
||||
CopyDestination::Primary,
|
||||
frames.clone(),
|
||||
);
|
||||
let read = worker
|
||||
.read_text(PasteSource::Primary, frames.clone())
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
worker.poll();
|
||||
drop(selection);
|
||||
drop(read);
|
||||
worker.advance(frames.clone());
|
||||
assert!(worker.is_busy());
|
||||
assert!(incoming.try_recv().is_err());
|
||||
outgoing
|
||||
.send(Response {
|
||||
result: Ok(CopyStatus::Confirmed),
|
||||
terminal_text: None,
|
||||
})
|
||||
.unwrap();
|
||||
worker.poll();
|
||||
worker.advance(frames.clone());
|
||||
assert!(!worker.is_busy());
|
||||
assert!(worker.take_text_result().is_none());
|
||||
assert!(incoming.try_recv().is_err());
|
||||
|
||||
let read = worker
|
||||
.read_text(PasteSource::Primary, frames.clone())
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let Ok(Request::Read { response, .. }) = incoming.try_recv() else {
|
||||
panic!("an available read must start immediately");
|
||||
};
|
||||
response
|
||||
.send(Ok("completed before validation".into()))
|
||||
.unwrap();
|
||||
worker.poll();
|
||||
drop(read);
|
||||
assert!(worker.take_text_result().is_none());
|
||||
let (_, selection) = worker.select(
|
||||
"cancelled during setup".into(),
|
||||
CopyFormat::PlainText,
|
||||
CopyDestination::Primary,
|
||||
frames,
|
||||
);
|
||||
let Ok(Request::Copy { setup, .. }) = incoming.try_recv() else {
|
||||
panic!("selection must reach the worker");
|
||||
};
|
||||
drop(selection);
|
||||
assert_eq!(
|
||||
setup.begin_delivery(),
|
||||
Err("selection ended before clipboard delivery".into())
|
||||
);
|
||||
drop(outgoing);
|
||||
}
|
||||
|
||||
@@ -22,13 +22,28 @@ pub(crate) fn read(deadline: Instant) -> Result<String, String> {
|
||||
.block_on(read_command(command, deadline));
|
||||
}
|
||||
#[cfg(not(target_os = "android"))]
|
||||
let text = match arboard::Clipboard::new().and_then(|mut clipboard| clipboard.get_text()) {
|
||||
{
|
||||
native_result(
|
||||
arboard::Clipboard::new().and_then(|mut clipboard| clipboard.get_text()),
|
||||
deadline,
|
||||
)
|
||||
}
|
||||
#[cfg(target_os = "android")]
|
||||
{
|
||||
validate(String::new(), deadline)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "android"))]
|
||||
pub(crate) fn native_result(
|
||||
result: Result<String, arboard::Error>,
|
||||
deadline: Instant,
|
||||
) -> Result<String, String> {
|
||||
let text = match result {
|
||||
Ok(text) => text,
|
||||
Err(arboard::Error::ContentNotAvailable) => String::new(),
|
||||
Err(_) => return Err("clipboard text is unavailable".into()),
|
||||
};
|
||||
#[cfg(target_os = "android")]
|
||||
let text = String::new();
|
||||
validate(text, deadline)
|
||||
}
|
||||
|
||||
|
||||
@@ -55,6 +55,7 @@ impl Overlay {
|
||||
) -> Self {
|
||||
let mut overlay = TranscriptOverlay::new(cells, keymap);
|
||||
overlay.view.copy_on_select = copy_on_select;
|
||||
overlay.view.primary_selection = crate::clipboard_copy::primary::available();
|
||||
Self::Transcript(overlay)
|
||||
}
|
||||
|
||||
|
||||
@@ -184,7 +184,8 @@ impl TranscriptOverlay {
|
||||
self.is_done = self.browsing_footer.is_some();
|
||||
}
|
||||
}
|
||||
if matches!(event, TuiEvent::Resume) {
|
||||
if matches!(event, TuiEvent::Resume | TuiEvent::FocusLost) {
|
||||
self.view.cancel_primary();
|
||||
self.view.end_drag();
|
||||
}
|
||||
// Apply a queued prompt jump before navigation can move away from it.
|
||||
@@ -220,10 +221,7 @@ impl TranscriptOverlay {
|
||||
self.draw(tui)?;
|
||||
return Ok(());
|
||||
}
|
||||
TuiEvent::FocusLost => {
|
||||
self.view.end_drag();
|
||||
None
|
||||
}
|
||||
TuiEvent::FocusLost => None,
|
||||
};
|
||||
if let Some(action) = action {
|
||||
self.apply_action(tui, action);
|
||||
@@ -443,15 +441,13 @@ impl TranscriptOverlay {
|
||||
let copy_on_select = matches!(action, ViewAction::CopyOnSelect(_));
|
||||
match action {
|
||||
ViewAction::Changed => {}
|
||||
ViewAction::PrimarySelection(text) => self.view.publish_primary(tui, &text),
|
||||
ViewAction::Copy(text)
|
||||
| ViewAction::CopyOnSelect(text)
|
||||
| ViewAction::CopyAndFollow(text) => {
|
||||
let result = self.view.copy_selected_text_with(
|
||||
&self.cells,
|
||||
&text,
|
||||
!copy_on_select,
|
||||
|text, format| tui.copy_transcript_selection(text, format),
|
||||
);
|
||||
let result = self
|
||||
.view
|
||||
.copy_selected_text(tui, &self.cells, &text, !copy_on_select);
|
||||
if resume_following
|
||||
&& matches!(result, Ok(crate::clipboard_copy::CopyStatus::Pending(_)))
|
||||
{
|
||||
|
||||
@@ -86,6 +86,7 @@ struct VisibleRow {
|
||||
/// Shared scrolling and interaction state for compact and detailed transcript presentations.
|
||||
pub(crate) struct TranscriptView {
|
||||
pub(crate) copy_on_select: bool,
|
||||
pub(crate) primary_selection: bool,
|
||||
position: Position,
|
||||
follow_control: follow_control::FollowControl,
|
||||
copy_feedback: Option<composer_gap::CopyFeedback>,
|
||||
@@ -118,6 +119,7 @@ impl Default for TranscriptView {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
copy_on_select: false,
|
||||
primary_selection: false,
|
||||
position: Position::Latest,
|
||||
follow_control: follow_control::FollowControl::default(),
|
||||
copy_feedback: None,
|
||||
|
||||
@@ -179,3 +179,36 @@ fn copy_on_select_preserves_link_activation_and_ignores_empty_drags() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn x11_release_preserves_primary_when_auto_copy_is_disabled() {
|
||||
let cells = vec![cell("visit the café")];
|
||||
for auto_copy in [false, true] {
|
||||
let mut view = TranscriptView {
|
||||
copy_on_select: auto_copy,
|
||||
primary_selection: true,
|
||||
..Default::default()
|
||||
};
|
||||
render(&mut view, &cells, /*width*/ 20, /*height*/ 1);
|
||||
for event in [
|
||||
mouse(MouseEventKind::Down(MouseButton::Left), /*column*/ 0),
|
||||
mouse(MouseEventKind::Drag(MouseButton::Left), /*column*/ 14),
|
||||
] {
|
||||
assert!(matches!(
|
||||
view.handle_mouse(event, &cells),
|
||||
Some(ViewAction::Changed)
|
||||
));
|
||||
}
|
||||
let release = mouse(MouseEventKind::Up(MouseButton::Left), /*column*/ 14);
|
||||
let text = match (auto_copy, view.handle_mouse(release, &cells)) {
|
||||
(false, Some(ViewAction::PrimarySelection(text)))
|
||||
| (true, Some(ViewAction::CopyOnSelect(text))) => text,
|
||||
_ => panic!("mouse release must publish to PRIMARY regardless of auto-copy"),
|
||||
};
|
||||
assert_eq!(
|
||||
(text.as_str(), view.selected_text(&cells).as_deref()),
|
||||
("visit the café", Some("visit the café"))
|
||||
);
|
||||
assert!(view.handle_mouse(release, &cells).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ pub(crate) enum ViewAction {
|
||||
Changed,
|
||||
Copy(String),
|
||||
CopyOnSelect(String),
|
||||
PrimarySelection(String),
|
||||
CopyAndFollow(String),
|
||||
OpenLink(String),
|
||||
}
|
||||
@@ -269,10 +270,13 @@ impl TranscriptView {
|
||||
if let Some(link) = link {
|
||||
return Some(ViewAction::OpenLink(link));
|
||||
}
|
||||
if self.copy_on_select
|
||||
&& let Some(text) = selected.filter(|text| !text.is_empty())
|
||||
{
|
||||
return Some(ViewAction::CopyOnSelect(text));
|
||||
if let Some(text) = selected.filter(|text| !text.is_empty()) {
|
||||
if self.copy_on_select {
|
||||
return Some(ViewAction::CopyOnSelect(text));
|
||||
}
|
||||
if self.primary_selection {
|
||||
return Some(ViewAction::PrimarySelection(text));
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => return None,
|
||||
|
||||
@@ -6,7 +6,11 @@ use ratatui::layout::Position as ScreenPosition;
|
||||
use unicode_segmentation::UnicodeSegmentation;
|
||||
|
||||
use super::*;
|
||||
use crate::clipboard_copy::CopyFormat;
|
||||
use crate::clipboard_copy::worker::CopyDestination;
|
||||
use crate::clipboard_copy::worker::CopyResult;
|
||||
use crate::text_selection::SelectionUnit;
|
||||
use crate::tui::Tui;
|
||||
|
||||
struct PendingCopy {
|
||||
id: u64,
|
||||
@@ -20,6 +24,7 @@ struct PendingCopy {
|
||||
|
||||
pub(super) struct Selection {
|
||||
pending_copy: Option<PendingCopy>,
|
||||
primary_owner: Option<Arc<()>>,
|
||||
pub(super) snapshot: ViewSnapshot,
|
||||
pub(super) start: Anchor,
|
||||
pub(super) end: Anchor,
|
||||
@@ -62,6 +67,7 @@ impl TranscriptView {
|
||||
let was_following = self.is_following();
|
||||
self.selection = Some(Selection {
|
||||
pending_copy: None,
|
||||
primary_owner: None,
|
||||
snapshot,
|
||||
start,
|
||||
end,
|
||||
@@ -137,6 +143,7 @@ impl TranscriptView {
|
||||
let origin = selection.origin;
|
||||
let backwards = (self.resolve(&cells, end), end.offset)
|
||||
< (self.resolve(&cells, origin.0), origin.0.offset);
|
||||
let previous = (selection.start, selection.end);
|
||||
selection.start = if backwards { origin.1 } else { origin.0 };
|
||||
selection.end = Anchor {
|
||||
offset: if backwards { range.start } else { range.end },
|
||||
@@ -148,6 +155,9 @@ impl TranscriptView {
|
||||
selection.pointer = Some(ScreenPosition::new(column, row));
|
||||
selection.preferred_column = None;
|
||||
self.pin_selection_range(&cells, &mut selection);
|
||||
if previous != (selection.start, selection.end) {
|
||||
selection.primary_owner = None;
|
||||
}
|
||||
self.selection = Some(selection);
|
||||
}
|
||||
|
||||
@@ -214,6 +224,58 @@ impl TranscriptView {
|
||||
Some(text)
|
||||
}
|
||||
|
||||
/// A PRIMARY write is silent: it neither claims CLIPBOARD was copied nor ends selection.
|
||||
pub(crate) fn publish_primary(&mut self, tui: &mut Tui, text: &str) {
|
||||
let (_, owner) = tui.clipboard.select(
|
||||
text.into(),
|
||||
CopyFormat::PlainText,
|
||||
CopyDestination::Primary,
|
||||
tui.frame_requester(),
|
||||
);
|
||||
if let Some(selection) = &mut self.selection {
|
||||
selection.primary_owner = Some(owner);
|
||||
}
|
||||
}
|
||||
|
||||
/// Both transcript surfaces honor the same automatic-copy and plaintext PRIMARY semantics.
|
||||
pub(crate) fn copy_selected_text(
|
||||
&mut self,
|
||||
tui: &mut Tui,
|
||||
cells: &[Arc<dyn HistoryCell>],
|
||||
text: &str,
|
||||
clear_selection: bool,
|
||||
) -> CopyResult {
|
||||
let publish_primary = !clear_selection && self.primary_selection;
|
||||
let mut primary_owner = None;
|
||||
let result =
|
||||
self.copy_selected_text_with(cells, text, clear_selection, |copy_text, format| {
|
||||
if publish_primary {
|
||||
let (result, owner) = tui.clipboard.select(
|
||||
copy_text.into(),
|
||||
format,
|
||||
CopyDestination::ClipboardAndPrimary(text.into()),
|
||||
tui.frame_requester(),
|
||||
);
|
||||
primary_owner = Some(owner);
|
||||
result
|
||||
} else {
|
||||
tui.clipboard
|
||||
.copy(copy_text.into(), format, tui.frame_requester())
|
||||
}
|
||||
});
|
||||
if publish_primary && let Some(selection) = &mut self.selection {
|
||||
selection.primary_owner = primary_owner;
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Hide or cancel deferred publication without relinquishing the existing X11 selection.
|
||||
pub(crate) fn cancel_primary(&mut self) {
|
||||
if let Some(selection) = &mut self.selection {
|
||||
selection.primary_owner = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// Track delivery for the current selection. Explicit copies release it on confirmation;
|
||||
/// automatic copies, failures, and unacknowledged terminal writes retain the revision.
|
||||
pub(crate) fn copy_selected_text_with(
|
||||
@@ -370,6 +432,9 @@ impl TranscriptView {
|
||||
return true;
|
||||
};
|
||||
if let Some(mut selection) = self.selection.take() {
|
||||
if selection.end != next {
|
||||
selection.primary_owner = None;
|
||||
}
|
||||
selection.end = next;
|
||||
selection.dragging = false;
|
||||
selection.preferred_column = column;
|
||||
|
||||
Reference in New Issue
Block a user