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>
This commit is contained in:
parent
5db200d392
commit
71371b9d58
File diff suppressed because one or more lines are too long
|
|
@ -29,7 +29,7 @@ mod window;
|
|||
use vectorcraft_engine::Session;
|
||||
use vectorcraft_engine::cmd::fileio;
|
||||
use vectorcraft_ui_egui::graphics::GraphicsLoss;
|
||||
use vectorcraft_ui_egui::{FilePick, Services, VectorcraftApp};
|
||||
use vectorcraft_ui_egui::{ClipboardProbeFactory, FilePick, Services, VectorcraftApp};
|
||||
|
||||
struct App {
|
||||
app: VectorcraftApp,
|
||||
|
|
@ -231,19 +231,6 @@ fn write_file(path: &str, bytes: &[u8]) -> Result<(), String> {
|
|||
fileio::write_atomic(std::path::Path::new(path), bytes).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// The factory for the off-thread clipboard check: Linux only for now. On Linux an X11 clipboard
|
||||
/// owner that never answers would otherwise block the frame loop (see `clipboard_probe`).
|
||||
fn clipboard_probe_factory() -> Option<vectorcraft_ui_egui::ClipboardProbeFactory> {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
Some(Box::new(clipboard::system_clipboard))
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn services() -> Services {
|
||||
Services {
|
||||
pick_open: Some(Box::new(|pick: &FilePick| file_dialog(pick).pick_file().map(|p| p.to_string_lossy().to_string()))),
|
||||
|
|
@ -269,9 +256,10 @@ fn services() -> Services {
|
|||
write_shared: Some(std::sync::Arc::new(write_file)),
|
||||
// Every format Copy offers and Paste reads (menu-bar Paste never sees egui's Paste event).
|
||||
system_clipboard: Some(clipboard::system_clipboard()),
|
||||
// Whether Paste has something to take is checked on a background thread (Linux), so a stuck
|
||||
// clipboard owner can't freeze the UI.
|
||||
clipboard_probe: clipboard_probe_factory(),
|
||||
// Linux checks whether Paste has something to take on a background thread: an X11 clipboard
|
||||
// owner that never answers holds a read for up to 4 s. Windows only asks which formats the
|
||||
// clipboard holds and macOS asks the pasteboard server, so they check in line.
|
||||
clipboard_probe: cfg!(target_os = "linux").then(|| Box::new(clipboard::system_clipboard) as ClipboardProbeFactory),
|
||||
// Help → Discord / website / GitHub, the Discord button, About and Home links.
|
||||
open_url: Some(Box::new(|url: &str| {
|
||||
let _ = webbrowser::open(url);
|
||||
|
|
|
|||
|
|
@ -1,24 +1,30 @@
|
|||
//! 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, publishes its verdict, 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).
|
||||
//! 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, TryRecvError};
|
||||
use std::time::Duration;
|
||||
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.
|
||||
/// 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 {
|
||||
rx: Option<Receiver<bool>>,
|
||||
/// The last verdict the worker sent; `None` until the first one arrives.
|
||||
last: Option<bool>,
|
||||
/// 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 {
|
||||
|
|
@ -28,41 +34,66 @@ impl Probe {
|
|||
if cfg!(target_arch = "wasm32") {
|
||||
return None;
|
||||
}
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
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 sent: Option<bool> = None;
|
||||
let (mut wanted, mut sent) = (false, None);
|
||||
loop {
|
||||
// The worker's thread may wait here for a slow owner; the UI's never does.
|
||||
let verdict = clipboard.has(&PASTE_ORDER);
|
||||
if tx.send(verdict).is_err() {
|
||||
return; // the app is gone: the receiver was dropped
|
||||
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();
|
||||
}
|
||||
}
|
||||
// Wake the UI only when the answer changed (it reads the verdict every frame).
|
||||
if 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,
|
||||
}
|
||||
sent = Some(verdict);
|
||||
std::thread::sleep(PROBE_INTERVAL);
|
||||
}
|
||||
// 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 { rx: Some(rx), last: None })
|
||||
Some(Self { wants: Some(wants), wanted: false, verdicts, last: false })
|
||||
}
|
||||
|
||||
/// The latest verdict the worker sent, if one has arrived. Never blocks.
|
||||
pub(crate) fn poll(&mut self) -> Option<bool> {
|
||||
/// 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.rx.as_ref().map(Receiver::try_recv) {
|
||||
Some(Ok(verdict)) => self.last = Some(verdict),
|
||||
Some(Err(TryRecvError::Empty)) => return self.last,
|
||||
Some(Err(TryRecvError::Disconnected)) | None => {
|
||||
self.rx = None;
|
||||
return self.last;
|
||||
}
|
||||
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() {}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -208,9 +208,9 @@ pub struct Services {
|
|||
/// Paste takes SVG, PDF, text and bitmaps from other apps. Without it, SVG text only (through
|
||||
/// egui and `clipboard_read`).
|
||||
pub system_clipboard: Option<Box<dyn SystemClipboard>>,
|
||||
/// A factory for a second system-clipboard handle, checked on a background thread so an
|
||||
/// unresponsive clipboard owner never freezes the UI ([`VectorcraftApp::system_paste`]).
|
||||
/// Without it, that check stays on the UI thread, as before.
|
||||
/// A second system-clipboard handle, for checking whether Paste has something to take on a
|
||||
/// background thread (Linux: an X11 clipboard owner that never answers would freeze the UI).
|
||||
/// Taken on the first frame. Without it the check runs on the UI thread.
|
||||
pub clipboard_probe: Option<ClipboardProbeFactory>,
|
||||
/// File → Show in Folder: select a file in the system file manager (desktop).
|
||||
pub reveal: Option<RevealFn>,
|
||||
|
|
@ -334,15 +334,11 @@ pub struct VectorcraftApp {
|
|||
/// (SVG without a `system_clipboard`) while the internal clipboard is empty. It enables the
|
||||
/// Paste menu items.
|
||||
pub(crate) system_paste: bool,
|
||||
/// When `system_paste` was last checked (app time, s; at most once per frame).
|
||||
/// When `system_paste` was last checked in line (app time, s; at most once per frame).
|
||||
system_paste_at: f64,
|
||||
/// The probe's last verdict: the system clipboard holds something Paste can take.
|
||||
system_paste_available: bool,
|
||||
/// The background thread that checks the system clipboard for Paste (native desktop), started
|
||||
/// on the first frame. Without it the check runs in line, throttled ([`SYSTEM_CLIPBOARD_POLL`]).
|
||||
/// The thread that checks the system clipboard for `system_paste` instead
|
||||
/// ([`Services::clipboard_probe`]).
|
||||
clipboard_probe: Option<clipboard_probe::Probe>,
|
||||
/// The host's factory for the probe, taken when its thread starts ([`Services::clipboard_probe`]).
|
||||
clipboard_probe_factory: Option<ClipboardProbeFactory>,
|
||||
/// Keyboard pastes of something other than text (see [`shortcuts::PasteChord`]).
|
||||
pub(crate) paste_chord: shortcuts::PasteChord,
|
||||
/// Saves and exports running in the background (Preferences → File Handling).
|
||||
|
|
@ -367,14 +363,12 @@ pub struct VectorcraftApp {
|
|||
const SYSTEM_CLIPBOARD_POLL: f64 = 0.25;
|
||||
|
||||
impl VectorcraftApp {
|
||||
pub fn new(mut session: Session, mut services: Services) -> Self {
|
||||
pub fn new(mut session: Session, services: Services) -> Self {
|
||||
// The font menus and the first file opened need the installed fonts: catalog them now.
|
||||
vectorcraft_text::FontDb::global().scan_in_background();
|
||||
if let Some(store) = &services.recovery_store {
|
||||
session.recovery.set_store(store.clone());
|
||||
}
|
||||
// The probe is started on the first frame, which is where an `egui::Context` is available.
|
||||
let clipboard_probe_factory = services.clipboard_probe.take();
|
||||
let views = session.documents().iter().map(View::of).collect();
|
||||
Self {
|
||||
session,
|
||||
|
|
@ -420,9 +414,7 @@ impl VectorcraftApp {
|
|||
place: Default::default(),
|
||||
system_paste: false,
|
||||
system_paste_at: f64::NEG_INFINITY,
|
||||
system_paste_available: false,
|
||||
clipboard_probe: None,
|
||||
clipboard_probe_factory,
|
||||
paste_chord: Default::default(),
|
||||
background: Default::default(),
|
||||
recovery: Default::default(),
|
||||
|
|
@ -748,26 +740,21 @@ impl VectorcraftApp {
|
|||
}
|
||||
self.last_time = now;
|
||||
self.sync_views();
|
||||
// Whether Paste can take from the system clipboard. On a native desktop a background thread
|
||||
// checks it (an unresponsive clipboard owner must never stall the frame loop) and only its
|
||||
// latest verdict is read here; without a probe the check runs in line, at most a few times a
|
||||
// second (opening the clipboard locks it against other apps on some systems).
|
||||
if self.clipboard_probe.is_none()
|
||||
&& let Some(make) = self.clipboard_probe_factory.take()
|
||||
{
|
||||
// Read the system clipboard only when that alone decides whether Paste is enabled. A
|
||||
// background thread reads it where the host installs one (an unresponsive owner must never
|
||||
// stall the frame loop); otherwise, or once that thread is gone, it is read here, at most a
|
||||
// few times a second (opening it locks it against other apps on some systems).
|
||||
let wanted = self.session.clipboard.is_empty() && self.session.active().is_some();
|
||||
if let Some(make) = self.services.clipboard_probe.take() {
|
||||
self.clipboard_probe = clipboard_probe::Probe::start(make, ctx.clone());
|
||||
}
|
||||
let verdict = self.clipboard_probe.as_mut().and_then(clipboard_probe::Probe::poll);
|
||||
let available = if self.clipboard_probe.is_some() {
|
||||
verdict.unwrap_or(self.system_paste_available)
|
||||
if let Some(pasteable) = self.clipboard_probe.as_mut().and_then(|p| p.pasteable(wanted)) {
|
||||
self.system_paste = pasteable;
|
||||
} else if !(0.0..SYSTEM_CLIPBOARD_POLL).contains(&(now - self.system_paste_at)) {
|
||||
self.clipboard_probe = None;
|
||||
self.system_paste_at = now;
|
||||
self.system_clipboard_pasteable()
|
||||
} else {
|
||||
self.system_paste_available
|
||||
};
|
||||
self.system_paste_available = available;
|
||||
self.system_paste = available && self.session.clipboard.is_empty() && self.session.active().is_some();
|
||||
self.system_paste = wanted && self.system_clipboard_pasteable();
|
||||
}
|
||||
background::poll(self);
|
||||
if !self.background.jobs.is_empty() {
|
||||
// Keep the status bar's progress moving and pick the result up when it arrives.
|
||||
|
|
|
|||
|
|
@ -24,10 +24,9 @@ pub trait SystemClipboard {
|
|||
fn has(&mut self, mimes: &[&'static str]) -> bool;
|
||||
}
|
||||
|
||||
/// Builds a second, independent system-clipboard handle, used only to check (off the UI thread)
|
||||
/// whether Paste has something to take. It is called on that thread and the value it returns lives
|
||||
/// and dies there, so it needs no `Send` bound: an unresponsive clipboard owner must never stall the
|
||||
/// thread that draws the window.
|
||||
/// Makes a second system-clipboard handle, to check off the UI thread whether Paste has something
|
||||
/// to take ([`Services::clipboard_probe`](crate::Services::clipboard_probe)). It is called on that
|
||||
/// thread and the handle lives and dies there, so the handle needn't be `Send`.
|
||||
pub type ClipboardProbeFactory = Box<dyn FnOnce() -> Box<dyn SystemClipboard> + Send>;
|
||||
|
||||
/// The command (and its params) that loads `f` into the internal clipboard, centred on `center`.
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@ pub(super) fn run(app: &mut VectorcraftApp, id: &str, p: Value) -> Value {
|
|||
app.run(id, p).unwrap_or_else(|e| panic!("{id}: {e}"))
|
||||
}
|
||||
|
||||
fn copy_rect(app: &mut VectorcraftApp) {
|
||||
pub(super) fn copy_rect(app: &mut VectorcraftApp) {
|
||||
let id = run(app, "shape.rectangle", json!({"x": 10, "y": 10, "width": 40, "height": 20}))["id"].clone();
|
||||
run(app, "paint.setFill", json!({"color": "#ff0000"}));
|
||||
run(app, "select.set", json!({"ids": [id]}));
|
||||
|
|
|
|||
|
|
@ -1,10 +1,7 @@
|
|||
//! The system clipboard is checked for Paste off the UI thread (the regression the app used to
|
||||
//! freeze on): an X11 clipboard owner that never answers `get_text()` blocked the frame loop for
|
||||
//! seconds, so the window stopped drawing and could not even be closed. These tests use a clipboard
|
||||
//! that blocks on demand and prove the UI frame is not the thread that waits on it.
|
||||
//!
|
||||
//! With no probe factory (the web, tests that predate this) the check stays on the UI thread, so the
|
||||
//! old path is kept and its tests still cover it.
|
||||
//! The system clipboard is checked for Paste off the UI thread where the host installs a probe
|
||||
//! (Linux): an X11 clipboard owner that never answers used to block the frame loop for seconds, so
|
||||
//! the window stopped drawing and could not even be closed. The in-line check (no probe: the web,
|
||||
//! Windows, macOS) is covered by `tests_clipboard` and `tests_sysclip`.
|
||||
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
|
@ -14,57 +11,44 @@ use serde_json::json;
|
|||
use vectorcraft_engine::Session;
|
||||
use vectorcraft_engine::cmd::clipboard::Flavour;
|
||||
|
||||
use crate::{ClipboardProbeFactory, Services, SystemClipboard, VectorcraftApp, menus};
|
||||
use crate::tests_sysclip::{copy_rect, run};
|
||||
use crate::{Services, SystemClipboard, VectorcraftApp, menus};
|
||||
|
||||
/// How long a blocking clipboard holds a call before giving up: bounded so a regression fails a
|
||||
/// test instead of hanging the whole suite.
|
||||
const GATE_MAX: Duration = Duration::from_millis(1000);
|
||||
|
||||
/// Lets `has()` through when opened, and otherwise at most [`GATE_MAX`].
|
||||
#[derive(Clone, Default)]
|
||||
struct Gate(Arc<std::sync::atomic::AtomicBool>);
|
||||
|
||||
impl Gate {
|
||||
fn open(&self) {
|
||||
self.0.store(true, Ordering::SeqCst);
|
||||
}
|
||||
fn wait(&self) {
|
||||
let end = Instant::now() + GATE_MAX;
|
||||
while !self.0.load(Ordering::SeqCst) && Instant::now() < end {
|
||||
std::thread::sleep(Duration::from_millis(1));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What the two clipboard handles recorded: the thread each `has()` ran on, and how many handles
|
||||
/// are alive.
|
||||
/// What the clipboard handles saw: the thread each `has()` ran on, and how many handles are alive.
|
||||
#[derive(Default)]
|
||||
struct Recorder {
|
||||
threads: Mutex<Vec<Option<String>>>,
|
||||
alive: AtomicUsize,
|
||||
}
|
||||
|
||||
/// A system clipboard whose `has()` blocks on a [`Gate`] (like an owner that never answers).
|
||||
struct Blocking {
|
||||
rec: Arc<Recorder>,
|
||||
gate: Gate,
|
||||
verdict: bool,
|
||||
}
|
||||
|
||||
impl Blocking {
|
||||
fn new(rec: Arc<Recorder>, gate: Gate, verdict: bool) -> Self {
|
||||
rec.alive.fetch_add(1, Ordering::SeqCst);
|
||||
Self { rec, gate, verdict }
|
||||
impl Recorder {
|
||||
fn looks(&self) -> usize {
|
||||
self.threads.lock().unwrap().len()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Blocking {
|
||||
/// A system clipboard whose `has()` takes `delay` (an owner slow to answer) and answers `verdict`,
|
||||
/// or panics without one (a bug in the platform code).
|
||||
struct Slow {
|
||||
rec: Arc<Recorder>,
|
||||
delay: Duration,
|
||||
verdict: Option<bool>,
|
||||
}
|
||||
|
||||
impl Slow {
|
||||
fn boxed(rec: &Arc<Recorder>, delay: Duration, verdict: Option<bool>) -> Box<dyn SystemClipboard> {
|
||||
rec.alive.fetch_add(1, Ordering::SeqCst);
|
||||
Box::new(Self { rec: Arc::clone(rec), delay, verdict })
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Slow {
|
||||
fn drop(&mut self) {
|
||||
self.rec.alive.fetch_sub(1, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
impl SystemClipboard for Blocking {
|
||||
impl SystemClipboard for Slow {
|
||||
fn write(&mut self, _flavours: &[Flavour]) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -76,130 +60,97 @@ impl SystemClipboard for Blocking {
|
|||
}
|
||||
fn has(&mut self, _mimes: &[&'static str]) -> bool {
|
||||
self.rec.threads.lock().unwrap().push(std::thread::current().name().map(str::to_owned));
|
||||
self.gate.wait();
|
||||
self.verdict
|
||||
std::thread::sleep(self.delay);
|
||||
self.verdict.expect("the clipboard failed")
|
||||
}
|
||||
}
|
||||
|
||||
/// An app with a document, a blocking `system_clipboard` (what the UI would call in line) and a
|
||||
/// probe factory whose own handle shares the recorder.
|
||||
fn app(system_verdict: bool, probe_verdict: bool, gate: &Gate) -> (VectorcraftApp, Arc<Recorder>) {
|
||||
let rec = Arc::new(Recorder::default());
|
||||
let system = Blocking::new(Arc::clone(&rec), gate.clone(), system_verdict);
|
||||
/// An app whose two clipboard handles record into `rec`: the UI's says `inline`, the probe's
|
||||
/// `probe`, each after `delay`.
|
||||
fn app(rec: &Arc<Recorder>, delay: Duration, inline: bool, probe: Option<bool>) -> VectorcraftApp {
|
||||
let make = {
|
||||
let rec = Arc::clone(&rec);
|
||||
let gate = gate.clone();
|
||||
move || -> Box<dyn SystemClipboard> { Box::new(Blocking::new(rec, gate, probe_verdict)) }
|
||||
let rec = Arc::clone(rec);
|
||||
move || Slow::boxed(&rec, delay, probe)
|
||||
};
|
||||
let probe: ClipboardProbeFactory = Box::new(make);
|
||||
let services = Services { system_clipboard: Some(Box::new(system)), clipboard_probe: Some(probe), ..Default::default() };
|
||||
let mut app = VectorcraftApp::new(Session::new(), services);
|
||||
app.run("file.new", json!({"width": 100, "height": 100})).unwrap();
|
||||
(app, rec)
|
||||
let services =
|
||||
Services { system_clipboard: Some(Slow::boxed(rec, delay, Some(inline))), clipboard_probe: Some(Box::new(make)), ..Default::default() };
|
||||
VectorcraftApp::new(Session::new(), services)
|
||||
}
|
||||
|
||||
/// One headless frame of app logic, in the test's own context (a new one would restart the fonts).
|
||||
/// Time advances by more than the clipboard poll interval each frame, so every frame re-checks (a
|
||||
/// headless context otherwise keeps `time` fixed and only the first frame would).
|
||||
/// One headless frame of app logic in the test's one context (a new one would restart the fonts),
|
||||
/// half a second after the last: past the in-line check's throttle, so it would run every frame.
|
||||
fn frame(app: &mut VectorcraftApp) {
|
||||
thread_local! {
|
||||
static CTX: egui::Context = egui::Context::default();
|
||||
static NOW: std::cell::Cell<f64> = const { std::cell::Cell::new(0.0) };
|
||||
}
|
||||
let ctx = CTX.with(Clone::clone);
|
||||
let time = NOW.with(|t| {
|
||||
let v = t.get() + 0.5;
|
||||
t.set(v);
|
||||
v
|
||||
t.set(t.get() + 0.5);
|
||||
t.get()
|
||||
});
|
||||
let input = egui::RawInput { time: Some(time), ..Default::default() };
|
||||
ctx.run_ui(input, |ui| app.logic(ui.ctx())).textures_delta.clear();
|
||||
CTX.with(|ctx| ctx.run_ui(input, |ui| app.logic(ui.ctx())).textures_delta.clear());
|
||||
}
|
||||
|
||||
/// A regression guard is only meaningful if the frame really does not wait: a blocking clipboard
|
||||
/// must not delay the frame, and the check must run on the probe thread.
|
||||
#[test]
|
||||
fn a_blocking_clipboard_never_blocks_the_ui_frame() {
|
||||
let gate = Gate::default();
|
||||
let (mut app, rec) = app(false, false, &gate);
|
||||
// Warm up: the first frames install the UI fonts.
|
||||
for _ in 0..3 {
|
||||
frame(&mut app);
|
||||
}
|
||||
let t0 = Instant::now();
|
||||
frame(&mut app);
|
||||
let elapsed = t0.elapsed();
|
||||
assert!(elapsed < Duration::from_millis(300), "the frame waited on the clipboard: {elapsed:?}");
|
||||
// The check ran on the probe thread, never on the test's.
|
||||
let end = Instant::now() + Duration::from_millis(1000);
|
||||
while rec.threads.lock().unwrap().is_empty() && Instant::now() < end {
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
let threads = rec.threads.lock().unwrap().clone();
|
||||
assert!(!threads.is_empty(), "the clipboard was never checked");
|
||||
assert!(threads.iter().all(|n| n.as_deref() == Some("vectorcraft-clip")), "checked on: {threads:?}");
|
||||
assert!(!app.system_paste);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_probe_reports_a_pasteable_clipboard_to_the_menu() {
|
||||
let gate = Gate::default();
|
||||
gate.open(); // both handles answer at once
|
||||
// The in-line clipboard reports nothing, only the probe's does: the verdict must come from the probe.
|
||||
let (mut app, _) = app(false, true, &gate);
|
||||
for _ in 0..3 {
|
||||
frame(&mut app);
|
||||
}
|
||||
let mut seen = false;
|
||||
for _ in 0..200 {
|
||||
frame(&mut app);
|
||||
if app.system_paste {
|
||||
seen = true;
|
||||
break;
|
||||
/// Frames for `wait`, or until `done` holds; whether it does.
|
||||
fn frames_until(app: &mut VectorcraftApp, wait: Duration, done: impl Fn(&VectorcraftApp) -> bool) -> bool {
|
||||
let end = Instant::now() + wait;
|
||||
loop {
|
||||
frame(app);
|
||||
if done(app) || Instant::now() > end {
|
||||
return done(app);
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
}
|
||||
assert!(seen, "the probe's verdict never reached `system_paste`");
|
||||
assert!(menus::enabled(&app, "edit.paste"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_probe_that_reports_nothing_leaves_paste_disabled() {
|
||||
let gate = Gate::default();
|
||||
gate.open();
|
||||
let (mut app, _) = app(false, false, &gate);
|
||||
for _ in 0..20 {
|
||||
frame(&mut app);
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
}
|
||||
assert!(!app.system_paste && !menus::enabled(&app, "edit.paste"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dropping_the_app_stops_the_probe() {
|
||||
let gate = Gate::default();
|
||||
gate.open();
|
||||
let (mut app, rec) = app(false, true, &gate);
|
||||
for _ in 0..3 {
|
||||
frame(&mut app);
|
||||
}
|
||||
assert!(rec.alive.load(Ordering::SeqCst) > 0, "no clipboard handle alive");
|
||||
drop(app);
|
||||
let end = Instant::now() + Duration::from_millis(2000);
|
||||
while rec.alive.load(Ordering::SeqCst) != 0 && Instant::now() < end {
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
assert_eq!(rec.alive.load(Ordering::SeqCst), 0, "the probe thread did not stop");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_a_probe_factory_the_inline_check_still_works() {
|
||||
// The web and older hosts: no probe, the UI thread checks the clipboard itself (fast when the
|
||||
// owner answers, which is the case here).
|
||||
let svg = r##"<svg xmlns="http://www.w3.org/2000/svg"><rect width="5" height="5"/></svg>"##;
|
||||
let services = Services { clipboard_read: Some(Box::new(move || Some(svg.to_string()))), ..Default::default() };
|
||||
let mut app = VectorcraftApp::new(Session::new(), services);
|
||||
app.run("file.new", json!({"width": 100, "height": 100})).unwrap();
|
||||
fn a_slow_clipboard_is_read_off_the_ui_thread() {
|
||||
let rec = Arc::new(Recorder::default());
|
||||
let mut app = app(&rec, Duration::from_secs(1), false, Some(true));
|
||||
// The first frames install the UI fonts. Without a document Paste is off whatever the clipboard
|
||||
// holds: nobody looks.
|
||||
assert!(!frames_until(&mut app, Duration::from_millis(100), |_| rec.looks() > 0));
|
||||
run(&mut app, "file.new", json!({"width": 100, "height": 100}));
|
||||
frame(&mut app);
|
||||
let t0 = Instant::now();
|
||||
frame(&mut app);
|
||||
assert!(t0.elapsed() < Duration::from_millis(300), "the frame waited on the clipboard: {:?}", t0.elapsed());
|
||||
// The probe's verdict (the UI's own handle would say no) enables Paste once it arrives.
|
||||
assert!(frames_until(&mut app, Duration::from_secs(3), |app| app.system_paste), "the probe's verdict never arrived");
|
||||
assert!(menus::enabled(&app, "edit.paste"));
|
||||
let threads = rec.threads.lock().unwrap().clone();
|
||||
assert!(threads.iter().all(|n| n.as_deref() == Some("vectorcraft-clip")), "checked on: {threads:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_probe_rests_after_a_copy_and_stops_with_the_app() {
|
||||
let rec = Arc::new(Recorder::default());
|
||||
let mut app = app(&rec, Duration::ZERO, false, Some(true));
|
||||
run(&mut app, "file.new", json!({"width": 100, "height": 100}));
|
||||
assert!(frames_until(&mut app, Duration::from_secs(2), |app| app.system_paste));
|
||||
// Something copied in the app enables Paste alone: the clipboard isn't read any more.
|
||||
copy_rect(&mut app);
|
||||
frames_until(&mut app, Duration::from_millis(50), |_| false);
|
||||
let looks = rec.looks();
|
||||
frames_until(&mut app, Duration::from_millis(600), |_| false);
|
||||
assert_eq!(rec.looks(), looks);
|
||||
assert!(!app.system_paste && menus::enabled(&app, "edit.paste"));
|
||||
// Quitting waits until the probe's thread has let go of its handle: on X11 the last handle
|
||||
// alive hands what the app copied to the clipboard manager.
|
||||
assert_eq!(rec.alive.load(Ordering::SeqCst), 2);
|
||||
drop(app);
|
||||
assert_eq!(rec.alive.load(Ordering::SeqCst), 0, "the probe's thread kept its clipboard");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_its_thread_the_check_runs_in_line_again() {
|
||||
let rec = Arc::new(Recorder::default());
|
||||
// The probe's clipboard panics: its thread ends.
|
||||
let mut app = app(&rec, Duration::ZERO, true, None);
|
||||
run(&mut app, "file.new", json!({"width": 100, "height": 100}));
|
||||
assert!(frames_until(&mut app, Duration::from_secs(2), |app| app.system_paste), "no fallback");
|
||||
assert!(app.clipboard_probe.is_none());
|
||||
let threads = rec.threads.lock().unwrap().clone();
|
||||
assert_eq!(threads.first().cloned().flatten().as_deref(), Some("vectorcraft-clip"), "{threads:?}");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -74,6 +74,7 @@ By default VectorCraft's own crates log at `info` and everything else at `warn`.
|
|||
The window runs natively on Wayland (eframe's `wayland` feature) and on X11. On machines with two GPUs it renders on the one the desktop runs on unless Preferences say otherwise; see [Desktop graphics processor](#desktop-graphics-processor) (#502). The system clipboard (`apps/vectorcraft/src/clipboard.rs`) is arboard with its `wayland-data-control` feature:
|
||||
|
||||
- **Clipboard:** under Wayland arboard talks to the compositor through the data-control protocol (wlroots compositors such as Sway and Hyprland, KDE Plasma), so bitmaps (`image/png`), SVG markup and text copied in any app paste (#398). Where the compositor lacks the protocol (GNOME's Mutter, [arboard#223](https://github.com/1Password/arboard/issues/223)), arboard falls back to the X11 clipboard through XWayland, which only holds what X11 apps copied. egui's own text paste into fields goes through smithay-clipboard and works either way.
|
||||
- **Clipboard owners that never answer (#510):** reading the clipboard waits on the app that owns it: arboard gives up on an X11 owner after 4 s, and a Wayland read waits for the owner to send its data. So on Linux the check that enables the Paste menu items runs on a background thread with a second clipboard handle (`crates/ui-egui/src/clipboard_probe.rs`, installed as `Services::clipboard_probe`), only while nothing is copied in the app and a document is open, and the window keeps drawing. A paste itself still reads on the UI thread, so pasting from such an owner waits for it once. Windows and macOS keep the check on the UI thread: Windows only asks which formats the clipboard holds, macOS asks the pasteboard server.
|
||||
- **Copied files:** files copied in a file manager (`text/uri-list`; `CF_HDROP` on Windows, file URLs on macOS) paste as the first one that is art (SVG, PDF, EMF/WMF or a bitmap), before the clipboard's other formats (which include the files' paths as text).
|
||||
- **Dropping files on the window does not work under Wayland:** winit 0.30, which eframe 0.36 runs on, has no Wayland drag and drop ([winit#1881](https://github.com/rust-windowing/winit/issues/1881), added in winit 0.31; [egui#1563](https://github.com/emilk/egui/issues/1563)), so no `dropped_files` arrive. Until eframe moves to winit 0.31: copy the files in the file manager and paste them, use File › Place or Open, or run the app under XWayland, where drops work (`WAYLAND_DISPLAY= vectorcraft`).
|
||||
- **Pens and pen displays under Wayland (#491):** winit 0.30 doesn't bind the Wayland tablet protocol (`zwp_tablet_v2`; tablet input arrives in winit 0.31, [winit#4318](https://github.com/rust-windowing/winit/pull/4318), [egui#7731](https://github.com/emilk/egui/pull/7731)). Since Plasma 6.3, KWin no longer turns tablet input into pointer input for such apps ([kwin!6336](https://invent.kde.org/plasma/kwin/-/merge_requests/6336)): the pen moves the compositor's cursor over the window, but the app gets no motion or presses and shows none of its own cursors. Until eframe moves to winit 0.31, either run the app under XWayland, which supports the tablet protocol and hands the pen to X11 apps as a pointer (`WAYLAND_DISPLAY= vectorcraft`, or `env -u WAYLAND_DISPLAY vectorcraft`; for a desktop launcher, put `env WAYLAND_DISPLAY=` before the command in its `Exec=` line), or turn KWin's deprecated emulation back on for the whole session by setting `KWIN_WAYLAND_EMULATE_TABLET=1` in KWin's environment (for example in a file under `~/.config/environment.d/` or in `/etc/environment`) and logging in again. The pen then works as a mouse, without pressure: winit 0.30 reads pen pressure on Windows only (Windows Ink, which reaches the Liquify tools' Use Pressure Pen). Other compositors that don't emulate a pointer for tablets behave the same way.
|
||||
|
|
|
|||
Loading…
Reference in New Issue