From f0c82a1863d71861a03d79f6d6953b05d88a7bb0 Mon Sep 17 00:00:00 2001 From: rgdevment Date: Tue, 20 Oct 2026 16:53:31 -0300 Subject: [PATCH 1/7] feat(win): a copied picture pastes as its path in a terminal with Ctrl+V and as itself everywhere else --- Cargo.toml | 1 + crates/cp-panel/src/app.rs | 19 ++- crates/cp-panel/src/dressing.rs | 126 ++++++++++++++ crates/cp-panel/src/dressing_test.rs | 200 +++++++++++++++++++++++ crates/cp-panel/src/engine.rs | 32 +++- crates/cp-panel/src/engine_test.rs | 29 ++++ crates/cp-panel/src/handing.rs | 77 +++++++-- crates/cp-panel/src/here.rs | 33 +++- crates/cp-panel/src/landing.rs | 2 + crates/cp-panel/src/landing_test.rs | 1 + crates/cp-panel/src/main.rs | 1 + crates/cp-win-sys/src/foreground.rs | 65 ++++++++ crates/cp-win-sys/src/foreground_test.rs | 34 ++++ crates/cp-win-sys/src/lib.rs | 1 + crates/cp-win/src/restore.rs | 8 + crates/cp-win/src/restore_test.rs | 19 +++ 16 files changed, 625 insertions(+), 23 deletions(-) create mode 100644 crates/cp-panel/src/dressing.rs create mode 100644 crates/cp-panel/src/dressing_test.rs create mode 100644 crates/cp-win-sys/src/foreground.rs create mode 100644 crates/cp-win-sys/src/foreground_test.rs diff --git a/Cargo.toml b/Cargo.toml index 0ffb3786..90aedbdd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -56,6 +56,7 @@ windows = { version = "0.62", features = [ "Win32_System_Ole", "Win32_System_Threading", "Win32_UI_WindowsAndMessaging", + "Win32_UI_Accessibility", "Win32_Graphics_Gdi", "Win32_UI_HiDpi", "Win32_Graphics_Dwm", diff --git a/crates/cp-panel/src/app.rs b/crates/cp-panel/src/app.rs index 03c75662..14cf949e 100644 --- a/crates/cp-panel/src/app.rs +++ b/crates/cp-panel/src/app.rs @@ -34,6 +34,7 @@ pub struct App { struct State { store: Rc, engine: Option>, + dressing: Option>>, ahead: Arc, query: String, tags: Vec, @@ -96,6 +97,7 @@ impl App { let state = Rc::new(RefCell::new(State { store: Rc::new(store), engine: None, + dressing: None, ahead: Arc::new(AtomicIsize::new(0)), query: String::new(), tags: Vec::new(), @@ -182,7 +184,22 @@ impl App { }); }; match crate::engine::Engine::start(&db, arrived, changed) { - Ok(engine) => self.state.borrow_mut().engine = Some(Rc::new(engine)), + Ok(engine) => { + let engine = Rc::new(engine); + let store = self.state.borrow().store.clone(); + let dressing = crate::dressing::Dressing::start(crate::handing::Shelves { + store, + engine: engine.clone(), + }); + if !dressing.listening() { + note( + "nobody hears which window comes forward, so terminals get no image paths", + ); + } + let mut state = self.state.borrow_mut(); + state.engine = Some(engine); + state.dressing = Some(dressing); + } Err(why) => crate::note::trouble( cp_core::trouble::Trouble::Unwatched, &format!("nothing watches the clipboard: {why}"), diff --git a/crates/cp-panel/src/dressing.rs b/crates/cp-panel/src/dressing.rs new file mode 100644 index 00000000..882df144 --- /dev/null +++ b/crates/cp-panel/src/dressing.rs @@ -0,0 +1,126 @@ +use crate::here; +use crate::landing::Towards; +use cp_core::item::Item; +use cp_core::kind::Kind; +use std::cell::{Cell, RefCell}; +use std::rc::Rc; +use std::time::Duration; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Copied { + pub sequence: i64, + pub id: i64, + pub picture: bool, +} + +impl Copied { + pub fn of(before: Option, after: Option, id: i64, item: &Item) -> Option { + let sequence = before.filter(|was| Some(*was) == after)?; + Some(Self { + sequence, + id, + picture: item.kind == Some(Kind::Image), + }) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Worn { + pub sequence: i64, + pub id: i64, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Step { + Stay, + Dress(i64), + Undress(i64), +} + +pub fn still_worn(worn: Option, now: Option) -> Option { + worn.filter(|was| Some(was.sequence) == now) +} + +pub fn step(worn: Option, terminal: bool, now: Option, copied: Option) -> Step { + match (worn, terminal) { + (Some(_), true) => Step::Stay, + (Some(was), false) => Step::Undress(was.id), + (None, false) => Step::Stay, + (None, true) => match copied { + Some(copy) if copy.picture && Some(copy.sequence) == now => Step::Dress(copy.id), + _ => Step::Stay, + }, + } +} + +pub trait Closet { + fn copied(&self) -> Option; + fn copied_again(&self, sequence: i64); + fn place(&self, id: i64, towards: Towards) -> bool; + fn sequence(&self) -> Option; +} + +pub struct Dressing { + closet: C, + worn: Cell>, + listening: RefCell>, +} + +impl Dressing { + pub fn start(closet: C) -> Rc { + let dressing = Rc::new(Self { + closet, + worn: Cell::new(None), + listening: RefCell::new(None), + }); + let weak = Rc::downgrade(&dressing); + let listening = here::on_foreground(move |window| { + let later = weak.clone(); + slint::Timer::single_shot(Duration::ZERO, move || { + if let Some(dressing) = later.upgrade() { + dressing.heard(window); + } + }); + }); + dressing.listening.replace(listening); + dressing + } + + pub fn listening(&self) -> bool { + self.listening.borrow().is_some() + } + + fn heard(&self, window: isize) { + self.turn( + here::towards(window) == Towards::Terminal, + self.closet.sequence(), + ); + } + + fn turn(&self, terminal: bool, now: Option) { + let worn = still_worn(self.worn.get(), now); + self.worn.set(worn); + match step(worn, terminal, now, self.closet.copied()) { + Step::Stay => {} + Step::Dress(id) => { + if self.closet.place(id, Towards::Terminal) { + let sequence = self.closet.sequence(); + self.worn + .set(sequence.map(|sequence| Worn { sequence, id })); + } + } + Step::Undress(id) => { + self.worn.set(None); + if self.closet.place(id, Towards::Elsewhere) + && let Some(sequence) = self.closet.sequence() + { + self.closet.copied_again(sequence); + } + } + } + } +} + +#[cfg(test)] +#[path = "dressing_test.rs"] +mod tests; diff --git a/crates/cp-panel/src/dressing_test.rs b/crates/cp-panel/src/dressing_test.rs new file mode 100644 index 00000000..f1a4654f --- /dev/null +++ b/crates/cp-panel/src/dressing_test.rs @@ -0,0 +1,200 @@ +use super::*; +use std::cell::{Cell, RefCell}; +use std::rc::Rc; + +fn picture(sequence: i64, id: i64) -> Option { + Some(Copied { + sequence, + id, + picture: true, + }) +} + +fn item(kind: Kind) -> Item { + Item { + kind: Some(kind), + formats: Vec::new(), + } +} + +#[test] +fn a_copy_is_remembered_only_when_nothing_was_copied_while_it_was_read() { + let image = item(Kind::Image); + assert_eq!(Copied::of(Some(7), Some(7), 3, &image), picture(7, 3)); + assert_eq!(Copied::of(Some(7), Some(8), 3, &image), None); + assert_eq!(Copied::of(None, None, 3, &image), None); + let words = Copied::of(Some(7), Some(7), 3, &item(Kind::Text)).expect("remembered"); + assert!(!words.picture); +} + +#[test] +fn a_picture_still_on_the_clipboard_gets_its_path_in_a_terminal() { + assert_eq!(step(None, true, Some(7), picture(7, 3)), Step::Dress(3)); +} + +#[test] +fn nothing_is_dressed_outside_a_terminal_or_once_something_newer_was_copied() { + assert_eq!(step(None, false, Some(7), picture(7, 3)), Step::Stay); + assert_eq!(step(None, true, Some(8), picture(7, 3)), Step::Stay); + assert_eq!(step(None, true, None, picture(7, 3)), Step::Stay); + assert_eq!(step(None, true, Some(7), None), Step::Stay); + let words = Some(Copied { + sequence: 7, + id: 3, + picture: false, + }); + assert_eq!(step(None, true, Some(7), words), Step::Stay); +} + +#[test] +fn leaving_the_terminal_takes_the_path_off_and_moving_between_terminals_keeps_it() { + let worn = Some(Worn { sequence: 9, id: 3 }); + assert_eq!(step(worn, false, Some(9), picture(7, 3)), Step::Undress(3)); + assert_eq!(step(worn, true, Some(9), picture(7, 3)), Step::Stay); +} + +#[test] +fn a_dress_is_forgotten_as_soon_as_anyone_else_writes_the_clipboard() { + let worn = Some(Worn { sequence: 9, id: 3 }); + assert_eq!(still_worn(worn, Some(9)), worn); + assert_eq!(still_worn(worn, Some(10)), None); + assert_eq!(still_worn(worn, None), None); + assert_eq!(still_worn(None, Some(9)), None); + assert_eq!( + step(still_worn(worn, Some(10)), false, Some(10), picture(7, 3)), + Step::Stay, + "someone else's copy is never undressed" + ); +} + +#[derive(Default)] +struct Shelf { + copied: Cell>, + sequence: Cell, + placed: RefCell>, + refuses: Cell, + blind: Cell, +} + +impl Closet for Rc { + fn copied(&self) -> Option { + self.copied.get() + } + + fn copied_again(&self, sequence: i64) { + if let Some(mut copy) = self.copied.get() { + copy.sequence = sequence; + self.copied.set(Some(copy)); + } + } + + fn place(&self, id: i64, towards: Towards) -> bool { + if self.refuses.get() { + return false; + } + self.placed.borrow_mut().push((id, towards)); + self.sequence.set(self.sequence.get() + 1); + true + } + + fn sequence(&self) -> Option { + (!self.blind.get()).then(|| self.sequence.get()) + } +} + +fn wardrobe(sequence: i64, copied: Option) -> (Rc, Dressing>) { + let shelf = Rc::new(Shelf::default()); + shelf.sequence.set(sequence); + shelf.copied.set(copied); + let dressing = Dressing { + closet: shelf.clone(), + worn: Cell::new(None), + listening: RefCell::new(None), + }; + (shelf, dressing) +} + +#[test] +fn into_a_terminal_and_out_again_puts_the_path_on_and_takes_it_off() { + let (shelf, dressing) = wardrobe(7, picture(7, 3)); + dressing.turn(true, shelf.sequence()); + assert_eq!(dressing.worn.get(), Some(Worn { sequence: 8, id: 3 })); + dressing.turn(true, shelf.sequence()); + assert_eq!( + shelf.placed.borrow().len(), + 1, + "a second terminal changes nothing" + ); + dressing.turn(false, shelf.sequence()); + assert_eq!( + *shelf.placed.borrow(), + [(3, Towards::Terminal), (3, Towards::Elsewhere)] + ); + assert_eq!(dressing.worn.get(), None); + assert_eq!( + shelf.copied.get(), + picture(9, 3), + "the picture is known again" + ); + dressing.turn(true, shelf.sequence()); + assert_eq!( + shelf.placed.borrow().len(), + 3, + "and it dresses again next time" + ); +} + +#[test] +fn a_copy_made_inside_the_terminal_is_left_alone_on_the_way_out() { + let (shelf, dressing) = wardrobe(7, picture(7, 3)); + dressing.turn(true, shelf.sequence()); + shelf.sequence.set(20); + dressing.turn(false, shelf.sequence()); + assert_eq!(shelf.placed.borrow().len(), 1); + assert_eq!(dressing.worn.get(), None); +} + +#[test] +fn a_write_the_clipboard_refuses_leaves_nothing_worn() { + let (shelf, dressing) = wardrobe(7, picture(7, 3)); + shelf.refuses.set(true); + dressing.turn(true, shelf.sequence()); + assert_eq!(dressing.worn.get(), None); + shelf.refuses.set(false); + dressing.turn(true, shelf.sequence()); + shelf.refuses.set(true); + dressing.turn(false, shelf.sequence()); + assert_eq!(dressing.worn.get(), None); + assert_eq!( + shelf.copied.get(), + picture(7, 3), + "nothing claims the clipboard went back" + ); +} + +#[test] +fn a_clipboard_that_cannot_be_counted_is_never_dressed() { + let (shelf, dressing) = wardrobe(7, picture(7, 3)); + shelf.blind.set(true); + dressing.turn(true, shelf.sequence()); + assert!(shelf.placed.borrow().is_empty()); + shelf.blind.set(false); + dressing.turn(true, shelf.sequence()); + shelf.blind.set(true); + dressing.turn(true, Some(8)); + assert_eq!(dressing.worn.get(), Some(Worn { sequence: 8, id: 3 })); +} + +#[test] +fn a_window_that_is_no_terminal_coming_forward_takes_the_path_off() { + let (shelf, dressing) = wardrobe(7, picture(7, 3)); + dressing.turn(true, shelf.sequence()); + dressing.heard(0); + assert_eq!(shelf.placed.borrow().last(), Some(&(3, Towards::Elsewhere))); +} + +#[test] +fn starting_listens_for_windows_where_the_system_can_tell() { + let dressing = Dressing::start(Rc::new(Shelf::default())); + assert_eq!(dressing.listening(), cfg!(target_os = "windows")); +} diff --git a/crates/cp-panel/src/engine.rs b/crates/cp-panel/src/engine.rs index 381b55ec..a4bc0a71 100644 --- a/crates/cp-panel/src/engine.rs +++ b/crates/cp-panel/src/engine.rs @@ -1,3 +1,4 @@ +use crate::dressing::Copied; use crate::here; use crate::note::note; use cp_core::capture::{Captured, Unreadable}; @@ -12,6 +13,7 @@ use std::sync::atomic::Ordering; pub struct Engine { watching: here::Watching, + copied: Arc>>, rest: Arc, errands: std::sync::Mutex>>, } @@ -26,8 +28,10 @@ impl Engine { let rest = Arc::new(Rest::new()); let waking = rest.clone(); let mut notice = Notice::default(); + let copied = Arc::new(std::sync::Mutex::new(None)); + let noted = copied.clone(); let watching = here::watch_start(move || { - if let Some(id) = kept(&store, &mut notice) { + if let Some(id) = kept(&store, &mut notice, ¬ed) { waking.wake(); fresh(id); } @@ -35,6 +39,7 @@ impl Engine { let errands = errands(db, rest.clone(), Box::new(changed)); Ok(Self { watching, + copied, rest, errands: std::sync::Mutex::new(errands), }) @@ -63,6 +68,18 @@ impl Engine { pub fn ours(&self) -> bool { self.watching.ours() } + + pub fn copied(&self) -> Option { + self.copied.lock().ok().and_then(|held| *held) + } + + pub fn copied_again(&self, sequence: i64) { + if let Ok(mut held) = self.copied.lock() + && let Some(copied) = held.as_mut() + { + copied.sequence = sequence; + } + } } impl Drop for Engine { @@ -417,10 +434,19 @@ fn give_up(store: &Store, id: i64, job: &str, why: &str, at: i64) -> bool { } } -fn kept(store: &Store, notice: &mut Notice) -> Option { +fn kept( + store: &Store, + notice: &mut Notice, + copied: &std::sync::Mutex>, +) -> Option { + let before = here::sequence(); let item = taken(here::capture_insisting(), here::unreadable, notice)?; let from = here::in_front(); - keep(store, &item, crate::app::now_ms(), from.as_deref()) + let id = keep(store, &item, crate::app::now_ms(), from.as_deref())?; + if let Ok(mut held) = copied.lock() { + *held = Copied::of(before, here::sequence(), id, &item); + } + Some(id) } fn taken( diff --git a/crates/cp-panel/src/engine_test.rs b/crates/cp-panel/src/engine_test.rs index 6ffa6f25..93225aaa 100644 --- a/crates/cp-panel/src/engine_test.rs +++ b/crates/cp-panel/src/engine_test.rs @@ -681,3 +681,32 @@ fn a_counted_folder_is_told_and_a_missing_one_is_not() { store.enqueue(id, "folder").expect("queued again"); assert!(told(&store, &thumbs).is_empty()); } + +#[test] +fn the_engine_remembers_the_copy_on_the_clipboard_and_hears_when_it_comes_back() { + let dir = aside("remembering"); + let engine = Engine::start(&dir.join("history.db"), |_| {}, |_| {}).expect("an engine"); + *engine.copied.lock().expect("unpoisoned") = None; + engine.copied_again(9); + assert_eq!( + engine.copied(), + None, + "nothing to move when nothing was copied" + ); + let copy = crate::dressing::Copied { + sequence: 7, + id: 3, + picture: true, + }; + *engine.copied.lock().expect("unpoisoned") = Some(copy); + engine.copied_again(9); + assert_eq!( + engine.copied(), + Some(crate::dressing::Copied { + sequence: 9, + ..copy + }) + ); + drop(engine); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/cp-panel/src/handing.rs b/crates/cp-panel/src/handing.rs index 750f2167..8fc1445e 100644 --- a/crates/cp-panel/src/handing.rs +++ b/crates/cp-panel/src/handing.rs @@ -22,7 +22,11 @@ fn landing_for(store: &Store, item: &cp_core::item::Item, id: i64, towards: Towa label.as_deref(), std::time::SystemTime::now(), ); - Landing { towards, files } + Landing { + towards, + files, + unrecorded: false, + } } pub fn hand_over( @@ -30,31 +34,76 @@ pub fn hand_over( engine: Option<&crate::engine::Engine>, id: i64, towards: Towards, +) -> bool { + let written = placed(store, engine, id, towards, false); + if written { + if let Err(why) = store.record_paste(id, crate::app::now_ms()) { + note(&format!("{id} was pasted and nobody wrote it down: {why}")); + } + } else { + note(&format!( + "pasting {id}: the write never reached the clipboard" + )); + } + written +} + +pub fn dress( + store: &Store, + engine: Option<&crate::engine::Engine>, + id: i64, + towards: Towards, +) -> bool { + placed(store, engine, id, towards, true) +} + +fn placed( + store: &Store, + engine: Option<&crate::engine::Engine>, + id: i64, + towards: Towards, + unrecorded: bool, ) -> bool { let item = match store.item(id) { Ok(Some(item)) => item, Ok(None) => { - note(&format!("pasting {id}: it is no longer in the store")); + note(&format!("placing {id}: it is no longer in the store")); return false; } Err(why) => { - note(&format!("pasting {id}: {why}")); + note(&format!("placing {id}: {why}")); return false; } }; - let landing = landing_for(store, &item, id, towards); + let landing = Landing { + unrecorded, + ..landing_for(store, &item, id, towards) + }; let landed = here::to_clipboard(&item, &landing, || starting(engine), || mark(engine)); - let written = short_of(id, landed); - if written { - if let Err(why) = store.record_paste(id, crate::app::now_ms()) { - note(&format!("{id} was pasted and nobody wrote it down: {why}")); - } - } else { - note(&format!( - "pasting {id}: the write never reached the clipboard" - )); + short_of(id, landed) +} + +pub struct Shelves { + pub store: std::rc::Rc, + pub engine: std::rc::Rc, +} + +impl crate::dressing::Closet for Shelves { + fn copied(&self) -> Option { + self.engine.copied() + } + + fn copied_again(&self, sequence: i64) { + self.engine.copied_again(sequence); + } + + fn place(&self, id: i64, towards: Towards) -> bool { + dress(&self.store, Some(&self.engine), id, towards) + } + + fn sequence(&self) -> Option { + here::sequence() } - written } fn glimpse(rendered: Option) -> String { diff --git a/crates/cp-panel/src/here.rs b/crates/cp-panel/src/here.rs index 6e51261f..f04dc045 100644 --- a/crates/cp-panel/src/here.rs +++ b/crates/cp-panel/src/here.rs @@ -6,11 +6,11 @@ use raw_window_handle::RawWindowHandle; use std::path::{Path, PathBuf}; pub use platform::{ - Dragged, THUMBNAILS_FILES, Watching, activated, ahead_now, capture_insisting, content_of, - data_dir, drag_out, dress, forward, ground, hand_back, host, in_front, keep_awake, media_of, - ocr_available, open_link, open_path, ours_up_front, paste_into, pointer, read_stuck, - system_is_light, text_in, thumb_of_file, thumbs_dir, to_clipboard, towards, unreadable, - utc_offset_at, watch_start, + Dragged, Listening, THUMBNAILS_FILES, Watching, activated, ahead_now, capture_insisting, + content_of, data_dir, drag_out, dress, forward, ground, hand_back, host, in_front, keep_awake, + media_of, ocr_available, on_foreground, open_link, open_path, ours_up_front, paste_into, + pointer, read_stuck, sequence, system_is_light, text_in, thumb_of_file, thumbs_dir, + to_clipboard, towards, unreadable, utc_offset_at, watch_start, }; #[derive(Debug, Clone, Copy, PartialEq)] @@ -47,6 +47,16 @@ mod platform { cp_win::watching::every(cp_core::watching::EVERY, on_fresh) } + pub fn sequence() -> Option { + cp_win_sys::clipboard::sequence() + } + + pub type Listening = cp_win_sys::foreground::Listening; + + pub fn on_foreground(heard: impl Fn(isize) + 'static) -> Option { + cp_win_sys::foreground::listen(heard) + } + pub fn data_dir() -> Option { cp_win_sys::paths::data_dir() } @@ -124,6 +134,9 @@ mod platform { crate::landing::Offer::Text(text) => ready.offer_text(&text), crate::landing::Offer::Nothing => {} } + if landing.unrecorded { + ready.unrecorded(); + } starting(); let Some(clipboard) = Clipboard::to_write() else { ours(); @@ -272,6 +285,16 @@ mod platform { cp_mac::watching::every(cp_core::watching::EVERY, on_fresh) } + pub fn sequence() -> Option { + None + } + + pub struct Listening; + + pub fn on_foreground(_heard: impl Fn(isize) + 'static) -> Option { + None + } + pub fn data_dir() -> Option { cp_mac_sys::paths::data_dir() } diff --git a/crates/cp-panel/src/landing.rs b/crates/cp-panel/src/landing.rs index 90514800..20fa63d7 100644 --- a/crates/cp-panel/src/landing.rs +++ b/crates/cp-panel/src/landing.rs @@ -8,6 +8,7 @@ pub enum Towards { pub struct Landing { pub towards: Towards, pub files: Vec, + pub unrecorded: bool, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -22,6 +23,7 @@ impl Landing { Self { towards: Towards::Elsewhere, files: Vec::new(), + unrecorded: false, } } diff --git a/crates/cp-panel/src/landing_test.rs b/crates/cp-panel/src/landing_test.rs index 5ee99859..a2dd95bc 100644 --- a/crates/cp-panel/src/landing_test.rs +++ b/crates/cp-panel/src/landing_test.rs @@ -156,6 +156,7 @@ fn landing(towards: Towards, files: &[&str]) -> Landing { Landing { towards, files: files.iter().map(std::path::PathBuf::from).collect(), + unrecorded: false, } } diff --git a/crates/cp-panel/src/main.rs b/crates/cp-panel/src/main.rs index a9bd34a7..12206028 100644 --- a/crates/cp-panel/src/main.rs +++ b/crates/cp-panel/src/main.rs @@ -2,6 +2,7 @@ mod age; mod app; mod aside; mod dragging; +mod dressing; mod engine; mod excuse; mod face; diff --git a/crates/cp-win-sys/src/foreground.rs b/crates/cp-win-sys/src/foreground.rs new file mode 100644 index 00000000..c173d0cc --- /dev/null +++ b/crates/cp-win-sys/src/foreground.rs @@ -0,0 +1,65 @@ +use std::cell::RefCell; +use windows::Win32::Foundation::HWND; +use windows::Win32::UI::Accessibility::{HWINEVENTHOOK, SetWinEventHook, UnhookWinEvent}; +use windows::Win32::UI::WindowsAndMessaging::{ + EVENT_SYSTEM_FOREGROUND, WINEVENT_OUTOFCONTEXT, WINEVENT_SKIPOWNPROCESS, +}; + +type Heard = Box; + +thread_local! { + static HEARD: RefCell> = const { RefCell::new(None) }; +} + +pub struct Listening(HWINEVENTHOOK); + +impl Drop for Listening { + fn drop(&mut self) { + let _ = unsafe { UnhookWinEvent(self.0) }; + HEARD.with(|heard| heard.borrow_mut().take()); + } +} + +pub fn listen(heard: impl Fn(isize) + 'static) -> Option { + HEARD.with(|held| *held.borrow_mut() = Some(Box::new(heard))); + let hook = unsafe { + SetWinEventHook( + EVENT_SYSTEM_FOREGROUND, + EVENT_SYSTEM_FOREGROUND, + None, + Some(changed), + 0, + 0, + WINEVENT_OUTOFCONTEXT | WINEVENT_SKIPOWNPROCESS, + ) + }; + if hook.is_invalid() { + HEARD.with(|held| held.borrow_mut().take()); + return None; + } + Some(Listening(hook)) +} + +fn told(window: HWND) { + HEARD.with(|held| { + if let Some(heard) = held.borrow().as_ref() { + heard(window.0 as isize); + } + }); +} + +unsafe extern "system" fn changed( + _hook: HWINEVENTHOOK, + _event: u32, + window: HWND, + _object: i32, + _child: i32, + _thread: u32, + _time: u32, +) { + told(window); +} + +#[cfg(test)] +#[path = "foreground_test.rs"] +mod tests; diff --git a/crates/cp-win-sys/src/foreground_test.rs b/crates/cp-win-sys/src/foreground_test.rs new file mode 100644 index 00000000..23f52043 --- /dev/null +++ b/crates/cp-win-sys/src/foreground_test.rs @@ -0,0 +1,34 @@ +use super::*; +use std::cell::Cell; +use std::rc::Rc; + +#[test] +fn a_window_coming_forward_reaches_whoever_listens_and_stops_once_they_stop() { + let seen = Rc::new(Cell::new(0isize)); + let heard = seen.clone(); + let listening = listen(move |window| heard.set(window)).expect("the system listens"); + told(HWND(7 as *mut _)); + assert_eq!(seen.get(), 7); + drop(listening); + told(HWND(9 as *mut _)); + assert_eq!(seen.get(), 7, "nobody hears after the listening stopped"); +} + +#[test] +fn the_system_calls_back_through_the_same_door() { + let seen = Rc::new(Cell::new(0isize)); + let heard = seen.clone(); + let _listening = listen(move |window| heard.set(window)).expect("the system listens"); + unsafe { + changed( + HWINEVENTHOOK::default(), + EVENT_SYSTEM_FOREGROUND, + HWND(3 as *mut _), + 0, + 0, + 0, + 0, + ) + }; + assert_eq!(seen.get(), 3); +} diff --git a/crates/cp-win-sys/src/lib.rs b/crates/cp-win-sys/src/lib.rs index f6e2a51d..accca81d 100644 --- a/crates/cp-win-sys/src/lib.rs +++ b/crates/cp-win-sys/src/lib.rs @@ -6,6 +6,7 @@ pub mod clock; pub mod com; pub mod dragging; pub mod files; +pub mod foreground; pub mod formats; pub mod frontmost; pub mod keystroke; diff --git a/crates/cp-win/src/restore.rs b/crates/cp-win/src/restore.rs index 07fc8578..158a820a 100644 --- a/crates/cp-win/src/restore.rs +++ b/crates/cp-win/src/restore.rs @@ -11,6 +11,7 @@ use crate::virtual_files::{self, Materialized}; const PNG: &str = "PNG"; const JFIF: &str = "JFIF"; const DROP_EFFECT: &str = "Preferred DropEffect"; +pub const UNRECORDED: [&str; 2] = ["CanIncludeInClipboardHistory", "CanUploadToCloudClipboard"]; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Restored { @@ -59,6 +60,13 @@ impl Ready { self.owned.push((CF_UNICODETEXT, utf16_of(text))); } + pub fn unrecorded(&mut self) { + for id in UNRECORDED.iter().filter_map(|name| id_of(name)) { + self.owned.retain(|(kept, _)| *kept != id); + self.owned.push((id, 0u32.to_le_bytes().to_vec())); + } + } + pub fn offers(&self, id: u32) -> bool { self.owned.iter().any(|(kept, _)| *kept == id) } diff --git a/crates/cp-win/src/restore_test.rs b/crates/cp-win/src/restore_test.rs index e45f30c6..eb3ef69b 100644 --- a/crates/cp-win/src/restore_test.rs +++ b/crates/cp-win/src/restore_test.rs @@ -330,3 +330,22 @@ fn ids_the_clipboard_refused_are_counted_against_what_it_was_given() { assert_eq!(ready.wanted(), 2); assert_eq!(ready.fitted(1), (1, 2)); } + +#[test] +fn a_write_kept_out_of_the_history_says_so_once_per_door() { + let mut ready = an_image(); + let before = ready.wanted(); + ready.unrecorded(); + ready.unrecorded(); + assert_eq!(ready.wanted(), before + UNRECORDED.len()); + for name in UNRECORDED { + let id = id_of(name).expect("a format the system registers"); + assert!( + ready + .owned + .iter() + .any(|(kept, bytes)| *kept == id && *bytes == 0u32.to_le_bytes()) + ); + } + assert!(ready.offers(CF_DIB), "the picture itself stays"); +} From c408fc6734366a7efc3d302c6465b11463475319 Mon Sep 17 00:00:00 2001 From: rgdevment Date: Tue, 20 Oct 2026 16:53:38 -0300 Subject: [PATCH 2/7] docs(news): 3.4.0 also brings pictures that reach the terminal --- app/src/news.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/app/src/news.json b/app/src/news.json index 468b5d59..a5383824 100644 --- a/app/src/news.json +++ b/app/src/news.json @@ -257,6 +257,10 @@ { "title": "Tus atajos, a tu gusto", "said": "en Ajustes › Atajos de teclado puedes cambiar o quitar cada uno, y si otro programa ya usa una combinación, CoPanda te sugiere otras libres." + }, + { + "title": "Imágenes que llegan a la terminal", + "said": "en Windows, si copias una imagen y pasas a una terminal, Ctrl+V pega la ruta del archivo lista para usar; al volver a cualquier otra app, se pega la imagen como siempre." } ], "en": [ @@ -271,6 +275,10 @@ { "title": "Your shortcuts, your way", "said": "in Settings › Keyboard shortcuts you can change or remove each one, and if another program already uses a combination, CoPanda suggests free ones." + }, + { + "title": "Pictures that reach the terminal", + "said": "on Windows, if you copy a picture and switch to a terminal, Ctrl+V pastes the path to its file, ready to use; back in any other app, the picture pastes as always." } ] } From a6c46aaa08110ee3d173b1c1abdf46804cfbd764 Mon Sep 17 00:00:00 2001 From: rgdevment Date: Tue, 20 Oct 2026 17:23:35 -0300 Subject: [PATCH 3/7] fix(win): the terminal path goes only on a picture that rebuilds whole, never over a newer copy, and off on quit --- crates/cp-core/src/watch.rs | 5 + crates/cp-core/src/watch_test.rs | 18 ++ crates/cp-core/src/watching.rs | 8 + crates/cp-core/src/watching_test.rs | 24 +++ crates/cp-panel/src/app.rs | 47 +++-- crates/cp-panel/src/dressing.rs | 136 ++++++++------ crates/cp-panel/src/dressing_test.rs | 218 ++++++++++++----------- crates/cp-panel/src/engine.rs | 33 +--- crates/cp-panel/src/engine_test.rs | 33 +--- crates/cp-panel/src/handing.rs | 102 ++++++----- crates/cp-panel/src/here.rs | 39 +++- crates/cp-panel/src/landing.rs | 4 +- crates/cp-panel/src/landing_test.rs | 2 +- crates/cp-panel/ui/panel.slint | 1 + crates/cp-win-sys/src/foreground.rs | 33 ++-- crates/cp-win-sys/src/foreground_test.rs | 31 +++- crates/cp-win/src/restore.rs | 22 +++ crates/cp-win/src/restore_test.rs | 47 +++++ 18 files changed, 511 insertions(+), 292 deletions(-) diff --git a/crates/cp-core/src/watch.rs b/crates/cp-core/src/watch.rs index aeedad7d..c5fb09ca 100644 --- a/crates/cp-core/src/watch.rs +++ b/crates/cp-core/src/watch.rs @@ -78,6 +78,11 @@ impl Watcher { self.ours = Some((from, None)); } + pub fn abandoned(&mut self, before: i64) { + self.ours = None; + self.last = Some(before); + } + pub fn wrote(&mut self, count: i64) { let from = self.ours.map_or(count, |(from, _)| from); self.ours = Some((from, Some(count))); diff --git a/crates/cp-core/src/watch_test.rs b/crates/cp-core/src/watch_test.rs index bd5eff31..df91f1b7 100644 --- a/crates/cp-core/src/watch_test.rs +++ b/crates/cp-core/src/watch_test.rs @@ -104,3 +104,21 @@ fn a_counter_that_goes_backwards_is_a_new_session_not_a_burst() { assert_eq!(watcher.tick(3), Seen::Fresh { skipped: Some(0) }); assert_eq!(watcher.missed(), Some(0)); } + +#[test] +fn a_write_given_up_lets_the_copy_that_beat_it_be_seen() { + let mut watcher = Watcher::new(Cadence::Opaque); + watcher.tick(10); + watcher.writing(10); + assert_eq!( + watcher.tick(11), + Seen::Ours, + "while writing, the next count looks ours" + ); + watcher.abandoned(10); + assert!( + matches!(watcher.tick(11), Seen::Fresh { .. }), + "once given up, that same count is someone else's copy" + ); + assert_eq!(watcher.tick(11), Seen::Nothing); +} diff --git a/crates/cp-core/src/watching.rs b/crates/cp-core/src/watching.rs index e57532c5..bca975f7 100644 --- a/crates/cp-core/src/watching.rs +++ b/crates/cp-core/src/watching.rs @@ -80,6 +80,14 @@ impl Watching { true } + pub fn abandoned(&self, before: i64) -> bool { + let Ok(mut watcher) = self.watcher.lock() else { + return false; + }; + watcher.abandoned(before); + true + } + pub fn ours(&self) -> bool { let Some(count) = (self.count)() else { return false; diff --git a/crates/cp-core/src/watching_test.rs b/crates/cp-core/src/watching_test.rs index aabd5cfe..0d6121c0 100644 --- a/crates/cp-core/src/watching_test.rs +++ b/crates/cp-core/src/watching_test.rs @@ -224,3 +224,27 @@ fn closing_in_the_middle_of_a_long_period_is_prompt() { assert!(watching.close()); assert!(started.elapsed() < Duration::from_millis(400)); } + +#[test] +fn a_copy_that_lands_while_a_write_is_given_up_still_wakes_the_watcher() { + let count = Arc::new(AtomicI64::new(30)); + let seen = Arc::new(AtomicUsize::new(0)); + let watching = Watching::every( + Cadence::Opaque, + QUICK, + NAP, + at(&count), + never_busy(), + counted(&seen), + ); + std::thread::sleep(QUICK * 4); + assert!(watching.writing()); + count.store(31, Ordering::Relaxed); + std::thread::sleep(QUICK * 4); + assert!(watching.abandoned(30)); + assert!( + waited_for(&seen, 1) >= 1, + "the copy that beat the write is kept" + ); + assert!(watching.close()); +} diff --git a/crates/cp-panel/src/app.rs b/crates/cp-panel/src/app.rs index 14cf949e..bc6def97 100644 --- a/crates/cp-panel/src/app.rs +++ b/crates/cp-panel/src/app.rs @@ -129,6 +129,10 @@ impl App { } pub fn close(&self) { + let dressing = self.state.borrow().dressing.clone(); + if let Some(dressing) = dressing { + dressing.put_back(); + } let engine = self.state.borrow().engine.clone(); if let Some(engine) = engine { engine.close(); @@ -171,11 +175,17 @@ impl App { fn keep_watch(&self, ui: slint::Weak) { let db = self.state.borrow().options.db.clone(); let drawn = ui.clone(); - let arrived = move |_| { + let latest: crate::dressing::Latest = Arc::new(std::sync::Mutex::new(None)); + let noted = latest.clone(); + let arrived = move |_, copied| { + if let Ok(mut held) = noted.lock() { + *held = copied; + } let _ = ui.upgrade_in_event_loop(|panel| { if panel.window().is_visible() && !panel.get_sheet_open() { panel.invoke_arrived(); } + panel.invoke_copied(); }); }; let changed = move |id| { @@ -186,19 +196,8 @@ impl App { match crate::engine::Engine::start(&db, arrived, changed) { Ok(engine) => { let engine = Rc::new(engine); - let store = self.state.borrow().store.clone(); - let dressing = crate::dressing::Dressing::start(crate::handing::Shelves { - store, - engine: engine.clone(), - }); - if !dressing.listening() { - note( - "nobody hears which window comes forward, so terminals get no image paths", - ); - } - let mut state = self.state.borrow_mut(); - state.engine = Some(engine); - state.dressing = Some(dressing); + self.dress_terminals(&engine, latest); + self.state.borrow_mut().engine = Some(engine); } Err(why) => crate::note::trouble( cp_core::trouble::Trouble::Unwatched, @@ -207,6 +206,26 @@ impl App { } } + fn dress_terminals(&self, engine: &Rc, latest: crate::dressing::Latest) { + if !cfg!(target_os = "windows") { + return; + } + let store = self.state.borrow().store.clone(); + let shelves = crate::handing::Shelves { + store, + engine: engine.clone(), + }; + let dressing = crate::dressing::Dressing::start(shelves, latest); + if !dressing.listening() { + note("nobody hears which window comes forward, so terminals get no image paths"); + } + if let Some(panel) = self.ui.upgrade() { + let heard = dressing.clone(); + panel.on_copied(move || heard.arrived()); + } + self.state.borrow_mut().dressing = Some(dressing); + } + pub fn ui(&self) -> slint::Weak { self.ui.clone() } diff --git a/crates/cp-panel/src/dressing.rs b/crates/cp-panel/src/dressing.rs index 882df144..a7a115b2 100644 --- a/crates/cp-panel/src/dressing.rs +++ b/crates/cp-panel/src/dressing.rs @@ -2,28 +2,29 @@ use crate::here; use crate::landing::Towards; use cp_core::item::Item; use cp_core::kind::Kind; -use std::cell::{Cell, RefCell}; -use std::rc::Rc; +use std::cell::Cell; +use std::rc::{Rc, Weak}; +use std::sync::{Arc, Mutex}; use std::time::Duration; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct Copied { pub sequence: i64, pub id: i64, - pub picture: bool, } impl Copied { pub fn of(before: Option, after: Option, id: i64, item: &Item) -> Option { + if item.kind != Some(Kind::Image) { + return None; + } let sequence = before.filter(|was| Some(*was) == after)?; - Some(Self { - sequence, - id, - picture: item.kind == Some(Kind::Image), - }) + Some(Self { sequence, id }) } } +pub type Latest = Arc>>; + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct Worn { pub sequence: i64, @@ -41,84 +42,109 @@ pub fn still_worn(worn: Option, now: Option) -> Option { worn.filter(|was| Some(was.sequence) == now) } -pub fn step(worn: Option, terminal: bool, now: Option, copied: Option) -> Step { - match (worn, terminal) { - (Some(_), true) => Step::Stay, - (Some(was), false) => Step::Undress(was.id), - (None, false) => Step::Stay, - (None, true) => match copied { - Some(copy) if copy.picture && Some(copy.sequence) == now => Step::Dress(copy.id), - _ => Step::Stay, - }, +pub fn still_copied(copied: Option, now: Option) -> Option { + copied.filter(|copy| Some(copy.sequence) == now) +} + +pub fn step(worn: Option, terminal: bool, copied: Option) -> Step { + match (worn, terminal, copied) { + (Some(_), true, _) => Step::Stay, + (Some(was), false, _) => Step::Undress(was.id), + (None, true, Some(copy)) => Step::Dress(copy.id), + (None, _, _) => Step::Stay, } } pub trait Closet { - fn copied(&self) -> Option; - fn copied_again(&self, sequence: i64); - fn place(&self, id: i64, towards: Towards) -> bool; + fn place(&self, id: i64, towards: Towards, over: i64) -> Option; fn sequence(&self) -> Option; + fn front(&self) -> isize; + fn terminal(&self, window: isize) -> bool; } pub struct Dressing { closet: C, + latest: Latest, worn: Cell>, - listening: RefCell>, + listening: Option, } impl Dressing { - pub fn start(closet: C) -> Rc { - let dressing = Rc::new(Self { - closet, - worn: Cell::new(None), - listening: RefCell::new(None), - }); - let weak = Rc::downgrade(&dressing); - let listening = here::on_foreground(move |window| { - let later = weak.clone(); - slint::Timer::single_shot(Duration::ZERO, move || { - if let Some(dressing) = later.upgrade() { - dressing.heard(window); - } + pub fn start(closet: C, latest: Latest) -> Rc { + Rc::new_cyclic(|weak: &Weak| { + let weak = weak.clone(); + let listening = here::on_foreground(move |window| { + let later = weak.clone(); + slint::Timer::single_shot(Duration::ZERO, move || { + if let Some(dressing) = later.upgrade() { + dressing.look(window); + } + }); }); - }); - dressing.listening.replace(listening); - dressing + Self { + closet, + latest, + worn: Cell::new(None), + listening, + } + }) } pub fn listening(&self) -> bool { - self.listening.borrow().is_some() + self.listening.is_some() + } + + pub fn arrived(&self) { + self.look(self.closet.front()); } - fn heard(&self, window: isize) { - self.turn( - here::towards(window) == Towards::Terminal, - self.closet.sequence(), - ); + pub fn put_back(&self) { + let now = self.closet.sequence(); + if let Some(was) = still_worn(self.worn.get(), now) { + self.undress(was); + } } - fn turn(&self, terminal: bool, now: Option) { + fn copied(&self) -> Option { + self.latest.lock().ok().and_then(|held| *held) + } + + fn look(&self, window: isize) { + let now = self.closet.sequence(); let worn = still_worn(self.worn.get(), now); self.worn.set(worn); - match step(worn, terminal, now, self.closet.copied()) { + let copied = still_copied(self.copied(), now); + if worn.is_none() && copied.is_none() { + return; + } + match step(worn, self.closet.terminal(window), copied) { Step::Stay => {} Step::Dress(id) => { - if self.closet.place(id, Towards::Terminal) { - let sequence = self.closet.sequence(); - self.worn - .set(sequence.map(|sequence| Worn { sequence, id })); + if let Some(over) = now { + let placed = self.closet.place(id, Towards::Terminal, over); + self.worn.set(placed.map(|sequence| Worn { sequence, id })); } } - Step::Undress(id) => { - self.worn.set(None); - if self.closet.place(id, Towards::Elsewhere) - && let Some(sequence) = self.closet.sequence() - { - self.closet.copied_again(sequence); + Step::Undress(_) => { + if let Some(was) = worn { + self.undress(was); } } } } + + fn undress(&self, was: Worn) { + let Some(sequence) = self.closet.place(was.id, Towards::Elsewhere, was.sequence) else { + return; + }; + self.worn.set(None); + if let Ok(mut held) = self.latest.lock() { + *held = Some(Copied { + sequence, + id: was.id, + }); + } + } } #[cfg(test)] diff --git a/crates/cp-panel/src/dressing_test.rs b/crates/cp-panel/src/dressing_test.rs index f1a4654f..1137ff3e 100644 --- a/crates/cp-panel/src/dressing_test.rs +++ b/crates/cp-panel/src/dressing_test.rs @@ -1,14 +1,8 @@ use super::*; -use std::cell::{Cell, RefCell}; -use std::rc::Rc; +use std::cell::RefCell; -fn picture(sequence: i64, id: i64) -> Option { - Some(Copied { - sequence, - id, - picture: true, - }) -} +const TERMINAL: isize = 1; +const CHAT: isize = 2; fn item(kind: Kind) -> Item { Item { @@ -18,125 +12,114 @@ fn item(kind: Kind) -> Item { } #[test] -fn a_copy_is_remembered_only_when_nothing_was_copied_while_it_was_read() { +fn only_a_picture_read_while_nothing_else_was_copied_is_remembered() { let image = item(Kind::Image); - assert_eq!(Copied::of(Some(7), Some(7), 3, &image), picture(7, 3)); + assert_eq!( + Copied::of(Some(7), Some(7), 3, &image), + Some(Copied { sequence: 7, id: 3 }) + ); assert_eq!(Copied::of(Some(7), Some(8), 3, &image), None); assert_eq!(Copied::of(None, None, 3, &image), None); - let words = Copied::of(Some(7), Some(7), 3, &item(Kind::Text)).expect("remembered"); - assert!(!words.picture); -} - -#[test] -fn a_picture_still_on_the_clipboard_gets_its_path_in_a_terminal() { - assert_eq!(step(None, true, Some(7), picture(7, 3)), Step::Dress(3)); + assert_eq!(Copied::of(Some(7), Some(7), 3, &item(Kind::Text)), None); } #[test] -fn nothing_is_dressed_outside_a_terminal_or_once_something_newer_was_copied() { - assert_eq!(step(None, false, Some(7), picture(7, 3)), Step::Stay); - assert_eq!(step(None, true, Some(8), picture(7, 3)), Step::Stay); - assert_eq!(step(None, true, None, picture(7, 3)), Step::Stay); - assert_eq!(step(None, true, Some(7), None), Step::Stay); - let words = Some(Copied { - sequence: 7, - id: 3, - picture: false, - }); - assert_eq!(step(None, true, Some(7), words), Step::Stay); -} - -#[test] -fn leaving_the_terminal_takes_the_path_off_and_moving_between_terminals_keeps_it() { +fn the_steps_put_the_path_on_in_a_terminal_and_take_it_off_elsewhere() { + let copy = Some(Copied { sequence: 7, id: 3 }); let worn = Some(Worn { sequence: 9, id: 3 }); - assert_eq!(step(worn, false, Some(9), picture(7, 3)), Step::Undress(3)); - assert_eq!(step(worn, true, Some(9), picture(7, 3)), Step::Stay); + assert_eq!(step(None, true, copy), Step::Dress(3)); + assert_eq!(step(None, false, copy), Step::Stay); + assert_eq!(step(None, true, None), Step::Stay); + assert_eq!(step(worn, true, copy), Step::Stay); + assert_eq!(step(worn, false, None), Step::Undress(3)); } #[test] -fn a_dress_is_forgotten_as_soon_as_anyone_else_writes_the_clipboard() { +fn what_is_worn_or_copied_counts_only_while_the_clipboard_still_holds_it() { let worn = Some(Worn { sequence: 9, id: 3 }); + let copy = Some(Copied { sequence: 7, id: 3 }); assert_eq!(still_worn(worn, Some(9)), worn); assert_eq!(still_worn(worn, Some(10)), None); assert_eq!(still_worn(worn, None), None); - assert_eq!(still_worn(None, Some(9)), None); - assert_eq!( - step(still_worn(worn, Some(10)), false, Some(10), picture(7, 3)), - Step::Stay, - "someone else's copy is never undressed" - ); + assert_eq!(still_copied(copy, Some(7)), copy); + assert_eq!(still_copied(copy, Some(8)), None); + assert_eq!(still_copied(None, Some(7)), None); } #[derive(Default)] struct Shelf { - copied: Cell>, sequence: Cell, - placed: RefCell>, + front: Cell, + placed: RefCell>, refuses: Cell, blind: Cell, + looked: Cell, } impl Closet for Rc { - fn copied(&self) -> Option { - self.copied.get() - } - - fn copied_again(&self, sequence: i64) { - if let Some(mut copy) = self.copied.get() { - copy.sequence = sequence; - self.copied.set(Some(copy)); - } - } - - fn place(&self, id: i64, towards: Towards) -> bool { - if self.refuses.get() { - return false; + fn place(&self, id: i64, towards: Towards, over: i64) -> Option { + if self.refuses.get() || over != self.sequence.get() { + return None; } - self.placed.borrow_mut().push((id, towards)); + self.placed.borrow_mut().push((id, towards, over)); self.sequence.set(self.sequence.get() + 1); - true + Some(self.sequence.get()) } fn sequence(&self) -> Option { (!self.blind.get()).then(|| self.sequence.get()) } + + fn front(&self) -> isize { + self.front.get() + } + + fn terminal(&self, window: isize) -> bool { + self.looked.set(self.looked.get() + 1); + window == TERMINAL + } } -fn wardrobe(sequence: i64, copied: Option) -> (Rc, Dressing>) { +fn wardrobe(copied: Option) -> (Rc, Dressing>, Latest) { let shelf = Rc::new(Shelf::default()); - shelf.sequence.set(sequence); - shelf.copied.set(copied); + shelf.sequence.set(7); + let latest: Latest = Arc::new(Mutex::new(copied)); let dressing = Dressing { closet: shelf.clone(), + latest: latest.clone(), worn: Cell::new(None), - listening: RefCell::new(None), + listening: None, }; - (shelf, dressing) + (shelf, dressing, latest) +} + +fn picture() -> Option { + Some(Copied { sequence: 7, id: 3 }) } #[test] fn into_a_terminal_and_out_again_puts_the_path_on_and_takes_it_off() { - let (shelf, dressing) = wardrobe(7, picture(7, 3)); - dressing.turn(true, shelf.sequence()); + let (shelf, dressing, latest) = wardrobe(picture()); + dressing.look(TERMINAL); assert_eq!(dressing.worn.get(), Some(Worn { sequence: 8, id: 3 })); - dressing.turn(true, shelf.sequence()); + dressing.look(TERMINAL); assert_eq!( shelf.placed.borrow().len(), 1, "a second terminal changes nothing" ); - dressing.turn(false, shelf.sequence()); + dressing.look(CHAT); assert_eq!( *shelf.placed.borrow(), - [(3, Towards::Terminal), (3, Towards::Elsewhere)] + [(3, Towards::Terminal, 7), (3, Towards::Elsewhere, 8)] ); assert_eq!(dressing.worn.get(), None); assert_eq!( - shelf.copied.get(), - picture(9, 3), - "the picture is known again" + *latest.lock().expect("unpoisoned"), + Some(Copied { sequence: 9, id: 3 }), + "the picture alone is known as the copy again" ); - dressing.turn(true, shelf.sequence()); + dressing.look(TERMINAL); assert_eq!( shelf.placed.borrow().len(), 3, @@ -144,57 +127,92 @@ fn into_a_terminal_and_out_again_puts_the_path_on_and_takes_it_off() { ); } +#[test] +fn a_picture_that_arrives_while_a_terminal_is_in_front_is_dressed_right_away() { + let (shelf, dressing, latest) = wardrobe(None); + shelf.front.set(TERMINAL); + dressing.arrived(); + assert!(shelf.placed.borrow().is_empty()); + *latest.lock().expect("unpoisoned") = picture(); + dressing.arrived(); + assert_eq!(*shelf.placed.borrow(), [(3, Towards::Terminal, 7)]); +} + +#[test] +fn copying_text_never_asks_which_window_is_in_front() { + let (shelf, dressing, _) = wardrobe(None); + dressing.look(TERMINAL); + dressing.look(CHAT); + assert_eq!(shelf.looked.get(), 0); +} + #[test] fn a_copy_made_inside_the_terminal_is_left_alone_on_the_way_out() { - let (shelf, dressing) = wardrobe(7, picture(7, 3)); - dressing.turn(true, shelf.sequence()); + let (shelf, dressing, _) = wardrobe(picture()); + dressing.look(TERMINAL); shelf.sequence.set(20); - dressing.turn(false, shelf.sequence()); + dressing.look(CHAT); assert_eq!(shelf.placed.borrow().len(), 1); assert_eq!(dressing.worn.get(), None); } #[test] -fn a_write_the_clipboard_refuses_leaves_nothing_worn() { - let (shelf, dressing) = wardrobe(7, picture(7, 3)); +fn a_dress_the_clipboard_refuses_leaves_nothing_worn() { + let (shelf, dressing, _) = wardrobe(picture()); shelf.refuses.set(true); - dressing.turn(true, shelf.sequence()); + dressing.look(TERMINAL); assert_eq!(dressing.worn.get(), None); - shelf.refuses.set(false); - dressing.turn(true, shelf.sequence()); + assert!(shelf.placed.borrow().is_empty()); +} + +#[test] +fn a_failed_undress_is_tried_again_at_the_next_window() { + let (shelf, dressing, _) = wardrobe(picture()); + dressing.look(TERMINAL); shelf.refuses.set(true); - dressing.turn(false, shelf.sequence()); + dressing.look(CHAT); + assert_eq!(dressing.worn.get(), Some(Worn { sequence: 8, id: 3 })); + shelf.refuses.set(false); + dressing.look(CHAT); assert_eq!(dressing.worn.get(), None); assert_eq!( - shelf.copied.get(), - picture(7, 3), - "nothing claims the clipboard went back" + shelf.placed.borrow().last(), + Some(&(3, Towards::Elsewhere, 8)) ); } #[test] -fn a_clipboard_that_cannot_be_counted_is_never_dressed() { - let (shelf, dressing) = wardrobe(7, picture(7, 3)); +fn a_clipboard_that_cannot_be_counted_is_never_touched() { + let (shelf, dressing, _) = wardrobe(picture()); shelf.blind.set(true); - dressing.turn(true, shelf.sequence()); + dressing.look(TERMINAL); assert!(shelf.placed.borrow().is_empty()); - shelf.blind.set(false); - dressing.turn(true, shelf.sequence()); - shelf.blind.set(true); - dressing.turn(true, Some(8)); - assert_eq!(dressing.worn.get(), Some(Worn { sequence: 8, id: 3 })); } #[test] -fn a_window_that_is_no_terminal_coming_forward_takes_the_path_off() { - let (shelf, dressing) = wardrobe(7, picture(7, 3)); - dressing.turn(true, shelf.sequence()); - dressing.heard(0); - assert_eq!(shelf.placed.borrow().last(), Some(&(3, Towards::Elsewhere))); +fn quitting_takes_the_path_off_only_while_it_is_still_on() { + let (shelf, dressing, _) = wardrobe(picture()); + dressing.put_back(); + assert!(shelf.placed.borrow().is_empty()); + dressing.look(TERMINAL); + dressing.put_back(); + assert_eq!( + shelf.placed.borrow().last(), + Some(&(3, Towards::Elsewhere, 8)) + ); + let (shelf, dressing, _) = wardrobe(picture()); + dressing.look(TERMINAL); + shelf.sequence.set(30); + dressing.put_back(); + assert_eq!( + shelf.placed.borrow().len(), + 1, + "someone else's copy is left alone" + ); } #[test] fn starting_listens_for_windows_where_the_system_can_tell() { - let dressing = Dressing::start(Rc::new(Shelf::default())); + let dressing = Dressing::start(Rc::new(Shelf::default()), Arc::new(Mutex::new(None))); assert_eq!(dressing.listening(), cfg!(target_os = "windows")); } diff --git a/crates/cp-panel/src/engine.rs b/crates/cp-panel/src/engine.rs index a4bc0a71..8867d13d 100644 --- a/crates/cp-panel/src/engine.rs +++ b/crates/cp-panel/src/engine.rs @@ -13,7 +13,6 @@ use std::sync::atomic::Ordering; pub struct Engine { watching: here::Watching, - copied: Arc>>, rest: Arc, errands: std::sync::Mutex>>, } @@ -21,25 +20,22 @@ pub struct Engine { impl Engine { pub fn start( db: &Path, - fresh: impl Fn(i64) + Send + 'static, + fresh: impl Fn(i64, Option) + Send + 'static, changed: impl Fn(i64) + Send + 'static, ) -> Result { let store = Store::open(db)?; let rest = Arc::new(Rest::new()); let waking = rest.clone(); let mut notice = Notice::default(); - let copied = Arc::new(std::sync::Mutex::new(None)); - let noted = copied.clone(); let watching = here::watch_start(move || { - if let Some(id) = kept(&store, &mut notice, ¬ed) { + if let Some((id, copied)) = kept(&store, &mut notice) { waking.wake(); - fresh(id); + fresh(id, copied); } }); let errands = errands(db, rest.clone(), Box::new(changed)); Ok(Self { watching, - copied, rest, errands: std::sync::Mutex::new(errands), }) @@ -69,16 +65,8 @@ impl Engine { self.watching.ours() } - pub fn copied(&self) -> Option { - self.copied.lock().ok().and_then(|held| *held) - } - - pub fn copied_again(&self, sequence: i64) { - if let Ok(mut held) = self.copied.lock() - && let Some(copied) = held.as_mut() - { - copied.sequence = sequence; - } + pub fn abandoned(&self, before: i64) -> bool { + self.watching.abandoned(before) } } @@ -434,19 +422,12 @@ fn give_up(store: &Store, id: i64, job: &str, why: &str, at: i64) -> bool { } } -fn kept( - store: &Store, - notice: &mut Notice, - copied: &std::sync::Mutex>, -) -> Option { +fn kept(store: &Store, notice: &mut Notice) -> Option<(i64, Option)> { let before = here::sequence(); let item = taken(here::capture_insisting(), here::unreadable, notice)?; let from = here::in_front(); let id = keep(store, &item, crate::app::now_ms(), from.as_deref())?; - if let Ok(mut held) = copied.lock() { - *held = Copied::of(before, here::sequence(), id, &item); - } - Some(id) + Some((id, Copied::of(before, here::sequence(), id, &item))) } fn taken( diff --git a/crates/cp-panel/src/engine_test.rs b/crates/cp-panel/src/engine_test.rs index 93225aaa..d35916b5 100644 --- a/crates/cp-panel/src/engine_test.rs +++ b/crates/cp-panel/src/engine_test.rs @@ -328,7 +328,7 @@ fn aside(name: &str) -> std::path::PathBuf { #[test] fn an_engine_that_started_closes_and_closing_it_twice_is_no_error() { let dir = aside("closing"); - let engine = Engine::start(&dir.join("history.db"), |_| {}, |_| {}).expect("an engine"); + let engine = Engine::start(&dir.join("history.db"), |_, _| {}, |_| {}).expect("an engine"); let started = std::time::Instant::now(); engine.close(); engine.close(); @@ -343,7 +343,7 @@ fn an_engine_that_started_closes_and_closing_it_twice_is_no_error() { #[test] fn an_engine_nobody_closed_is_still_closed_by_dropping_it() { let dir = aside("dropping"); - let engine = Engine::start(&dir.join("history.db"), |_| {}, |_| {}).expect("an engine"); + let engine = Engine::start(&dir.join("history.db"), |_, _| {}, |_| {}).expect("an engine"); let started = std::time::Instant::now(); drop(engine); assert!( @@ -683,30 +683,11 @@ fn a_counted_folder_is_told_and_a_missing_one_is_not() { } #[test] -fn the_engine_remembers_the_copy_on_the_clipboard_and_hears_when_it_comes_back() { - let dir = aside("remembering"); - let engine = Engine::start(&dir.join("history.db"), |_| {}, |_| {}).expect("an engine"); - *engine.copied.lock().expect("unpoisoned") = None; - engine.copied_again(9); - assert_eq!( - engine.copied(), - None, - "nothing to move when nothing was copied" - ); - let copy = crate::dressing::Copied { - sequence: 7, - id: 3, - picture: true, - }; - *engine.copied.lock().expect("unpoisoned") = Some(copy); - engine.copied_again(9); - assert_eq!( - engine.copied(), - Some(crate::dressing::Copied { - sequence: 9, - ..copy - }) - ); +fn a_write_given_up_is_handed_back_to_the_watcher() { + let dir = aside("abandoning"); + let engine = Engine::start(&dir.join("history.db"), |_, _| {}, |_| {}).expect("an engine"); + assert!(engine.writing()); + assert!(engine.abandoned(5)); drop(engine); let _ = std::fs::remove_dir_all(&dir); } diff --git a/crates/cp-panel/src/handing.rs b/crates/cp-panel/src/handing.rs index 8fc1445e..1afaf7fc 100644 --- a/crates/cp-panel/src/handing.rs +++ b/crates/cp-panel/src/handing.rs @@ -25,7 +25,7 @@ fn landing_for(store: &Store, item: &cp_core::item::Item, id: i64, towards: Towa Landing { towards, files, - unrecorded: false, + only_over: None, } } @@ -35,7 +35,19 @@ pub fn hand_over( id: i64, towards: Towards, ) -> bool { - let written = placed(store, engine, id, towards, false); + let item = match store.item(id) { + Ok(Some(item)) => item, + Ok(None) => { + note(&format!("pasting {id}: it is no longer in the store")); + return false; + } + Err(why) => { + note(&format!("pasting {id}: {why}")); + return false; + } + }; + let landing = landing_for(store, &item, id, towards); + let written = short_of(id, written_over(&item, &landing, engine)); if written { if let Err(why) = store.record_paste(id, crate::app::now_ms()) { note(&format!("{id} was pasted and nobody wrote it down: {why}")); @@ -48,39 +60,43 @@ pub fn hand_over( written } -pub fn dress( - store: &Store, +fn written_over( + item: &cp_core::item::Item, + landing: &Landing, engine: Option<&crate::engine::Engine>, - id: i64, - towards: Towards, -) -> bool { - placed(store, engine, id, towards, true) +) -> here::Landed { + here::to_clipboard( + item, + landing, + || starting(engine), + || mark(engine), + |before| abandoned(engine, before), + ) } -fn placed( +fn worn_over( store: &Store, - engine: Option<&crate::engine::Engine>, + engine: &crate::engine::Engine, id: i64, towards: Towards, - unrecorded: bool, -) -> bool { - let item = match store.item(id) { - Ok(Some(item)) => item, - Ok(None) => { - note(&format!("placing {id}: it is no longer in the store")); - return false; - } - Err(why) => { - note(&format!("placing {id}: {why}")); - return false; - } - }; + over: i64, +) -> Option { + let item = store.item(id).ok().flatten()?; + if !here::rebuilds_whole(&item) { + return None; + } let landing = Landing { - unrecorded, + only_over: Some(over), ..landing_for(store, &item, id, towards) }; - let landed = here::to_clipboard(&item, &landing, || starting(engine), || mark(engine)); - short_of(id, landed) + if towards == Towards::Terminal && !matches!(landing.offer(), crate::landing::Offer::Text(_)) { + return None; + } + let landed = written_over(&item, &landing, Some(engine)); + if landed == here::Landed::Nothing { + return None; + } + here::sequence() } pub struct Shelves { @@ -89,20 +105,20 @@ pub struct Shelves { } impl crate::dressing::Closet for Shelves { - fn copied(&self) -> Option { - self.engine.copied() + fn place(&self, id: i64, towards: Towards, over: i64) -> Option { + worn_over(&self.store, &self.engine, id, towards, over) } - fn copied_again(&self, sequence: i64) { - self.engine.copied_again(sequence); + fn sequence(&self) -> Option { + here::sequence() } - fn place(&self, id: i64, towards: Towards) -> bool { - dress(&self.store, Some(&self.engine), id, towards) + fn front(&self) -> isize { + here::front() } - fn sequence(&self) -> Option { - here::sequence() + fn terminal(&self, window: isize) -> bool { + here::towards(window) == Towards::Terminal } } @@ -226,15 +242,7 @@ pub fn paste_as( return Err(Refused::Unmade); }; let made = rendered.into_item(); - let written = short_of( - id, - here::to_clipboard( - &made, - &Landing::anywhere(), - || starting(engine), - || mark(engine), - ), - ); + let written = short_of(id, written_over(&made, &Landing::anywhere(), engine)); if written && let Err(why) = store.record_paste(id, crate::app::now_ms()) { note(&format!("{id} was pasted and nobody wrote it down: {why}")); } @@ -282,6 +290,14 @@ fn starting(engine: Option<&crate::engine::Engine>) { } } +fn abandoned(engine: Option<&crate::engine::Engine>, before: i64) { + if let Some(engine) = engine + && !engine.abandoned(before) + { + note("a write given up could not be handed back to the watcher"); + } +} + fn mark(engine: Option<&crate::engine::Engine>) { let Some(engine) = engine else { return; diff --git a/crates/cp-panel/src/here.rs b/crates/cp-panel/src/here.rs index f04dc045..d9a7887a 100644 --- a/crates/cp-panel/src/here.rs +++ b/crates/cp-panel/src/here.rs @@ -7,10 +7,10 @@ use std::path::{Path, PathBuf}; pub use platform::{ Dragged, Listening, THUMBNAILS_FILES, Watching, activated, ahead_now, capture_insisting, - content_of, data_dir, drag_out, dress, forward, ground, hand_back, host, in_front, keep_awake, - media_of, ocr_available, on_foreground, open_link, open_path, ours_up_front, paste_into, - pointer, read_stuck, sequence, system_is_light, text_in, thumb_of_file, thumbs_dir, - to_clipboard, towards, unreadable, utc_offset_at, watch_start, + content_of, data_dir, drag_out, dress, forward, front, ground, hand_back, host, in_front, + keep_awake, media_of, ocr_available, on_foreground, open_link, open_path, ours_up_front, + paste_into, pointer, read_stuck, rebuilds_whole, sequence, system_is_light, text_in, + thumb_of_file, thumbs_dir, to_clipboard, towards, unreadable, utc_offset_at, watch_start, }; #[derive(Debug, Clone, Copy, PartialEq)] @@ -51,6 +51,14 @@ mod platform { cp_win_sys::clipboard::sequence() } + pub fn front() -> isize { + cp_win_sys::frontmost::foreground().map_or(0, |window| window.0 as isize) + } + + pub fn rebuilds_whole(item: &Item) -> bool { + cp_win::restore::rebuilt_whole(item) + } + pub type Listening = cp_win_sys::foreground::Listening; pub fn on_foreground(heard: impl Fn(isize) + 'static) -> Option { @@ -127,6 +135,7 @@ mod platform { landing: &crate::landing::Landing, starting: impl FnOnce(), ours: impl FnOnce(), + abandon: impl FnOnce(i64), ) -> Landed { let mut ready = cp_win::restore::ready_for(item); match landing.offer() { @@ -134,14 +143,25 @@ mod platform { crate::landing::Offer::Text(text) => ready.offer_text(&text), crate::landing::Offer::Nothing => {} } - if landing.unrecorded { + let moved = |over: i64| cp_win_sys::clipboard::sequence() != Some(over); + if let Some(over) = landing.only_over { ready.unrecorded(); + if moved(over) { + return Landed::Nothing; + } } starting(); let Some(clipboard) = Clipboard::to_write() else { ours(); return Landed::Nothing; }; + if let Some(over) = landing.only_over + && moved(over) + { + drop(clipboard); + abandon(over); + return Landed::Nothing; + } let landed = match cp_win::restore::place(&clipboard, &ready) { cp_win::restore::Restored::Written { formats, @@ -289,6 +309,14 @@ mod platform { None } + pub fn front() -> isize { + 0 + } + + pub fn rebuilds_whole(_item: &Item) -> bool { + false + } + pub struct Listening; pub fn on_foreground(_heard: impl Fn(isize) + 'static) -> Option { @@ -364,6 +392,7 @@ mod platform { landing: &crate::landing::Landing, starting: impl FnOnce(), ours: impl FnOnce(), + _abandon: impl FnOnce(i64), ) -> Landed { let offered = match landing.offer() { crate::landing::Offer::Text(text) => Some(text), diff --git a/crates/cp-panel/src/landing.rs b/crates/cp-panel/src/landing.rs index 20fa63d7..8d86e63b 100644 --- a/crates/cp-panel/src/landing.rs +++ b/crates/cp-panel/src/landing.rs @@ -8,7 +8,7 @@ pub enum Towards { pub struct Landing { pub towards: Towards, pub files: Vec, - pub unrecorded: bool, + pub only_over: Option, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -23,7 +23,7 @@ impl Landing { Self { towards: Towards::Elsewhere, files: Vec::new(), - unrecorded: false, + only_over: None, } } diff --git a/crates/cp-panel/src/landing_test.rs b/crates/cp-panel/src/landing_test.rs index a2dd95bc..28b6e09b 100644 --- a/crates/cp-panel/src/landing_test.rs +++ b/crates/cp-panel/src/landing_test.rs @@ -156,7 +156,7 @@ fn landing(towards: Towards, files: &[&str]) -> Landing { Landing { towards, files: files.iter().map(std::path::PathBuf::from).collect(), - unrecorded: false, + only_over: None, } } diff --git a/crates/cp-panel/ui/panel.slint b/crates/cp-panel/ui/panel.slint index 22ed81c6..267ed4e6 100644 --- a/crates/cp-panel/ui/panel.slint +++ b/crates/cp-panel/ui/panel.slint @@ -103,6 +103,7 @@ export component Panel inherits Window { callback forms-asked(); callback quitting(); callback arrived(); + callback copied(); callback sheet-chosen(string); callback paste-as(int, string); callback cycle(int); diff --git a/crates/cp-win-sys/src/foreground.rs b/crates/cp-win-sys/src/foreground.rs index c173d0cc..27934cea 100644 --- a/crates/cp-win-sys/src/foreground.rs +++ b/crates/cp-win-sys/src/foreground.rs @@ -1,14 +1,15 @@ use std::cell::RefCell; +use std::collections::HashMap; use windows::Win32::Foundation::HWND; use windows::Win32::UI::Accessibility::{HWINEVENTHOOK, SetWinEventHook, UnhookWinEvent}; use windows::Win32::UI::WindowsAndMessaging::{ EVENT_SYSTEM_FOREGROUND, WINEVENT_OUTOFCONTEXT, WINEVENT_SKIPOWNPROCESS, }; -type Heard = Box; +type Heard = std::rc::Rc; thread_local! { - static HEARD: RefCell> = const { RefCell::new(None) }; + static HOOKED: RefCell> = RefCell::new(HashMap::new()); } pub struct Listening(HWINEVENTHOOK); @@ -16,12 +17,11 @@ pub struct Listening(HWINEVENTHOOK); impl Drop for Listening { fn drop(&mut self) { let _ = unsafe { UnhookWinEvent(self.0) }; - HEARD.with(|heard| heard.borrow_mut().take()); + HOOKED.with(|hooked| hooked.borrow_mut().remove(&key_of(self.0))); } } pub fn listen(heard: impl Fn(isize) + 'static) -> Option { - HEARD.with(|held| *held.borrow_mut() = Some(Box::new(heard))); let hook = unsafe { SetWinEventHook( EVENT_SYSTEM_FOREGROUND, @@ -34,22 +34,29 @@ pub fn listen(heard: impl Fn(isize) + 'static) -> Option { ) }; if hook.is_invalid() { - HEARD.with(|held| held.borrow_mut().take()); return None; } + HOOKED.with(|hooked| { + hooked + .borrow_mut() + .insert(key_of(hook), std::rc::Rc::new(heard)) + }); Some(Listening(hook)) } -fn told(window: HWND) { - HEARD.with(|held| { - if let Some(heard) = held.borrow().as_ref() { - heard(window.0 as isize); - } - }); +fn key_of(hook: HWINEVENTHOOK) -> isize { + hook.0 as isize +} + +fn told(hook: HWINEVENTHOOK, window: HWND) { + let heard = HOOKED.with(|hooked| hooked.borrow().get(&key_of(hook)).cloned()); + if let Some(heard) = heard { + heard(window.0 as isize); + } } unsafe extern "system" fn changed( - _hook: HWINEVENTHOOK, + hook: HWINEVENTHOOK, _event: u32, window: HWND, _object: i32, @@ -57,7 +64,7 @@ unsafe extern "system" fn changed( _thread: u32, _time: u32, ) { - told(window); + told(hook, window); } #[cfg(test)] diff --git a/crates/cp-win-sys/src/foreground_test.rs b/crates/cp-win-sys/src/foreground_test.rs index 23f52043..992ca512 100644 --- a/crates/cp-win-sys/src/foreground_test.rs +++ b/crates/cp-win-sys/src/foreground_test.rs @@ -2,26 +2,43 @@ use super::*; use std::cell::Cell; use std::rc::Rc; +fn heard_by(seen: &Rc>) -> impl Fn(isize) + 'static { + let seen = seen.clone(); + move |window| seen.set(window) +} + #[test] fn a_window_coming_forward_reaches_whoever_listens_and_stops_once_they_stop() { let seen = Rc::new(Cell::new(0isize)); - let heard = seen.clone(); - let listening = listen(move |window| heard.set(window)).expect("the system listens"); - told(HWND(7 as *mut _)); + let listening = listen(heard_by(&seen)).expect("the system listens"); + let hook = listening.0; + told(hook, HWND(7 as *mut _)); assert_eq!(seen.get(), 7); drop(listening); - told(HWND(9 as *mut _)); + told(hook, HWND(9 as *mut _)); assert_eq!(seen.get(), 7, "nobody hears after the listening stopped"); } +#[test] +fn one_listener_stopping_never_silences_another() { + let first = Rc::new(Cell::new(0isize)); + let second = Rc::new(Cell::new(0isize)); + let a = listen(heard_by(&first)).expect("the system listens"); + let b = listen(heard_by(&second)).expect("the system listens twice"); + let hook = b.0; + drop(a); + told(hook, HWND(5 as *mut _)); + assert_eq!(second.get(), 5); + assert_eq!(first.get(), 0); +} + #[test] fn the_system_calls_back_through_the_same_door() { let seen = Rc::new(Cell::new(0isize)); - let heard = seen.clone(); - let _listening = listen(move |window| heard.set(window)).expect("the system listens"); + let listening = listen(heard_by(&seen)).expect("the system listens"); unsafe { changed( - HWINEVENTHOOK::default(), + listening.0, EVENT_SYSTEM_FOREGROUND, HWND(3 as *mut _), 0, diff --git a/crates/cp-win/src/restore.rs b/crates/cp-win/src/restore.rs index 158a820a..f8951a72 100644 --- a/crates/cp-win/src/restore.rs +++ b/crates/cp-win/src/restore.rs @@ -72,6 +72,28 @@ impl Ready { } } +const PICTURE_TWINS: [&str; 6] = [ + SYNTHETIC_IMAGE, + PNG, + JFIF, + "CF_DIBV5", + "CF_DIB", + "CF_BITMAP", +]; + +pub fn rebuilt_whole(item: &Item) -> bool { + let mut picture = false; + for format in &item.formats { + let held = payload_of(format).is_some(); + let twin = PICTURE_TWINS.contains(&format.id.as_str()); + if !held && !twin { + return false; + } + picture |= held && twin; + } + picture +} + pub fn ready_for(item: &Item) -> Ready { let (mut owned, mut returned) = pasted_files(item); for format in &item.formats { diff --git a/crates/cp-win/src/restore_test.rs b/crates/cp-win/src/restore_test.rs index eb3ef69b..42465370 100644 --- a/crates/cp-win/src/restore_test.rs +++ b/crates/cp-win/src/restore_test.rs @@ -349,3 +349,50 @@ fn a_write_kept_out_of_the_history_says_so_once_per_door() { } assert!(ready.offers(CF_DIB), "the picture itself stays"); } + +fn announced(id: &str) -> Format { + Format { + id: id.into(), + payload: Payload::Announced { size: Some(10) }, + } +} + +#[test] +fn a_plain_screenshot_goes_back_exactly_as_it_came() { + let png = image_bytes(); + let screenshot = Item { + kind: Some(cp_core::kind::Kind::Image), + formats: vec![ + inline(PNG, &png), + announced("CF_DIBV5"), + announced("CF_BITMAP"), + ], + }; + assert!(rebuilt_whole(&screenshot)); +} + +#[test] +fn a_picture_that_carried_more_than_its_pixels_cannot_be_rebuilt_whole() { + let png = image_bytes(); + let slide = Item { + kind: Some(cp_core::kind::Kind::Image), + formats: vec![inline(PNG, &png), announced("Art::GVML ClipFormat")], + }; + assert!(!rebuilt_whole(&slide)); + let unread = Item { + kind: Some(cp_core::kind::Kind::Image), + formats: vec![announced(PNG), announced("CF_DIB")], + }; + assert!( + !rebuilt_whole(&unread), + "with no pixels kept there is nothing to rebuild" + ); + let too_big = Item { + kind: Some(cp_core::kind::Kind::Image), + formats: vec![Format { + id: PNG.into(), + payload: Payload::TooBig { size: 1 << 30 }, + }], + }; + assert!(!rebuilt_whole(&too_big)); +} From 72cee7eb98feedb0b0cae4a861704f63dc5fe7db Mon Sep 17 00:00:00 2001 From: rgdevment Date: Tue, 20 Oct 2026 17:54:30 -0300 Subject: [PATCH 4/7] fix(win): a given-up write never repeats a copy, clipboard managers skip the rewrite, a refused picture waits --- crates/cp-core/src/watch.rs | 7 ++++--- crates/cp-core/src/watch_test.rs | 22 +++++++++++++++++++++- crates/cp-core/src/watching.rs | 4 ++-- crates/cp-core/src/watching_test.rs | 2 +- crates/cp-panel/src/dressing.rs | 8 +++++++- crates/cp-panel/src/dressing_test.rs | 17 +++++++++++++++-- crates/cp-panel/src/engine.rs | 4 ++-- crates/cp-panel/src/engine_test.rs | 2 +- crates/cp-panel/src/handing.rs | 6 +++--- crates/cp-panel/src/here.rs | 14 +++++--------- crates/cp-win/src/restore.rs | 5 +++++ crates/cp-win/src/restore_test.rs | 7 ++++++- 12 files changed, 72 insertions(+), 26 deletions(-) diff --git a/crates/cp-core/src/watch.rs b/crates/cp-core/src/watch.rs index c5fb09ca..7c2a5495 100644 --- a/crates/cp-core/src/watch.rs +++ b/crates/cp-core/src/watch.rs @@ -78,9 +78,10 @@ impl Watcher { self.ours = Some((from, None)); } - pub fn abandoned(&mut self, before: i64) { - self.ours = None; - self.last = Some(before); + pub fn abandoned(&mut self) { + if let Some((from, _)) = self.ours.take() { + self.last = Some(from); + } } pub fn wrote(&mut self, count: i64) { diff --git a/crates/cp-core/src/watch_test.rs b/crates/cp-core/src/watch_test.rs index df91f1b7..ab16d6d2 100644 --- a/crates/cp-core/src/watch_test.rs +++ b/crates/cp-core/src/watch_test.rs @@ -115,10 +115,30 @@ fn a_write_given_up_lets_the_copy_that_beat_it_be_seen() { Seen::Ours, "while writing, the next count looks ours" ); - watcher.abandoned(10); + watcher.abandoned(); assert!( matches!(watcher.tick(11), Seen::Fresh { .. }), "once given up, that same count is someone else's copy" ); assert_eq!(watcher.tick(11), Seen::Nothing); } + +#[test] +fn a_copy_seen_before_the_write_began_is_not_seen_twice_when_it_is_given_up() { + let mut watcher = Watcher::new(Cadence::Opaque); + watcher.tick(10); + assert!(matches!(watcher.tick(11), Seen::Fresh { .. })); + watcher.writing(11); + watcher.abandoned(); + assert_eq!( + watcher.tick(11), + Seen::Nothing, + "already kept before the write" + ); + watcher.abandoned(); + assert_eq!( + watcher.tick(11), + Seen::Nothing, + "giving up twice changes nothing" + ); +} diff --git a/crates/cp-core/src/watching.rs b/crates/cp-core/src/watching.rs index bca975f7..7a09c4e7 100644 --- a/crates/cp-core/src/watching.rs +++ b/crates/cp-core/src/watching.rs @@ -80,11 +80,11 @@ impl Watching { true } - pub fn abandoned(&self, before: i64) -> bool { + pub fn abandoned(&self) -> bool { let Ok(mut watcher) = self.watcher.lock() else { return false; }; - watcher.abandoned(before); + watcher.abandoned(); true } diff --git a/crates/cp-core/src/watching_test.rs b/crates/cp-core/src/watching_test.rs index 0d6121c0..de7e9424 100644 --- a/crates/cp-core/src/watching_test.rs +++ b/crates/cp-core/src/watching_test.rs @@ -241,7 +241,7 @@ fn a_copy_that_lands_while_a_write_is_given_up_still_wakes_the_watcher() { assert!(watching.writing()); count.store(31, Ordering::Relaxed); std::thread::sleep(QUICK * 4); - assert!(watching.abandoned(30)); + assert!(watching.abandoned()); assert!( waited_for(&seen, 1) >= 1, "the copy that beat the write is kept" diff --git a/crates/cp-panel/src/dressing.rs b/crates/cp-panel/src/dressing.rs index a7a115b2..d045b6b6 100644 --- a/crates/cp-panel/src/dressing.rs +++ b/crates/cp-panel/src/dressing.rs @@ -66,6 +66,7 @@ pub struct Dressing { closet: C, latest: Latest, worn: Cell>, + refused: Cell>, listening: Option, } @@ -85,6 +86,7 @@ impl Dressing { closet, latest, worn: Cell::new(None), + refused: Cell::new(None), listening, } }) @@ -113,7 +115,8 @@ impl Dressing { let now = self.closet.sequence(); let worn = still_worn(self.worn.get(), now); self.worn.set(worn); - let copied = still_copied(self.copied(), now); + let copied = + still_copied(self.copied(), now).filter(|copy| self.refused.get() != Some(*copy)); if worn.is_none() && copied.is_none() { return; } @@ -123,6 +126,9 @@ impl Dressing { if let Some(over) = now { let placed = self.closet.place(id, Towards::Terminal, over); self.worn.set(placed.map(|sequence| Worn { sequence, id })); + if placed.is_none() { + self.refused.set(copied); + } } } Step::Undress(_) => { diff --git a/crates/cp-panel/src/dressing_test.rs b/crates/cp-panel/src/dressing_test.rs index 1137ff3e..463814b7 100644 --- a/crates/cp-panel/src/dressing_test.rs +++ b/crates/cp-panel/src/dressing_test.rs @@ -88,6 +88,7 @@ fn wardrobe(copied: Option) -> (Rc, Dressing>, Latest) closet: shelf.clone(), latest: latest.clone(), worn: Cell::new(None), + refused: Cell::new(None), listening: None, }; (shelf, dressing, latest) @@ -157,12 +158,24 @@ fn a_copy_made_inside_the_terminal_is_left_alone_on_the_way_out() { } #[test] -fn a_dress_the_clipboard_refuses_leaves_nothing_worn() { - let (shelf, dressing, _) = wardrobe(picture()); +fn a_picture_that_could_not_be_dressed_is_not_tried_again_until_a_new_copy() { + let (shelf, dressing, latest) = wardrobe(picture()); shelf.refuses.set(true); dressing.look(TERMINAL); assert_eq!(dressing.worn.get(), None); assert!(shelf.placed.borrow().is_empty()); + shelf.refuses.set(false); + shelf.looked.set(0); + dressing.look(TERMINAL); + assert!(shelf.placed.borrow().is_empty()); + assert_eq!(shelf.looked.get(), 0, "not even the window is asked about"); + shelf.sequence.set(12); + *latest.lock().expect("unpoisoned") = Some(Copied { + sequence: 12, + id: 4, + }); + dressing.look(TERMINAL); + assert_eq!(*shelf.placed.borrow(), [(4, Towards::Terminal, 12)]); } #[test] diff --git a/crates/cp-panel/src/engine.rs b/crates/cp-panel/src/engine.rs index 8867d13d..6d1f2425 100644 --- a/crates/cp-panel/src/engine.rs +++ b/crates/cp-panel/src/engine.rs @@ -65,8 +65,8 @@ impl Engine { self.watching.ours() } - pub fn abandoned(&self, before: i64) -> bool { - self.watching.abandoned(before) + pub fn abandoned(&self) -> bool { + self.watching.abandoned() } } diff --git a/crates/cp-panel/src/engine_test.rs b/crates/cp-panel/src/engine_test.rs index d35916b5..e4b917ae 100644 --- a/crates/cp-panel/src/engine_test.rs +++ b/crates/cp-panel/src/engine_test.rs @@ -687,7 +687,7 @@ fn a_write_given_up_is_handed_back_to_the_watcher() { let dir = aside("abandoning"); let engine = Engine::start(&dir.join("history.db"), |_, _| {}, |_| {}).expect("an engine"); assert!(engine.writing()); - assert!(engine.abandoned(5)); + assert!(engine.abandoned()); drop(engine); let _ = std::fs::remove_dir_all(&dir); } diff --git a/crates/cp-panel/src/handing.rs b/crates/cp-panel/src/handing.rs index 1afaf7fc..ed620131 100644 --- a/crates/cp-panel/src/handing.rs +++ b/crates/cp-panel/src/handing.rs @@ -70,7 +70,7 @@ fn written_over( landing, || starting(engine), || mark(engine), - |before| abandoned(engine, before), + || abandoned(engine), ) } @@ -290,9 +290,9 @@ fn starting(engine: Option<&crate::engine::Engine>) { } } -fn abandoned(engine: Option<&crate::engine::Engine>, before: i64) { +fn abandoned(engine: Option<&crate::engine::Engine>) { if let Some(engine) = engine - && !engine.abandoned(before) + && !engine.abandoned() { note("a write given up could not be handed back to the watcher"); } diff --git a/crates/cp-panel/src/here.rs b/crates/cp-panel/src/here.rs index d9a7887a..11b43dc9 100644 --- a/crates/cp-panel/src/here.rs +++ b/crates/cp-panel/src/here.rs @@ -135,7 +135,7 @@ mod platform { landing: &crate::landing::Landing, starting: impl FnOnce(), ours: impl FnOnce(), - abandon: impl FnOnce(i64), + abandon: impl FnOnce(), ) -> Landed { let mut ready = cp_win::restore::ready_for(item); match landing.offer() { @@ -143,12 +143,8 @@ mod platform { crate::landing::Offer::Text(text) => ready.offer_text(&text), crate::landing::Offer::Nothing => {} } - let moved = |over: i64| cp_win_sys::clipboard::sequence() != Some(over); - if let Some(over) = landing.only_over { + if landing.only_over.is_some() { ready.unrecorded(); - if moved(over) { - return Landed::Nothing; - } } starting(); let Some(clipboard) = Clipboard::to_write() else { @@ -156,10 +152,10 @@ mod platform { return Landed::Nothing; }; if let Some(over) = landing.only_over - && moved(over) + && cp_win_sys::clipboard::sequence() != Some(over) { drop(clipboard); - abandon(over); + abandon(); return Landed::Nothing; } let landed = match cp_win::restore::place(&clipboard, &ready) { @@ -392,7 +388,7 @@ mod platform { landing: &crate::landing::Landing, starting: impl FnOnce(), ours: impl FnOnce(), - _abandon: impl FnOnce(i64), + _abandon: impl FnOnce(), ) -> Landed { let offered = match landing.offer() { crate::landing::Offer::Text(text) => Some(text), diff --git a/crates/cp-win/src/restore.rs b/crates/cp-win/src/restore.rs index f8951a72..8720848b 100644 --- a/crates/cp-win/src/restore.rs +++ b/crates/cp-win/src/restore.rs @@ -12,6 +12,7 @@ const PNG: &str = "PNG"; const JFIF: &str = "JFIF"; const DROP_EFFECT: &str = "Preferred DropEffect"; pub const UNRECORDED: [&str; 2] = ["CanIncludeInClipboardHistory", "CanUploadToCloudClipboard"]; +pub const UNWATCHED: &str = "ExcludeClipboardContentFromMonitorProcessing"; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Restored { @@ -65,6 +66,10 @@ impl Ready { self.owned.retain(|(kept, _)| *kept != id); self.owned.push((id, 0u32.to_le_bytes().to_vec())); } + if let Some(id) = id_of(UNWATCHED) { + self.owned.retain(|(kept, _)| *kept != id); + self.owned.push((id, Vec::new())); + } } pub fn offers(&self, id: u32) -> bool { diff --git a/crates/cp-win/src/restore_test.rs b/crates/cp-win/src/restore_test.rs index 42465370..836458b2 100644 --- a/crates/cp-win/src/restore_test.rs +++ b/crates/cp-win/src/restore_test.rs @@ -337,7 +337,12 @@ fn a_write_kept_out_of_the_history_says_so_once_per_door() { let before = ready.wanted(); ready.unrecorded(); ready.unrecorded(); - assert_eq!(ready.wanted(), before + UNRECORDED.len()); + assert_eq!(ready.wanted(), before + UNRECORDED.len() + 1); + let unwatched = id_of(UNWATCHED).expect("a format the system registers"); + assert!( + ready.offers(unwatched), + "other clipboard managers look away too" + ); for name in UNRECORDED { let id = id_of(name).expect("a format the system registers"); assert!( From be3c8123e0ae304ef5b1636c52074df612cb0d91 Mon Sep 17 00:00:00 2001 From: rgdevment Date: Tue, 20 Oct 2026 18:09:10 -0300 Subject: [PATCH 5/7] test(win): giving a write up is checked without needing the clipboard, as on CI runners --- crates/cp-panel/src/engine_test.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/crates/cp-panel/src/engine_test.rs b/crates/cp-panel/src/engine_test.rs index e4b917ae..490f8cca 100644 --- a/crates/cp-panel/src/engine_test.rs +++ b/crates/cp-panel/src/engine_test.rs @@ -686,8 +686,11 @@ fn a_counted_folder_is_told_and_a_missing_one_is_not() { fn a_write_given_up_is_handed_back_to_the_watcher() { let dir = aside("abandoning"); let engine = Engine::start(&dir.join("history.db"), |_, _| {}, |_| {}).expect("an engine"); - assert!(engine.writing()); - assert!(engine.abandoned()); + let _ = engine.writing(); + assert!( + engine.abandoned(), + "giving a write up needs no clipboard, only the watcher" + ); drop(engine); let _ = std::fs::remove_dir_all(&dir); } From 8bcb41e936c4effca93e3eadf4bb5f6429581824 Mon Sep 17 00:00:00 2001 From: rgdevment Date: Tue, 20 Oct 2026 18:30:27 -0300 Subject: [PATCH 6/7] fix(win): only a marked clipboard counts as ours, windows settle before acting, a given-up write restores the watcher --- crates/cp-core/src/watch.rs | 7 +++++-- crates/cp-core/src/watch_test.rs | 12 ++++++++++++ crates/cp-panel/src/dressing.rs | 25 ++++++++++++++++++------- crates/cp-panel/src/dressing_test.rs | 19 +++++++++++++++++++ crates/cp-panel/src/handing.rs | 2 +- crates/cp-panel/src/here.rs | 11 +++++++++++ crates/cp-win-sys/src/clipboard.rs | 9 ++++++++- crates/cp-win-sys/src/clipboard_test.rs | 5 +++++ crates/cp-win/src/restore.rs | 3 ++- crates/cp-win/src/restore_test.rs | 4 +++- 10 files changed, 84 insertions(+), 13 deletions(-) diff --git a/crates/cp-core/src/watch.rs b/crates/cp-core/src/watch.rs index 7c2a5495..ad73bc66 100644 --- a/crates/cp-core/src/watch.rs +++ b/crates/cp-core/src/watch.rs @@ -60,6 +60,7 @@ pub fn insist( pub struct Watcher { last: Option, ours: Option<(i64, Option)>, + before_writing: Option, missed: u64, cadence: Cadence, } @@ -69,18 +70,20 @@ impl Watcher { Self { last: None, ours: None, + before_writing: None, missed: 0, cadence, } } pub fn writing(&mut self, from: i64) { + self.before_writing = self.last; self.ours = Some((from, None)); } pub fn abandoned(&mut self) { - if let Some((from, _)) = self.ours.take() { - self.last = Some(from); + if self.ours.take().is_some() { + self.last = self.before_writing.take(); } } diff --git a/crates/cp-core/src/watch_test.rs b/crates/cp-core/src/watch_test.rs index ab16d6d2..31aeec4a 100644 --- a/crates/cp-core/src/watch_test.rs +++ b/crates/cp-core/src/watch_test.rs @@ -142,3 +142,15 @@ fn a_copy_seen_before_the_write_began_is_not_seen_twice_when_it_is_given_up() { "giving up twice changes nothing" ); } + +#[test] +fn a_copy_made_before_a_write_began_and_never_ticked_is_seen_once_it_is_given_up() { + let mut watcher = Watcher::new(Cadence::Opaque); + watcher.tick(10); + watcher.writing(11); + watcher.abandoned(); + assert!( + matches!(watcher.tick(11), Seen::Fresh { .. }), + "the copy that slipped in before the write is still someone's copy" + ); +} diff --git a/crates/cp-panel/src/dressing.rs b/crates/cp-panel/src/dressing.rs index d045b6b6..6ea827dd 100644 --- a/crates/cp-panel/src/dressing.rs +++ b/crates/cp-panel/src/dressing.rs @@ -7,6 +7,8 @@ use std::rc::{Rc, Weak}; use std::sync::{Arc, Mutex}; use std::time::Duration; +const SETTLES: Duration = Duration::from_millis(60); + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct Copied { pub sequence: i64, @@ -67,6 +69,7 @@ pub struct Dressing { latest: Latest, worn: Cell>, refused: Cell>, + settling: slint::Timer, listening: Option, } @@ -74,19 +77,17 @@ impl Dressing { pub fn start(closet: C, latest: Latest) -> Rc { Rc::new_cyclic(|weak: &Weak| { let weak = weak.clone(); - let listening = here::on_foreground(move |window| { - let later = weak.clone(); - slint::Timer::single_shot(Duration::ZERO, move || { - if let Some(dressing) = later.upgrade() { - dressing.look(window); - } - }); + let listening = here::on_foreground(move |_window| { + if let Some(dressing) = weak.upgrade() { + dressing.settle(); + } }); Self { closet, latest, worn: Cell::new(None), refused: Cell::new(None), + settling: slint::Timer::default(), listening, } }) @@ -100,6 +101,16 @@ impl Dressing { self.look(self.closet.front()); } + fn settle(self: &Rc) { + let weak = Rc::downgrade(self); + self.settling + .start(slint::TimerMode::SingleShot, SETTLES, move || { + if let Some(dressing) = weak.upgrade() { + dressing.arrived(); + } + }); + } + pub fn put_back(&self) { let now = self.closet.sequence(); if let Some(was) = still_worn(self.worn.get(), now) { diff --git a/crates/cp-panel/src/dressing_test.rs b/crates/cp-panel/src/dressing_test.rs index 463814b7..422711ad 100644 --- a/crates/cp-panel/src/dressing_test.rs +++ b/crates/cp-panel/src/dressing_test.rs @@ -89,6 +89,7 @@ fn wardrobe(copied: Option) -> (Rc, Dressing>, Latest) latest: latest.clone(), worn: Cell::new(None), refused: Cell::new(None), + settling: slint::Timer::default(), listening: None, }; (shelf, dressing, latest) @@ -229,3 +230,21 @@ fn starting_listens_for_windows_where_the_system_can_tell() { let dressing = Dressing::start(Rc::new(Shelf::default()), Arc::new(Mutex::new(None))); assert_eq!(dressing.listening(), cfg!(target_os = "windows")); } + +#[test] +fn windows_flicking_past_are_waited_out_and_only_the_one_left_in_front_counts() { + i_slint_backend_testing::init_no_event_loop(); + let shelf = Rc::new(Shelf::default()); + shelf.sequence.set(7); + let dressing = Dressing::start(shelf.clone(), Arc::new(Mutex::new(picture()))); + shelf.front.set(CHAT); + dressing.settle(); + shelf.front.set(TERMINAL); + dressing.settle(); + assert!( + shelf.placed.borrow().is_empty(), + "nothing happens while windows still move" + ); + i_slint_backend_testing::mock_elapsed_time(SETTLES + Duration::from_millis(5)); + assert_eq!(*shelf.placed.borrow(), [(3, Towards::Terminal, 7)]); +} diff --git a/crates/cp-panel/src/handing.rs b/crates/cp-panel/src/handing.rs index ed620131..d1c302ee 100644 --- a/crates/cp-panel/src/handing.rs +++ b/crates/cp-panel/src/handing.rs @@ -96,7 +96,7 @@ fn worn_over( if landed == here::Landed::Nothing { return None; } - here::sequence() + here::worn_sequence() } pub struct Shelves { diff --git a/crates/cp-panel/src/here.rs b/crates/cp-panel/src/here.rs index 11b43dc9..e1ea83db 100644 --- a/crates/cp-panel/src/here.rs +++ b/crates/cp-panel/src/here.rs @@ -11,6 +11,7 @@ pub use platform::{ keep_awake, media_of, ocr_available, on_foreground, open_link, open_path, ours_up_front, paste_into, pointer, read_stuck, rebuilds_whole, sequence, system_is_light, text_in, thumb_of_file, thumbs_dir, to_clipboard, towards, unreadable, utc_offset_at, watch_start, + worn_sequence, }; #[derive(Debug, Clone, Copy, PartialEq)] @@ -55,6 +56,12 @@ mod platform { cp_win_sys::frontmost::foreground().map_or(0, |window| window.0 as isize) } + pub fn worn_sequence() -> Option { + let sequence = cp_win_sys::clipboard::sequence()?; + let ours = cp_win_sys::clipboard::holds(cp_win::restore::WORN); + (ours && cp_win_sys::clipboard::sequence() == Some(sequence)).then_some(sequence) + } + pub fn rebuilds_whole(item: &Item) -> bool { cp_win::restore::rebuilt_whole(item) } @@ -309,6 +316,10 @@ mod platform { 0 } + pub fn worn_sequence() -> Option { + None + } + pub fn rebuilds_whole(_item: &Item) -> bool { false } diff --git a/crates/cp-win-sys/src/clipboard.rs b/crates/cp-win-sys/src/clipboard.rs index 4a9dbec9..cb53e56b 100644 --- a/crates/cp-win-sys/src/clipboard.rs +++ b/crates/cp-win-sys/src/clipboard.rs @@ -1,7 +1,7 @@ use windows::Win32::Foundation::{HGLOBAL, HWND}; use windows::Win32::System::DataExchange::{ CloseClipboard, EnumClipboardFormats, GetClipboardData, GetClipboardOwner, - GetClipboardSequenceNumber, OpenClipboard, + GetClipboardSequenceNumber, IsClipboardFormatAvailable, OpenClipboard, }; use windows::Win32::System::Memory::{GlobalLock, GlobalSize, GlobalUnlock}; @@ -253,6 +253,13 @@ pub(crate) fn global_bytes(global: HGLOBAL, up_to: usize) -> Option> { Some(bytes) } +pub fn holds(name: &str) -> bool { + let Some(id) = crate::formats::id_of(name) else { + return false; + }; + unsafe { IsClipboardFormatAvailable(id) }.is_ok() +} + pub fn sequence() -> Option { let raw = unsafe { GetClipboardSequenceNumber() }; (raw != 0).then_some(i64::from(raw)) diff --git a/crates/cp-win-sys/src/clipboard_test.rs b/crates/cp-win-sys/src/clipboard_test.rs index 15c306bf..3875549b 100644 --- a/crates/cp-win-sys/src/clipboard_test.rs +++ b/crates/cp-win-sys/src/clipboard_test.rs @@ -84,3 +84,8 @@ fn a_second_read_waits_for_the_first_to_close_instead_of_sharing_it() { closed - second ); } + +#[test] +fn a_format_nobody_ever_wrote_is_not_held() { + assert!(!holds("CoPanda Never Written By Anyone")); +} diff --git a/crates/cp-win/src/restore.rs b/crates/cp-win/src/restore.rs index 8720848b..cac528a7 100644 --- a/crates/cp-win/src/restore.rs +++ b/crates/cp-win/src/restore.rs @@ -13,6 +13,7 @@ const JFIF: &str = "JFIF"; const DROP_EFFECT: &str = "Preferred DropEffect"; pub const UNRECORDED: [&str; 2] = ["CanIncludeInClipboardHistory", "CanUploadToCloudClipboard"]; pub const UNWATCHED: &str = "ExcludeClipboardContentFromMonitorProcessing"; +pub const WORN: &str = "CoPanda Worn"; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Restored { @@ -66,7 +67,7 @@ impl Ready { self.owned.retain(|(kept, _)| *kept != id); self.owned.push((id, 0u32.to_le_bytes().to_vec())); } - if let Some(id) = id_of(UNWATCHED) { + for id in [UNWATCHED, WORN].iter().filter_map(|name| id_of(name)) { self.owned.retain(|(kept, _)| *kept != id); self.owned.push((id, Vec::new())); } diff --git a/crates/cp-win/src/restore_test.rs b/crates/cp-win/src/restore_test.rs index 836458b2..2a6171c3 100644 --- a/crates/cp-win/src/restore_test.rs +++ b/crates/cp-win/src/restore_test.rs @@ -337,7 +337,9 @@ fn a_write_kept_out_of_the_history_says_so_once_per_door() { let before = ready.wanted(); ready.unrecorded(); ready.unrecorded(); - assert_eq!(ready.wanted(), before + UNRECORDED.len() + 1); + assert_eq!(ready.wanted(), before + UNRECORDED.len() + 2); + let worn = id_of(WORN).expect("a format the system registers"); + assert!(ready.offers(worn), "the write can be told apart as ours"); let unwatched = id_of(UNWATCHED).expect("a format the system registers"); assert!( ready.offers(unwatched), From 95a3f2b5129e22e0c289f83ed430d1305f5b8db3 Mon Sep 17 00:00:00 2001 From: rgdevment Date: Tue, 20 Oct 2026 19:06:44 -0300 Subject: [PATCH 7/7] fix(win): a screenshot's clipboard plumbing no longer stops it from reaching the terminal as a path --- crates/cp-panel/src/engine.rs | 8 +++++++- crates/cp-panel/src/handing.rs | 6 ++++++ crates/cp-win/src/restore.rs | 9 ++++++++- crates/cp-win/src/restore_test.rs | 19 +++++++++++++++++++ 4 files changed, 40 insertions(+), 2 deletions(-) diff --git a/crates/cp-panel/src/engine.rs b/crates/cp-panel/src/engine.rs index 6d1f2425..1aaa0eda 100644 --- a/crates/cp-panel/src/engine.rs +++ b/crates/cp-panel/src/engine.rs @@ -427,7 +427,13 @@ fn kept(store: &Store, notice: &mut Notice) -> Option<(i64, Option)> { let item = taken(here::capture_insisting(), here::unreadable, notice)?; let from = here::in_front(); let id = keep(store, &item, crate::app::now_ms(), from.as_deref())?; - Some((id, Copied::of(before, here::sequence(), id, &item))) + let copied = Copied::of(before, here::sequence(), id, &item); + if copied.is_none() && item.kind == Some(Kind::Image) && before.is_some() { + note(&format!( + "{id} changed while it was read, so a terminal gets no path for it" + )); + } + Some((id, copied)) } fn taken( diff --git a/crates/cp-panel/src/handing.rs b/crates/cp-panel/src/handing.rs index d1c302ee..e068aa60 100644 --- a/crates/cp-panel/src/handing.rs +++ b/crates/cp-panel/src/handing.rs @@ -83,6 +83,9 @@ fn worn_over( ) -> Option { let item = store.item(id).ok().flatten()?; if !here::rebuilds_whole(&item) { + note(&format!( + "{id} carries more than its picture, so a terminal gets no path for it" + )); return None; } let landing = Landing { @@ -90,6 +93,9 @@ fn worn_over( ..landing_for(store, &item, id, towards) }; if towards == Towards::Terminal && !matches!(landing.offer(), crate::landing::Offer::Text(_)) { + note(&format!( + "{id} has no file a terminal could take, so it gets no path" + )); return None; } let landed = written_over(&item, &landing, Some(engine)); diff --git a/crates/cp-win/src/restore.rs b/crates/cp-win/src/restore.rs index cac528a7..cfb37818 100644 --- a/crates/cp-win/src/restore.rs +++ b/crates/cp-win/src/restore.rs @@ -86,13 +86,20 @@ const PICTURE_TWINS: [&str; 6] = [ "CF_DIB", "CF_BITMAP", ]; +const PLUMBING: [&str; 5] = [ + "DataObject", + "Ole Private Data", + "CanIncludeInClipboardHistory", + "CanUploadToCloudClipboard", + "CF_LOCALE", +]; pub fn rebuilt_whole(item: &Item) -> bool { let mut picture = false; for format in &item.formats { let held = payload_of(format).is_some(); let twin = PICTURE_TWINS.contains(&format.id.as_str()); - if !held && !twin { + if !held && !twin && !PLUMBING.contains(&format.id.as_str()) { return false; } picture |= held && twin; diff --git a/crates/cp-win/src/restore_test.rs b/crates/cp-win/src/restore_test.rs index 2a6171c3..fb01331a 100644 --- a/crates/cp-win/src/restore_test.rs +++ b/crates/cp-win/src/restore_test.rs @@ -403,3 +403,22 @@ fn a_picture_that_carried_more_than_its_pixels_cannot_be_rebuilt_whole() { }; assert!(!rebuilt_whole(&too_big)); } + +#[test] +fn a_snipping_tool_screenshot_with_its_clipboard_plumbing_goes_back_whole() { + let png = image_bytes(); + let snip = Item { + kind: Some(cp_core::kind::Kind::Image), + formats: vec![ + announced("CF_BITMAP"), + announced("CF_DIB"), + announced("CF_DIBV5"), + inline("CanIncludeInClipboardHistory", &1u32.to_le_bytes()), + announced("CanUploadToCloudClipboard"), + announced("DataObject"), + announced("Ole Private Data"), + inline(PNG, &png), + ], + }; + assert!(rebuilt_whole(&snip)); +}