mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-29 12:17:14 -06:00
- 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>
100 lines
4.4 KiB
Rust
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() {}
|
|
}
|
|
}
|