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;