From e67d089d92cc5bb62682f965ada1c5acc7829c2b Mon Sep 17 00:00:00 2001
From: rgdevment
Date: Wed, 30 Sep 2026 19:30:40 -0300
Subject: [PATCH 1/5] fix(panel): the things a first real session with the app
found
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Seven reports from using the 3.0 for an afternoon. Two of them were not what
they looked like.
**No switch in the app had ever looked on.** `Knob` renders `aria-checked`,
which is what `role="switch"` takes, and the stylesheet keyed off
`aria-pressed`. So «Ocultar al hacer clic fuera» read as off while
`cp-config` defaulted it to `true` and the config on disk said `true`. The unit
test asserted the attribute and the stylesheet asserted the other one, so each
passed on its own and nobody compared them.
**The tray's left click opened Settings instead of the panel.** It calls
`panel::show` now; the menu already had both.
**What you paste rises to the top, without moving the retention clock.**
`record_paste` wrote `last_used_at` and the count, and the list orders by
`modified_at`, so the number went up and the item stayed put. Writing
`modified_at` there was the obvious fix and the wrong one: it is also what
`expire` compares, what `keep_at_most` and the eviction pick victims by, what
the `d:`/`since:` search operator reads, and the keyset cursor `Order::Recent`
pages on. A pasted item would have outlived its keep window forever while the
settings copy promises that only pinning does that. So the order key is now
`MAX(modified_at, COALESCE(last_used_at, 0))` with an index to match, and
`record_paste` is untouched. A test holds the clock still and another holds the
expression next to the index that was built for it — they have to be spelled the
same or sqlite plans a scan.
There was a test asserting the opposite behaviour («pasting counts, but it does
not reorder the history»). That was a decision written down, not an oversight,
and it is reversed on purpose.
**A shortcut the system refuses now says what is free.** The refusal was already
detected and shown, but `useKeys` asked once on mount, so the warning outlived
the fix. `change` returns its promise now and the row asks again when the
backend has actually answered — before, the optimistic `setKept` re-ran the
effect ahead of `keep`, so the answer was about the shortcut that had not been
bound yet. A `spare` command offers the combinations the system will still give,
probed with a handler attached rather than a bare `register`, so a probe never
leaves a combination grabbed and dead for every other application. The
comparison against the current one is between parsed combinations, not strings:
the picker emits `Shift+Cmd+V` and a list spelling `Cmd+Shift+V` would have
offered the user the shortcut they already had.
**The «Copia de seguridad» page was eight amber boxes stacked.** `.said` paints
a warning, and what it was painting is mostly a plain account of what comes
over. One quiet list now; the two that really are warnings keep their alarm.
**The panel can be moved.** It is `no-frame`, so there was nothing to drag it
by. The footer strip is a handle, and the buttons on it keep their own clicks.
**The footer's sliders button opens Settings**, and the shortcut reference it
used to open lives there now, as a table, in both languages. Three guards moved
with it and all three fail when broken: every key the table promises is one
`panel.slint` looks for, every row says something different in each language,
and the `#imagen · #carpeta` example names kinds the search box knows. The first
one is stricter than the Rust test it replaces, which skipped a name it did not
recognise instead of refusing it.
**And the double click does paste.** The report said it did not; `cp-panel.log`
had no attempt at all, which ruled out the clipboard, and a probe on the two
callbacks answered `moved(0)`, `moved(0)`, `paste(20)`. Nothing was changed for
it.
Two repairs to `038adfe`, both found by a review of it: lifting group G out of
the probe's `main` took the battery's title line with it, so the output started
with no idea what was running; and G5's `waited == 0` could not happen, because
`waited` moved on the same path as `placed` three lines below and `placed == 0`
was checked first. It prints the shortest wait it measured now — 38.7 ms against
a 25 ms floor — which is the number that says the guard held.
`main` in the probe and `wire` in the panel both shrank, and
.github/oversized.txt records the smaller numbers.
865 tests, two runs. The Windows probe: 45 ok, 0 fallan. fmt, clippy with
`-D warnings` on the workspace and against aarch64-apple-darwin, tsc, biome and
45 frontend tests.
---
.github/oversized.txt | 4 +-
app/src-tauri/src/keys.rs | 55 +++++++++
app/src-tauri/src/keys_test.rs | 41 +++++++
app/src-tauri/src/lib.rs | 1 +
app/src-tauri/src/panel.rs | 7 +-
app/src-tauri/src/tray.rs | 2 +-
app/src/App.tsx | 14 ++-
app/src/core.ts | 42 +++++--
app/src/index.css | 86 ++++++++++++-
app/src/locales.ts | 50 ++++++++
app/src/tests/bindings.test.ts | 104 ++++++++++++++++
app/src/tests/setup.ts | 3 +
app/src/tests/window.test.tsx | 22 ++++
app/src/ui/Backup.tsx | 28 ++---
app/src/ui/General.tsx | 39 +++++-
crates/cp-panel/assets/icons/settings.svg | 16 +++
crates/cp-panel/src/app.rs | 139 ++++------------------
crates/cp-panel/src/app_test.rs | 67 -----------
crates/cp-panel/ui/panel.slint | 17 ++-
crates/cp-store/src/schema.rs | 2 +
crates/cp-store/src/store.rs | 4 +-
crates/cp-store/src/store_listing_test.rs | 43 ++++++-
crates/cp-store/src/store_test.rs | 6 +-
crates/cp-win/examples/probe/battery.rs | 38 +++---
24 files changed, 586 insertions(+), 244 deletions(-)
create mode 100644 app/src/tests/bindings.test.ts
create mode 100644 crates/cp-panel/assets/icons/settings.svg
diff --git a/.github/oversized.txt b/.github/oversized.txt
index e035564f..b12c6363 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
+1020 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..fdbe8f57 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", "general");
+ 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..e4f69152 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)
diff --git a/app/src/App.tsx b/app/src/App.tsx
index 4364f760..9aada6c8 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";
@@ -21,6 +22,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 (
<>
diff --git a/app/src/core.ts b/app/src/core.ts
index d72ab83e..bf37ab8d 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) => {
+ setTrouble(String(why));
+ look();
+ });
+ 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..17f0dd2d 100644
--- a/app/src/locales.ts
+++ b/app/src/locales.ts
@@ -31,6 +31,9 @@ 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:",
+ 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",
@@ -195,6 +198,9 @@ 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:",
+ 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 +331,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 +391,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..4a440475
--- /dev/null
+++ b/app/src/tests/bindings.test.ts
@@ -0,0 +1,104 @@
+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/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..35b33f89 100644
--- a/app/src/tests/window.test.tsx
+++ b/app/src/tests/window.test.tsx
@@ -117,6 +117,28 @@ describe("la ventana", () => {
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( );
+ 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 () => {
render( );
expect(await screen.findByText("Ctrl + Alt + V")).toBeDefined();
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..35e50de8 100644
--- a/app/src/ui/General.tsx
+++ b/app/src/ui/General.tsx
@@ -10,7 +10,7 @@ import {
useTrust,
useWaking,
} from "../core";
-import { t } from "../locales";
+import { panelKeys, t } from "../locales";
import { Band, Knob, Line } from "./Bits";
export default function General({
@@ -18,11 +18,11 @@ 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 { keys, spare, recheck } = useKeys(kept.shortcut);
const [asking, setAsking] = useState(false);
return (
@@ -107,6 +107,23 @@ export default function General({
0 ? (
+
+ {t("keysFree")}
+ {spare.map((one) => (
+ void change({ shortcut: one }).finally(() => recheck(one))}
+ >
+ {one.replaceAll("+", " + ")}
+
+ ))}
+
+ ) : null
+ }
>
{kept.shortcut.replaceAll("+", " + ")}
@@ -127,7 +144,7 @@ export default function General({
const said = combination(press);
if (said !== null) {
setAsking(false);
- change({ shortcut: said });
+ void change({ shortcut: said }).finally(() => recheck(said));
}
}}
>
@@ -142,6 +159,20 @@ export default function General({
onPress={() => change({ "hides-when-left": !kept["hides-when-left"] })}
/>
+
+
+ {t("keysTableWhy")}
+
+
+
+ {panelKeys().map((one) => (
+
+ {one.keys}
+ {one.does}
+
+ ))}
+
+
>
);
}
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..e4883862 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);
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/ui/panel.slint b/crates/cp-panel/ui/panel.slint
index f9e0d351..a24f0ba7 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();
@@ -165,7 +166,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
@@ -556,6 +557,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 +585,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/examples/probe/battery.rs b/crates/cp-win/examples/probe/battery.rs
index 00f3ae90..73f70d5a 100644
--- a/crates/cp-win/examples/probe/battery.rs
+++ b/crates/cp-win/examples/probe/battery.rs
@@ -263,7 +263,7 @@ 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 || {
@@ -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,19 @@ fn main() -> std::process::ExitCode {
println!(" {} formats: {}", names.len(), names.join(", "));
Ok(())
});
+}
+
+fn main() -> std::process::ExitCode {
+ println!("
+Core 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");
From 5f230d7f6d25d062e444bf29d4e56797c61f4376 Mon Sep 17 00:00:00 2001
From: rgdevment
Date: Wed, 30 Sep 2026 19:56:53 -0300
Subject: [PATCH 2/5] fix(clipboard): the half of the protocol that was never
running
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Six findings a review left against `cc891d6` and `038adfe`, both already in
main, plus the three checks this PR had red.
**The write-priority half of the protocol was dead code.** Of the three ways to
open the clipboard, only `Clipboard::within` has a production caller, and it was
the one that never looked at `WRITES_COMING`. So the capture path barged past a
write that had already announced itself, and the deference `open()` adds was
exercised by nothing but the probe. It defers now.
**And the const assert next to it proved nothing.** `CLEARING_MS` was
byte-identical to `BACKOFF_MS`, so `clearing >= backoff` reduced to `x >= x`
while implying the two budgets were tied. They were not: a writer sleeps before
each probe and a reader probes before each sleep, so the reader's last try
landed 400 ms after the writer had given up. The writer's table is genuinely
longer now and the assert is strict.
**`Clipboard::within` took its guard as a token it never used**, and `Self`
carried no lifetime, so `let held = Clipboard::within(&reading());` compiled: the
guard dropped at the end of the statement while a read-open handle stayed live,
which is exactly the hole #92 closed. It borrows the guard now, and that line is
refused with «temporary value dropped while borrowed».
**`TooSlow` meant two things and the engine could only log one of them.**
`capture_counted` answered `TooSlow` when `OpenClipboard` lost the race — a busy
resource, not a deadline — and `insisting` promoted it to `Superseded` whenever
the sequence had moved, which is the one outcome `engine.rs` drops without a
line in the log. There is a `Captured::Busy` now: `Nothing` and `Busy` promote to
`Superseded`, a real timeout stays `TooSlow` and is logged as one, and the two
read differently in cp-panel.log.
**The restart loop was written twice and only ran on one platform.**
`cp_mac::capture` never returns `TooSlow`, so on macOS the arm that restarts an
abandoned read could not match. `Pending`, `Waited`, `begin` and the loop itself
now live once in `cp-core`, which both platform crates already depended on, and
`cp-mac-sys/src/reading.rs` is gone. The whole change is 88 lines shorter than
what it replaces.
**A read that never comes back no longer costs the wait twice.** It still holds
its count — that part is correct, because `EmptyClipboard` under an in-flight
`IDataObject::GetData` is a use-after-free — but `to_write()` recognises the
condition instead of sleeping through its whole budget on the UI thread, and the
panel says «an app stopped answering about what it copied» rather than «the
clipboard is busy».
One correction to the review, which got the mechanism right and the multiplier
wrong: guards do not pile up every 60 ms. The watcher is one thread and calls
`capture_insisting` synchronously, and on `Answered` or `Gone` the worker has
already dropped its guard before anything is relaunched. It takes a second copy
arriving while the first read is stuck.
**The three red checks.** `fmt`: restoring the probe's title line in the last
commit put a real line break inside the string instead of `\n`. `frontend`: the
last biome run happened before `bindings.test.ts` existed, so its formatting went
unchecked — 18 files then, 19 now. `sonarcloud`: coverage on new code at 78
against a gate of 80, from seven lines — the rail listener and the paths where
the backend refuses. They are covered now, and writing that test found a defect
of mine: `look()` opens with `setTrouble(null)`, so calling it before setting the
reason wiped the reason. The user would have seen a shortcut silently revert.
871 tests, two runs. The Windows probe: 44 ok, 0 fallan. fmt, clippy with
`-D warnings` on the workspace and against aarch64-apple-darwin, the ceiling,
tsc, biome, and 50 frontend tests with lines at 88.6%.
Two things this does not fix, both named rather than hidden. A provider that
hangs for good still makes pasting impossible until CopyPaste is restarted;
refusing is the correct answer there, and what changed is that it is fast and
explained. And `hand_over` still runs on the Slint event loop, so a paste during
a live capture waits for it; moving it off needs `Store` out of its `Rc`.
---
app/src/core.ts | 2 +-
app/src/tests/bindings.test.ts | 7 +-
app/src/tests/rail.test.tsx | 43 ++++++++++
app/src/tests/refusals.test.tsx | 71 ++++++++++++++++
crates/cp-core/src/capture.rs | 41 ++++++++-
crates/cp-core/src/capture_test.rs | 59 +++++++++++++
crates/cp-core/src/lib.rs | 1 +
crates/{cp-mac-sys => cp-core}/src/reading.rs | 0
.../src/reading_test.rs | 39 +--------
crates/cp-mac-sys/src/lib.rs | 1 -
crates/cp-mac/src/capture.rs | 35 ++------
crates/cp-panel/src/app.rs | 6 ++
crates/cp-panel/src/engine.rs | 6 +-
crates/cp-panel/src/here.rs | 12 ++-
crates/cp-win-sys/src/clipboard.rs | 85 ++++++++++++++-----
crates/cp-win-sys/src/clipboard_test.rs | 26 ++++++
crates/cp-win-sys/src/reading.rs | 50 ++---------
crates/cp-win-sys/src/reading_test.rs | 55 ------------
crates/cp-win-sys/src/writing.rs | 2 +-
crates/cp-win/examples/probe/battery.rs | 5 +-
crates/cp-win/src/capture.rs | 27 +-----
21 files changed, 351 insertions(+), 222 deletions(-)
create mode 100644 app/src/tests/rail.test.tsx
create mode 100644 app/src/tests/refusals.test.tsx
rename crates/{cp-mac-sys => cp-core}/src/reading.rs (100%)
rename crates/{cp-mac-sys => cp-core}/src/reading_test.rs (59%)
diff --git a/app/src/core.ts b/app/src/core.ts
index bf37ab8d..a2877628 100644
--- a/app/src/core.ts
+++ b/app/src/core.ts
@@ -72,8 +72,8 @@ export function useKept() {
}
})
.catch((why) => {
- setTrouble(String(why));
look();
+ setTrouble(String(why));
});
return queue.current.then(() => undefined);
},
diff --git a/app/src/tests/bindings.test.ts b/app/src/tests/bindings.test.ts
index 4a440475..298e250d 100644
--- a/app/src/tests/bindings.test.ts
+++ b/app/src/tests/bindings.test.ts
@@ -62,9 +62,10 @@ describe("la tabla de atajos", () => {
if (!looked) {
continue;
}
- expect(PANEL, `«${row.keys}» promises ${looked} and the panel never looks for it`).toContain(
- looked,
- );
+ expect(
+ PANEL,
+ `«${row.keys}» promises ${looked} and the panel never looks for it`,
+ ).toContain(looked);
}
}
});
diff --git a/app/src/tests/rail.test.tsx b/app/src/tests/rail.test.tsx
new file mode 100644
index 00000000..f181ee85
--- /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 General cuando el panel pide los ajustes", async () => {
+ render( );
+ await screen.findByText("Ctrl + Alt + V");
+
+ const rail = await heardOn("rail");
+ rail({ payload: "backup" });
+ expect(await screen.findByText("Exportar")).toBeDefined();
+
+ rail({ payload: "general" });
+ 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.findByText("Ctrl + Alt + V");
+
+ const rail = await heardOn("rail");
+ rail({ payload: "inventada" });
+ await waitFor(() => {
+ expect(screen.getByText("Atajo del panel")).toBeDefined();
+ });
+ });
+});
diff --git a/app/src/tests/refusals.test.tsx b/app/src/tests/refusals.test.tsx
new file mode 100644
index 00000000..0752234e
--- /dev/null
+++ b/app/src/tests/refusals.test.tsx
@@ -0,0 +1,71 @@
+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)),
+ );
+ render( );
+ 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;
+ }),
+ );
+ render( );
+ expect(await screen.findByText(/Otro programa ya usa esa combinación/)).toBeDefined();
+ expect(screen.queryByText(/Estas están libres/)).toBeNull();
+ });
+});
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/src/app.rs b/crates/cp-panel/src/app.rs
index e4883862..5a61ada8 100644
--- a/crates/cp-panel/src/app.rs
+++ b/crates/cp-panel/src/app.rs
@@ -860,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/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-win-sys/src/clipboard.rs b/crates/cp-win-sys/src/clipboard.rs
index 172f675a..99c0bfd0 100644
--- a/crates/cp-win-sys/src/clipboard.rs
+++ b/crates/cp-win-sys/src/clipboard.rs
@@ -6,7 +6,9 @@ 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 _: () = assert!(CLEARING_MS[0] == 0);
const _: () = {
@@ -22,29 +24,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 +95,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 +123,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 {
+ if read_stuck_for().is_some() {
+ return None;
+ }
+ let coming = coming();
+ for wait in CLEARING_MS {
+ 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));
}
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 +204,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 73f70d5a..e720cdb8 100644
--- a/crates/cp-win/examples/probe/battery.rs
+++ b/crates/cp-win/examples/probe/battery.rs
@@ -266,7 +266,7 @@ fn what_is_abandoned(b: &mut Battery) {
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));
});
@@ -337,8 +337,7 @@ fn what_the_clipboard_answers(b: &mut Battery) {
}
fn main() -> std::process::ExitCode {
- println!("
-Core battery against the Windows clipboard");
+ println!("\nCore battery against the Windows clipboard");
let mut b = Battery {
passed: 0,
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;
From 70cf6dfa5159e284b07833a5b56d75ab4fc1ee50 Mon Sep 17 00:00:00 2001
From: rgdevment
Date: Wed, 30 Sep 2026 20:14:27 -0300
Subject: [PATCH 3/5] fix(clipboard): a write keeps its cheap tries when a read
looks stuck
---
.github/oversized.txt | 2 +-
crates/cp-win-sys/src/clipboard.rs | 12 ++--
crates/cp-win/examples/probe/battery.rs | 74 +++++++++++++------------
3 files changed, 49 insertions(+), 39 deletions(-)
diff --git a/.github/oversized.txt b/.github/oversized.txt
index b12c6363..6e6e1462 100644
--- a/.github/oversized.txt
+++ b/.github/oversized.txt
@@ -1,3 +1,3 @@
357 crates/cp-panel/src/app.rs::wire the panel callbacks, one after another: splitting it would only move the list
-1020 crates/cp-win/examples/probe/battery.rs::main a diagnostic main that walks every invariant of its platform in one pass
+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/crates/cp-win-sys/src/clipboard.rs b/crates/cp-win-sys/src/clipboard.rs
index 99c0bfd0..4e355deb 100644
--- a/crates/cp-win-sys/src/clipboard.rs
+++ b/crates/cp-win-sys/src/clipboard.rs
@@ -10,6 +10,10 @@ 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 _: () = {
let mut backoff = 0;
@@ -124,11 +128,8 @@ impl Clipboard<'static> {
}
pub fn to_write() -> Option {
- if read_stuck_for().is_some() {
- return None;
- }
let coming = coming();
- for wait in CLEARING_MS {
+ 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()
@@ -140,6 +141,9 @@ impl Clipboard<'static> {
_coming: Some(coming),
});
}
+ if turn >= QUICK_TURNS && read_stuck_for().is_some() {
+ return None;
+ }
}
None
}
diff --git a/crates/cp-win/examples/probe/battery.rs b/crates/cp-win/examples/probe/battery.rs
index e720cdb8..60330796 100644
--- a/crates/cp-win/examples/probe/battery.rs
+++ b/crates/cp-win/examples/probe/battery.rs
@@ -336,6 +336,45 @@ fn what_the_clipboard_answers(b: &mut Battery) {
});
}
+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");
@@ -977,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");
From 87efa02b5ac9db4dbb66a0093e490f685569e2b0 Mon Sep 17 00:00:00 2001
From: rgdevment
Date: Wed, 30 Sep 2026 20:27:56 -0300
Subject: [PATCH 4/5] fix(panel): a handle you can find, and Settings that
comes to the front
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Two more from using the app, and the first was my fault twice over.
**The panel could already be dragged; nobody could tell.** The handle went on the
32 px footer strip, under the icon, the count and the hint text. The mechanism
was wired and nothing repositions the window on show, so the drag worked — it
was simply unfindable. There is a grab bar at the top now: 14 px with a short
rounded tab in the middle that lights up under the pointer and turns the cursor
to «move», which is what a frameless window is expected to offer. The footer
handle stays.
**And `set_focus()` alone does not raise a window on Windows.**
`SetForegroundWindow` is refused when the foreground belongs to another process,
and here it belonged to the panel, which is a separate process — so opening
Settings from the panel left it behind whatever was in front. It goes to the top
of the z-order for an instant, takes the focus and returns to how it was; a
window already pinned on top stays pinned.
871 tests, fmt, clippy with `-D warnings` across the workspace, the ceiling, and
the frontend's lint, build and 50 tests.
---
app/src-tauri/src/tray.rs | 16 ++++++++++++++--
crates/cp-panel/ui/panel.slint | 20 ++++++++++++++++++++
2 files changed, 34 insertions(+), 2 deletions(-)
diff --git a/app/src-tauri/src/tray.rs b/app/src-tauri/src/tray.rs
index e4f69152..6c345924 100644
--- a/app/src-tauri/src/tray.rs
+++ b/app/src-tauri/src/tray.rs
@@ -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/crates/cp-panel/ui/panel.slint b/crates/cp-panel/ui/panel.slint
index a24f0ba7..6e10f4dd 100644
--- a/crates/cp-panel/ui/panel.slint
+++ b/crates/cp-panel/ui/panel.slint
@@ -103,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 {
From 369b2a402cf85758a9c8714d62e868f978be3225 Mon Sep 17 00:00:00 2001
From: rgdevment
Date: Wed, 30 Sep 2026 20:38:42 -0300
Subject: [PATCH 5/5] fix(panel): the filter chips were under a transparent lid
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
**No chip in the filter strip could be clicked.** The `TouchArea` that turns the
wheel into a horizontal scroll was declared after the `Flickable` that holds
them, and a later sibling in Slint sits on top: it covered the whole strip and
swallowed every press before a `Pill` saw it. It wraps the `Flickable` now
instead of covering it, so the chips get their clicks and the wheel handler
gets what they do not take.
**And the shortcuts have a section of their own.** The table of what the panel
answers to was at the bottom of General, under the settings nobody scrolls past,
and the one shortcut you can actually change sat several rows above it. Both are
in «Atajos de teclado» now, second in the rail, so the combination you edit and
the keys it opens read as one page. The panel's gear lands there rather than on
General, which is what it replaced.
The three suites that assumed the shortcut lived in General now walk to the
section first, and one new test holds the move: the heading, the editable
combination and a row of the table, all in the same place.
51 frontend tests, lint and build; fmt, and both touched crates compile.
---
app/src-tauri/src/panel.rs | 2 +-
app/src/App.tsx | 5 +-
app/src/locales.ts | 4 ++
app/src/tests/rail.test.tsx | 10 ++--
app/src/tests/refusals.test.tsx | 4 ++
app/src/tests/window.test.tsx | 27 +++++++++--
app/src/ui/General.tsx | 78 +-----------------------------
app/src/ui/Keys.tsx | 86 +++++++++++++++++++++++++++++++++
crates/cp-panel/ui/panel.slint | 30 ++++++------
9 files changed, 145 insertions(+), 101 deletions(-)
create mode 100644 app/src/ui/Keys.tsx
diff --git a/app/src-tauri/src/panel.rs b/app/src-tauri/src/panel.rs
index fdbe8f57..cfaea966 100644
--- a/app/src-tauri/src/panel.rs
+++ b/app/src-tauri/src/panel.rs
@@ -16,7 +16,7 @@ pub fn trouble(app: &AppHandle) -> Option {
fn heard_from_panel(app: &AppHandle, said: &str) {
if said == "settings" {
- let _ = app.emit("rail", "general");
+ let _ = app.emit("rail", "keys");
crate::tray::surface(app);
return;
}
diff --git a/app/src/App.tsx b/app/src/App.tsx
index 9aada6c8..83dc4180 100644
--- a/app/src/App.tsx
+++ b/app/src/App.tsx
@@ -7,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;
@@ -70,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/locales.ts b/app/src/locales.ts
index 17f0dd2d..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",
@@ -32,6 +33,7 @@ const ES = {
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",
@@ -168,6 +170,7 @@ type Said = typeof ES;
const EN: Record = {
railGeneral: "General",
railHistory: "History",
+ railKeys: "Keyboard shortcuts",
railBackup: "Backup",
railAbout: "About",
railSections: "Sections",
@@ -199,6 +202,7 @@ const EN: Record = {
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",
diff --git a/app/src/tests/rail.test.tsx b/app/src/tests/rail.test.tsx
index f181ee85..978f4d66 100644
--- a/app/src/tests/rail.test.tsx
+++ b/app/src/tests/rail.test.tsx
@@ -18,26 +18,26 @@ describe("la ventana", () => {
vi.clearAllMocks();
});
- it("aterriza en General cuando el panel pide los ajustes", async () => {
+ it("aterriza en los atajos cuando el panel pide los ajustes", async () => {
render( );
- await screen.findByText("Ctrl + Alt + V");
+ await screen.findByLabelText("Idioma");
const rail = await heardOn("rail");
rail({ payload: "backup" });
expect(await screen.findByText("Exportar")).toBeDefined();
- rail({ payload: "general" });
+ 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.findByText("Ctrl + Alt + V");
+ await screen.findByLabelText("Idioma");
const rail = await heardOn("rail");
rail({ payload: "inventada" });
await waitFor(() => {
- expect(screen.getByText("Atajo del panel")).toBeDefined();
+ expect(screen.getByLabelText("Idioma")).toBeDefined();
});
});
});
diff --git a/app/src/tests/refusals.test.tsx b/app/src/tests/refusals.test.tsx
index 0752234e..27c93784 100644
--- a/app/src/tests/refusals.test.tsx
+++ b/app/src/tests/refusals.test.tsx
@@ -50,7 +50,9 @@ describe("cuando el backend dice que no", () => {
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();
});
@@ -64,7 +66,9 @@ describe("cuando el backend dice que no", () => {
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/window.test.tsx b/app/src/tests/window.test.tsx
index 35b33f89..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,7 +127,9 @@ 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);
});
@@ -126,6 +143,7 @@ describe("la ventana", () => {
? 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);
@@ -140,7 +158,9 @@ describe("la ventana", () => {
});
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();
});
@@ -258,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/General.tsx b/app/src/ui/General.tsx
index 35e50de8..aa8a6288 100644
--- a/app/src/ui/General.tsx
+++ b/app/src/ui/General.tsx
@@ -1,16 +1,6 @@
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 { panelKeys, t } from "../locales";
+import { type Kept, type Look, onMac, PRIVACY_PANE, useTrust, useWaking } from "../core";
+import { t } from "../locales";
import { Band, Knob, Line } from "./Bits";
export default function General({
@@ -22,8 +12,6 @@ export default function General({
}) {
const { waking, trouble, ask } = useWaking();
const { trust, asked, ask: askTrust } = useTrust();
- const { keys, spare, recheck } = useKeys(kept.shortcut);
- const [asking, setAsking] = useState(false);
return (
<>
@@ -104,54 +92,6 @@ export default function General({
)}
- 0 ? (
-
- {t("keysFree")}
- {spare.map((one) => (
- void change({ shortcut: one }).finally(() => recheck(one))}
- >
- {one.replaceAll("+", " + ")}
-
- ))}
-
- ) : null
- }
- >
-
- {kept.shortcut.replaceAll("+", " + ")}
-
- setAsking(!asking)}
- onKeyDown={(press) => {
- if (!asking) {
- return;
- }
- press.preventDefault();
- if (press.code === "Escape") {
- setAsking(false);
- return;
- }
- const said = combination(press);
- if (said !== null) {
- setAsking(false);
- void change({ shortcut: said }).finally(() => recheck(said));
- }
- }}
- >
- {asking ? t("keysStop") : t("keysChange")}
-
-
-
change({ "hides-when-left": !kept["hides-when-left"] })}
/>
-
-
- {t("keysTableWhy")}
-
-
-
- {panelKeys().map((one) => (
-
- {one.keys}
- {one.does}
-
- ))}
-
-
>
);
}
diff --git a/app/src/ui/Keys.tsx b/app/src/ui/Keys.tsx
new file mode 100644
index 00000000..53e7256a
--- /dev/null
+++ b/app/src/ui/Keys.tsx
@@ -0,0 +1,86 @@
+import { useState } from "react";
+import { combination, type Kept, useKeys } from "../core";
+import { panelKeys, t } from "../locales";
+import { Band, Line } from "./Bits";
+
+export default function Keys({
+ kept,
+ change,
+}: {
+ kept: Kept;
+ change: (what: Partial) => Promise;
+}) {
+ const { keys, spare, recheck } = useKeys(kept.shortcut);
+ const [asking, setAsking] = useState(false);
+
+ return (
+ <>
+ {t("railKeys")}
+
+
+
+ 0 ? (
+
+ {t("keysFree")}
+ {spare.map((one) => (
+ void change({ shortcut: one }).finally(() => recheck(one))}
+ >
+ {one.replaceAll("+", " + ")}
+
+ ))}
+
+ ) : null
+ }
+ >
+
+ {kept.shortcut.replaceAll("+", " + ")}
+
+ setAsking(!asking)}
+ onKeyDown={(press) => {
+ if (!asking) {
+ return;
+ }
+ press.preventDefault();
+ if (press.code === "Escape") {
+ setAsking(false);
+ return;
+ }
+ const said = combination(press);
+ if (said !== null) {
+ setAsking(false);
+ void change({ shortcut: said }).finally(() => recheck(said));
+ }
+ }}
+ >
+ {asking ? t("keysStop") : t("keysChange")}
+
+
+
+
+
+ {t("keysTableWhy")}
+
+
+
+ {panelKeys().map((one) => (
+
+ {one.keys}
+ {one.does}
+
+ ))}
+
+
+ >
+ );
+}
diff --git a/crates/cp-panel/ui/panel.slint b/crates/cp-panel/ui/panel.slint
index 6e10f4dd..eabf5d68 100644
--- a/crates/cp-panel/ui/panel.slint
+++ b/crates/cp-panel/ui/panel.slint
@@ -363,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(
@@ -387,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;