diff --git a/.github/oversized.txt b/.github/oversized.txt
index e035564f..6e6e1462 100644
--- a/.github/oversized.txt
+++ b/.github/oversized.txt
@@ -1,3 +1,3 @@
-370 crates/cp-panel/src/app.rs::wire the panel callbacks, one after another: splitting it would only move the list
-1039 crates/cp-win/examples/probe/battery.rs::main a diagnostic main that walks every invariant of its platform in one pass
+357 crates/cp-panel/src/app.rs::wire the panel callbacks, one after another: splitting it would only move the list
+986 crates/cp-win/examples/probe/battery.rs::main a diagnostic main that walks every invariant of its platform in one pass
1037 crates/cp-mac/examples/probe/battery.rs::main the same battery, for the other platform
diff --git a/app/src-tauri/src/keys.rs b/app/src-tauri/src/keys.rs
index 2f57b44e..6ceca8f0 100644
--- a/app/src-tauri/src/keys.rs
+++ b/app/src-tauri/src/keys.rs
@@ -46,6 +46,61 @@ fn remember(app: &AppHandle, said: Option<&str>) {
}
}
+#[cfg(target_os = "macos")]
+pub const SPARE: &[&str] = &[
+ "Alt+Cmd+V",
+ "Shift+Cmd+V",
+ "Alt+Cmd+C",
+ "Shift+Cmd+Space",
+ "Ctrl+Alt+V",
+ "Alt+Cmd+Space",
+];
+#[cfg(not(target_os = "macos"))]
+pub const SPARE: &[&str] = &[
+ "Ctrl+Alt+V",
+ "Ctrl+Shift+V",
+ "Ctrl+Alt+C",
+ "Ctrl+Shift+Space",
+ "Alt+Shift+V",
+ "Ctrl+Alt+Space",
+];
+
+fn grantable(app: &AppHandle, one: Shortcut) -> bool {
+ let keys = app.global_shortcut();
+ if keys.is_registered(one) {
+ return false;
+ }
+ let handle = app.clone();
+ if keys
+ .on_shortcut(one, move |_app, _shortcut, event| {
+ if event.state() == ShortcutState::Pressed {
+ crate::panel::show(&handle);
+ }
+ })
+ .is_err()
+ {
+ return false;
+ }
+ if let Err(why) = keys.unregister(one) {
+ note(&format!(
+ "«{one:?}» stayed registered after the probe: {why}"
+ ));
+ }
+ true
+}
+
+#[tauri::command]
+pub fn spare(app: tauri::AppHandle, taken: String) -> Vec {
+ let held = taken.parse::().ok();
+ SPARE
+ .iter()
+ .filter_map(|said| said.parse::().ok().map(|one| (said, one)))
+ .filter(|(_, one)| held != Some(*one))
+ .filter(|(_, one)| grantable(&app, *one))
+ .map(|(said, _)| (*said).to_owned())
+ .collect()
+}
+
#[tauri::command]
pub fn keys(app: tauri::AppHandle) -> Keys {
let wanted = crate::settings::settings()
diff --git a/app/src-tauri/src/keys_test.rs b/app/src-tauri/src/keys_test.rs
index 21668aae..30ac0564 100644
--- a/app/src-tauri/src/keys_test.rs
+++ b/app/src-tauri/src/keys_test.rs
@@ -16,3 +16,44 @@ fn nothing_is_bound_until_the_system_says_yes() {
let bound = Bound::default();
assert!(bound.0.lock().expect("unpoisoned").is_none());
}
+
+#[test]
+fn every_spare_combination_is_one_the_system_can_be_asked_for() {
+ assert!(!SPARE.is_empty());
+ for said in SPARE {
+ assert!(
+ said.parse::().is_ok(),
+ "«{said}» would be dropped in silence"
+ );
+ }
+}
+
+#[test]
+fn the_spare_list_spells_its_modifiers_the_way_the_picker_does() {
+ for said in SPARE {
+ let parts: Vec<&str> = said.split('+').collect();
+ let order = ["Ctrl", "Alt", "Shift", "Cmd"];
+ let mut seen = 0;
+ for part in &parts[..parts.len() - 1] {
+ let at = order
+ .iter()
+ .position(|one| one == part)
+ .unwrap_or_else(|| panic!("«{part}» is not a modifier the picker emits"));
+ assert!(at >= seen, "«{said}» is spelled out of the picker's order");
+ seen = at;
+ }
+ }
+}
+
+#[test]
+fn no_two_spare_entries_are_the_same_combination_spelled_differently() {
+ let parsed: Vec = SPARE
+ .iter()
+ .filter_map(|said| said.parse::().ok())
+ .collect();
+ for (at, one) in parsed.iter().enumerate() {
+ for other in &parsed[at + 1..] {
+ assert_ne!(one, other, "one combination is offered twice");
+ }
+ }
+}
diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs
index 7dbd6878..fa19ec77 100644
--- a/app/src-tauri/src/lib.rs
+++ b/app/src-tauri/src/lib.rs
@@ -26,6 +26,7 @@ pub fn run() {
waking::waking,
waking::wake,
keys::keys,
+ keys::spare,
trust::trust,
trust::ask_trust,
backup::save_backup,
diff --git a/app/src-tauri/src/panel.rs b/app/src-tauri/src/panel.rs
index 68a38f8b..cfaea966 100644
--- a/app/src-tauri/src/panel.rs
+++ b/app/src-tauri/src/panel.rs
@@ -1,5 +1,5 @@
use std::sync::Mutex;
-use tauri::{AppHandle, Manager, Runtime};
+use tauri::{AppHandle, Emitter, Manager, Runtime};
use tauri_plugin_shell::ShellExt;
use tauri_plugin_shell::process::{CommandChild, CommandEvent};
@@ -15,6 +15,11 @@ pub fn trouble(app: &AppHandle) -> Option {
}
fn heard_from_panel(app: &AppHandle, said: &str) {
+ if said == "settings" {
+ let _ = app.emit("rail", "keys");
+ crate::tray::surface(app);
+ return;
+ }
let Some(what) = said.strip_prefix("trouble ") else {
return;
};
diff --git a/app/src-tauri/src/tray.rs b/app/src-tauri/src/tray.rs
index 1a3dd983..6c345924 100644
--- a/app/src-tauri/src/tray.rs
+++ b/app/src-tauri/src/tray.rs
@@ -63,7 +63,7 @@ pub fn raise(app: &AppHandle, spanish: bool) -> Option<()> {
..
} = event
{
- surface(tray.app_handle());
+ crate::panel::show(tray.app_handle());
}
})
.build(app)
@@ -96,17 +96,29 @@ pub fn surface(app: &AppHandle) {
if let Some(window) = app.get_webview_window("main") {
let _ = window.show();
let _ = window.unminimize();
- let _ = window.set_focus();
+ ahead(&window);
return;
}
- let _ = WebviewWindowBuilder::new(app, "main", WebviewUrl::default())
+ let built = WebviewWindowBuilder::new(app, "main", WebviewUrl::default())
.title("CopyPaste")
.inner_size(780.0, 580.0)
.min_inner_size(620.0, 460.0)
.decorations(false)
.center()
.build();
+ if let Ok(window) = built {
+ ahead(&window);
+ }
+}
+
+fn ahead(window: &tauri::WebviewWindow) {
+ let was = window.is_always_on_top().unwrap_or(false);
+ let _ = window.set_always_on_top(true);
+ let _ = window.set_focus();
+ if !was {
+ let _ = window.set_always_on_top(false);
+ }
}
#[cfg(test)]
diff --git a/app/src/App.tsx b/app/src/App.tsx
index 4364f760..83dc4180 100644
--- a/app/src/App.tsx
+++ b/app/src/App.tsx
@@ -1,4 +1,5 @@
-import { useState } from "react";
+import { listen } from "@tauri-apps/api/event";
+import { useEffect, useState } from "react";
import { useKept, useTrouble } from "./core";
import { fill, t } from "./locales";
import About from "./ui/About";
@@ -6,10 +7,12 @@ import Backup from "./ui/Backup";
import Chrome from "./ui/Chrome";
import General from "./ui/General";
import History from "./ui/History";
-import { Clock, Gear, Info, Vault } from "./ui/Icons";
+import { Clock, Gear, Info, Key, Vault } from "./ui/Icons";
+import Keys from "./ui/Keys";
const WHERE = [
{ key: "general", says: "railGeneral", icon: Gear },
+ { key: "keys", says: "railKeys", icon: Key },
{ key: "history", says: "railHistory", icon: Clock },
{ key: "backup", says: "railBackup", icon: Vault },
] as const;
@@ -21,6 +24,17 @@ export default function App() {
const { kept, trouble, change, look } = useKept();
const panelTrouble = useTrouble();
+ useEffect(() => {
+ const asked = listen("rail", (event) => {
+ if (WHERE.some((one) => one.key === event.payload)) {
+ setWhere(event.payload as Where);
+ }
+ });
+ return () => {
+ void asked.then((drop) => drop());
+ };
+ }, []);
+
return (
<>
@@ -58,6 +72,7 @@ export default function App() {
)}
{kept && where === "general" && }
+ {kept && where === "keys" && }
{kept && where === "history" && }
{where === "backup" && }
{where === "about" && }
diff --git a/app/src/core.ts b/app/src/core.ts
index d72ab83e..a2877628 100644
--- a/app/src/core.ts
+++ b/app/src/core.ts
@@ -50,7 +50,7 @@ export function useKept() {
(what: Partial) => {
const was = held.current;
if (!was) {
- return;
+ return Promise.resolve();
}
const next = { ...was, ...what };
held.current = next;
@@ -71,9 +71,13 @@ export function useKept() {
setTrouble(null);
}
})
- .catch((why) => setTrouble(String(why)));
+ .catch((why) => {
+ look();
+ setTrouble(String(why));
+ });
+ return queue.current.then(() => undefined);
},
- [land],
+ [land, look],
);
return { kept, trouble, change, look };
@@ -140,16 +144,38 @@ export function empty(): Promise {
export type Keys = { wanted: string; bound: boolean };
-export function useKeys() {
+export function useKeys(shortcut: string) {
const [keys, setKeys] = useState(null);
+ const [spare, setSpare] = useState([]);
+ const live = useRef(true);
- useEffect(() => {
+ const ask = useCallback((taken: string) => {
invoke("keys")
- .then(setKeys)
- .catch(() => setKeys(null));
+ .then((said) => {
+ if (!live.current) {
+ return;
+ }
+ setKeys(said);
+ if (said.bound) {
+ setSpare([]);
+ return;
+ }
+ invoke("spare", { taken })
+ .then((free) => live.current && setSpare(free))
+ .catch(() => live.current && setSpare([]));
+ })
+ .catch(() => live.current && setKeys(null));
}, []);
- return keys;
+ useEffect(() => {
+ live.current = true;
+ ask(shortcut);
+ return () => {
+ live.current = false;
+ };
+ }, [shortcut, ask]);
+
+ return { keys, spare, recheck: ask };
}
export type Waking = { offered: boolean; wakes: boolean; theirs: boolean };
diff --git a/app/src/index.css b/app/src/index.css
index f8144e14..86de4b15 100644
--- a/app/src/index.css
+++ b/app/src/index.css
@@ -359,6 +359,29 @@ select {
padding: 6px 11px;
}
+.spare {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 8px;
+ margin-top: 9px;
+ font-size: 12.5px;
+ color: var(--muted);
+}
+
+.spare-one {
+ cursor: pointer;
+ color: var(--ink);
+ transition:
+ border-color 140ms ease,
+ background 140ms ease;
+}
+
+.spare-one:hover {
+ background: var(--tile);
+ border-color: var(--selected-edge);
+}
+
.path {
font-family: var(--mono);
font-size: 12px;
@@ -391,12 +414,12 @@ select {
background 140ms ease;
}
-.knob[aria-pressed="true"] {
+.knob[aria-checked="true"] {
background: var(--accent-soft);
border-color: var(--selected-edge);
}
-.knob[aria-pressed="true"]::after {
+.knob[aria-checked="true"]::after {
transform: translateX(17px);
background: var(--accent);
}
@@ -447,6 +470,65 @@ select {
font-size: 12.5px;
}
+.said-plain {
+ margin: 10px 0 0;
+ color: var(--muted);
+ font-size: 12.5px;
+}
+
+.bindings {
+ width: 100%;
+ margin-top: 12px;
+ border-collapse: collapse;
+ font-size: 12.5px;
+}
+
+.bindings th,
+.bindings td {
+ padding: 7px 0;
+ text-align: left;
+ font-weight: 400;
+ border-bottom: 1px solid var(--edge);
+ vertical-align: top;
+}
+
+.bindings th {
+ width: 40%;
+ padding-right: 14px;
+ font-family: var(--mono);
+ color: var(--ink);
+ white-space: nowrap;
+}
+
+.bindings td {
+ color: var(--muted);
+}
+
+.bindings tr:last-child th,
+.bindings tr:last-child td {
+ border-bottom: 0;
+}
+
+.facts {
+ margin: 9px 0 0;
+ padding: 10px 12px 10px 26px;
+ border: 1px solid var(--edge);
+ border-radius: 9px;
+ background: var(--tile);
+ color: var(--muted);
+ font-size: 12.5px;
+ line-height: 1.55;
+}
+
+.facts li {
+ margin: 0;
+ padding: 0;
+}
+
+.facts li::marker {
+ color: var(--placeholder);
+}
+
.said {
margin-top: 9px;
padding: 9px 11px;
diff --git a/app/src/locales.ts b/app/src/locales.ts
index 8857254f..5dc533c0 100644
--- a/app/src/locales.ts
+++ b/app/src/locales.ts
@@ -1,6 +1,7 @@
const ES = {
railGeneral: "General",
railHistory: "Historial",
+ railKeys: "Atajos de teclado",
railBackup: "Copia de seguridad",
railAbout: "Acerca de",
railSections: "Secciones",
@@ -31,6 +32,10 @@ const ES = {
"El panel no está funcionando: {one}. Lo copiado no se está guardando hasta que se resuelva",
keysTaken:
"Otro programa ya usa esa combinación, así que el panel no se abre con ella. Elige otra cuando puedas cambiarla",
+ keysFree: "Estas están libres ahora mismo:",
+ bandOpens: "ABRIR EL PANEL",
+ keysTable: "Lo que responde el panel",
+ keysTableWhy: "Estas teclas funcionan mientras el panel está abierto",
hides: "Ocultar al hacer clic fuera",
hidesWhy: "El panel se va solo en cuanto haces clic en otra ventana",
@@ -165,6 +170,7 @@ type Said = typeof ES;
const EN: Record = {
railGeneral: "General",
railHistory: "History",
+ railKeys: "Keyboard shortcuts",
railBackup: "Backup",
railAbout: "About",
railSections: "Sections",
@@ -195,6 +201,10 @@ const EN: Record = {
"The panel is not working: {one}. Nothing you copy is being kept until this is fixed",
keysTaken:
"Another program already uses that combination, so the panel will not open with it. Pick another one when you can change it",
+ keysFree: "These are free right now:",
+ bandOpens: "OPENING THE PANEL",
+ keysTable: "What the panel answers to",
+ keysTableWhy: "These keys work while the panel is open",
hides: "Hide when you click elsewhere",
hidesWhy: "The panel goes away on its own as soon as you click another window",
@@ -325,6 +335,46 @@ const EN: Record = {
itemMany: "{one} items",
};
+export type Binding = { keys: string; does: string };
+
+const PANEL_KEYS_ES: Binding[] = [
+ { keys: "Enter", does: "pegar lo seleccionado" },
+ { keys: "Shift + Enter", does: "pegar en plano" },
+ { keys: "Alt + Enter · Ctrl + Enter", does: "pegar como…" },
+ { keys: "Flechas", does: "moverse por la lista" },
+ { keys: "Clic", does: "abrir la tarjeta; otro clic la cierra" },
+ { keys: "Doble clic", does: "pegar esa tarjeta" },
+ { keys: "Tab · Shift + Tab", does: "recorrer los filtros" },
+ { keys: "#imagen · #carpeta", does: "filtrar por tipo desde el buscador" },
+ { keys: "Retroceso", does: "quitar la última etiqueta" },
+ { keys: "Supr", does: "borrar la seleccionada" },
+ { keys: "Ctrl + P", does: "anclar o desanclar" },
+ { keys: "Ctrl + E", does: "editar la seleccionada" },
+ { keys: "Flecha derecha", does: "abrir o cerrar la tarjeta" },
+ { keys: "Ctrl + 1 · Ctrl + 2", does: "todo · solo lo anclado" },
+ { keys: "Alt + G · Alt + T", does: "elegir el tipo" },
+ { keys: "Esc", does: "cerrar el panel" },
+];
+
+const PANEL_KEYS_EN: Binding[] = [
+ { keys: "Enter", does: "paste what is selected" },
+ { keys: "Shift + Enter", does: "paste as plain text" },
+ { keys: "Alt + Enter · Ctrl + Enter", does: "paste as…" },
+ { keys: "Arrows", does: "move through the list" },
+ { keys: "Click", does: "open the card; another click closes it" },
+ { keys: "Double click", does: "paste that card" },
+ { keys: "Tab · Shift + Tab", does: "step through the filters" },
+ { keys: "#image · #folder", does: "filter by kind from the search box" },
+ { keys: "Backspace", does: "drop the last tag" },
+ { keys: "Delete", does: "delete the selected one" },
+ { keys: "Ctrl + P", does: "pin or unpin" },
+ { keys: "Ctrl + E", does: "edit the selected one" },
+ { keys: "Right arrow", does: "open or close the card" },
+ { keys: "Ctrl + 1 · Ctrl + 2", does: "everything · only what is pinned" },
+ { keys: "Alt + G · Alt + T", does: "choose the kind" },
+ { keys: "Esc", does: "close the panel" },
+];
+
let now: Record = ES;
export function adopt(locale: string | null) {
@@ -345,3 +395,7 @@ export function fill(key: keyof Said, one: string) {
export function items(count: number) {
return count === 1 ? t("itemOne") : fill("itemMany", String(count));
}
+
+export function panelKeys(): Binding[] {
+ return now === EN ? PANEL_KEYS_EN : PANEL_KEYS_ES;
+}
diff --git a/app/src/tests/bindings.test.ts b/app/src/tests/bindings.test.ts
new file mode 100644
index 00000000..298e250d
--- /dev/null
+++ b/app/src/tests/bindings.test.ts
@@ -0,0 +1,105 @@
+import { readFileSync } from "node:fs";
+import { join } from "node:path";
+import { describe, expect, it } from "vitest";
+import { adopt, panelKeys } from "../locales";
+
+const PANEL = readFileSync(
+ join(__dirname, "..", "..", "..", "crates", "cp-panel", "ui", "panel.slint"),
+ "utf8",
+);
+
+const LOOKED_FOR: Record = {
+ enter: "event.text == Key.Return",
+ esc: "event.text == Key.Escape",
+ retroceso: "event.text == Key.Backspace",
+ backspace: "event.text == Key.Backspace",
+ supr: "event.text == Key.Delete",
+ delete: "event.text == Key.Delete",
+ tab: "event.text == Key.Tab",
+ "flecha derecha": "event.text == Key.RightArrow",
+ "right arrow": "event.text == Key.RightArrow",
+};
+
+const DESCRIBED = new Set([
+ "flechas",
+ "arrows",
+ "clic",
+ "click",
+ "doble clic",
+ "double click",
+ "space",
+]);
+
+function wanted(last: string) {
+ const one = last.toLowerCase();
+ if (one.length === 1) {
+ return `event.text == "${one}"`;
+ }
+ if (DESCRIBED.has(one) || one.startsWith("#")) {
+ return null;
+ }
+ const looked = LOOKED_FOR[one];
+ if (!looked) {
+ throw new Error(`«${last}» is neither a key the panel looks for nor a described gesture`);
+ }
+ return looked;
+}
+
+describe("la tabla de atajos", () => {
+ it("promete solo teclas que el panel mira de verdad", () => {
+ adopt("en");
+ for (const row of panelKeys()) {
+ for (const combination of row.keys.split("·")) {
+ const parts = combination
+ .split("+")
+ .map((one) => one.trim())
+ .filter((one) => one.length > 0);
+ const last = parts.at(-1);
+ if (!last) {
+ continue;
+ }
+ const looked = wanted(last);
+ if (!looked) {
+ continue;
+ }
+ expect(
+ PANEL,
+ `«${row.keys}» promises ${looked} and the panel never looks for it`,
+ ).toContain(looked);
+ }
+ }
+ });
+
+ it("dice lo mismo en los dos idiomas, fila por fila", () => {
+ adopt("es");
+ const es = panelKeys();
+ adopt("en");
+ const en = panelKeys();
+ expect(es.length).toBe(en.length);
+ expect(es.length).toBeGreaterThan(0);
+ for (let at = 0; at < es.length; at += 1) {
+ expect(es[at].keys.length).toBeGreaterThan(0);
+ expect(en[at].keys.length).toBeGreaterThan(0);
+ expect(es[at].does).not.toBe(en[at].does);
+ }
+ });
+
+ it("filtra por tipos que el buscador conoce", () => {
+ const view = readFileSync(
+ join(__dirname, "..", "..", "..", "crates", "cp-panel", "src", "view.rs"),
+ "utf8",
+ );
+ adopt("es");
+ for (const row of [...panelKeys()]) {
+ for (const word of row.keys.split("·")) {
+ const tag = word.trim();
+ if (!tag.startsWith("#")) {
+ continue;
+ }
+ expect(view, `«${tag}» is not a kind the search box knows`).toContain(
+ `"${tag.slice(1)}" =>`,
+ );
+ }
+ }
+ });
+});
diff --git a/app/src/tests/rail.test.tsx b/app/src/tests/rail.test.tsx
new file mode 100644
index 00000000..978f4d66
--- /dev/null
+++ b/app/src/tests/rail.test.tsx
@@ -0,0 +1,43 @@
+import { render, screen, waitFor } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import App from "../App";
+
+type Heard = (event: { payload: unknown }) => void;
+
+async function heardOn(name: string): Promise {
+ const { listen } = await import("@tauri-apps/api/event");
+ const call = vi.mocked(listen).mock.calls.find(([what]) => what === name) as
+ | [string, Heard]
+ | undefined;
+ expect(call, `nobody is listening for «${name}»`).toBeDefined();
+ return (call as [string, Heard])[1];
+}
+
+describe("la ventana", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("aterriza en los atajos cuando el panel pide los ajustes", async () => {
+ render();
+ await screen.findByLabelText("Idioma");
+
+ const rail = await heardOn("rail");
+ rail({ payload: "backup" });
+ expect(await screen.findByText("Exportar")).toBeDefined();
+
+ rail({ payload: "keys" });
+ expect(await screen.findByText("Atajo del panel")).toBeDefined();
+ });
+
+ it("no se mueve si le piden una sección que no existe", async () => {
+ render();
+ await screen.findByLabelText("Idioma");
+
+ const rail = await heardOn("rail");
+ rail({ payload: "inventada" });
+ await waitFor(() => {
+ expect(screen.getByLabelText("Idioma")).toBeDefined();
+ });
+ });
+});
diff --git a/app/src/tests/refusals.test.tsx b/app/src/tests/refusals.test.tsx
new file mode 100644
index 00000000..27c93784
--- /dev/null
+++ b/app/src/tests/refusals.test.tsx
@@ -0,0 +1,75 @@
+import { render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import App from "../App";
+
+type Invoke = (what: string, args?: never) => Promise;
+
+async function insteadOf(handle: (what: string, real?: Invoke) => Promise | null) {
+ const { invoke } = await import("@tauri-apps/api/core");
+ const real = vi.mocked(invoke).getMockImplementation() as Invoke;
+ vi.mocked(invoke).mockImplementation(((what: string, args?: never) => {
+ const mine = handle(what, real);
+ return mine ?? real(what, args);
+ }) as never);
+ return () => vi.mocked(invoke).mockImplementation(real as never);
+}
+
+describe("cuando el backend dice que no", () => {
+ const undo: (() => void)[] = [];
+
+ afterEach(() => {
+ while (undo.length > 0) {
+ undo.pop()?.();
+ }
+ });
+
+ it("muestra el motivo y vuelve a leer lo que quedó guardado", async () => {
+ const who = userEvent.setup();
+ undo.push(
+ await insteadOf((what) =>
+ what === "keep" ? Promise.reject(new Error("el sistema no cedió esa tecla")) : null,
+ ),
+ );
+ render();
+ const look = await screen.findByLabelText("Tema");
+ const { invoke } = await import("@tauri-apps/api/core");
+ const asked = vi.mocked(invoke).mock.calls.filter(([what]) => what === "settings").length;
+
+ await who.selectOptions(look, "dark");
+
+ expect(await screen.findByText(/el sistema no cedió esa tecla/)).toBeDefined();
+ await waitFor(() => {
+ expect(
+ vi.mocked(invoke).mock.calls.filter(([what]) => what === "settings").length,
+ ).toBeGreaterThan(asked);
+ });
+ });
+
+ it("calla sobre el atajo si ni siquiera puede preguntar", async () => {
+ undo.push(
+ await insteadOf((what) => (what === "keys" ? Promise.reject(new Error("no answer")) : null)),
+ );
+ const who = userEvent.setup();
+ render();
+ await who.click(screen.getByRole("button", { name: "Atajos de teclado" }));
+ expect(await screen.findByText("Atajo del panel")).toBeDefined();
+ expect(screen.queryByText(/Otro programa ya usa/)).toBeNull();
+ });
+
+ it("avisa del atajo tomado aunque no consiga proponer otros", async () => {
+ undo.push(
+ await insteadOf((what) => {
+ if (what === "keys") {
+ return Promise.resolve({ wanted: "Ctrl+Alt+V", bound: false });
+ }
+ return what === "spare" ? Promise.reject(new Error("no answer")) : null;
+ }),
+ );
+ const who = userEvent.setup();
+ render();
+ await who.click(screen.getByRole("button", { name: "Atajos de teclado" }));
+ expect(await screen.findByText(/Otro programa ya usa esa combinación/)).toBeDefined();
+ expect(screen.queryByText(/Estas están libres/)).toBeNull();
+ });
+});
diff --git a/app/src/tests/setup.ts b/app/src/tests/setup.ts
index caaa4ac1..18684431 100644
--- a/app/src/tests/setup.ts
+++ b/app/src/tests/setup.ts
@@ -90,6 +90,9 @@ vi.mock("@tauri-apps/api/core", () => ({
if (what === "save_backup") {
return Promise.resolve({ path: "/donde/quiera/CopyPaste.cpbackup", items: 3, bytes: 2048 });
}
+ if (what === "spare") {
+ return Promise.resolve(["Ctrl+Shift+V", "Ctrl+Alt+C"]);
+ }
if (what === "load_backup") {
return Promise.resolve({ added: 2, already: 1 });
}
diff --git a/app/src/tests/window.test.tsx b/app/src/tests/window.test.tsx
index d8598023..9f6e3957 100644
--- a/app/src/tests/window.test.tsx
+++ b/app/src/tests/window.test.tsx
@@ -13,14 +13,29 @@ describe("la ventana", () => {
it("abre en General, que es donde está lo que se toca una vez", async () => {
render();
expect(await screen.findByRole("heading", { level: 1 })).toHaveTextContent("General");
- expect(screen.getByText("Ctrl + Alt + V")).toBeDefined();
+ expect(await screen.findByLabelText("Idioma")).toBeDefined();
});
- it("ofrece tres secciones y el acerca de, y nada más", () => {
+ it("el atajo y lo que responde el panel viven en su propia sección", async () => {
+ const who = userEvent.setup();
+ render();
+ await who.click(screen.getByRole("button", { name: "Atajos de teclado" }));
+ expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent("Atajos de teclado");
+ expect(await screen.findByText("Ctrl + Alt + V")).toBeDefined();
+ expect(screen.getByText("Doble clic")).toBeDefined();
+ });
+
+ it("ofrece cuatro secciones y el acerca de, y nada más", () => {
render();
const rail = screen.getByRole("navigation", { name: "Secciones" });
const says = buttonsIn(rail).map((one) => one.textContent);
- expect(says).toEqual(["General", "Historial", "Copia de seguridad", "Acerca de"]);
+ expect(says).toEqual([
+ "General",
+ "Atajos de teclado",
+ "Historial",
+ "Copia de seguridad",
+ "Acerca de",
+ ]);
});
it("cambia de sección al elegirla", async () => {
@@ -112,13 +127,40 @@ describe("la ventana", () => {
what === "keys"
? Promise.resolve({ wanted: "Ctrl+Alt+V", bound: false })
: real?.(what, args)) as never);
+ const who = userEvent.setup();
render();
+ await who.click(screen.getByRole("button", { name: "Atajos de teclado" }));
expect(await screen.findByText(/Otro programa ya usa esa combinación/)).toBeDefined();
vi.mocked(invoke).mockImplementation(real as never);
});
+ it("ofrece las combinaciones libres y adopta la que se pulsa", async () => {
+ const who = userEvent.setup();
+ const { invoke } = await import("@tauri-apps/api/core");
+ const real = vi.mocked(invoke).getMockImplementation();
+ vi.mocked(invoke).mockImplementation(((what: string, args?: never) =>
+ what === "keys"
+ ? Promise.resolve({ wanted: "Ctrl+Alt+V", bound: false })
+ : real?.(what, args)) as never);
+ render();
+ await who.click(screen.getByRole("button", { name: "Atajos de teclado" }));
+ expect(await screen.findByText(/Estas están libres ahora mismo/)).toBeDefined();
+ const offered = await screen.findByRole("button", { name: "Ctrl + Shift + V" });
+ await who.click(offered);
+ const asked = vi
+ .mocked(invoke)
+ .mock.calls.filter(([what]) => what === "keep")
+ .pop();
+ expect(asked).toBeDefined();
+ const sent = asked?.[1] as { config: { shortcut: string } } | undefined;
+ expect(sent?.config.shortcut).toBe("Ctrl+Shift+V");
+ vi.mocked(invoke).mockImplementation(real as never);
+ });
+
it("calla sobre el atajo cuando el sistema sí lo cedió", async () => {
+ const who = userEvent.setup();
render();
+ await who.click(screen.getByRole("button", { name: "Atajos de teclado" }));
expect(await screen.findByText("Ctrl + Alt + V")).toBeDefined();
expect(screen.queryByText(/Otro programa ya usa/)).toBeNull();
});
@@ -236,6 +278,7 @@ describe("la ventana", () => {
it("cambiar el atajo guarda la combinación que se presiona", async () => {
const { invoke } = await import("@tauri-apps/api/core");
render();
+ await userEvent.click(screen.getByRole("button", { name: "Atajos de teclado" }));
await userEvent.click(await screen.findByRole("button", { name: "Cambiar" }));
const stop = await screen.findByRole("button", { name: "Dejarlo como está" });
stop.focus();
diff --git a/app/src/ui/Backup.tsx b/app/src/ui/Backup.tsx
index c0dca03b..5442ff35 100644
--- a/app/src/ui/Backup.tsx
+++ b/app/src/ui/Backup.tsx
@@ -216,23 +216,23 @@ export default function Backup() {
{fill("formerUnreadable", former.unreadable)}
) : (
<>
- {fill("formerHas", items(former.items))}
- {t("formerKeeps")}
- {former.withStyles > 0 ? (
- {fill("formerKeepsStyles", String(former.withStyles))}
- ) : null}
- {t("formerLosesPlain")}
- {former.picturesGone > 0 && (
-
- {fill("formerLosesPictures", String(former.picturesGone))}
-
- )}
+
+ - {fill("formerHas", items(former.items))}
+ - {t("formerKeeps")}
+ {former.withStyles > 0 ? (
+ - {fill("formerKeepsStyles", String(former.withStyles))}
+ ) : null}
+ - {t("formerLosesPlain")}
+ {former.picturesGone > 0 && (
+ - {fill("formerLosesPictures", String(former.picturesGone))}
+ )}
+ - {t("formerKeepsSecrets")}
+ - {t("formerPanelRests")}
+ - {t("formerStays")}
+
{former.beyondKeep > 0 && (
{fill("formerLosesKept", items(former.beyondKeep))}
)}
- {t("formerKeepsSecrets")}
- {t("formerPanelRests")}
- {t("formerStays")}
{sure && {t("formerDropWhy")}
}
>
)
diff --git a/app/src/ui/General.tsx b/app/src/ui/General.tsx
index cf9a99bf..aa8a6288 100644
--- a/app/src/ui/General.tsx
+++ b/app/src/ui/General.tsx
@@ -1,15 +1,5 @@
import { openUrl } from "@tauri-apps/plugin-opener";
-import { useState } from "react";
-import {
- combination,
- type Kept,
- type Look,
- onMac,
- PRIVACY_PANE,
- useKeys,
- useTrust,
- useWaking,
-} from "../core";
+import { type Kept, type Look, onMac, PRIVACY_PANE, useTrust, useWaking } from "../core";
import { t } from "../locales";
import { Band, Knob, Line } from "./Bits";
@@ -18,12 +8,10 @@ export default function General({
change,
}: {
kept: Kept;
- change: (what: Partial) => void;
+ change: (what: Partial) => Promise;
}) {
const { waking, trouble, ask } = useWaking();
const { trust, asked, ask: askTrust } = useTrust();
- const keys = useKeys();
- const [asking, setAsking] = useState(false);
return (
<>
@@ -104,37 +92,6 @@ export default function General({
)}
-
-
- {kept.shortcut.replaceAll("+", " + ")}
-
-
-
-
) => Promise;
+}) {
+ const { keys, spare, recheck } = useKeys(kept.shortcut);
+ const [asking, setAsking] = useState(false);
+
+ return (
+ <>
+ {t("railKeys")}
+
+
+
+ 0 ? (
+
+ {t("keysFree")}
+ {spare.map((one) => (
+
+ ))}
+
+ ) : null
+ }
+ >
+
+ {kept.shortcut.replaceAll("+", " + ")}
+
+
+
+
+
+
+ {t("keysTableWhy")}
+
+
+
+ {panelKeys().map((one) => (
+
+ | {one.keys} |
+ {one.does} |
+
+ ))}
+
+
+ >
+ );
+}
diff --git a/crates/cp-core/src/capture.rs b/crates/cp-core/src/capture.rs
index 97e229e2..738e3eb1 100644
--- a/crates/cp-core/src/capture.rs
+++ b/crates/cp-core/src/capture.rs
@@ -1,5 +1,6 @@
use crate::formats::Refusal;
use crate::item::Item;
+use crate::reading::{Pending, Waited};
use crate::watch::{Retried, Retry, insist};
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -8,6 +9,7 @@ pub enum Captured {
Refused(Refusal),
Nothing,
TooSlow,
+ Busy,
Superseded,
}
@@ -26,7 +28,7 @@ pub fn insisting(
mut once: impl FnMut() -> Captured,
) -> Captured {
let attempt = || match once() {
- Captured::TooSlow => None,
+ Captured::TooSlow | Captured::Busy => None,
other => Some(other),
};
match insist(retry, count, attempt, std::thread::sleep) {
@@ -36,6 +38,43 @@ pub fn insisting(
}
}
+pub fn insisting_afresh(
+ retry: Retry,
+ patience: std::time::Duration,
+ count: impl Fn() -> i64,
+ mut afresh: impl FnMut() -> Pending,
+) -> Captured {
+ let started = count();
+ let mut pending = afresh();
+ let mut left = retry.attempts;
+ let mut last = Captured::TooSlow;
+ let got = insisting(retry, &count, || {
+ left = left.saturating_sub(1);
+ match pending.waited(patience) {
+ Waited::StillRunning => {
+ last = Captured::TooSlow;
+ Captured::TooSlow
+ }
+ Waited::Answered(Captured::TooSlow | Captured::Busy) | Waited::Gone => {
+ if left > 0 && count() == started {
+ pending = afresh();
+ }
+ last = Captured::Busy;
+ Captured::Busy
+ }
+ Waited::Answered(other) => other,
+ }
+ });
+ let got = match got {
+ Captured::TooSlow => last,
+ other => other,
+ };
+ match got {
+ Captured::Nothing | Captured::Busy if count() != started => Captured::Superseded,
+ other => other,
+ }
+}
+
#[cfg(test)]
#[path = "capture_test.rs"]
mod tests;
diff --git a/crates/cp-core/src/capture_test.rs b/crates/cp-core/src/capture_test.rs
index bbdbbbdc..6f8146ba 100644
--- a/crates/cp-core/src/capture_test.rs
+++ b/crates/cp-core/src/capture_test.rs
@@ -96,3 +96,62 @@ fn only_what_was_kept_is_an_item() {
assert_eq!(Captured::Superseded.kept(), None);
assert_eq!(Captured::Refused(Refusal::Declined("x")).kept(), None);
}
+
+#[test]
+fn a_clipboard_held_by_someone_else_is_insisted_on_like_a_slow_one() {
+ let mut tries = 0;
+ let got = insisting(
+ quick(),
+ || 7,
+ || {
+ tries += 1;
+ if tries < 3 {
+ Captured::Busy
+ } else {
+ Captured::Nothing
+ }
+ },
+ );
+ assert_eq!(got, Captured::Nothing);
+ assert_eq!(tries, 3, "Busy is retried, not taken as an answer");
+}
+
+#[test]
+fn a_read_that_never_comes_back_stays_a_timeout_and_is_not_swallowed() {
+ let got = insisting_afresh(
+ quick(),
+ std::time::Duration::from_millis(5),
+ || 7,
+ || {
+ crate::reading::begin(|| {
+ std::thread::sleep(std::time::Duration::from_secs(30));
+ Captured::Nothing
+ })
+ },
+ );
+ assert_eq!(
+ got,
+ Captured::TooSlow,
+ "a read still running is a timeout the engine must log"
+ );
+}
+
+#[test]
+fn a_busy_clipboard_becomes_superseded_only_when_a_newer_copy_arrived() {
+ let moved = std::sync::atomic::AtomicI64::new(1);
+ let got = insisting_afresh(
+ quick(),
+ std::time::Duration::from_secs(5),
+ || moved.fetch_add(1, std::sync::atomic::Ordering::SeqCst),
+ || crate::reading::begin(|| Captured::Busy),
+ );
+ assert_eq!(got, Captured::Superseded);
+
+ let still = insisting_afresh(
+ quick(),
+ std::time::Duration::from_secs(5),
+ || 7,
+ || crate::reading::begin(|| Captured::Busy),
+ );
+ assert_eq!(still, Captured::Busy, "nothing newer, so it stays busy");
+}
diff --git a/crates/cp-core/src/lib.rs b/crates/cp-core/src/lib.rs
index d4fa4760..38a56cfb 100644
--- a/crates/cp-core/src/lib.rs
+++ b/crates/cp-core/src/lib.rs
@@ -8,6 +8,7 @@ pub mod item;
pub mod kind;
pub mod paste;
pub mod paste_as;
+pub mod reading;
pub mod search;
pub mod thumbnail;
pub mod token;
diff --git a/crates/cp-mac-sys/src/reading.rs b/crates/cp-core/src/reading.rs
similarity index 100%
rename from crates/cp-mac-sys/src/reading.rs
rename to crates/cp-core/src/reading.rs
diff --git a/crates/cp-mac-sys/src/reading_test.rs b/crates/cp-core/src/reading_test.rs
similarity index 59%
rename from crates/cp-mac-sys/src/reading_test.rs
rename to crates/cp-core/src/reading_test.rs
index 8bf738f2..0c0794cd 100644
--- a/crates/cp-mac-sys/src/reading_test.rs
+++ b/crates/cp-core/src/reading_test.rs
@@ -1,32 +1,5 @@
use super::*;
-#[test]
-fn what_answers_in_time_is_delivered() {
- assert_eq!(
- anything_within(Duration::from_millis(400), || vec![1, 2, 3]),
- Some(vec![1, 2, 3])
- );
-}
-
-#[test]
-fn what_does_not_answer_in_time_is_abandoned() {
- let seen = anything_within(Duration::from_millis(20), || {
- std::thread::sleep(Duration::from_millis(400));
- vec![9]
- });
- assert_eq!(seen, None);
-}
-
-#[test]
-fn abandoning_one_read_does_not_poison_the_next() {
- let abandoned = anything_within(Duration::from_millis(10), || {
- std::thread::sleep(Duration::from_millis(300));
- 0
- });
- assert_eq!(abandoned, None);
- assert_eq!(anything_within(Duration::from_millis(400), || 7), Some(7));
-}
-
#[test]
fn a_late_answer_is_picked_up_by_a_later_wait_on_the_same_read() {
let (release, gate) = std::sync::mpsc::channel::<()>();
@@ -39,23 +12,15 @@ fn a_late_answer_is_picked_up_by_a_later_wait_on_the_same_read() {
assert_eq!(
pending.wait(Duration::from_secs(5)),
Some(42),
- "not read twice"
+ "sin leer dos veces"
);
assert_eq!(
pending.wait(Duration::from_millis(20)),
None,
- "and once delivered, there is no more"
+ "and once handed over, there is no more"
);
}
-#[test]
-fn a_read_that_panics_is_abandoned_like_any_other() {
- let seen = anything_within(Duration::from_millis(50), || -> u8 {
- panic!("the provider died")
- });
- assert_eq!(seen, None);
-}
-
#[test]
fn waiting_tells_a_read_still_running_from_one_whose_answer_is_spent() {
let (release, gate) = std::sync::mpsc::channel::<()>();
diff --git a/crates/cp-mac-sys/src/lib.rs b/crates/cp-mac-sys/src/lib.rs
index c9773fc8..ef549bb1 100644
--- a/crates/cp-mac-sys/src/lib.rs
+++ b/crates/cp-mac-sys/src/lib.rs
@@ -11,6 +11,5 @@ pub mod ocr;
pub mod pasteboard;
pub mod paths;
pub mod permissions;
-pub mod reading;
pub mod runloop;
pub mod theme;
diff --git a/crates/cp-mac/src/capture.rs b/crates/cp-mac/src/capture.rs
index 79dd776a..69653f1d 100644
--- a/crates/cp-mac/src/capture.rs
+++ b/crates/cp-mac/src/capture.rs
@@ -1,11 +1,11 @@
use crate::formats::CATALOG;
pub use cp_core::capture::Captured;
-use cp_core::capture::insisting;
+use cp_core::capture::insisting_afresh;
use cp_core::formats::{Family, Take};
use cp_core::item::{Format, Item, Payload};
use cp_core::kind::{self, Kind};
+use cp_core::reading;
use cp_mac_sys::pasteboard::{self, Pasteboard};
-use cp_mac_sys::reading;
pub const PATIENCE: std::time::Duration = std::time::Duration::from_millis(400);
@@ -17,31 +17,12 @@ pub fn capture_within(patience: std::time::Duration) -> Captured {
}
pub fn capture_insisting(patience: std::time::Duration, retry: cp_core::watch::Retry) -> Captured {
- let started = pasteboard::change_count_from_any_thread();
- let afresh = || reading::begin(|| capture(&Pasteboard::general_from_any_thread()));
- let mut pending = afresh();
- let mut left = retry.attempts;
- let got = insisting(retry, pasteboard::change_count_from_any_thread, || {
- left = left.saturating_sub(1);
- match pending.waited(patience) {
- reading::Waited::StillRunning => Captured::TooSlow,
- reading::Waited::Answered(Captured::TooSlow) | reading::Waited::Gone => {
- if left > 0 && pasteboard::change_count_from_any_thread() == started {
- pending = afresh();
- }
- Captured::TooSlow
- }
- reading::Waited::Answered(other) => other,
- }
- });
- match got {
- Captured::Nothing | Captured::TooSlow
- if pasteboard::change_count_from_any_thread() != started =>
- {
- Captured::Superseded
- }
- other => other,
- }
+ insisting_afresh(
+ retry,
+ patience,
+ pasteboard::change_count_from_any_thread,
+ || reading::begin(|| capture(&Pasteboard::general_from_any_thread())),
+ )
}
pub fn capture(pb: &Pasteboard) -> Captured {
diff --git a/crates/cp-panel/assets/icons/settings.svg b/crates/cp-panel/assets/icons/settings.svg
new file mode 100644
index 00000000..6ac27f53
--- /dev/null
+++ b/crates/cp-panel/assets/icons/settings.svg
@@ -0,0 +1,16 @@
+
+
diff --git a/crates/cp-panel/src/app.rs b/crates/cp-panel/src/app.rs
index 207a2b8f..5a61ada8 100644
--- a/crates/cp-panel/src/app.rs
+++ b/crates/cp-panel/src/app.rs
@@ -55,7 +55,6 @@ struct State {
enum Asking {
Forms(i64),
Kinds,
- Keys,
}
struct Request {
@@ -174,6 +173,27 @@ impl App {
self.refresh();
}
+ fn wire_chrome(&self, panel: &Panel) {
+ let ui = self.ui.clone();
+ panel.on_ask_settings(move || {
+ let Some(ui) = ui.upgrade() else {
+ return;
+ };
+ ui.set_sheet_open(false);
+ let _ = ui.hide();
+ crate::note::tell("settings");
+ });
+ let ui = self.ui.clone();
+ panel.on_nudge(move |dx, dy| {
+ let Some(ui) = ui.upgrade() else {
+ return;
+ };
+ let window = ui.window();
+ let at = window.position().to_logical(window.scale_factor());
+ window.set_position(slint::LogicalPosition::new(at.x + dx, at.y + dy));
+ });
+ }
+
fn wire(&self, panel: &Panel) {
let ui = self.ui.clone();
let state = self.state.clone();
@@ -478,19 +498,7 @@ impl App {
keeping_place(&ui, &state);
}
});
- let ui = self.ui.clone();
- let state = self.state.clone();
- panel.on_ask_keys(move || {
- let Some(ui) = ui.upgrade() else {
- return;
- };
- state.borrow_mut().asking = Asking::Keys;
- ui.set_sheet_anchor(0.0);
- ui.set_sheet_span(0.0);
- ui.set_sheet_subject(Default::default());
- open_sheet(&ui, crate::say::pick("ATAJOS", "SHORTCUTS"), keys_sheet());
- arm_sheet(&state, &ui);
- });
+ self.wire_chrome(panel);
let ui = self.ui.clone();
let state = self.state.clone();
panel.on_sheet_chosen(move |key| {
@@ -516,7 +524,6 @@ impl App {
blink(&ui);
refresh(&ui, &state);
}
- Asking::Keys => {}
}
});
let ui = self.ui.clone();
@@ -799,108 +806,6 @@ fn glimpse(rendered: Option) -> String {
format!("{}…", kept.trim_end())
}
-fn keys_sheet() -> Vec {
- keys_sheet_in(crate::say::in_english())
-}
-
-fn keys_sheet_in(english: bool) -> Vec {
- const KEYS: [(&str, &str, &str, &str); 16] = [
- (
- "Enter",
- "Enter",
- "pegar lo seleccionado",
- "paste what is selected",
- ),
- (
- "Shift + Enter",
- "Shift + Enter",
- "pegar en plano",
- "paste as plain text",
- ),
- (
- "Alt + Enter · Ctrl + Enter",
- "Alt + Enter · Ctrl + Enter",
- "pegar como…",
- "paste as…",
- ),
- (
- "Flechas",
- "Arrows",
- "moverse por la lista",
- "move through the list",
- ),
- (
- "Clic",
- "Click",
- "abrir la tarjeta; otro clic la cierra",
- "open the card; another click closes it",
- ),
- (
- "Doble clic",
- "Double click",
- "pegar esa tarjeta",
- "paste that card",
- ),
- (
- "Tab · Shift + Tab",
- "Tab · Shift + Tab",
- "recorrer los filtros",
- "step through the filters",
- ),
- (
- "#imagen · #carpeta",
- "#image · #folder",
- "filtrar por tipo desde el buscador",
- "filter by kind from the search box",
- ),
- (
- "Retroceso",
- "Backspace",
- "quitar la última etiqueta",
- "drop the last tag",
- ),
- (
- "Supr",
- "Delete",
- "borrar la seleccionada",
- "delete the selected one",
- ),
- ("Ctrl + P", "Ctrl + P", "anclar o desanclar", "pin or unpin"),
- (
- "Ctrl + E",
- "Ctrl + E",
- "editar la seleccionada",
- "edit the selected one",
- ),
- (
- "Flecha derecha",
- "Right arrow",
- "abrir o cerrar la tarjeta",
- "open or close the card",
- ),
- (
- "Ctrl + 1 · Ctrl + 2",
- "Ctrl + 1 · Ctrl + 2",
- "todo · solo lo anclado",
- "everything · only what is pinned",
- ),
- (
- "Alt + G · Alt + T",
- "Alt + G · Alt + T",
- "elegir el tipo",
- "choose the kind",
- ),
- ("Esc", "Esc", "cerrar el panel", "close the panel"),
- ];
- KEYS.iter()
- .map(|(keys_es, keys_en, what_es, what_en)| FormRow {
- key: Default::default(),
- label: crate::say::pick_in(english, what_es, what_en).into(),
- preview: crate::say::pick_in(english, keys_es, keys_en).into(),
- })
- .collect()
-}
-
fn open_sheet(ui: &Panel, title: &str, rows: Vec) {
ui.set_sheet_at(ui.get_scroll_y());
ui.set_sheet_armed(false);
@@ -955,6 +860,12 @@ fn vanish(ui: &Panel) {
}
fn busy() -> &'static str {
+ if crate::here::read_stuck() {
+ return crate::say::pick(
+ "una app dejó de responder con lo copiado; reinicia CopyPaste",
+ "an app stopped answering about what it copied; restart CopyPaste",
+ );
+ }
crate::say::pick(
"no se pudo pegar: el portapapeles está ocupado",
"could not paste: the clipboard is busy",
diff --git a/crates/cp-panel/src/app_test.rs b/crates/cp-panel/src/app_test.rs
index d3a8e89a..97dff5b7 100644
--- a/crates/cp-panel/src/app_test.rs
+++ b/crates/cp-panel/src/app_test.rs
@@ -1,38 +1,3 @@
-#[test]
-fn the_shortcut_sheet_is_written_in_both_tongues() {
- let spanish = keys_sheet_in(false);
- let english = keys_sheet_in(true);
- assert_eq!(spanish.len(), english.len());
- assert!(!spanish.is_empty());
- for (es, en) in spanish.iter().zip(english.iter()) {
- assert!(!es.label.is_empty() && !en.label.is_empty());
- assert!(!es.preview.is_empty() && !en.preview.is_empty());
- }
- let shared = spanish
- .iter()
- .zip(english.iter())
- .filter(|(es, en)| es.label == en.label)
- .count();
- assert_eq!(shared, 0, "not one row was left untranslated");
-}
-
-#[test]
-fn the_filter_example_names_kinds_the_search_box_understands() {
- for sheet in [keys_sheet_in(false), keys_sheet_in(true)] {
- let row = sheet
- .iter()
- .find(|row| row.preview.starts_with('#'))
- .expect("the filter row is there");
- for word in row.preview.split('·') {
- let tag = word.trim().trim_start_matches('#');
- assert!(
- crate::view::kind_from_word(tag).is_some(),
- "«{tag}» is not a kind the search box knows"
- );
- }
- }
-}
-
use super::*;
#[test]
@@ -46,35 +11,3 @@ fn leaving_it_to_the_system_follows_the_system_both_ways() {
assert!(light_for(cp_config::Theme::System, true));
assert!(!light_for(cp_config::Theme::System, false));
}
-
-#[test]
-fn every_shortcut_the_sheet_promises_is_one_the_panel_handles() {
- const PANEL: &str = include_str!("../ui/panel.slint");
- for row in keys_sheet_in(true) {
- for combination in row.preview.split('·') {
- let parts: Vec<&str> = combination
- .split('+')
- .map(str::trim)
- .filter(|one| !one.is_empty())
- .collect();
- let Some(last) = parts.last() else {
- continue;
- };
- let wanted = match last.to_ascii_lowercase().as_str() {
- one if one.len() == 1 => format!("event.text == \"{one}\""),
- "enter" => "event.text == Key.Return".to_owned(),
- "esc" => "event.text == Key.Escape".to_owned(),
- "backspace" => "event.text == Key.Backspace".to_owned(),
- "delete" => "event.text == Key.Delete".to_owned(),
- "tab" => "event.text == Key.Tab".to_owned(),
- "right arrow" => "event.text == Key.RightArrow".to_owned(),
- _ => continue,
- };
- assert!(
- PANEL.contains(&wanted),
- "the sheet offers «{}» and the panel never looks for {wanted}",
- row.preview
- );
- }
- }
-}
diff --git a/crates/cp-panel/src/engine.rs b/crates/cp-panel/src/engine.rs
index 048d4b8f..bd3beed9 100644
--- a/crates/cp-panel/src/engine.rs
+++ b/crates/cp-panel/src/engine.rs
@@ -223,7 +223,11 @@ fn kept(store: &Store) -> Option {
return None;
}
Captured::TooSlow => {
- note("the clipboard stayed busy however much we insisted");
+ note("a read of the clipboard never came back, however much we insisted");
+ return None;
+ }
+ Captured::Busy => {
+ note("the clipboard was held by another program every time we asked");
return None;
}
Captured::Nothing | Captured::Superseded => return None,
diff --git a/crates/cp-panel/src/here.rs b/crates/cp-panel/src/here.rs
index 10577709..f835397b 100644
--- a/crates/cp-panel/src/here.rs
+++ b/crates/cp-panel/src/here.rs
@@ -7,8 +7,8 @@ use std::path::{Path, PathBuf};
pub use platform::{
THUMBNAILS_FILES, Watching, ahead_now, capture_insisting, content_of, data_dir, dress, forward,
- in_front, ocr_available, paste_into, stay_out_of_the_dock, system_is_light, text_in,
- thumb_of_file, thumbs_dir, to_clipboard,
+ in_front, ocr_available, paste_into, read_stuck, stay_out_of_the_dock, system_is_light,
+ text_in, thumb_of_file, thumbs_dir, to_clipboard,
};
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -74,6 +74,10 @@ mod platform {
written
}
+ pub fn read_stuck() -> bool {
+ cp_win_sys::clipboard::read_stuck_for().is_some()
+ }
+
pub fn system_is_light() -> bool {
cp_win_sys::theme::wants_light().unwrap_or(false)
}
@@ -166,6 +170,10 @@ mod platform {
written
}
+ pub fn read_stuck() -> bool {
+ false
+ }
+
pub fn system_is_light() -> bool {
cp_mac_sys::theme::wants_light().unwrap_or(false)
}
diff --git a/crates/cp-panel/ui/panel.slint b/crates/cp-panel/ui/panel.slint
index f9e0d351..eabf5d68 100644
--- a/crates/cp-panel/ui/panel.slint
+++ b/crates/cp-panel/ui/panel.slint
@@ -53,7 +53,8 @@ export component Panel inherits Window {
callback clear-filters();
callback ask-forms(int);
callback ask-kinds();
- callback ask-keys();
+ callback ask-settings();
+ callback nudge(length, length);
callback reopened();
callback fresh-start();
callback emptied();
@@ -102,8 +103,28 @@ export component Panel inherits Window {
padding: 0px;
spacing: 0px;
+ Rectangle {
+ height: 14px;
+ handle := TouchArea {
+ mouse-cursor: move;
+ moved => {
+ if (handle.pressed) {
+ root.nudge(handle.mouse-x - handle.pressed-x, handle.mouse-y - handle.pressed-y);
+ }
+ }
+ }
+ Rectangle {
+ width: 34px;
+ height: 3px;
+ border-radius: 2px;
+ background: handle.has-hover ? Theme.muted : Theme.selected-edge;
+ animate background { duration: 120ms; }
+ }
+ }
+
VerticalLayout {
padding: 10px;
+ padding-top: 2px;
padding-bottom: 8px;
spacing: 9px;
search-box := Rectangle {
@@ -165,7 +186,7 @@ export component Panel inherits Window {
return reject;
}
if event.text == Key.F1 {
- root.ask-keys();
+ root.ask-settings();
return accept;
}
if event.text == Key.Return
@@ -342,21 +363,6 @@ export component Panel inherits Window {
Rectangle {
horizontal-stretch: 1;
clip: true;
- strip := Flickable {
- width: parent.width;
- height: 24px;
- interactive: true;
- content-width: row.preferred-width;
- content-height: 24px;
- row := HorizontalLayout {
- spacing: 6px;
- alignment: start;
- for chip in root.chips : Pill {
- chip: chip;
- chosen(key) => { root.chip-chosen(key); }
- }
- }
- }
TouchArea {
scroll-event(event) => {
strip.content-x = clamp(
@@ -366,6 +372,21 @@ export component Panel inherits Window {
);
return accept;
}
+ strip := Flickable {
+ width: parent.width;
+ height: 24px;
+ interactive: true;
+ content-width: row.preferred-width;
+ content-height: 24px;
+ row := HorizontalLayout {
+ spacing: 6px;
+ alignment: start;
+ for chip in root.chips : Pill {
+ chip: chip;
+ chosen(key) => { root.chip-chosen(key); }
+ }
+ }
+ }
}
Rectangle {
x: parent.width - 20px;
@@ -556,6 +577,14 @@ export component Panel inherits Window {
Rectangle {
height: 32px;
background: Theme.footer;
+ mover := TouchArea {
+ mouse-cursor: move;
+ moved => {
+ if (mover.pressed) {
+ root.nudge(mover.mouse-x - mover.pressed-x, mover.mouse-y - mover.pressed-y);
+ }
+ }
+ }
HorizontalLayout {
padding-left: 10px;
padding-right: 8px;
@@ -576,9 +605,9 @@ export component Panel inherits Window {
}
}
Action {
- icon: @image-url("../assets/icons/sliders-horizontal.svg");
+ icon: @image-url("../assets/icons/settings.svg");
y: (parent.height - 22px) / 2;
- pressed => { root.ask-keys(); }
+ pressed => { root.ask-settings(); }
}
}
}
diff --git a/crates/cp-store/src/schema.rs b/crates/cp-store/src/schema.rs
index a5c6eca5..38935b60 100644
--- a/crates/cp-store/src/schema.rs
+++ b/crates/cp-store/src/schema.rs
@@ -84,6 +84,8 @@ fn ocr_text_is_missing(db: &Connection) -> Result {
const ORDERING_INDEXES: &str = "
CREATE INDEX IF NOT EXISTS items_by_recency ON items(modified_at DESC, id DESC);
+ CREATE INDEX IF NOT EXISTS items_by_touch
+ ON items(MAX(modified_at, COALESCE(last_used_at, 0)) DESC, id DESC);
CREATE INDEX IF NOT EXISTS items_by_kind ON items(kind, modified_at DESC, id DESC);
";
diff --git a/crates/cp-store/src/store.rs b/crates/cp-store/src/store.rs
index 85fc73d5..9c557f19 100644
--- a/crates/cp-store/src/store.rs
+++ b/crates/cp-store/src/store.rs
@@ -72,6 +72,8 @@ pub struct Page {
pub next: Option,
}
+pub const TOUCHED: &str = "MAX(items.modified_at, COALESCE(items.last_used_at, 0))";
+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Order {
#[default]
@@ -97,7 +99,7 @@ impl Order {
fn key(self) -> &'static str {
match self {
- Order::Recent => "items.modified_at",
+ Order::Recent => TOUCHED,
Order::MostPasted => "items.paste_count",
Order::LastUsed => "COALESCE(items.last_used_at, -1)",
}
diff --git a/crates/cp-store/src/store_listing_test.rs b/crates/cp-store/src/store_listing_test.rs
index d5e01b38..6fe9f14e 100644
--- a/crates/cp-store/src/store_listing_test.rs
+++ b/crates/cp-store/src/store_listing_test.rs
@@ -57,7 +57,7 @@ fn the_card_gets_everything_the_row_knows() {
assert_eq!(card.paste_count, 1);
assert_eq!(card.last_used_at, Some(62));
assert_eq!(card.created_at, 50);
- assert_eq!(card.modified_at, 50, "pasting does not move it");
+ assert_eq!(card.modified_at, 50, "pasting does not move the clock");
assert!(card.pinned);
assert_eq!(card.broken_since, None);
assert_eq!(card.thumb_path, None);
@@ -946,3 +946,44 @@ fn nothing_a_person_can_type_as_an_application_breaks_the_query() {
"the table is still there"
);
}
+
+#[test]
+fn the_ordering_expression_is_the_one_the_index_was_built_for() {
+ const SCHEMA: &str = include_str!("schema.rs");
+ let bare = TOUCHED.replace("items.", "");
+ assert!(
+ SCHEMA.contains(&bare),
+ "items_by_touch has to spell «{bare}» or sqlite plans a scan"
+ );
+}
+
+#[test]
+fn pasting_does_not_move_the_retention_clock() {
+ let store = history();
+ let one = all(&store, &Filter::default())[0].id;
+ let was = all(&store, &Filter::default())[0].modified_at;
+ store.record_paste(one, 10_000).expect("pasted");
+ let after = all(&store, &Filter::default())
+ .into_iter()
+ .find(|row| row.id == one)
+ .expect("still there");
+ assert_eq!(after.modified_at, was, "expiry and d: read this one");
+ assert_eq!(after.last_used_at, Some(10_000));
+}
+
+#[test]
+fn what_you_paste_rises_to_the_top_of_the_recent_order() {
+ let store = history();
+ let rows = all(&store, &Filter::default());
+ let buried = rows.last().expect("there are rows").id;
+ assert_ne!(rows[0].id, buried, "it starts at the bottom");
+
+ store.record_paste(buried, 100).expect("pasted");
+
+ let ordered = all(&store, &Filter::default());
+ assert_eq!(
+ ordered[0].id, buried,
+ "pasting moves it up, not just its count"
+ );
+ assert_eq!(ordered[0].paste_count, 1);
+}
diff --git a/crates/cp-store/src/store_test.rs b/crates/cp-store/src/store_test.rs
index ac82f20f..713f1b66 100644
--- a/crates/cp-store/src/store_test.rs
+++ b/crates/cp-store/src/store_test.rs
@@ -675,7 +675,7 @@ fn pasting_from_the_history_is_what_counts() {
}
#[test]
-fn pasting_does_not_move_the_item_up_the_list() {
+fn pasting_moves_the_item_to_the_top_of_the_history() {
let store = a_little_history();
let listed = store
.list(&Filter::default(), 10, None)
@@ -688,9 +688,9 @@ fn pasting_does_not_move_the_item_up_the_list() {
.expect("listed")
.rows;
assert_eq!(
- after.last().map(|one| one.id),
+ after.first().map(|one| one.id),
Some(oldest),
- "pasting counts, but it does not reorder the history"
+ "what you paste is what you reach for next"
);
}
diff --git a/crates/cp-win-sys/src/clipboard.rs b/crates/cp-win-sys/src/clipboard.rs
index 172f675a..4e355deb 100644
--- a/crates/cp-win-sys/src/clipboard.rs
+++ b/crates/cp-win-sys/src/clipboard.rs
@@ -6,7 +6,13 @@ use windows::Win32::System::DataExchange::{
use windows::Win32::System::Memory::{GlobalLock, GlobalSize, GlobalUnlock};
const BACKOFF_MS: &[u64] = &[0, 1, 2, 5, 10, 20, 50, 100, 200, 400];
-const CLEARING_MS: &[u64] = &[0, 1, 2, 5, 10, 20, 50, 100, 200, 400];
+const CLEARING_MS: &[u64] = &[0, 1, 2, 5, 10, 20, 50, 100, 200, 400, 400, 400];
+
+pub const STUCK_AFTER: std::time::Duration = std::time::Duration::from_secs(5);
+
+const QUICK_TURNS: usize = 4;
+
+const _: () = assert!(QUICK_TURNS < CLEARING_MS.len());
const _: () = assert!(CLEARING_MS[0] == 0);
const _: () = {
@@ -22,29 +28,56 @@ const _: () = {
clearing += CLEARING_MS[turn];
turn += 1;
}
- assert!(clearing >= backoff);
+ assert!(clearing > backoff);
};
-use std::sync::atomic::{AtomicUsize, Ordering};
+use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
static READERS: AtomicUsize = AtomicUsize::new(0);
static WRITES_COMING: AtomicUsize = AtomicUsize::new(0);
+static OLDEST_READ: AtomicU64 = AtomicU64::new(0);
+
+fn now_ms() -> u64 {
+ std::time::UNIX_EPOCH.elapsed().map_or(0, |gone| {
+ u64::try_from(gone.as_millis()).unwrap_or(u64::MAX)
+ })
+}
pub struct Reading {
_private: (),
}
pub fn reading() -> Reading {
- READERS.fetch_add(1, Ordering::SeqCst);
+ if READERS.fetch_add(1, Ordering::SeqCst) == 0 {
+ OLDEST_READ.store(now_ms(), Ordering::SeqCst);
+ }
Reading { _private: () }
}
impl Drop for Reading {
fn drop(&mut self) {
- READERS.fetch_sub(1, Ordering::SeqCst);
+ if READERS.fetch_sub(1, Ordering::SeqCst) == 1 {
+ OLDEST_READ.store(0, Ordering::SeqCst);
+ }
}
}
+fn stuck_for(readers: usize, since: u64, now: u64) -> Option {
+ if readers == 0 || since == 0 || now < since {
+ return None;
+ }
+ let gone = std::time::Duration::from_millis(now - since);
+ (gone >= STUCK_AFTER).then_some(gone)
+}
+
+pub fn read_stuck_for() -> Option {
+ stuck_for(
+ READERS.load(Ordering::SeqCst),
+ OLDEST_READ.load(Ordering::SeqCst),
+ now_ms(),
+ )
+}
+
struct Coming {
_private: (),
}
@@ -66,23 +99,24 @@ enum For {
Writing,
}
-pub struct Clipboard {
+pub struct Clipboard<'a> {
_counted: Option,
+ _borrowed: Option<&'a Reading>,
opened_for: For,
_coming: Option,
}
-impl Clipboard {
+impl Clipboard<'static> {
pub fn open() -> Option {
for wait in BACKOFF_MS {
if WRITES_COMING.load(Ordering::SeqCst) == 0 {
let counted = reading();
- if READERS.load(Ordering::SeqCst) > 0
- && WRITES_COMING.load(Ordering::SeqCst) == 0
+ if WRITES_COMING.load(Ordering::SeqCst) == 0
&& unsafe { OpenClipboard(Some(HWND::default())) }.is_ok()
{
return Some(Self {
_counted: Some(counted),
+ _borrowed: None,
opened_for: For::Reading,
_coming: None,
});
@@ -93,33 +127,42 @@ impl Clipboard {
None
}
- pub fn within(_counted: &Reading) -> Option {
- for wait in BACKOFF_MS {
- if unsafe { OpenClipboard(Some(HWND::default())) }.is_ok() {
+ pub fn to_write() -> Option {
+ let coming = coming();
+ for (turn, wait) in CLEARING_MS.iter().enumerate() {
+ std::thread::sleep(std::time::Duration::from_millis(*wait));
+ if READERS.load(Ordering::SeqCst) == 0
+ && unsafe { OpenClipboard(Some(HWND::default())) }.is_ok()
+ {
return Some(Self {
_counted: None,
- opened_for: For::Reading,
- _coming: None,
+ _borrowed: None,
+ opened_for: For::Writing,
+ _coming: Some(coming),
});
}
- std::thread::sleep(std::time::Duration::from_millis(*wait));
+ if turn >= QUICK_TURNS && read_stuck_for().is_some() {
+ return None;
+ }
}
None
}
+}
- pub fn to_write() -> Option {
- let coming = coming();
- for wait in CLEARING_MS {
- std::thread::sleep(std::time::Duration::from_millis(*wait));
- if READERS.load(Ordering::SeqCst) == 0
+impl<'a> Clipboard<'a> {
+ pub fn within(counted: &'a Reading) -> Option {
+ for wait in BACKOFF_MS {
+ if WRITES_COMING.load(Ordering::SeqCst) == 0
&& unsafe { OpenClipboard(Some(HWND::default())) }.is_ok()
{
return Some(Self {
_counted: None,
- opened_for: For::Writing,
- _coming: Some(coming),
+ _borrowed: Some(counted),
+ opened_for: For::Reading,
+ _coming: None,
});
}
+ std::thread::sleep(std::time::Duration::from_millis(*wait));
}
None
}
@@ -165,7 +208,7 @@ impl Clipboard {
}
}
-impl Drop for Clipboard {
+impl Drop for Clipboard<'_> {
fn drop(&mut self) {
let _ = unsafe { CloseClipboard() };
}
diff --git a/crates/cp-win-sys/src/clipboard_test.rs b/crates/cp-win-sys/src/clipboard_test.rs
index d4c655fa..9d8ae3bd 100644
--- a/crates/cp-win-sys/src/clipboard_test.rs
+++ b/crates/cp-win-sys/src/clipboard_test.rs
@@ -29,3 +29,29 @@ fn a_write_that_got_through_is_the_only_one_allowed_to_replace() {
"a clipboard opened to read claims it may replace"
);
}
+
+#[test]
+fn a_read_is_only_called_stuck_once_it_is_past_the_ceiling() {
+ let floor = STUCK_AFTER.as_millis() as u64;
+ assert_eq!(
+ stuck_for(1, 1_000, 1_000 + floor - 1),
+ None,
+ "still working"
+ );
+ assert_eq!(
+ stuck_for(1, 1_000, 1_000 + floor),
+ Some(STUCK_AFTER),
+ "past the ceiling it is stuck"
+ );
+}
+
+#[test]
+fn nothing_is_stuck_when_no_read_is_counted() {
+ assert_eq!(stuck_for(0, 1_000, 9_999_999), None);
+ assert_eq!(stuck_for(1, 0, 9_999_999), None, "nobody wrote a start");
+}
+
+#[test]
+fn a_clock_that_went_backwards_is_not_read_as_an_eternity() {
+ assert_eq!(stuck_for(1, 9_000, 1_000), None);
+}
diff --git a/crates/cp-win-sys/src/reading.rs b/crates/cp-win-sys/src/reading.rs
index d2d60108..d07b177d 100644
--- a/crates/cp-win-sys/src/reading.rs
+++ b/crates/cp-win-sys/src/reading.rs
@@ -29,53 +29,15 @@ pub fn within(
patience: Duration,
read: impl FnOnce() -> Option> + Send + 'static,
) -> Reading {
- match anything_within(patience, read) {
- Some(Some(bytes)) => Reading::Delivered(bytes),
- Some(None) => Reading::Empty,
- None => Reading::TooSlow,
- }
-}
-
-pub struct Pending {
- hear: std::sync::mpsc::Receiver,
-}
-
-pub enum Waited {
- Answered(T),
- StillRunning,
- Gone,
-}
-
-impl Pending {
- pub fn wait(&self, patience: Duration) -> Option {
- match self.waited(patience) {
- Waited::Answered(what) => Some(what),
- Waited::StillRunning | Waited::Gone => None,
- }
- }
-
- pub fn waited(&self, patience: Duration) -> Waited {
- match self.hear.recv_timeout(patience) {
- Ok(what) => Waited::Answered(what),
- Err(std::sync::mpsc::RecvTimeoutError::Timeout) => Waited::StillRunning,
- Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => Waited::Gone,
- }
- }
-}
-
-pub fn begin(work: impl FnOnce() -> T + Send + 'static) -> Pending {
let (tell, hear) = std::sync::mpsc::channel();
std::thread::spawn(move || {
- let _ = tell.send(work());
+ let _ = tell.send(read());
});
- Pending { hear }
-}
-
-pub fn anything_within(
- patience: Duration,
- work: impl FnOnce() -> T + Send + 'static,
-) -> Option {
- begin(work).wait(patience)
+ match hear.recv_timeout(patience) {
+ Ok(Some(bytes)) => Reading::Delivered(bytes),
+ Ok(None) => Reading::Empty,
+ Err(_) => Reading::TooSlow,
+ }
}
#[cfg(test)]
diff --git a/crates/cp-win-sys/src/reading_test.rs b/crates/cp-win-sys/src/reading_test.rs
index 8c4a1d3b..d37aba07 100644
--- a/crates/cp-win-sys/src/reading_test.rs
+++ b/crates/cp-win-sys/src/reading_test.rs
@@ -39,27 +39,6 @@ fn abandoning_one_read_does_not_poison_the_next() {
);
}
-#[test]
-fn a_late_answer_is_picked_up_by_a_later_wait_on_the_same_read() {
- let (release, gate) = std::sync::mpsc::channel::<()>();
- let pending = begin(move || {
- let _ = gate.recv();
- 42
- });
- assert_eq!(pending.wait(Duration::from_millis(20)), None, "not yet");
- release.send(()).expect("the source answers");
- assert_eq!(
- pending.wait(Duration::from_secs(5)),
- Some(42),
- "sin leer dos veces"
- );
- assert_eq!(
- pending.wait(Duration::from_millis(20)),
- None,
- "and once handed over, there is no more"
- );
-}
-
#[test]
fn the_patience_sits_between_the_two_measured_worlds() {
let good = Duration::from_millis(2);
@@ -73,37 +52,3 @@ fn a_read_that_panics_is_abandoned_like_any_other() {
let seen = within(Duration::from_millis(50), || panic!("the provider died"));
assert_eq!(seen, Reading::TooSlow);
}
-
-#[test]
-fn waiting_tells_a_read_still_running_from_one_whose_answer_is_spent() {
- let (release, gate) = std::sync::mpsc::channel::<()>();
- let pending = begin(move || {
- let _ = gate.recv();
- 42
- });
- assert!(
- matches!(
- pending.waited(Duration::from_millis(20)),
- Waited::StillRunning
- ),
- "still working"
- );
- release.send(()).expect("the source answers");
- assert!(matches!(
- pending.waited(Duration::from_secs(5)),
- Waited::Answered(42)
- ));
- assert!(
- matches!(pending.waited(Duration::from_secs(5)), Waited::Gone),
- "a spent channel is not an expired deadline"
- );
-}
-
-#[test]
-fn a_read_that_panics_is_gone_not_still_running() {
- let pending = begin(|| -> u8 { panic!("the provider died") });
- assert!(matches!(
- pending.waited(Duration::from_secs(5)),
- Waited::Gone
- ));
-}
diff --git a/crates/cp-win-sys/src/writing.rs b/crates/cp-win-sys/src/writing.rs
index 676bc0a8..def466e6 100644
--- a/crates/cp-win-sys/src/writing.rs
+++ b/crates/cp-win-sys/src/writing.rs
@@ -10,7 +10,7 @@ pub enum Written {
Refused,
}
-impl Clipboard {
+impl Clipboard<'_> {
pub fn replace(&self, entries: &[(u32, &[u8])]) -> Written {
if entries.is_empty() {
return Written::Refused;
diff --git a/crates/cp-win/examples/probe/battery.rs b/crates/cp-win/examples/probe/battery.rs
index 00f3ae90..60330796 100644
--- a/crates/cp-win/examples/probe/battery.rs
+++ b/crates/cp-win/examples/probe/battery.rs
@@ -263,10 +263,10 @@ fn what_is_abandoned(b: &mut Battery) {
let big = cp_win_sys::writing::utf16_of(&"abcdefghij".repeat(20_000));
let mut refused = 0;
let mut placed = 0;
- let mut waited = 0;
+ let mut quickest = std::time::Duration::MAX;
for _ in 0..40 {
let counted = cp_win_sys::clipboard::reading();
- let pending = cp_win_sys::reading::begin(move || {
+ let pending = cp_core::reading::begin(move || {
let _held = counted;
std::thread::sleep(std::time::Duration::from_millis(30));
});
@@ -274,13 +274,13 @@ fn what_is_abandoned(b: &mut Battery) {
match Clipboard::to_write() {
None => refused += 1,
Some(clipboard) => {
- if asked.elapsed() < std::time::Duration::from_millis(25) {
+ let took = asked.elapsed();
+ if took < std::time::Duration::from_millis(25) {
return Err(format!(
- "the write took the clipboard after {:?}, before the read let go",
- asked.elapsed()
+ "the write took the clipboard after {took:?}, before the read let go"
));
}
- waited += 1;
+ quickest = quickest.min(took);
if clipboard.replace(&[(CF_UNICODETEXT, &big)])
!= (cp_win_sys::writing::Written::Placed { formats: 1 })
{
@@ -304,26 +304,15 @@ fn what_is_abandoned(b: &mut Battery) {
if placed == 0 {
return Err("every write was refused, so nothing was proven".into());
}
- if waited == 0 {
- return Err(
- "not one write was made to wait, so the guard was never exercised".into(),
- );
- }
println!(
- " {waited} writes waited for a read to let go, {refused} gave up waiting"
+ " {placed} writes waited, the quickest {quickest:?}, {refused} gave up"
);
Ok(())
},
);
}
-fn main() -> std::process::ExitCode {
- let mut b = Battery {
- passed: 0,
- failed: 0,
- skipped: Vec::new(),
- };
-
+fn what_the_clipboard_answers(b: &mut Battery) {
b.group("A · The clipboard responds");
b.case("A1", "it opens and closes without staying locked", || {
@@ -345,6 +334,57 @@ fn main() -> std::process::ExitCode {
println!(" {} formats: {}", names.len(), names.join(", "));
Ok(())
});
+}
+
+fn what_is_read_inside_an_image(b: &mut Battery) {
+ b.group("M · Text inside an image");
+
+ b.case("M1", "the system offers a reading engine", || {
+ if ocr::is_available() {
+ Ok(())
+ } else {
+ Err("there is no engine for the profile's languages".into())
+ }
+ });
+
+ b.case("M2", "the text of a real image is read", || {
+ let png = std::fs::read("fixtures/texto-en-imagen.png")
+ .map_err(|why| format!("could not read the fixture: {why}"))?;
+ let started = std::time::Instant::now();
+ let text = (0..3)
+ .find_map(|_| ocr::text_in(&png))
+ .ok_or("nothing was recognised in three tries")?;
+ println!(
+ " {:?} to read «{}»",
+ started.elapsed(),
+ text.lines().next().unwrap_or("").trim()
+ );
+ Ok(())
+ });
+
+ b.case("M3", "a blank image does not invent text", || {
+ let blank = image::RgbaImage::from_pixel(120, 60, image::Rgba([255, 255, 255, 255]));
+ let mut png = std::io::Cursor::new(Vec::new());
+ image::DynamicImage::ImageRgba8(blank)
+ .write_to(&mut png, image::ImageFormat::Png)
+ .map_err(|why| why.to_string())?;
+ match ocr::text_in(&png.into_inner()) {
+ None => Ok(()),
+ Some(invented) => Err(format!("invented «{invented}»")),
+ }
+ });
+}
+
+fn main() -> std::process::ExitCode {
+ println!("\nCore battery against the Windows clipboard");
+
+ let mut b = Battery {
+ passed: 0,
+ failed: 0,
+ skipped: Vec::new(),
+ };
+
+ what_the_clipboard_answers(&mut b);
b.group("B · The catalog against what is really there");
@@ -976,40 +1016,7 @@ fn main() -> std::process::ExitCode {
},
);
- b.group("M · Text inside an image");
-
- b.case("M1", "the system offers a reading engine", || {
- if ocr::is_available() {
- Ok(())
- } else {
- Err("there is no engine for the profile's languages".into())
- }
- });
-
- b.case("M2", "the text of a real image is read", || {
- let png = std::fs::read("fixtures/texto-en-imagen.png")
- .map_err(|why| format!("could not read the fixture: {why}"))?;
- let started = std::time::Instant::now();
- let text = ocr::text_in(&png).ok_or("nothing was recognised")?;
- println!(
- " {:?} to read «{}»",
- started.elapsed(),
- text.lines().next().unwrap_or("").trim()
- );
- Ok(())
- });
-
- b.case("M3", "a blank image does not invent text", || {
- let blank = image::RgbaImage::from_pixel(120, 60, image::Rgba([255, 255, 255, 255]));
- let mut png = std::io::Cursor::new(Vec::new());
- image::DynamicImage::ImageRgba8(blank)
- .write_to(&mut png, image::ImageFormat::Png)
- .map_err(|why| why.to_string())?;
- match ocr::text_in(&png.into_inner()) {
- None => Ok(()),
- Some(invented) => Err(format!("invented «{invented}»")),
- }
- });
+ what_is_read_inside_an_image(&mut b);
b.group("N · Thumbnails and media via the shell");
diff --git a/crates/cp-win/src/capture.rs b/crates/cp-win/src/capture.rs
index b96ee6e9..75ccb0b1 100644
--- a/crates/cp-win/src/capture.rs
+++ b/crates/cp-win/src/capture.rs
@@ -1,12 +1,12 @@
pub use cp_core::capture::Captured;
-use cp_core::capture::insisting;
+use cp_core::capture::insisting_afresh;
use cp_core::dib;
use cp_core::formats::{Family, Refusal, Take};
use cp_core::item::{BLOB_UP_TO, Format, Item, Payload, SYNTHETIC_IMAGE};
use cp_core::kind::{self, Kind};
+use cp_core::reading;
use cp_win_sys::clipboard::{self, Clipboard};
use cp_win_sys::formats::name_of;
-use cp_win_sys::reading;
use cp_win_sys::writing::text_of;
pub use crate::drop::paths_in;
@@ -25,26 +25,7 @@ pub fn capture_within(patience: std::time::Duration) -> Captured {
}
pub fn capture_insisting(patience: std::time::Duration, retry: cp_core::watch::Retry) -> Captured {
- let started = sequence_now();
- let mut pending = begin_counted();
- let mut left = retry.attempts;
- let got = insisting(retry, sequence_now, || {
- left = left.saturating_sub(1);
- match pending.waited(patience) {
- reading::Waited::StillRunning => Captured::TooSlow,
- reading::Waited::Answered(Captured::TooSlow) | reading::Waited::Gone => {
- if left > 0 && sequence_now() == started {
- pending = begin_counted();
- }
- Captured::TooSlow
- }
- reading::Waited::Answered(other) => other,
- }
- });
- match got {
- Captured::Nothing | Captured::TooSlow if sequence_now() != started => Captured::Superseded,
- other => other,
- }
+ insisting_afresh(retry, patience, sequence_now, begin_counted)
}
fn begin_counted() -> reading::Pending {
@@ -60,7 +41,7 @@ pub fn capture_now() -> Captured {
fn capture_counted(counted: &clipboard::Reading) -> Captured {
let captured = match Clipboard::within(counted) {
Some(clipboard) => capture(&clipboard),
- None => Captured::TooSlow,
+ None => Captured::Busy,
};
let Captured::Kept(item) = captured else {
return captured;