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/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." } ] } diff --git a/crates/cp-core/src/watch.rs b/crates/cp-core/src/watch.rs index aeedad7d..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,15 +70,23 @@ 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 self.ours.take().is_some() { + self.last = self.before_writing.take(); + } + } + 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..31aeec4a 100644 --- a/crates/cp-core/src/watch_test.rs +++ b/crates/cp-core/src/watch_test.rs @@ -104,3 +104,53 @@ 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(); + 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" + ); +} + +#[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-core/src/watching.rs b/crates/cp-core/src/watching.rs index e57532c5..7a09c4e7 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) -> bool { + let Ok(mut watcher) = self.watcher.lock() else { + return false; + }; + watcher.abandoned(); + 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..de7e9424 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()); + 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 03c75662..bc6def97 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(), @@ -127,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(); @@ -169,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| { @@ -182,7 +194,11 @@ 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); + self.dress_terminals(&engine, latest); + self.state.borrow_mut().engine = Some(engine); + } Err(why) => crate::note::trouble( cp_core::trouble::Trouble::Unwatched, &format!("nothing watches the clipboard: {why}"), @@ -190,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 new file mode 100644 index 00000000..6ea827dd --- /dev/null +++ b/crates/cp-panel/src/dressing.rs @@ -0,0 +1,169 @@ +use crate::here; +use crate::landing::Towards; +use cp_core::item::Item; +use cp_core::kind::Kind; +use std::cell::Cell; +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, + pub id: i64, +} + +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 }) + } +} + +pub type Latest = Arc>>; + +#[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 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 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>, + refused: Cell>, + settling: slint::Timer, + listening: Option, +} + +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| { + if let Some(dressing) = weak.upgrade() { + dressing.settle(); + } + }); + Self { + closet, + latest, + worn: Cell::new(None), + refused: Cell::new(None), + settling: slint::Timer::default(), + listening, + } + }) + } + + pub fn listening(&self) -> bool { + self.listening.is_some() + } + + pub fn arrived(&self) { + 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) { + self.undress(was); + } + } + + 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); + let copied = + still_copied(self.copied(), now).filter(|copy| self.refused.get() != Some(*copy)); + if worn.is_none() && copied.is_none() { + return; + } + match step(worn, self.closet.terminal(window), copied) { + Step::Stay => {} + Step::Dress(id) => { + 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(_) => { + 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)] +#[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..422711ad --- /dev/null +++ b/crates/cp-panel/src/dressing_test.rs @@ -0,0 +1,250 @@ +use super::*; +use std::cell::RefCell; + +const TERMINAL: isize = 1; +const CHAT: isize = 2; + +fn item(kind: Kind) -> Item { + Item { + kind: Some(kind), + formats: Vec::new(), + } +} + +#[test] +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), + 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); + assert_eq!(Copied::of(Some(7), Some(7), 3, &item(Kind::Text)), None); +} + +#[test] +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(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 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_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 { + sequence: Cell, + front: Cell, + placed: RefCell>, + refuses: Cell, + blind: Cell, + looked: Cell, +} + +impl Closet for Rc { + 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, over)); + self.sequence.set(self.sequence.get() + 1); + 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(copied: Option) -> (Rc, Dressing>, Latest) { + let shelf = Rc::new(Shelf::default()); + 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), + refused: Cell::new(None), + settling: slint::Timer::default(), + listening: None, + }; + (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, latest) = wardrobe(picture()); + dressing.look(TERMINAL); + assert_eq!(dressing.worn.get(), Some(Worn { sequence: 8, id: 3 })); + dressing.look(TERMINAL); + assert_eq!( + shelf.placed.borrow().len(), + 1, + "a second terminal changes nothing" + ); + dressing.look(CHAT); + assert_eq!( + *shelf.placed.borrow(), + [(3, Towards::Terminal, 7), (3, Towards::Elsewhere, 8)] + ); + assert_eq!(dressing.worn.get(), None); + assert_eq!( + *latest.lock().expect("unpoisoned"), + Some(Copied { sequence: 9, id: 3 }), + "the picture alone is known as the copy again" + ); + dressing.look(TERMINAL); + assert_eq!( + shelf.placed.borrow().len(), + 3, + "and it dresses again next time" + ); +} + +#[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(picture()); + dressing.look(TERMINAL); + shelf.sequence.set(20); + dressing.look(CHAT); + assert_eq!(shelf.placed.borrow().len(), 1); + assert_eq!(dressing.worn.get(), None); +} + +#[test] +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] +fn a_failed_undress_is_tried_again_at_the_next_window() { + let (shelf, dressing, _) = wardrobe(picture()); + dressing.look(TERMINAL); + shelf.refuses.set(true); + 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.placed.borrow().last(), + Some(&(3, Towards::Elsewhere, 8)) + ); +} + +#[test] +fn a_clipboard_that_cannot_be_counted_is_never_touched() { + let (shelf, dressing, _) = wardrobe(picture()); + shelf.blind.set(true); + dressing.look(TERMINAL); + assert!(shelf.placed.borrow().is_empty()); +} + +#[test] +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()), 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/engine.rs b/crates/cp-panel/src/engine.rs index 381b55ec..1aaa0eda 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}; @@ -19,7 +20,7 @@ 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)?; @@ -27,9 +28,9 @@ impl Engine { let waking = rest.clone(); let mut notice = Notice::default(); let watching = here::watch_start(move || { - if let Some(id) = kept(&store, &mut notice) { + 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)); @@ -63,6 +64,10 @@ impl Engine { pub fn ours(&self) -> bool { self.watching.ours() } + + pub fn abandoned(&self) -> bool { + self.watching.abandoned() + } } impl Drop for Engine { @@ -417,10 +422,18 @@ 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) -> Option<(i64, 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())?; + 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/engine_test.rs b/crates/cp-panel/src/engine_test.rs index 6ffa6f25..490f8cca 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!( @@ -681,3 +681,16 @@ 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 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"); + 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); +} diff --git a/crates/cp-panel/src/handing.rs b/crates/cp-panel/src/handing.rs index 750f2167..e068aa60 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, + only_over: None, + } } pub fn hand_over( @@ -43,8 +47,7 @@ pub fn hand_over( } }; let landing = landing_for(store, &item, id, towards); - let landed = here::to_clipboard(&item, &landing, || starting(engine), || mark(engine)); - let written = short_of(id, landed); + 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}")); @@ -57,6 +60,74 @@ pub fn hand_over( written } +fn written_over( + item: &cp_core::item::Item, + landing: &Landing, + engine: Option<&crate::engine::Engine>, +) -> here::Landed { + here::to_clipboard( + item, + landing, + || starting(engine), + || mark(engine), + || abandoned(engine), + ) +} + +fn worn_over( + store: &Store, + engine: &crate::engine::Engine, + id: i64, + towards: Towards, + over: i64, +) -> 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 { + only_over: Some(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)); + if landed == here::Landed::Nothing { + return None; + } + here::worn_sequence() +} + +pub struct Shelves { + pub store: std::rc::Rc, + pub engine: std::rc::Rc, +} + +impl crate::dressing::Closet for Shelves { + fn place(&self, id: i64, towards: Towards, over: i64) -> Option { + worn_over(&self.store, &self.engine, id, towards, over) + } + + fn sequence(&self) -> Option { + here::sequence() + } + + fn front(&self) -> isize { + here::front() + } + + fn terminal(&self, window: isize) -> bool { + here::towards(window) == Towards::Terminal + } +} + fn glimpse(rendered: Option) -> String { const SHOWN: usize = 22; let text = match rendered { @@ -177,15 +248,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}")); } @@ -233,6 +296,14 @@ fn starting(engine: Option<&crate::engine::Engine>) { } } +fn abandoned(engine: Option<&crate::engine::Engine>) { + if let Some(engine) = engine + && !engine.abandoned() + { + 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 6e51261f..e1ea83db 100644 --- a/crates/cp-panel/src/here.rs +++ b/crates/cp-panel/src/here.rs @@ -6,11 +6,12 @@ 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, 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, + worn_sequence, }; #[derive(Debug, Clone, Copy, PartialEq)] @@ -47,6 +48,30 @@ mod platform { cp_win::watching::every(cp_core::watching::EVERY, on_fresh) } + pub fn sequence() -> Option { + cp_win_sys::clipboard::sequence() + } + + pub fn front() -> isize { + 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) + } + + 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() } @@ -117,6 +142,7 @@ mod platform { landing: &crate::landing::Landing, starting: impl FnOnce(), ours: impl FnOnce(), + abandon: impl FnOnce(), ) -> Landed { let mut ready = cp_win::restore::ready_for(item); match landing.offer() { @@ -124,11 +150,21 @@ mod platform { crate::landing::Offer::Text(text) => ready.offer_text(&text), crate::landing::Offer::Nothing => {} } + if landing.only_over.is_some() { + ready.unrecorded(); + } starting(); let Some(clipboard) = Clipboard::to_write() else { ours(); return Landed::Nothing; }; + if let Some(over) = landing.only_over + && cp_win_sys::clipboard::sequence() != Some(over) + { + drop(clipboard); + abandon(); + return Landed::Nothing; + } let landed = match cp_win::restore::place(&clipboard, &ready) { cp_win::restore::Restored::Written { formats, @@ -272,6 +308,28 @@ mod platform { cp_mac::watching::every(cp_core::watching::EVERY, on_fresh) } + pub fn sequence() -> Option { + None + } + + pub fn front() -> isize { + 0 + } + + pub fn worn_sequence() -> Option { + None + } + + pub fn rebuilds_whole(_item: &Item) -> bool { + false + } + + 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() } @@ -341,6 +399,7 @@ mod platform { landing: &crate::landing::Landing, starting: impl FnOnce(), ours: impl FnOnce(), + _abandon: impl FnOnce(), ) -> 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 90514800..8d86e63b 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 only_over: Option, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -22,6 +23,7 @@ impl Landing { Self { towards: Towards::Elsewhere, files: Vec::new(), + only_over: None, } } diff --git a/crates/cp-panel/src/landing_test.rs b/crates/cp-panel/src/landing_test.rs index 5ee99859..28b6e09b 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(), + only_over: None, } } 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-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/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-sys/src/foreground.rs b/crates/cp-win-sys/src/foreground.rs new file mode 100644 index 00000000..27934cea --- /dev/null +++ b/crates/cp-win-sys/src/foreground.rs @@ -0,0 +1,72 @@ +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 = std::rc::Rc; + +thread_local! { + static HOOKED: RefCell> = RefCell::new(HashMap::new()); +} + +pub struct Listening(HWINEVENTHOOK); + +impl Drop for Listening { + fn drop(&mut self) { + let _ = unsafe { UnhookWinEvent(self.0) }; + HOOKED.with(|hooked| hooked.borrow_mut().remove(&key_of(self.0))); + } +} + +pub fn listen(heard: impl Fn(isize) + 'static) -> Option { + let hook = unsafe { + SetWinEventHook( + EVENT_SYSTEM_FOREGROUND, + EVENT_SYSTEM_FOREGROUND, + None, + Some(changed), + 0, + 0, + WINEVENT_OUTOFCONTEXT | WINEVENT_SKIPOWNPROCESS, + ) + }; + if hook.is_invalid() { + return None; + } + HOOKED.with(|hooked| { + hooked + .borrow_mut() + .insert(key_of(hook), std::rc::Rc::new(heard)) + }); + Some(Listening(hook)) +} + +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, + _event: u32, + window: HWND, + _object: i32, + _child: i32, + _thread: u32, + _time: u32, +) { + told(hook, 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..992ca512 --- /dev/null +++ b/crates/cp-win-sys/src/foreground_test.rs @@ -0,0 +1,51 @@ +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 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(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 listening = listen(heard_by(&seen)).expect("the system listens"); + unsafe { + changed( + listening.0, + 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..cfb37818 100644 --- a/crates/cp-win/src/restore.rs +++ b/crates/cp-win/src/restore.rs @@ -11,6 +11,9 @@ 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"]; +pub const UNWATCHED: &str = "ExcludeClipboardContentFromMonitorProcessing"; +pub const WORN: &str = "CoPanda Worn"; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Restored { @@ -59,11 +62,51 @@ 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())); + } + for id in [UNWATCHED, WORN].iter().filter_map(|name| id_of(name)) { + self.owned.retain(|(kept, _)| *kept != id); + self.owned.push((id, Vec::new())); + } + } + pub fn offers(&self, id: u32) -> bool { self.owned.iter().any(|(kept, _)| *kept == id) } } +const PICTURE_TWINS: [&str; 6] = [ + SYNTHETIC_IMAGE, + PNG, + JFIF, + "CF_DIBV5", + "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 && !PLUMBING.contains(&format.id.as_str()) { + 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 e45f30c6..fb01331a 100644 --- a/crates/cp-win/src/restore_test.rs +++ b/crates/cp-win/src/restore_test.rs @@ -330,3 +330,95 @@ 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() + 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), + "other clipboard managers look away too" + ); + 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"); +} + +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)); +} + +#[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)); +}