Files
vectorcraft/crates/ui-egui/src/clipboard_probe.rs
T
bflatasticandClaude Opus 5.5 71371b9d58 Linux clipboard probe: look only while Paste depends on it, stop at once (review of #510)
- The worker reads the clipboard only while nothing is copied in the app
  and a document is open (as the in-line check did before), and idles on
  a channel otherwise; it sends a verdict and wakes the UI only when the
  answer changes.
- The in-line check (Windows, macOS, web) keeps its short-circuit: it
  doesn't open the system clipboard after an in-app copy or without a
  document, as before the PR.
- A dead worker hands the check back to the UI thread instead of
  freezing the last verdict.
- Dropping the app wakes the worker and waits (250 ms at most) until it
  has let go of its clipboard handle: on X11 the last arboard handle
  alive hands what the app copied to the clipboard manager, and a
  detached worker holding it would lose that handover at exit.
- The factory stays in `Services` until the first frame takes it (no
  extra app fields); main.rs installs it with `cfg!(target_os = "linux")`.
- Tests trimmed from five to three that share tests_sysclip's helpers:
  the frame never waits on a slow clipboard and only the probe thread
  reads it (nothing read without a document), the probe rests after an
  in-app copy and lets go of its handle when the app drops, and a dead
  probe falls back to the in-line check. The in-line case is already
  covered by tests_clipboard.
- ROADMAP (Last updated, Platforms, Shipped so far) and
  docs/development.md (Linux: clipboard owners that never answer).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 22:36:44 +07:00

100 lines
4.4 KiB
Rust

//! Checks the system clipboard for Paste off the UI thread. `has()` can block for seconds when the
//! clipboard owner never answers (an unresponsive X11 client), so it must not run on the thread that
//! draws the window: a worker owns a second clipboard handle, sends its verdict when it changes, and
//! the UI only reads the latest one. Without a worker the check stays in line, as before (the web,
//! and hosts that don't install a factory).
use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender, TryRecvError};
use std::time::{Duration, Instant};
use vectorcraft_engine::cmd::clipboard::PASTE_ORDER;
use crate::sysclip::ClipboardProbeFactory;
/// How often the worker looks at the clipboard while the UI wants to know.
const PROBE_INTERVAL: Duration = Duration::from_millis(250);
/// How long dropping the probe waits for the worker to let go of its clipboard handle.
const STOP_WAIT: Duration = Duration::from_millis(250);
/// The background clipboard check ([`crate::VectorcraftApp`]'s `clipboard_probe`).
pub(crate) struct Probe {
/// Tells the worker whether Paste depends on the system clipboard; dropped to stop it.
wants: Option<Sender<bool>>,
wanted: bool,
verdicts: Receiver<bool>,
/// The worker's last verdict.
last: bool,
}
impl Probe {
/// Start the worker. `None` on wasm, or when the thread can't be spawned: the caller then falls
/// back to the in-line check.
pub(crate) fn start(make: ClipboardProbeFactory, ctx: egui::Context) -> Option<Self> {
if cfg!(target_arch = "wasm32") {
return None;
}
let (wants, wanted_rx) = std::sync::mpsc::channel();
let (tx, verdicts) = std::sync::mpsc::channel();
std::thread::Builder::new()
.name("vectorcraft-clip".into())
.spawn(move || {
let mut clipboard = make();
let (mut wanted, mut sent) = (false, None);
loop {
if wanted {
// This thread may wait here for a slow owner; the UI's never does.
let verdict = clipboard.has(&PASTE_ORDER);
if sent != Some(verdict) {
if tx.send(verdict).is_err() {
break;
}
sent = Some(verdict);
ctx.request_repaint();
}
}
// Sleep until the next look (no looks while unwanted) or the UI's next word.
let next =
if wanted { wanted_rx.recv_timeout(PROBE_INTERVAL) } else { wanted_rx.recv().map_err(|_| RecvTimeoutError::Disconnected) };
match next {
Ok(w) => wanted = w,
Err(RecvTimeoutError::Timeout) => {}
Err(RecvTimeoutError::Disconnected) => break,
}
}
// The handle goes before the app stops waiting (`Drop`): on X11 the last one alive
// hands what the app copied to the clipboard manager, which must finish before exit.
drop(clipboard);
drop(tx);
})
.ok()?;
Some(Self { wants: Some(wants), wanted: false, verdicts, last: false })
}
/// Paste can take from the system clipboard: `wanted` (nothing copied in the app, a document
/// open) and the worker's latest verdict says so. Never blocks. `None` once the worker is gone.
pub(crate) fn pasteable(&mut self, wanted: bool) -> Option<bool> {
if wanted != self.wanted {
self.wants.as_ref()?.send(wanted).ok()?;
self.wanted = wanted;
}
loop {
match self.verdicts.try_recv() {
Ok(verdict) => self.last = verdict,
Err(TryRecvError::Empty) => return Some(wanted && self.last),
Err(TryRecvError::Disconnected) => return None,
}
}
}
}
impl Drop for Probe {
/// Wake the worker to stop and wait (a moment at most: it may be stuck on an owner that never
/// answers) until it has let go of its clipboard handle.
fn drop(&mut self) {
self.wants = None;
let end = Instant::now() + STOP_WAIT;
while self.verdicts.recv_timeout(end.saturating_duration_since(Instant::now())).is_ok() {}
}
}