From c56de9be0cee4a59545e6b798745e04b631ded39 Mon Sep 17 00:00:00 2001 From: superduck00 <294879536+superduck00@users.noreply.github.com> Date: Wed, 4 Nov 2026 22:14:50 -0500 Subject: [PATCH 1/4] Add Clipboard History, Raycast style A wl-paste watcher records text, links, colours, files and images with their source app, copy count and first/last copied times. A list/detail view with a type dropdown pastes, copies, opens, pins and deletes entries. Retention defaults to a week (pins kept); password managers' copies are skipped. omacast:emoji and the new omacast:clipboard shortcuts close their view on a second press. Co-Authored-By: Claude Opus 5.5 --- Backend.qml | 168 +++++++++++++++++- CLAUDE.md | 1 + Launcher.qml | 15 ++ README.md | 20 ++- core/ClipboardHistory.js | 296 ++++++++++++++++++++++++++++++++ core/Paths.js | 2 +- core/RootSearch.js | 9 + core/Schema.js | 28 ++- services/ClipboardWatcher.qml | 110 ++++++++++++ services/ClipboardWriter.qml | 27 +-- tests/clipboard-history.test.js | 83 +++++++++ tests/migrate.test.js | 3 + tests/paths.test.js | 2 +- tests/privacy.test.js | 14 +- ui/DetailPane.qml | 140 +++++++++++++++ ui/LauncherWindow.qml | 71 ++++++-- 16 files changed, 956 insertions(+), 33 deletions(-) create mode 100644 core/ClipboardHistory.js create mode 100644 services/ClipboardWatcher.qml create mode 100644 tests/clipboard-history.test.js create mode 100644 ui/DetailPane.qml diff --git a/Backend.qml b/Backend.qml index 8895670..d6a22fa 100644 --- a/Backend.qml +++ b/Backend.qml @@ -1,6 +1,7 @@ import QtQuick import Quickshell import Quickshell.Io +import Quickshell.Wayland import qs.Commons import "core/Rpc.js" as Rpc import "core/Paths.js" as Paths @@ -12,6 +13,7 @@ import "core/Frecency.js" as Frecency import "core/OmarchyMenu.js" as OmarchyMenu import "core/RootSearch.js" as RootSearch import "core/Calculator.js" as Calculator +import "core/ClipboardHistory.js" as ClipboardHistory import "services" Item { @@ -202,6 +204,122 @@ Item { return null } + // ----------------------------------------------------- clipboard history + + // Our own write from the history comes back through the watcher; it moves + // the entry up without counting as a new copy. + property string clipboardSelfHash: "" + property double clipboardSelfAt: 0 + + // The focused app: the source of a copy, and where a paste lands. + function activeApp() { + var t = ToplevelManager.activeToplevel + var appId = t ? String(t.appId || "") : "" + if (!appId) return null + var entry = DesktopEntries.heuristicLookup(appId) + return { appId: appId, name: entry && entry.name ? String(entry.name) : appId } + } + + function clipboardEntries() { return clipboardStore.loaded ? clipboardStore.doc.entries : [] } + + function clipboardEntry(id) { + var key = String(id).replace(/^clipboard:/, "") + var list = backend.clipboardEntries() + for (var i = 0; i < list.length; i++) if (list[i].id === key) return list[i] + return null + } + + // Only files inside our clipboard directory, whatever the store says. + function deleteClipboardFiles(paths) { + var dir = backend.paths.clipboard + "/" + var mine = paths.filter(function(p) { return p.indexOf(dir) === 0 && p.indexOf("/..") < 0 }) + if (mine.length) Quickshell.execDetached(["rm", "-f", "--"].concat(mine)) + } + + // Applies mutate(entries) → entries, then retention, and deletes the files + // no entry uses any more. + function updateClipboard(mutate) { + if (!clipboardStore.loaded) throw new Error("Clipboard History is not loaded") + var orphans = [] + clipboardStore.update(function(doc) { + var before = doc.entries + var pruned = ClipboardHistory.prune(mutate(before), Date.now(), backend.config.clipboardHistoryDuration) + var kept = ({}) + pruned.entries.forEach(function(e) { kept[e.id] = true }) + var gone = before.filter(function(e) { return !kept[e.id] }).concat(pruned.removed) + orphans = ClipboardHistory.orphanedPaths(gone, pruned.entries) + doc.entries = pruned.entries + }) + backend.deleteClipboardFiles(orphans) + backend.emit("clipboard.stateChanged", null) + } + + function pruneClipboard() { + if (!clipboardStore.loaded) return + if (ClipboardHistory.prune(clipboardStore.doc.entries, Date.now(), backend.config.clipboardHistoryDuration).removed.length) + backend.updateClipboard(function(entries) { return entries }) + } + + function recordClipboard(obs) { + if (!clipboardStore.loaded || !obs || !obs.hash) return + // The selection that was there when the watcher started is old news. + if (obs.initial && backend.clipboardEntries().some(function(e) { return e.hash === obs.hash })) return + delete obs.initial + var source = backend.activeApp() + if (ClipboardHistory.isDisabledSource(source, backend.config.clipboardDisabledApplications)) { + backend.deleteClipboardFiles(ClipboardHistory.orphanedPaths([obs], backend.clipboardEntries())) + return + } + obs.source = source + var now = Date.now() + var self = obs.hash === backend.clipboardSelfHash && now - backend.clipboardSelfAt < 5000 + var id = now.toString(36) + Math.floor(Math.random() * 1e6).toString(36) + backend.updateClipboard(function(entries) { return ClipboardHistory.add(entries, obs, now, id, self).entries }) + } + + // Puts an entry back on the clipboard, pasting it into the focused app if asked. + function writeClipboardEntry(e, paste) { + backend.clipboardSelfHash = e.hash + backend.clipboardSelfAt = Date.now() + var mime = e.mime === "text/uri-list" || e.type === "image" ? e.mime : "" + if (e.path) clipboard.copyFile(e.path, e.mime || "text/plain", paste) + else if (paste) clipboard.paste(e.text, mime) + else clipboard.copy(e.text, mime) + } + + function clipboardAction(action, id, confirmed) { + if (action === "delete-all-entries") { + if (!confirmed) return { confirm: { title: "Delete All Entries", message: "This removes every unpinned entry from Clipboard History. Pinned entries are kept.", primary: "Delete All", destructive: true } } + backend.updateClipboard(function(entries) { return entries.filter(function(e) { return typeof e.pinned === "number" }) }) + return { toast: { style: "success", title: "History cleared" } } + } + var e = backend.clipboardEntry(id) + if (!e) throw new Error("No such clipboard entry") + switch (action) { + case "open": + case "paste": + backend.writeClipboardEntry(e, true) + return { close: true } + case "copy": + backend.writeClipboardEntry(e, false) + return { close: true, hud: "Copied to Clipboard" } + case "open-link": + var files = ClipboardHistory.filePaths(e.text) + var target = files.length ? files[0] : String(e.text || "").trim() + if (/^https?:/i.test(target)) Util.execArgv(["omarchy-launch-browser", target]) + else Util.execArgv(["xdg-open", target]) + return { close: true } + case "pin": + case "unpin": + backend.updateClipboard(function(entries) { return ClipboardHistory.setPinned(entries, e.id, action === "pin") }) + return { toast: { style: "success", title: action === "pin" ? "Pinned" : "Unpinned" } } + case "delete-entry": + backend.updateClipboard(function(entries) { return entries.filter(function(x) { return x.id !== e.id }) }) + return { toast: { style: "success", title: "Entry deleted" } } + } + throw new Error("Unknown action " + action) + } + // ------------------------------------------------------------- execution function summonPlugin(id, payload) { @@ -428,6 +546,7 @@ Item { if (data && data.fleaHits) ctx.fleaHits = data.fleaHits var sections if (view && view.kind === "menu") sections = RootSearch.submenuSections(backend.candidates, view.menuId, query, ctx, backend.deps) + else if (view && view.kind === "clipboard") sections = ClipboardHistory.sections(backend.clipboardEntries(), query, String(data && data.filter || "all"), ctx.nowMs) else if (view && view.kind === "calculator-history") sections = RootSearch.historySections(calculatorStore.doc.history, query, ctx.nowMs, Calculator.filterHistory) else { ctx.filter = String(data && data.filter || "all") @@ -442,6 +561,12 @@ Item { var rowId = String(data && data.id || "") if (rowId === RootSearch.CALCULATOR_ID) return RootSearch.actionsFor({ kind: "calculator" }) if (rowId.indexOf("calculator-history:") === 0) return RootSearch.actionsFor({ kind: "calculator-history" }) + if (rowId.indexOf("clipboard:") === 0) { + var clip = backend.clipboardEntry(rowId) + if (!clip) throw new Error("No such clipboard entry") + var app = backend.activeApp() + return ClipboardHistory.actionsFor(clip, app ? app.name : "") + } var candidate = backend.decorated(rowId) if (!candidate) { var fallback = RootSearch.FALLBACKS.filter(function(f) { return f.id === data.id })[0] @@ -461,6 +586,10 @@ Item { var entry = backend.historyEntry(rowId) return entry ? RootSearch.aboutFor(RootSearch.historyCandidate(entry, nowMs), nowMs) : [] } + if (rowId.indexOf("clipboard:") === 0) { + var clip = backend.clipboardEntry(rowId) + return clip ? ClipboardHistory.detail(clip).info : [] + } var candidate = backend.decorated(rowId) if (!candidate) { var f = RootSearch.FALLBACKS.filter(function(x) { return x.id === rowId })[0] @@ -470,6 +599,8 @@ Item { return RootSearch.aboutFor(candidate, nowMs) }, performAction: function(data) { + if (data && String(data.id).indexOf("clipboard:") === 0) + return backend.clipboardAction(String(data.action || "open"), data.id, !!data.confirmed) // Destructive commands come back as a confirmation unless confirmed. if (!(data && data.confirmed)) { var confirm = RootSearch.confirmationFor(String(data && data.id || ""), String(data && data.action || "open")) @@ -520,6 +651,22 @@ Item { } }) + // Names from Raycast's clipboard namespace (ref 02 §2); getDetail feeds + // the detail pane [D]. + Rpc.register(backend.registry, "clipboard", { + getAll: function() { return backend.clipboardEntries() }, + getDetail: function(data) { + var e = backend.clipboardEntry(String(data && data.id || "")) + return e ? ClipboardHistory.detail(e) : null + }, + copy: function(data) { return backend.clipboardAction("copy", String(data && data.id || ""), true) }, + paste: function(data) { return backend.clipboardAction("paste", String(data && data.id || ""), true) }, + pin: function(data) { return backend.clipboardAction("pin", String(data && data.id || ""), true) }, + unpin: function(data) { return backend.clipboardAction("unpin", String(data && data.id || ""), true) }, + "delete": function(data) { return backend.clipboardAction("delete-entry", String(data && data.id || ""), true) }, + deleteAll: function() { return backend.clipboardAction("delete-all-entries", "", true) } + }) + Rpc.register(backend.registry, "frecency", { visit: function(data) { backend.recordVisit(String(data.id), String(data.query || "")); return null }, get: function(data) { @@ -548,11 +695,12 @@ Item { function onValuesChanged() { rebuildTimer.restart() } } - Component.onDestruction: { settingsStore.flush(); frecencyStore.flush(); calculatorStore.flush() } + Component.onDestruction: { settingsStore.flush(); frecencyStore.flush(); calculatorStore.flush(); clipboardStore.flush() } onNotification: function(method, data) { if (method === "window.visibilityChanged" && data && data.visible) { menuSource.refresh() calculatorData.refresh() + backend.pruneClipboard() } } @@ -564,6 +712,13 @@ Item { onFailed: function(message) { backend.log("error", "clipboard", message, null) } } + ClipboardWatcher { + directory: backend.directoriesReady && clipboardStore.loaded ? backend.paths.clipboard : "" + enabled: backend.config.clipboardHistory + onCaptured: function(observation) { backend.recordClipboard(observation) } + onMessage: function(level, text) { backend.log(level, "clipboard", text, null) } + } + AppFocuser { id: appFocuser onLaunchRequested: function(candidate) { backend.launchApp(candidate) } @@ -574,7 +729,7 @@ Item { id: mkdirs // Moves directories left under the old name, then creates everything // owner-only and repairs modes left readable by older builds. - command: Paths.setupArgv(backend.paths, [Schema.documents.settings.file, Schema.documents.frecency.file, Schema.documents.calculator.file] + command: Paths.setupArgv(backend.paths, [Schema.documents.settings.file, Schema.documents.frecency.file, Schema.documents.calculator.file, Schema.documents.clipboard.file] .map(function(file) { return backend.paths.db + "/" + file })) onExited: function(exitCode) { if (exitCode !== 0) backend.log("error", "paths", "could not create data directories", { exitCode: exitCode }) @@ -612,6 +767,15 @@ Item { onMessage: function(level, text, metadata) { backend.log(level, "store", text, metadata) } } + JsonStore { + id: clipboardStore + ready: backend.directoriesReady + path: backend.paths.db + "/" + Schema.documents.clipboard.file + migrations: Schema.documents.clipboard.migrations + onMessage: function(level, text, metadata) { backend.log(level, "store", text, metadata) } + onLoadedChanged: if (loaded) backend.pruneClipboard() + } + CalculatorData { id: calculatorData cacheDir: backend.directoriesReady ? backend.paths.cache : "" diff --git a/CLAUDE.md b/CLAUDE.md index b9d6019..9647f7c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -149,6 +149,7 @@ Works: config.json sets `remToPxRate` (16), `workHoursPerDay` (8) and `clockFormat` (`auto` follows the Qt locale, or `12h` / `24h`). ↵ copies the answer (HUD), Ctrl+↵ pastes it (wl-copy on stdin, then Shift+Insert), Ctrl+Shift+C copies "question = answer". Each one saves to **Calculator History** (`db/calculator.json`, 4096 rows / 90 days): a built-in command and pushed view, searchable, Ctrl+D deletes an entry, Ctrl+Shift+D deletes all. RPC: `calculator.calculate|historyGetAll|historyDelete|historyDeleteAll|refreshCurrencies|getCurrencies`. A bare number, word or quantity (`42`, `firefox`, `5 kg`) never produces a row, because the section takes ↵. +- **Clipboard History** (Phase 3, SPEC §5.3/§8.2, `core/ClipboardHistory.js`, `builtin:clipboard-history`, global shortcut `omacast:clipboard`): `services/ClipboardWatcher.qml` runs one `wl-paste --watch` whose constant bash script skips `CLIPBOARD_STATE != data` and `x-kde-passwordManagerHint`, prefers an `image/*` offer, keeps `text/uri-list` for `file://` copies, and prints one JSON line per copy. Images and text ≥ 30,720 bytes go to `~/.local/share/omacast/clipboard/` by MD5; short text travels in the JSON line. The backend adds the source (`ToplevelManager.activeToplevel` + `DesktopEntries.heuristicLookup`), drops disabled apps (`clipboardDisabledApplications`), dedups by hash (copy count, last copied), and prunes by `clipboardHistoryDuration` (default `P1W`, pins kept) plus a 1000-unpinned cap [D], deleting orphaned files. The first line after the watcher starts is skipped if already known, and our own writes from the history (5 s hash window) don't count as copies. UI: 40/60 list/detail (`ui/DetailPane.qml`: text, image or colour swatch, then Information rows), sections Pinned/Today/Yesterday/Earlier [I], type dropdown on Ctrl+P. Actions: Paste (↵, via `ClipboardWriter.copyFile` for files), Copy (Ctrl+↵), Open link/file (Ctrl+O), Pin (Ctrl+Shift+P), Delete (Ctrl+D), Delete All unpinned (Ctrl+Shift+D, confirmed). RPC: `clipboard.getAll|getDetail|copy|paste|pin|unpin|delete|deleteAll`. Not done: OCR, Paste Sequentially, rich-text representations (only plain text, URI lists and one image type are kept), edit/rename, Save as Snippet, moving pins, image dimensions. `omacast:clipboard` toggles: a second press closes the view. - **Latency:** show → mapped in 59–66 ms, hide in 24–42 ms on Omarchy 4.0.6 (`tests/smoke.sh`; it was 37–44 ms on the old `OverlayWindow`). - **Tests:** root search, calculator and privacy coverage (`node --test tests/*.test.js`). diff --git a/Launcher.qml b/Launcher.qml index f14679f..039e02b 100644 --- a/Launcher.qml +++ b/Launcher.qml @@ -55,6 +55,13 @@ Item { else root.show() } + // A command's own hotkey toggles it: a second press while its view is on + // screen closes the launcher, as the launcher hotkey does. + function toggleView(commandId, kind) { + if (root.opened && launcher.view.kind === kind) root.hide() + else root.run(commandId) + } + function run(commandId) { // `omacast run` is explicit, so it skips the confirmation alert. var r = backend.callSync("rootSearch.performAction", { id: commandId, action: "open", query: "", confirmed: true }) @@ -92,6 +99,14 @@ Item { onPressed: root.toggle() } + // Toggles Clipboard History directly: hl.dsp.global("omacast:clipboard"). + GlobalShortcut { + appid: "omacast" + name: "clipboard" + description: "Toggle omacast Clipboard History" + onPressed: root.toggleView("builtin:clipboard-history", "clipboard") + } + IpcHandler { target: "omacast" diff --git a/README.md b/README.md index 5900ced..589eb64 100644 --- a/README.md +++ b/README.md @@ -132,6 +132,23 @@ the old plugin link and `~/.local/bin/oma-cast`, and `omacast setup hyprland` mo | `Esc` / `Shift+Esc` | Back / back to root | | `Ctrl +` / `Ctrl −` / `Ctrl 0` | Zoom | +## Clipboard History + +Search **Clipboard History** in the root search, or bind it directly: + +```lua +o.bind("SUPER + CTRL + V", "omacast clipboard history", hl.dsp.global("omacast:clipboard")) +``` + +Pressing the binding again closes it. + +- omacast records text, links, colours, files and images you copy, with the app you copied from, when, and how many times. +- The list is on the left and a preview with its details is on the right. The type dropdown (`Ctrl+P`) filters by All Types, Text, Images, Files, Links or Colors. +- `↵` pastes into the app you came from, `Ctrl+↵` copies, `Ctrl+O` opens a link or file, `Ctrl+Shift+P` pins, `Ctrl+D` deletes an entry and `Ctrl+Shift+D` deletes everything except pins. +- Entries are kept for a week, as in Raycast. Pinned entries are always kept. Change it with `"clipboardHistoryDuration"` in `config.json` (`P1D`, `P1W`, `P1M`, `P3M`, `P6M`, `P1Y` or `unlimited`). +- Copies from password managers are never recorded: anything marked sensitive, plus the apps in `"clipboardDisabledApplications"` (Wayland app ids; KeePassXC, Bitwarden, 1Password and Seahorse by default). `"clipboardHistory": false` stops recording. +- The history is in `~/.local/share/omacast/db/clipboard.json`, and images and long text are in `~/.local/share/omacast/clipboard/`, both readable only by you. + ## CLI ```sh @@ -147,7 +164,8 @@ omacast is early. Search, ranking and the Omarchy integration are done. Coming n - [ ] Settings window - [ ] Script Commands -- [ ] Clipboard history, snippets, quicklinks, calculator +- [x] Clipboard history, calculator +- [ ] Snippets, quicklinks - [ ] Running Raycast extensions built from the open-source [`raycast/extensions`](https://github.com/raycast/extensions) repo - [ ] Window management - [ ] Importing your `.rayconfig` from macOS diff --git a/core/ClipboardHistory.js b/core/ClipboardHistory.js new file mode 100644 index 0000000..59b5591 --- /dev/null +++ b/core/ClipboardHistory.js @@ -0,0 +1,296 @@ +// Clipboard History (SPEC §8.2, ref 02 §5.1): categories, dedup by content +// hash, retention, sections, the detail pane and actions. Pure logic; the +// watcher (services/ClipboardWatcher.qml) and the store live in QML. + +// Text of this many bytes or more lives in a file under clipboard/ [O]. +var INLINE_TEXT_LIMIT = 30720 +// Search and the list read only this much of each entry. shortcut: a match +// past 8 KB of a long paste is missed; index the full text if anyone notices. +var SEARCH_LIMIT = 8192 +// Unpinned entries kept regardless of age, so a busy week can't grow the +// JSON store without bound. Raycast has no count cap. [D] +var MAX_ENTRIES = 1000 + +var FILTERS = [ + { id: "all", title: "All Types" }, + { id: "text", title: "Text" }, + { id: "image", title: "Images" }, + { id: "file", title: "Files" }, + { id: "link", title: "Links" }, + { id: "color", title: "Colors" } +] + +var DURATIONS = { + P1D: 1, P1W: 7, P1M: 30, P3M: 91, P6M: 182, P1Y: 365, unlimited: 0 +} + +var GLYPHS = { + text: "", // nf-fa-file_text + link: "", // nf-fa-link + email: "", // nf-fa-envelope + image: "", // nf-fa-picture_o + file: "", // nf-fa-file + color: "" // nf-fa-paint_brush +} + +var TYPE_LABELS = { text: "Text", link: "Link", email: "Email", image: "Image", file: "File", color: "Color" } + +function filePaths(text) { + var lines = String(text || "").split(/\r?\n/).filter(function(l) { return l.trim() }) + if (!lines.length) return [] + var paths = [] + for (var i = 0; i < lines.length; i++) { + var m = /^file:\/\/(?:localhost)?(\/.*)$/.exec(lines[i].trim()) + if (!m) return [] + try { paths.push(decodeURIComponent(m[1])) } catch (e) { paths.push(m[1]) } + } + return paths +} + +function isImagePath(path) { return /\.(png|jpe?g|webp|gif|bmp|tiff?|svg)$/i.test(path) } + +// "#abc", "#a1b2c3", "#a1b2c3d4", "rgb(1, 2, 3)" and "rgba(…)" → "#rrggbb[aa]", +// else "". hsl() and named colours stay text. [I] +function colorHex(text) { + var s = String(text || "").trim() + var m = /^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i.exec(s) + if (m) { + var h = m[1] + if (h.length <= 4) h = h.split("").map(function(c) { return c + c }).join("") + return "#" + h.toLowerCase() + } + m = /^rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*(?:,\s*([\d.]+%?)\s*)?\)$/i.exec(s) + if (!m) return "" + var parts = [m[1], m[2], m[3]].map(Number) + if (parts.some(function(n) { return n > 255 })) return "" + var out = "#" + parts.map(function(n) { return (n < 16 ? "0" : "") + n.toString(16) }).join("") + if (m[4] !== undefined) { + var a = m[4].slice(-1) === "%" ? parseFloat(m[4]) / 100 : parseFloat(m[4]) + if (!(a >= 0 && a <= 1)) return "" + var byte = Math.round(a * 255) + out += (byte < 16 ? "0" : "") + byte.toString(16) + } + return out +} + +// ref 02 §5.1 categories: file (+ image), image, link (+ email), color, text. +function categorize(obs) { + if (obs.type === "image") return ["image"] + var text = String(obs.text || "") + var files = filePaths(text) + if (files.length) return files.length === 1 && isImagePath(files[0]) ? ["file", "image"] : ["file"] + var t = text.trim() + if (/^mailto:\S+$/i.test(t)) return ["link", "email"] + if (/^[a-z][a-z0-9+.-]*:\/\/\S+$/i.test(t)) return ["link"] + if (colorHex(t)) return ["color"] + return ["text"] +} + +// The category a row is shown as: the most specific one. +function primaryCategory(entry) { + var cats = entry.categories || [] + var order = ["email", "color", "image", "file", "link", "text"] + for (var i = 0; i < order.length; i++) if (cats.indexOf(order[i]) >= 0) return order[i] + return "text" +} + +// Adds an observation, or bumps the entry with the same hash (count, time, +// source) [O]. selfCopy: the observation is our own write from the history, +// which moves the entry up without counting a copy or changing its source. +// Returns {entries, entry, added}; entries stay newest first. +function add(entries, obs, nowMs, id, selfCopy) { + var list = Array.isArray(entries) ? entries.slice() : [] + if (obs.type === "text" && !String(obs.text || "").trim()) return { entries: list, entry: null, added: false } + for (var i = 0; i < list.length; i++) { + if (list[i].hash !== obs.hash) continue + var e = Object.assign({}, list[i], { updatedAt: nowMs }) + if (!selfCopy) { + e.copyCount = (e.copyCount || 1) + 1 + if (obs.source) e.source = obs.source + } + list.splice(i, 1) + list.unshift(e) + return { entries: list, entry: e, added: false } + } + var entry = { + id: String(id), type: obs.type, hash: obs.hash, mime: obs.mime || (obs.type === "image" ? "image/png" : "text/plain"), + bytes: obs.bytes || 0, categories: categorize(obs), source: obs.source || null, + copyCount: 1, createdAt: nowMs, updatedAt: nowMs, pinned: null + } + if (obs.text !== undefined) entry.text = String(obs.text) + if (obs.path) entry.path = String(obs.path) + list.unshift(entry) + return { entries: list, entry: entry, added: true } +} + +// Drops unpinned entries older than the retention and past MAX_ENTRIES. +// Pinned entries are kept [O]. Returns {entries, removed}. +function prune(entries, nowMs, duration) { + var days = DURATIONS[duration] + if (days === undefined) days = DURATIONS.P1W + var cutoff = days ? nowMs - days * 86400000 : -Infinity + var kept = [], removed = [], unpinned = 0 + var list = Array.isArray(entries) ? entries : [] + for (var i = 0; i < list.length; i++) { + var e = list[i] + var keep = typeof e.pinned === "number" || (e.updatedAt >= cutoff && unpinned < MAX_ENTRIES) + if (keep && typeof e.pinned !== "number") unpinned++ + ;(keep ? kept : removed).push(e) + } + return { entries: kept, removed: removed } +} + +// Blob files no remaining entry points at, safe to delete. +function orphanedPaths(removed, kept) { + var used = {} + kept.forEach(function(e) { if (e.path) used[e.path] = true }) + var out = [] + removed.forEach(function(e) { if (e.path && !used[e.path] && out.indexOf(e.path) < 0) out.push(e.path) }) + return out +} + +function title(entry) { + if (entry.type === "image") return "Image" + var text = String(entry.text || "") + var files = filePaths(text) + if (files.length === 1) return files[0].split("/").pop() || files[0] + if (files.length > 1) return files.length + " Files" + var line = text.slice(0, 400).replace(/\s+/g, " ").trim() + return line.length > 200 ? line.slice(0, 200) + "…" : line +} + +function relativeTime(ms, nowMs) { + var s = Math.max(0, Math.round((nowMs - ms) / 1000)) + if (s < 60) return "Just now" + if (s < 3600) return Math.floor(s / 60) + " min ago" + if (s < 86400) return Math.floor(s / 3600) + " h ago" + var d = Math.floor(s / 86400) + return d === 1 ? "Yesterday" : d + " days ago" +} + +function candidate(entry, nowMs) { + var cat = primaryCategory(entry) + return { + id: "clipboard:" + entry.id, type: "command", kind: "clipboard", + title: title(entry), subtitle: "", typeLabel: relativeTime(entry.updatedAt, nowMs), + glyph: GLYPHS[cat], pinned: typeof entry.pinned === "number", rootSearch: { when: "always" } + } +} + +function matches(entry, needle, filter) { + if (filter && filter !== "all" && (entry.categories || []).indexOf(filter) < 0) return false + if (!needle) return true + var hay = (entry.type === "image" ? "image " : String(entry.text || "").slice(0, SEARCH_LIMIT) + " ") + + (entry.source ? entry.source.name || "" : "") + return hay.toLowerCase().indexOf(needle) >= 0 +} + +// Pinned (in pin order), then Today, Yesterday and Earlier by last copy. [I] +function sections(entries, query, filter, nowMs) { + var needle = String(query || "").trim().toLowerCase() + var list = (Array.isArray(entries) ? entries : []).filter(function(e) { return matches(e, needle, filter) }) + var midnight = new Date(nowMs); midnight.setHours(0, 0, 0, 0) + var today = midnight.getTime(), yesterday = today - 86400000 + var pinned = [], groups = { today: [], yesterday: [], earlier: [] } + for (var i = 0; i < list.length; i++) { + var e = list[i] + var row = { candidate: candidate(e, nowMs) } + if (typeof e.pinned === "number") pinned.push({ order: e.pinned, row: row }) + else groups[e.updatedAt >= today ? "today" : e.updatedAt >= yesterday ? "yesterday" : "earlier"].push(row) + } + pinned.sort(function(a, b) { return a.order - b.order }) + var out = [] + if (pinned.length) out.push({ id: "clipboard-pinned", title: "Pinned", rows: pinned.map(function(p) { return p.row }) }) + if (groups.today.length) out.push({ id: "clipboard-today", title: "Today", rows: groups.today }) + if (groups.yesterday.length) out.push({ id: "clipboard-yesterday", title: "Yesterday", rows: groups.yesterday }) + if (groups.earlier.length) out.push({ id: "clipboard-earlier", title: "Earlier", rows: groups.earlier }) + return out +} + +function words(text) { + var m = String(text || "").match(/\S+/g) + return m ? m.length : 0 +} + +function formatBytes(n) { + if (n < 1024) return n + " B" + if (n < 1048576) return (Math.round(n / 102.4) / 10) + " KB" + return (Math.round(n / 104857.6) / 10) + " MB" +} + +function formatDate(ms) { + var d = new Date(ms) + return d.toDateString() + " " + d.toTimeString().slice(0, 5) +} + +// The detail pane: what to preview, then Information rows (SPEC §8.2) [O]. +function detail(entry) { + var cat = primaryCategory(entry) + var info = [] + function add(label, value) { if (value !== undefined && value !== null && value !== "") info.push({ label: label, value: String(value) }) } + add("Source", entry.source ? entry.source.name || entry.source.appId : "") + add("Content type", TYPE_LABELS[cat]) + if (entry.type === "text") { + var text = String(entry.text || "") + var truncated = entry.bytes >= INLINE_TEXT_LIMIT + add("Characters", truncated ? formatBytes(entry.bytes) : text.length) + if (!truncated) add("Words", words(text)) + } else { + add("Image size", formatBytes(entry.bytes || 0)) + } + add("Times copied", entry.copyCount || 1) + add("Last copied", formatDate(entry.updatedAt)) + add("First copied", formatDate(entry.createdAt)) + var files = entry.type === "text" ? filePaths(entry.text) : [] + return { + category: cat, + text: entry.type === "text" ? (files.length ? files.join("\n") : String(entry.text || "").slice(0, SEARCH_LIMIT)) : "", + image: entry.type === "image" ? entry.path : (files.length === 1 && isImagePath(files[0]) ? files[0] : ""), + color: cat === "color" ? colorHex(entry.text) : "", + info: info + } +} + +// Keys follow the rest of omacast: Ctrl+Shift+P pins, Ctrl+D / Ctrl+Shift+D +// delete, as in Calculator History. [D] +function actionsFor(entry, appName) { + var cat = primaryCategory(entry) + var list = [ + { id: "paste", title: "Paste to " + (appName || "Active App"), shortcut: "↵", section: "primary", icon: "" }, + { id: "copy", title: "Copy to Clipboard", shortcut: "Ctrl ↵", section: "primary", icon: "" } + ] + if (cat === "link" || cat === "email") list.push({ id: "open-link", title: cat === "email" ? "Compose Email" : "Open in Browser", shortcut: "Ctrl O", section: "primary", icon: "" }) + if (cat === "file" || (cat === "image" && entry.type === "text")) list.push({ id: "open-link", title: "Open File", shortcut: "Ctrl O", section: "primary", icon: "" }) + var pinned = typeof entry.pinned === "number" + list.push({ id: pinned ? "unpin" : "pin", title: pinned ? "Unpin Entry" : "Pin Entry", shortcut: "Ctrl Shift P", section: "pin", icon: "" }) + list.push({ id: "delete-entry", title: "Delete Entry", shortcut: "Ctrl D", section: "delete", icon: "" }) + list.push({ id: "delete-all-entries", title: "Delete All Entries", shortcut: "Ctrl Shift D", section: "delete", icon: "" }) + return list +} + +function setPinned(entries, id, pin) { + var max = -1 + entries.forEach(function(e) { if (typeof e.pinned === "number" && e.pinned > max) max = e.pinned }) + return entries.map(function(e) { + if (e.id !== id) return e + return Object.assign({}, e, { pinned: pin ? max + 1 : null }) + }) +} + +// Clipboard changes from these apps are not recorded [O]; matched against +// the Wayland app id, case-insensitively. +function isDisabledSource(source, disabled) { + if (!source || !source.appId) return false + var id = String(source.appId).toLowerCase() + return (disabled || []).some(function(d) { return String(d).toLowerCase() === id }) +} + +if (typeof module !== "undefined") { + module.exports = { + INLINE_TEXT_LIMIT: INLINE_TEXT_LIMIT, SEARCH_LIMIT: SEARCH_LIMIT, MAX_ENTRIES: MAX_ENTRIES, + FILTERS: FILTERS, DURATIONS: DURATIONS, + filePaths: filePaths, colorHex: colorHex, categorize: categorize, primaryCategory: primaryCategory, + add: add, prune: prune, orphanedPaths: orphanedPaths, title: title, candidate: candidate, + sections: sections, detail: detail, actionsFor: actionsFor, setPinned: setPinned, isDisabledSource: isDisabledSource + } +} diff --git a/core/Paths.js b/core/Paths.js index 75bcdcc..c5a6c2b 100644 --- a/core/Paths.js +++ b/core/Paths.js @@ -48,7 +48,7 @@ function legacyMoves(paths) { } function directoriesToCreate(paths) { - return [paths.config, paths.db, paths.cache, paths.logs, paths.backups, paths.runtime] + return [paths.config, paths.db, paths.clipboard, paths.cache, paths.logs, paths.backups, paths.runtime] } // Histories and settings are private: other local users must not read them. diff --git a/core/RootSearch.js b/core/RootSearch.js index b424a03..2b7766d 100644 --- a/core/RootSearch.js +++ b/core/RootSearch.js @@ -31,6 +31,15 @@ var BUILTINS = [ run: { summon: "omarchy.menu", payload: "{\"menu\":\"root\"}" }, rootSearch: { when: "always" } }, + { + id: "builtin:clipboard-history", + title: "Clipboard History", + subtitle: "Clipboard History", + glyph: "\uf0ea", // nf-fa-clipboard + keywords: ["clipboard", "history", "paste", "copy", "pasteboard"], + run: { push: { kind: "clipboard", title: "Clipboard History" } }, + rootSearch: { when: "always" } + }, { id: "builtin:calculator-history", title: "Calculator History", diff --git a/core/Schema.js b/core/Schema.js index cdac573..d41681d 100644 --- a/core/Schema.js +++ b/core/Schema.js @@ -46,6 +46,18 @@ var calculatorMigrations = [ } ] +// Clipboard History (SPEC §8.2): ClipboardHistory.add entries, newest first. +var clipboardMigrations = [ + { + version: 1, + name: "clipboard-history", + up: function(doc) { + doc.entries = [] // {id, type, text?, path?, mime, hash, bytes, categories, source, copyCount, createdAt, updatedAt, pinned} + return doc + } + } +] + var SEARCH_HISTORY_LIMIT = 20 function pushSearchHistory(history, query) { @@ -60,7 +72,8 @@ function pushSearchHistory(history, query) { var documents = { settings: { file: "settings.json", migrations: settingsMigrations }, frecency: { file: "frecency.json", migrations: frecencyMigrations }, - calculator: { file: "calculator.json", migrations: calculatorMigrations } + calculator: { file: "calculator.json", migrations: calculatorMigrations }, + clipboard: { file: "clipboard.json", migrations: clipboardMigrations } } var configDefaults = { @@ -73,12 +86,19 @@ var configDefaults = { remToPxRate: 16, workHoursPerDay: 8, // "auto" follows the Qt locale; "12h" or "24h" forces a clock. [D] - clockFormat: "auto" + clockFormat: "auto", + // Clipboard History preferences named after Raycast's (ref 04 App. B) [O]. + // clipboardHistory false stops recording; the history stays. [D] + clipboardHistory: true, + clipboardHistoryDuration: "P1W", + // Wayland app ids whose copies are never recorded (SPEC §5.3 defaults). + clipboardDisabledApplications: ["org.keepassxc.KeePassXC", "Bitwarden", "1Password", "seahorse", "org.gnome.seahorse.Application"] } // Values a config key or general setting may take, beyond its type. var configChoices = { - clockFormat: ["auto", "12h", "24h"] + clockFormat: ["auto", "12h", "24h"], + clipboardHistoryDuration: ["P1D", "P1W", "P1M", "P3M", "P6M", "P1Y", "unlimited"] } var generalChoices = { @@ -90,6 +110,7 @@ var generalChoices = { function validConfigValue(key, value) { if (typeof value !== typeof configDefaults[key]) return false + if (Array.isArray(configDefaults[key])) return Array.isArray(value) && value.every(function(v) { return typeof v === "string" }) if (configChoices[key]) return configChoices[key].indexOf(value) >= 0 if (typeof value === "number") return isFinite(value) && value > 0 return true @@ -111,6 +132,7 @@ if (typeof module !== "undefined") { settingsMigrations: settingsMigrations, frecencyMigrations: frecencyMigrations, calculatorMigrations: calculatorMigrations, + clipboardMigrations: clipboardMigrations, pushSearchHistory: pushSearchHistory, SEARCH_HISTORY_LIMIT: SEARCH_HISTORY_LIMIT, documents: documents, diff --git a/services/ClipboardWatcher.qml b/services/ClipboardWatcher.qml new file mode 100644 index 0000000..326911b --- /dev/null +++ b/services/ClipboardWatcher.qml @@ -0,0 +1,110 @@ +import QtQuick +import Quickshell +import Quickshell.Io + +// Clipboard capture for Clipboard History (SPEC §5.3): `wl-paste --watch` +// runs captureScript on every new selection, which prints one JSON line per +// copy. Images and long text are saved under `directory` (0700) by content +// hash; short text travels in the JSON line, never on a command line. +// Password managers' copies (x-kde-passwordManagerHint, CLIPBOARD_STATE +// sensitive) are skipped before anything is read or written. +Item { + id: watcher + + property string directory: "" + property bool enabled: true + signal captured(var observation) + signal message(string level, string text) + + readonly property bool wanted: watcher.enabled && watcher.directory !== "" + onWantedChanged: watcher.restart() + Component.onCompleted: watcher.restart() + + function restart() { + watch.running = false + if (watcher.wanted && !reap.running) reap.running = true + } + + // $1 is the clipboard directory. The text limit matches + // ClipboardHistory.INLINE_TEXT_LIMIT. + readonly property string captureScript: ' +dir=$1 +umask 077 +[[ ${CLIPBOARD_STATE:-} == data ]] || exit 0 +tmp=$(mktemp -p "$dir" .capture.XXXXXX) || exit 0 +trap \'rm -f -- "$tmp"\' EXIT +types=$(timeout 1s wl-paste --list-types 2>/dev/null) +grep -qx x-kde-passwordManagerHint <<<"$types" && exit 0 +# The selection owner streams the copy and can stall; a cut-off copy is dropped. +timeout 2s cat >"$tmp" 2>/dev/null || exit 0 +hash_of() { local h; h=$(md5sum <"$1") && echo "${h%% *}"; } +image=$(grep -m1 -xE "image/(png|jpeg|webp|gif|bmp)" <<<"$types") +if [[ $image ]]; then + timeout 2s wl-paste --type "$image" >"$tmp" 2>/dev/null && [[ -s $tmp ]] || exit 0 + hash=$(hash_of "$tmp") || exit 0 + ext=${image#image/}; [[ $ext == jpeg ]] && ext=jpg + file=$dir/$hash.$ext + [[ -e $file ]] || mv -- "$tmp" "$file" || exit 0 + jq -cn --arg mime "$image" --arg path "$file" --arg hash "$hash" --argjson bytes "$(stat -c %s -- "$file")" \ + \'{type:"image", mime:$mime, path:$path, hash:$hash, bytes:$bytes}\' + exit 0 +fi +grep -qE "^(text/|UTF8_STRING$|STRING$|TEXT$)" <<<"$types" || exit 0 +mime=text/plain +# Files copied in a file manager: keep their URI list, not the plain-text paths. +if grep -qx text/uri-list <<<"$types"; then + uris=$(timeout 2s wl-paste --type text/uri-list 2>/dev/null) + if [[ $uris == file://* ]]; then printf %s "$uris" >"$tmp"; mime=text/uri-list; fi +fi +[[ -s $tmp ]] || exit 0 +bytes=$(stat -c %s -- "$tmp") +hash=$(hash_of "$tmp") || exit 0 +if (( bytes >= 30720 )); then + file=$dir/file-$hash.txt + [[ -e $file ]] || cp -- "$tmp" "$file" || exit 0 + head -c 30720 -- "$file" | jq -cRs --arg mime "$mime" --arg path "$file" --arg hash "$hash" --argjson bytes "$bytes" \ + \'{type:"text", text:., mime:$mime, path:$path, hash:$hash, bytes:$bytes}\' +else + jq -cRs --arg mime "$mime" --arg hash "$hash" --argjson bytes "$bytes" \ + \'{type:"text", text:., mime:$mime, hash:$hash, bytes:$bytes}\' <"$tmp" +fi +' + + // Reap a watcher left by an earlier instance of the plugin (omacast + // reload), then start ours; pdeathsig ends it with the shell. + Process { + id: reap + command: ["pkill", "-f", "^wl-paste --watch bash -c .* omacast-clipboard "] + onExited: if (watcher.wanted) watch.running = true + } + + // wl-paste reports the selection already there when it starts; that + // first line is marked so a restart doesn't count it as a new copy. + property bool fresh: false + + Process { + id: watch + command: ["setpriv", "--pdeathsig", "TERM", "wl-paste", "--watch", "bash", "-c", watcher.captureScript, "omacast-clipboard", watcher.directory] + onStarted: watcher.fresh = true + stdout: SplitParser { + onRead: function(line) { + var initial = watcher.fresh + watcher.fresh = false + try { watcher.captured(Object.assign(JSON.parse(line), { initial: initial })) } + catch (e) { watcher.message("warning", "unreadable capture: " + e) } + } + } + // A dead watcher silently stops the history; bring it back. + onExited: function(exitCode) { + if (!watcher.wanted) return + watcher.message("warning", "clipboard watcher exited (" + exitCode + "); restarting") + restartTimer.restart() + } + } + + Timer { + id: restartTimer + interval: 2000 + onTriggered: if (watcher.wanted && !watch.running) watch.running = true + } +} diff --git a/services/ClipboardWriter.qml b/services/ClipboardWriter.qml index 4df6c6c..095def4 100644 --- a/services/ClipboardWriter.qml +++ b/services/ClipboardWriter.qml @@ -11,26 +11,31 @@ Item { property var queued: null signal failed(string message) - function copy(text) { writer.send(String(text), false) } - function paste(text) { writer.send(String(text), true) } + function copy(text, mime) { writer.send({ text: String(text), mime: mime || "", paste: false }) } + function paste(text, mime) { writer.send({ text: String(text), mime: mime || "", paste: true }) } + // A file of ours (a Clipboard History image or long text): its path and + // MIME type are on the command line, never its contents. + function copyFile(path, mime, paste) { writer.send({ file: String(path), mime: String(mime), paste: !!paste }) } - function send(text, paste) { - if (proc.running) { writer.queued = { text: text, paste: paste }; return } - proc.text = text - proc.command = ["bash", "-lc", paste ? writer.pasteScript : writer.copyScript] - proc.stdinEnabled = true + function send(item) { + if (proc.running) { writer.queued = item; return } + proc.text = item.file ? "" : item.text + proc.command = ["bash", "-lc", item.paste ? writer.pasteScript : writer.copyScript, "omacast-copy", item.mime || "", item.file || ""] + proc.stdinEnabled = !item.file proc.running = true } - // Constant scripts: no caller data ever reaches the command line. - readonly property string copyScript: 'exec wl-copy' + // Constant scripts: no caller text ever reaches the command line. $1 is a + // MIME type, $2 a file to copy instead of stdin. + readonly property string copyScript: 'if [[ $2 ]]; then exec wl-copy ${1:+--type "$1"} <"$2"; else exec wl-copy ${1:+--type "$1"}; fi' // Wait for the launcher to unmap, as omarchy-clipboard-paste-text does. - readonly property string pasteScript: 'wl-copy || exit; sleep 0.15; wtype -M shift -k Insert -m shift 2>/dev/null || true' + readonly property string pasteScript: 'if [[ $2 ]]; then wl-copy ${1:+--type "$1"} <"$2"; else wl-copy ${1:+--type "$1"}; fi || exit; sleep 0.15; wtype -M shift -k Insert -m shift 2>/dev/null || true' Process { id: proc property string text: "" onStarted: { + if (!proc.stdinEnabled) return write(proc.text) proc.text = "" proc.stdinEnabled = false @@ -39,7 +44,7 @@ Item { if (exitCode !== 0) writer.failed("Could not copy to the clipboard") var next = writer.queued writer.queued = null - if (next) writer.send(next.text, next.paste) + if (next) writer.send(next) } } } diff --git a/tests/clipboard-history.test.js b/tests/clipboard-history.test.js new file mode 100644 index 0000000..c35979c --- /dev/null +++ b/tests/clipboard-history.test.js @@ -0,0 +1,83 @@ +const test = require("node:test") +const assert = require("node:assert/strict") +const C = require("../core/ClipboardHistory.js") + +const DAY = 86400000 +const now = new Date(2026, 10, 4, 15, 0).getTime() +const text = (t, hash) => ({ type: "text", text: t, hash: hash || t, bytes: t.length, source: { name: "Ghostty", appId: "com.mitchellh.ghostty" } }) + +test("categorizes like Raycast", () => { + assert.deepEqual(C.categorize(text("hello world")), ["text"]) + assert.deepEqual(C.categorize(text("https://omarchy.org/x?y=1")), ["link"]) + assert.deepEqual(C.categorize(text("mailto:a@b.co")), ["link", "email"]) + assert.deepEqual(C.categorize(text("#1e1e2e")), ["color"]) + assert.deepEqual(C.categorize(text("rgb(255, 0, 10)")), ["color"]) + assert.deepEqual(C.categorize(text("file:///home/u/a%20b.png")), ["file", "image"]) + assert.deepEqual(C.categorize(text("file:///a\nfile:///b")), ["file"]) + assert.deepEqual(C.categorize(text("see https://x.org")), ["text"]) + assert.deepEqual(C.categorize({ type: "image", hash: "h" }), ["image"]) + assert.equal(C.colorHex("#abc"), "#aabbcc") + assert.equal(C.colorHex("rgba(0,0,0,0.5)"), "#00000080") + assert.equal(C.colorHex("rgb(300,0,0)"), "") + assert.deepEqual(C.filePaths("file:///home/u/a%20b.png"), ["/home/u/a b.png"]) +}) + +test("dedups by hash: bumps count and moves up; self copies don't count", () => { + let r = C.add([], text("a"), now - 2000, "1") + r = C.add(r.entries, text("b"), now - 1000, "2") + r = C.add(r.entries, text("a"), now, "3") + assert.equal(r.added, false) + assert.equal(r.entries.length, 2) + assert.equal(r.entries[0].id, "1") + assert.equal(r.entries[0].copyCount, 2) + assert.equal(r.entries[0].createdAt, now - 2000) + r = C.add(r.entries, Object.assign(text("b"), { source: { name: "Firefox", appId: "firefox" } }), now + 1, "4", true) + assert.equal(r.entries[0].id, "2") + assert.equal(r.entries[0].copyCount, 1) + assert.equal(r.entries[0].source.name, "Ghostty") + assert.equal(C.add(r.entries, text(" \n"), now, "5").entry, null) +}) + +test("prune keeps pinned entries and honours retention and the cap", () => { + const old = Object.assign(C.add([], text("old"), now - 8 * DAY, "o").entry, { path: "/c/old.txt" }) + const pinned = Object.assign({}, old, { id: "p", hash: "p", pinned: 0, path: "/c/shared.png" }) + const fresh = Object.assign(C.add([], text("new"), now, "n").entry, { path: "/c/shared.png" }) + const r = C.prune([fresh, old, pinned], now, "P1W") + assert.deepEqual(r.entries.map((e) => e.id), ["n", "p"]) + assert.deepEqual(C.orphanedPaths(r.removed, r.entries), ["/c/old.txt"]) + assert.equal(C.prune([old], now, "unlimited").entries.length, 1) + const many = Array.from({ length: C.MAX_ENTRIES + 5 }, (_, i) => Object.assign({}, fresh, { id: String(i) })) + assert.equal(C.prune(many, now, "unlimited").entries.length, C.MAX_ENTRIES) +}) + +test("sections: pinned in pin order, then by day; filter and search", () => { + let list = [] + list = C.add(list, text("yesterday note"), now - DAY, "y").entries + list = C.add(list, text("https://a.org"), now - 60000, "l").entries + list = C.add(list, text("today note"), now, "t").entries + list = C.setPinned(list, "l", true) + list = C.setPinned(list, "y", true) + const s = C.sections(list, "", "all", now) + assert.deepEqual(s.map((x) => x.title), ["Pinned", "Today"]) + assert.deepEqual(s[0].rows.map((r) => r.candidate.id), ["clipboard:l", "clipboard:y"]) + assert.deepEqual(C.sections(list, "", "link", now).map((x) => x.rows.length), [1]) + assert.deepEqual(C.sections(list, "NOTE", "all", now).flatMap((x) => x.rows.map((r) => r.candidate.id)), ["clipboard:y", "clipboard:t"]) + assert.deepEqual(C.sections(list, "ghostty", "all", now).flatMap((x) => x.rows).length, 3) + list = C.setPinned(list, "y", false) + assert.deepEqual(C.sections(list, "", "all", now).map((x) => x.title), ["Pinned", "Today", "Yesterday"]) +}) + +test("detail and actions", () => { + const e = C.add([], text("two words"), now, "1").entry + const d = C.detail(e) + assert.equal(d.text, "two words") + assert.deepEqual(d.info.slice(0, 4).map((r) => [r.label, r.value]), [["Source", "Ghostty"], ["Content type", "Text"], ["Characters", "9"], ["Words", "2"]]) + assert.deepEqual(C.actionsFor(e, "Firefox").map((a) => a.id), ["paste", "copy", "pin", "delete-entry", "delete-all-entries"]) + assert.equal(C.actionsFor(e, "Firefox")[0].title, "Paste to Firefox") + const link = C.add([], text("https://a.org"), now, "2").entry + assert.ok(C.actionsFor(link).some((a) => a.id === "open-link")) + assert.equal(C.detail(C.add([], text("#ff0000"), now, "3").entry).color, "#ff0000") + assert.equal(C.title(C.add([], text("file:///x/y.txt"), now, "4").entry), "y.txt") + assert.ok(C.isDisabledSource({ appId: "org.keepassxc.KeePassXC" }, ["org.keepassxc.keepassxc"])) + assert.ok(!C.isDisabledSource(null, ["x"])) +}) diff --git a/tests/migrate.test.js b/tests/migrate.test.js index 0e27640..46ffbdc 100644 --- a/tests/migrate.test.js +++ b/tests/migrate.test.js @@ -93,5 +93,8 @@ test("config rejects out-of-range calculator values and unknown choices", () => assert.equal(c.workHoursPerDay, 8, "zero hours falls back to the default") assert.equal(c.clockFormat, "24h") assert.equal(Schema.withConfigDefaults({ clockFormat: "36h" }).clockFormat, "auto") + assert.equal(Schema.withConfigDefaults({ clipboardHistoryDuration: "P2W" }).clipboardHistoryDuration, "P1W") + assert.deepEqual(Schema.withConfigDefaults({ clipboardDisabledApplications: ["a"] }).clipboardDisabledApplications, ["a"]) + assert.equal(Schema.withConfigDefaults({ clipboardDisabledApplications: "a" }).clipboardDisabledApplications.length, 5) assert.deepEqual(Schema.generalChoices.navigationBindings, ["emacs", "vim-motions"]) }) diff --git a/tests/paths.test.js b/tests/paths.test.js index f7a1d21..f4c6a55 100644 --- a/tests/paths.test.js +++ b/tests/paths.test.js @@ -35,7 +35,7 @@ test("missing HOME is an error", () => { test("startup creates the persistent dirs and the runtime dir", () => { const p = Paths.resolve(envOf({ HOME: "/h", XDG_RUNTIME_DIR: "/r" })) assert.deepEqual(Paths.directoriesToCreate(p), [ - "/h/.config/omacast", "/h/.local/share/omacast/db", "/h/.cache/omacast", + "/h/.config/omacast", "/h/.local/share/omacast/db", "/h/.local/share/omacast/clipboard", "/h/.cache/omacast", "/h/.local/state/omacast/logs", "/h/.local/state/omacast/backups", "/r/omacast" ]) }) diff --git a/tests/privacy.test.js b/tests/privacy.test.js index af350cb..feae5ad 100644 --- a/tests/privacy.test.js +++ b/tests/privacy.test.js @@ -126,11 +126,23 @@ for (const kind of ["copy", "paste"]) { assert.equal(fs.readFileSync(log + ".clipboard", "utf8"), secret) const cmdlines = fs.readFileSync(log, "utf8") assert.ok(!cmdlines.includes("private"), cmdlines) - assert.ok(!script.includes("$"), "the script takes no arguments") if (kind === "paste") assert.match(cmdlines, /wtype -M shift -k Insert -m shift/) }) } +test("a Clipboard History file is copied from its path with its MIME type", (t) => { + const home = tempHome(t) + const log = home + "/cmdlines" + fs.writeFileSync(home + "/wl-copy", '#!/bin/bash\necho "$@" >>"$LOG"; cat >"$LOG.clipboard"\n', { mode: 0o755 }) + fs.writeFileSync(home + "/blob.png", "PNGDATA") + const r = spawnSync("bash", ["-c", clipboardScripts().copy, "omacast-copy", "image/png", home + "/blob.png"], { + encoding: "utf8", env: { ...process.env, LOG: log, PATH: home + ":" + process.env.PATH } + }) + assert.equal(r.status, 0, r.stderr) + assert.equal(fs.readFileSync(log, "utf8").trim(), "--type image/png") + assert.equal(fs.readFileSync(log + ".clipboard", "utf8"), "PNGDATA") +}) + test("no QML hands clipboard text or prompts to a process's argv", () => { const files = ["Backend.qml", "Launcher.qml", ...["ui", "services"].flatMap((d) => fs.readdirSync(path.join(root, d)).filter((f) => f.endsWith(".qml")).map((f) => d + "/" + f))] diff --git a/ui/DetailPane.qml b/ui/DetailPane.qml new file mode 100644 index 0000000..0e7c051 --- /dev/null +++ b/ui/DetailPane.qml @@ -0,0 +1,140 @@ +import QtQuick +import qs.Commons + +// The detail pane of a list/detail view (Clipboard History, SPEC §8.2): a preview (text, +// image or colour swatch) above its Information rows +// (ClipboardHistory.detail). +Item { + id: pane + + property var detail: null + property var look: null + property real zoom: 1 + + function px(v) { return Math.round(Style.space(v) * pane.zoom) } + + Rectangle { + anchors.left: parent.left + anchors.top: parent.top + anchors.bottom: parent.bottom + width: Style.spacing.hairline + color: Util.alpha(pane.look.foreground, 0.12) + } + + Item { + id: preview + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.bottom: info.top + anchors.margins: pane.px(14) + clip: true + + Text { + anchors.fill: parent + visible: !!pane.detail && !pane.detail.image && !pane.detail.color + textFormat: Text.PlainText + text: pane.detail ? pane.detail.text : "" + color: pane.look.foreground + wrapMode: Text.WrapAnywhere + elide: Text.ElideRight + font.family: Style.fontFamily + font.pixelSize: Math.round(Style.font.bodySmall * pane.zoom) + } + + Image { + anchors.fill: parent + visible: !!pane.detail && !!pane.detail.image + source: pane.detail && pane.detail.image ? Util.fileUrl(pane.detail.image) : "" + fillMode: Image.PreserveAspectFit + asynchronous: true + cache: false + // Decode at the pane's size: a 4K screenshot needn't cost 33 MB. + sourceSize.width: Math.max(1, Math.round(width * 2)) + sourceSize.height: Math.max(1, Math.round(height * 2)) + } + + Column { + anchors.centerIn: parent + visible: !!pane.detail && !!pane.detail.color + spacing: pane.px(10) + + Rectangle { + anchors.horizontalCenter: parent.horizontalCenter + width: pane.px(96) + height: pane.px(96) + radius: Style.cornerRadius + color: pane.detail && pane.detail.color ? pane.detail.color : "transparent" + border.width: 1 + border.color: Util.alpha(pane.look.foreground, 0.2) + } + Text { + anchors.horizontalCenter: parent.horizontalCenter + textFormat: Text.PlainText + text: pane.detail ? pane.detail.text.trim() : "" + color: pane.look.foreground + font.family: Style.fontFamily + font.pixelSize: Math.round(Style.font.body * pane.zoom) + } + } + } + + Column { + id: info + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.leftMargin: pane.px(14) + anchors.rightMargin: pane.px(14) + anchors.bottomMargin: pane.px(8) + spacing: pane.px(5) + + Rectangle { + width: parent.width + height: Style.spacing.hairline + color: Util.alpha(pane.look.foreground, 0.12) + } + + Text { + textFormat: Text.PlainText + text: "Information" + color: pane.look.foreground + opacity: 0.7 + font.family: pane.look.fontFamily + font.pixelSize: Math.round(Style.font.bodySmall * pane.zoom) + font.bold: true + } + + Repeater { + model: pane.detail ? pane.detail.info : [] + + Item { + required property var modelData + width: info.width + height: label.implicitHeight + + Text { + id: label + anchors.left: parent.left + textFormat: Text.PlainText + text: modelData.label + color: pane.look.foreground + opacity: 0.55 + font.family: pane.look.fontFamily + font.pixelSize: Math.round(Style.font.bodySmall * pane.zoom) + } + Text { + anchors.right: parent.right + width: parent.width - label.implicitWidth - pane.px(12) + horizontalAlignment: Text.AlignRight + textFormat: Text.PlainText + text: modelData.value + color: pane.look.foreground + elide: Text.ElideLeft + font.family: pane.look.fontFamily + font.pixelSize: Math.round(Style.font.bodySmall * pane.zoom) + } + } + } + } +} diff --git a/ui/LauncherWindow.qml b/ui/LauncherWindow.qml index 618a8ad..918f661 100644 --- a/ui/LauncherWindow.qml +++ b/ui/LauncherWindow.qml @@ -6,6 +6,7 @@ import qs.Commons as Commons import qs.Ui import "../core/RootSearch.js" as RootSearch import "../core/Rows.js" as Rows +import "../core/ClipboardHistory.js" as ClipboardHistory // Omarchy 4.0.x dropped qs.Ui.OverlayWindow; this is the same full-screen // overlay layer the shell's own emoji picker uses. @@ -73,6 +74,12 @@ PanelWindow { // Ctrl+P root filter (RootSearch.FILTERS), Ctrl+T toast actions, // Ctrl+Shift+K About panel and the confirm alert. property string rootFilter: "all" + // Clipboard History's type dropdown (ref 04 App. A: All/Text/Images/…). + property string clipboardFilter: "all" + readonly property bool clipboardView: window.view.kind === "clipboard" + readonly property bool hasFilter: window.clipboardView || window.atRoot + readonly property var filterOptions: window.clipboardView ? ClipboardHistory.FILTERS : RootSearch.FILTERS + readonly property string activeFilter: window.clipboardView ? window.clipboardFilter : window.atRoot ? window.rootFilter : "all" property bool dropdownOpen: false property bool toastPanelOpen: false property bool aboutOpen: false @@ -98,6 +105,7 @@ PanelWindow { readonly property string placeholder: window.view.kind === "alias" ? "Type an alias, then press ↵" : window.view.kind === "menu" ? "Search " + window.view.title + "…" : window.view.kind === "calculator-history" ? "Search calculations…" + : window.clipboardView ? "Type to filter entries…" : "Search for apps and commands…" readonly property int cardWidth: Math.min(px(750), width - Style.gapsOut * 2) @@ -107,6 +115,14 @@ PanelWindow { ListModel { id: rows } + property int clipboardRevision: 0 + readonly property var clipboardDetail: { + window.clipboardRevision + if (!window.clipboardView || !window.selectedRow) return null + var r = window.backend.callSync("clipboard.getDetail", { id: window.selectedRow.itemId }) + return r.ok ? r.data : null + } + PointerMoveGate { id: pointerGate referenceItem: card @@ -150,6 +166,7 @@ PanelWindow { window.stack = [{ kind: "root", title: "omacast", query: "" }] window.query = "" window.rootFilter = "all" + window.clipboardFilter = "all" window.rebuild(true) } @@ -158,6 +175,7 @@ PanelWindow { var saved = window.stack.slice() saved[saved.length - 1] = Object.assign({}, window.view, { query: window.query, selectedIndex: window.selectedIndex }) window.stack = saved.concat([next]) + if (next.kind === "clipboard") window.clipboardFilter = "all" window.query = next.kind === "alias" ? (next.value || "") : "" window.rebuild(true) } @@ -181,7 +199,7 @@ PanelWindow { if (window.view.kind === "alias") { Rows.update(rows, []); window.select(-1); return } window.searchPending = true var revision = window.backend.candidatesRevision - window.backend.searchAsync({ query: window.query, view: window.view, filter: window.atRoot ? window.rootFilter : "all" }, function(result) { + window.backend.searchAsync({ query: window.query, view: window.view, filter: window.activeFilter }, function(result) { if (serial !== window.searchSerial || revision !== window.backend.candidatesRevision) return window.applySearch(result, keepId, restoreIndex) window.searchPending = false @@ -223,7 +241,7 @@ PanelWindow { alias: String(c.alias || ""), checked: !!c.checked, disabled: !!c.disabled, - favorite: typeof c.favoriteOrder === "number", + favorite: typeof c.favoriteOrder === "number" || !!c.pinned, calcInput: String(c.calcInput || ""), calcResult: String(c.calcResult || ""), calcInputCaption: String(c.calcInputCaption || ""), @@ -360,17 +378,18 @@ PanelWindow { } function toggleDropdown() { - if (window.dropdownOpen || !window.atRoot) { window.dropdownOpen = false; return } + if (window.dropdownOpen || !window.hasFilter) { window.dropdownOpen = false; return } window.dropdownOpen = true dropdown.item.filter = "" - var at = RootSearch.FILTERS.map(function(f) { return f.id }).indexOf(window.rootFilter) + var at = window.filterOptions.map(function(f) { return f.id }).indexOf(window.activeFilter) dropdown.item.selectedIndex = Math.max(0, at) } function setRootFilter(id) { window.dropdownOpen = false - if (window.rootFilter === id) return - window.rootFilter = id + if (window.activeFilter === id) return + if (window.clipboardView) window.clipboardFilter = id + else window.rootFilter = id window.rebuild(true) } @@ -622,6 +641,8 @@ PanelWindow { if (window.historyIndex >= 0) { window.recallHistory(-1); return true } window.move(1); return true } + if (ctrl && shift && key === Qt.Key_P) { window.performIfOffered(["pin", "unpin"]); return true } + if (ctrl && !shift && key === Qt.Key_O) { window.performIfOffered(["open-link"]); return true } if (key === Qt.Key_PageUp) { window.move(-8); return true } if (key === Qt.Key_PageDown) { window.move(8); return true } if (ctrl && shift && key === Qt.Key_F) { window.performIfOffered(["favorite-add", "favorite-remove"]); return true } @@ -652,6 +673,12 @@ PanelWindow { function onCandidatesRevisionChanged() { if (window.shown && !window.actionPanelOpen && !window.alert) window.rebuild(false) } // Exchange rates or zone offsets arrived: re-evaluate a calculation on screen. function onCalculatorRevisionChanged() { if (window.shown && !window.actionPanelOpen && window.query) window.rebuild(false) } + // A copy, pin or delete: refresh an open Clipboard History. + function onNotification(method) { + if (method !== "clipboard.stateChanged") return + window.clipboardRevision++ + if (window.shown && window.clipboardView && !window.actionPanelOpen && !window.alert) window.rebuild(false) + } } @@ -812,7 +839,7 @@ PanelWindow { Rectangle { id: filterButton - visible: window.atRoot && !window.compact + visible: window.hasFilter && !window.compact anchors.right: parent.right anchors.rightMargin: window.px(16) anchors.verticalCenter: parent.verticalCenter @@ -831,9 +858,9 @@ PanelWindow { Text { anchors.verticalCenter: parent.verticalCenter textFormat: Text.PlainText - text: (RootSearch.FILTERS.filter(function(f) { return f.id === window.rootFilter })[0] || RootSearch.FILTERS[0]).title + text: (window.filterOptions.filter(function(f) { return f.id === window.activeFilter })[0] || window.filterOptions[0]).title color: window.look.foreground - opacity: window.rootFilter === "all" ? 0.7 : 1 + opacity: window.activeFilter === "all" ? 0.7 : 1 font.family: window.look.fontFamily font.pixelSize: Math.round(Style.font.bodySmall * window.zoom) } @@ -876,7 +903,11 @@ PanelWindow { ListView { id: list - anchors.fill: parent + anchors.left: parent.left + anchors.top: parent.top + anchors.bottom: parent.bottom + // Clipboard History is list on the left, detail on the right (ref 04 §2 master-detail). + width: window.clipboardView ? Math.round(parent.width * 0.4) : parent.width anchors.topMargin: window.px(4) anchors.bottomMargin: window.px(4) visible: window.view.kind !== "alias" @@ -897,6 +928,17 @@ PanelWindow { } } + DetailPane { + visible: window.clipboardView && rows.count > 0 && !!detail + anchors.left: list.right + anchors.right: parent.right + anchors.top: parent.top + anchors.bottom: parent.bottom + detail: window.clipboardDetail + look: window.look + zoom: window.zoom + } + Column { anchors.centerIn: parent spacing: window.px(8) @@ -915,6 +957,7 @@ PanelWindow { textFormat: Text.PlainText text: window.view.kind === "alias" ? window.view.title : window.view.kind === "calculator-history" && !window.query ? "No Calculations Yet" + : window.clipboardView && !window.query && window.clipboardFilter === "all" ? "No Clipboard Entries" : (window.backend.ready ? "No results" : "Loading…") color: window.look.foreground font.family: window.look.fontFamily @@ -924,7 +967,9 @@ PanelWindow { anchors.horizontalCenter: parent.horizontalCenter textFormat: Text.PlainText text: window.view.kind === "alias" ? "Typing the alias exactly in the root search puts this command on top. Leave it empty to remove it." - : window.view.kind === "calculator-history" && !window.query ? "Type a calculation in the root search, then copy or paste the answer." : "" + : window.view.kind === "calculator-history" && !window.query ? "Type a calculation in the root search, then copy or paste the answer." + : window.clipboardView && !window.query && window.clipboardFilter === "all" + ? (window.backend.config.clipboardHistory ? "Copy something and it shows up here." : "Recording is off: clipboardHistory is false in config.json.") : "" visible: text.length > 0 color: window.look.foreground opacity: 0.55 @@ -1074,8 +1119,8 @@ PanelWindow { anchors.topMargin: window.px(4) sourceComponent: ActionPanel { placeholder: "Filter…" - actions: RootSearch.FILTERS.map(function(f) { - return { id: f.id, title: f.title, shortcut: "", section: "filter", icon: f.id === window.rootFilter ? "✓" : " " } + actions: window.filterOptions.map(function(f) { + return { id: f.id, title: f.title, shortcut: "", section: "filter", icon: f.id === window.activeFilter ? "✓" : " " } }) look: window.look zoom: window.zoom From e61316c348f53ebfd9609d21a0b9bbe77bc6f781 Mon Sep 17 00:00:00 2001 From: superduck00 <294879536+superduck00@users.noreply.github.com> Date: Wed, 4 Nov 2026 23:15:14 -0500 Subject: [PATCH 2/4] Add Switch Theme for Omarchy themes Lists the themes Omarchy's own picker lists, with the current one checked and selected and a preview beside the list, and applies the chosen one with omarchy-theme-set. Co-Authored-By: Claude Opus 5.5 --- Backend.qml | 37 +++++++++++++++++++++++++- CLAUDE.md | 1 + README.md | 4 +++ core/RootSearch.js | 9 +++++++ core/Themes.js | 56 ++++++++++++++++++++++++++++++++++++++++ services/ThemeSource.qml | 39 ++++++++++++++++++++++++++++ tests/themes.test.js | 29 +++++++++++++++++++++ ui/LauncherWindow.qml | 18 ++++++++----- 8 files changed, 186 insertions(+), 7 deletions(-) create mode 100644 core/Themes.js create mode 100644 services/ThemeSource.qml create mode 100644 tests/themes.test.js diff --git a/Backend.qml b/Backend.qml index d6a22fa..be5a1a0 100644 --- a/Backend.qml +++ b/Backend.qml @@ -14,6 +14,7 @@ import "core/OmarchyMenu.js" as OmarchyMenu import "core/RootSearch.js" as RootSearch import "core/Calculator.js" as Calculator import "core/ClipboardHistory.js" as ClipboardHistory +import "core/Themes.js" as Themes import "services" Item { @@ -37,7 +38,7 @@ Item { property var candidates: [] property var candidatesById: ({}) property int candidatesRevision: 0 - readonly property bool loading: menuSource.loading || nativeSearch.pending !== null + readonly property bool loading: menuSource.loading || themeSource.loading || nativeSearch.pending !== null // Bumped when exchange rates or zone offsets arrive, so an open // calculator row re-evaluates. readonly property int calculatorRevision: (calculatorData.rates ? 1 : 0) + calculatorData.ratesFetchedAt + calculatorData.zonesAt @@ -320,6 +321,13 @@ Item { throw new Error("Unknown action " + action) } + // ----------------------------------------------------------------- themes + + function themeEntry(id) { + var slug = String(id).replace(/^theme:/, "") + return themeSource.themes.filter(function(t) { return t.slug === slug })[0] || null + } + // ------------------------------------------------------------- execution function summonPlugin(id, payload) { @@ -410,6 +418,7 @@ Item { return { close: true } } backend.recordVisit(id, query) + if (candidate.run.push && candidate.run.push.kind === "theme") themeSource.refresh() if (candidate.run.push) return { push: candidate.run.push } if (candidate.run.summon) backend.summonPlugin(candidate.run.summon, candidate.run.payload || "{}") return { close: true } @@ -546,6 +555,8 @@ Item { if (data && data.fleaHits) ctx.fleaHits = data.fleaHits var sections if (view && view.kind === "menu") sections = RootSearch.submenuSections(backend.candidates, view.menuId, query, ctx, backend.deps) + else if (view && view.kind === "theme") + sections = Themes.sections(themeSource.themes, themeSource.current, query) else if (view && view.kind === "clipboard") sections = ClipboardHistory.sections(backend.clipboardEntries(), query, String(data && data.filter || "all"), ctx.nowMs) else if (view && view.kind === "calculator-history") sections = RootSearch.historySections(calculatorStore.doc.history, query, ctx.nowMs, Calculator.filterHistory) else { @@ -561,6 +572,11 @@ Item { var rowId = String(data && data.id || "") if (rowId === RootSearch.CALCULATOR_ID) return RootSearch.actionsFor({ kind: "calculator" }) if (rowId.indexOf("calculator-history:") === 0) return RootSearch.actionsFor({ kind: "calculator-history" }) + if (rowId.indexOf("theme:") === 0) { + var shown = backend.themeEntry(rowId) + if (!shown) throw new Error("No such theme") + return Themes.actionsFor(shown, themeSource.current) + } if (rowId.indexOf("clipboard:") === 0) { var clip = backend.clipboardEntry(rowId) if (!clip) throw new Error("No such clipboard entry") @@ -599,6 +615,12 @@ Item { return RootSearch.aboutFor(candidate, nowMs) }, performAction: function(data) { + if (data && String(data.id).indexOf("theme:") === 0) { + var theme = backend.themeEntry(data.id) + if (!theme) throw new Error("No such theme") + themeSource.apply(theme.slug) + return { close: true } + } if (data && String(data.id).indexOf("clipboard:") === 0) return backend.clipboardAction(String(data.action || "open"), data.id, !!data.confirmed) // Destructive commands come back as a confirmation unless confirmed. @@ -667,6 +689,14 @@ Item { deleteAll: function() { return backend.clipboardAction("delete-all-entries", "", true) } }) + Rpc.register(backend.registry, "themes", { + getAll: function() { return { current: themeSource.current, themes: themeSource.themes } }, + getDetail: function(data) { + var t = backend.themeEntry(String(data && data.id || "")) + return t ? Themes.detail(t, themeSource.current) : null + } + }) + Rpc.register(backend.registry, "frecency", { visit: function(data) { backend.recordVisit(String(data.id), String(data.query || "")); return null }, get: function(data) { @@ -719,6 +749,11 @@ Item { onMessage: function(level, text) { backend.log(level, "clipboard", text, null) } } + ThemeSource { + id: themeSource + onLoaded: backend.emit("themes.changed", null) + } + AppFocuser { id: appFocuser onLaunchRequested: function(candidate) { backend.launchApp(candidate) } diff --git a/CLAUDE.md b/CLAUDE.md index 9647f7c..98abdb8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -150,6 +150,7 @@ Works: ↵ copies the answer (HUD), Ctrl+↵ pastes it (wl-copy on stdin, then Shift+Insert), Ctrl+Shift+C copies "question = answer". Each one saves to **Calculator History** (`db/calculator.json`, 4096 rows / 90 days): a built-in command and pushed view, searchable, Ctrl+D deletes an entry, Ctrl+Shift+D deletes all. RPC: `calculator.calculate|historyGetAll|historyDelete|historyDeleteAll|refreshCurrencies|getCurrencies`. A bare number, word or quantity (`42`, `firefox`, `5 kg`) never produces a row, because the section takes ↵. - **Clipboard History** (Phase 3, SPEC §5.3/§8.2, `core/ClipboardHistory.js`, `builtin:clipboard-history`, global shortcut `omacast:clipboard`): `services/ClipboardWatcher.qml` runs one `wl-paste --watch` whose constant bash script skips `CLIPBOARD_STATE != data` and `x-kde-passwordManagerHint`, prefers an `image/*` offer, keeps `text/uri-list` for `file://` copies, and prints one JSON line per copy. Images and text ≥ 30,720 bytes go to `~/.local/share/omacast/clipboard/` by MD5; short text travels in the JSON line. The backend adds the source (`ToplevelManager.activeToplevel` + `DesktopEntries.heuristicLookup`), drops disabled apps (`clipboardDisabledApplications`), dedups by hash (copy count, last copied), and prunes by `clipboardHistoryDuration` (default `P1W`, pins kept) plus a 1000-unpinned cap [D], deleting orphaned files. The first line after the watcher starts is skipped if already known, and our own writes from the history (5 s hash window) don't count as copies. UI: 40/60 list/detail (`ui/DetailPane.qml`: text, image or colour swatch, then Information rows), sections Pinned/Today/Yesterday/Earlier [I], type dropdown on Ctrl+P. Actions: Paste (↵, via `ClipboardWriter.copyFile` for files), Copy (Ctrl+↵), Open link/file (Ctrl+O), Pin (Ctrl+Shift+P), Delete (Ctrl+D), Delete All unpinned (Ctrl+Shift+D, confirmed). RPC: `clipboard.getAll|getDetail|copy|paste|pin|unpin|delete|deleteAll`. Not done: OCR, Paste Sequentially, rich-text representations (only plain text, URI lists and one image type are kept), edit/rename, Save as Snippet, moving pins, image dimensions. `omacast:clipboard` toggles: a second press closes the view. +- **Switch Theme** (`builtin:switch-theme`, `core/Themes.js`, `services/ThemeSource.qml`; Raycast's command name, Omarchy's themes [D]): the list is Omarchy's own picker rows (`omarchy-theme-switcher --print-rows`, "\t", ~40 ms), reloaded on each push; the current theme is the watched `~/.local/state/omarchy/current/theme.name`. List/detail layout with the preview image (a video preview falls back to its thumbnail), opens on the current theme, ↵ runs `omarchy-theme-set `. RPC: `themes.getAll|getDetail`. Not done: live preview while moving through the list, palette swatches. - **Latency:** show → mapped in 59–66 ms, hide in 24–42 ms on Omarchy 4.0.6 (`tests/smoke.sh`; it was 37–44 ms on the old `OverlayWindow`). - **Tests:** root search, calculator and privacy coverage (`node --test tests/*.test.js`). diff --git a/README.md b/README.md index 589eb64..4cddc4e 100644 --- a/README.md +++ b/README.md @@ -149,6 +149,10 @@ Pressing the binding again closes it. - Copies from password managers are never recorded: anything marked sensitive, plus the apps in `"clipboardDisabledApplications"` (Wayland app ids; KeePassXC, Bitwarden, 1Password and Seahorse by default). `"clipboardHistory": false` stops recording. - The history is in `~/.local/share/omacast/db/clipboard.json`, and images and long text are in `~/.local/share/omacast/clipboard/`, both readable only by you. +## Switch Theme + +**Switch Theme** lists your Omarchy themes, your own and Omarchy's, with the current one checked and a preview of the highlighted theme beside the list. `↵` applies it with `omarchy-theme-set`, and omacast changes with it. + ## CLI ```sh diff --git a/core/RootSearch.js b/core/RootSearch.js index 2b7766d..d6e2b43 100644 --- a/core/RootSearch.js +++ b/core/RootSearch.js @@ -40,6 +40,15 @@ var BUILTINS = [ run: { push: { kind: "clipboard", title: "Clipboard History" } }, rootSearch: { when: "always" } }, + { + id: "builtin:switch-theme", + title: "Switch Theme", + subtitle: "Omarchy", + glyph: "\uf1fc", // nf-fa-paint_brush + keywords: ["theme", "themes", "appearance", "colors", "colours", "dark", "light", "omarchy"], + run: { push: { kind: "theme", title: "Switch Theme" } }, + rootSearch: { when: "always" } + }, { id: "builtin:calculator-history", title: "Calculator History", diff --git a/core/Themes.js b/core/Themes.js new file mode 100644 index 0000000..4c292b4 --- /dev/null +++ b/core/Themes.js @@ -0,0 +1,56 @@ +// Switch Theme (ref 04 App. A raycast.switch-theme [O]), backed by Omarchy's +// themes instead of Raycast's. The rows come from Omarchy's own picker +// (`omarchy-theme-switcher --print-rows`: "\t" per theme, +// the preview named .), so omacast lists exactly what it lists. + +var VIDEO = /\.(mp4|m4v|mov|webm|mkv|avi)$/i + +// "tokyo-night" → "Tokyo Night", as omarchy-theme-list prints it. +function displayName(slug) { + return String(slug).replace(/(^|-)([a-z])/g, function(m, sep, c) { return sep + c.toUpperCase() }).replace(/-/g, " ") +} + +function parseRows(text) { + var out = [], seen = {} + String(text || "").split("\n").forEach(function(line) { + var cols = line.split("\t") + var preview = cols[0].trim() + if (!preview) return + var slug = preview.split("/").pop().replace(/\.[^.]+$/, "") + if (!slug || seen[slug]) return + seen[slug] = true + // A video preview can't show in an Image; the thumbnail is its first frame. + var image = VIDEO.test(preview) ? String(cols[1] || "").trim() : preview + out.push({ slug: slug, name: displayName(slug), image: image }) + }) + return out.sort(function(a, b) { return a.name.localeCompare(b.name) }) +} + +function sections(themes, current, query) { + var needle = String(query || "").trim().toLowerCase() + var rows = (themes || []).filter(function(t) { + return !needle || t.name.toLowerCase().indexOf(needle) >= 0 || t.slug.indexOf(needle) >= 0 + }).map(function(t) { + return { candidate: { + id: "theme:" + t.slug, type: "command", kind: "theme", title: t.name, subtitle: "", + typeLabel: t.slug === current ? "Current" : "", checked: t.slug === current, + glyph: "", rootSearch: { when: "always" } // nf-fa-palette + } } + }) + return rows.length ? [{ id: "themes", title: "", rows: rows }] : [] +} + +function detail(theme, current) { + return { + category: "image", text: "", color: "", image: theme.image, + info: [{ label: "Theme", value: theme.name }].concat(theme.slug === current ? [{ label: "Status", value: "Current theme" }] : []) + } +} + +function actionsFor(theme, current) { + return [{ id: "open", title: theme.slug === current ? "Reapply Theme" : "Apply Theme", shortcut: "↵", section: "primary", icon: "" }] +} + +if (typeof module !== "undefined") { + module.exports = { displayName: displayName, parseRows: parseRows, sections: sections, detail: detail, actionsFor: actionsFor } +} diff --git a/services/ThemeSource.qml b/services/ThemeSource.qml new file mode 100644 index 0000000..409d30e --- /dev/null +++ b/services/ThemeSource.qml @@ -0,0 +1,39 @@ +import QtQuick +import Quickshell +import Quickshell.Io +import "../core/Themes.js" as Themes + +// Omarchy's themes for Switch Theme: the list from Omarchy's own picker +// (refreshed whenever the view opens), the current theme, and applying one. +Item { + id: source + + property var themes: [] + property string current: "" + readonly property bool loading: rows.running + signal loaded() + + function refresh() { if (!rows.running) rows.running = true } + + // A slug from the list, never typed text. + function apply(slug) { Quickshell.execDetached(["omarchy-theme-set", slug]) } + + Process { + id: rows + command: ["omarchy-theme-switcher", "--print-rows"] + stdout: StdioCollector { + onStreamFinished: { + source.themes = Themes.parseRows(text) + source.loaded() + } + } + } + + FileView { + path: (Quickshell.env("XDG_STATE_HOME") || Quickshell.env("HOME") + "/.local/state") + "/omarchy/current/theme.name" + watchChanges: true + printErrors: false + onFileChanged: reload() + onLoaded: { source.current = text().trim(); source.loaded() } + } +} diff --git a/tests/themes.test.js b/tests/themes.test.js new file mode 100644 index 0000000..d091ea6 --- /dev/null +++ b/tests/themes.test.js @@ -0,0 +1,29 @@ +const test = require("node:test") +const assert = require("node:assert/strict") +const T = require("../core/Themes.js") + +const rows = [ + "/c/previews/tokyo-night.png\t/c/thumbs/a.jpg", + "/c/previews/catppuccin-latte.png\t/c/thumbs/b.jpg", + "/c/previews/last-horizon.mp4\t/c/thumbs/c.jpg", + "", + "/c/previews/tokyo-night.jpg\t/c/thumbs/d.jpg" +].join("\n") + +test("parses Omarchy's picker rows into sorted themes", () => { + const themes = T.parseRows(rows) + assert.deepEqual(themes.map((t) => t.name), ["Catppuccin Latte", "Last Horizon", "Tokyo Night"]) + assert.equal(themes[1].image, "/c/thumbs/c.jpg") + assert.equal(themes[2].image, "/c/previews/tokyo-night.png") + assert.equal(T.displayName("rose-pine"), "Rose Pine") +}) + +test("checks the current theme, searches, and labels the action", () => { + const themes = T.parseRows(rows) + const list = T.sections(themes, "tokyo-night", "")[0].rows.map((r) => r.candidate) + assert.deepEqual(list.filter((c) => c.checked).map((c) => c.id), ["theme:tokyo-night"]) + assert.deepEqual(T.sections(themes, "", "latte")[0].rows.map((r) => r.candidate.title), ["Catppuccin Latte"]) + assert.deepEqual(T.sections(themes, "", "zzz"), []) + assert.equal(T.actionsFor(themes[2], "tokyo-night")[0].title, "Reapply Theme") + assert.equal(T.detail(themes[0], "x").image, "/c/previews/catppuccin-latte.png") +}) diff --git a/ui/LauncherWindow.qml b/ui/LauncherWindow.qml index 918f661..4673a96 100644 --- a/ui/LauncherWindow.qml +++ b/ui/LauncherWindow.qml @@ -77,6 +77,8 @@ PanelWindow { // Clipboard History's type dropdown (ref 04 App. A: All/Text/Images/…). property string clipboardFilter: "all" readonly property bool clipboardView: window.view.kind === "clipboard" + // List on the left, DetailPane on the right. + readonly property bool splitView: window.clipboardView || window.view.kind === "theme" readonly property bool hasFilter: window.clipboardView || window.atRoot readonly property var filterOptions: window.clipboardView ? ClipboardHistory.FILTERS : RootSearch.FILTERS readonly property string activeFilter: window.clipboardView ? window.clipboardFilter : window.atRoot ? window.rootFilter : "all" @@ -106,6 +108,7 @@ PanelWindow { : window.view.kind === "menu" ? "Search " + window.view.title + "…" : window.view.kind === "calculator-history" ? "Search calculations…" : window.clipboardView ? "Type to filter entries…" + : window.view.kind === "theme" ? "Search themes…" : "Search for apps and commands…" readonly property int cardWidth: Math.min(px(750), width - Style.gapsOut * 2) @@ -118,8 +121,8 @@ PanelWindow { property int clipboardRevision: 0 readonly property var clipboardDetail: { window.clipboardRevision - if (!window.clipboardView || !window.selectedRow) return null - var r = window.backend.callSync("clipboard.getDetail", { id: window.selectedRow.itemId }) + if (!window.splitView || !window.selectedRow) return null + var r = window.backend.callSync(window.clipboardView ? "clipboard.getDetail" : "themes.getDetail", { id: window.selectedRow.itemId }) return r.ok ? r.data : null } @@ -223,6 +226,9 @@ PanelWindow { next.push(window.rowData(c, c.kind === "calculator" ? "calculator" : "item")) } } + // Switch Theme opens on the current theme. + if (keepIndex < 0 && window.view.kind === "theme") + for (var n = 0; n < next.length; n++) if (next[n].checked) { keepIndex = n; break } Rows.update(rows, next) if (restoreIndex >= 0 && restoreIndex < rows.count && window.isSelectable(restoreIndex)) window.select(restoreIndex) else window.select(keepIndex >= 0 ? keepIndex : window.nextSelectable(-1, 1)) @@ -675,9 +681,9 @@ PanelWindow { function onCalculatorRevisionChanged() { if (window.shown && !window.actionPanelOpen && window.query) window.rebuild(false) } // A copy, pin or delete: refresh an open Clipboard History. function onNotification(method) { - if (method !== "clipboard.stateChanged") return + if (method !== "clipboard.stateChanged" && method !== "themes.changed") return window.clipboardRevision++ - if (window.shown && window.clipboardView && !window.actionPanelOpen && !window.alert) window.rebuild(false) + if (window.shown && window.splitView && !window.actionPanelOpen && !window.alert) window.rebuild(false) } } @@ -907,7 +913,7 @@ PanelWindow { anchors.top: parent.top anchors.bottom: parent.bottom // Clipboard History is list on the left, detail on the right (ref 04 §2 master-detail). - width: window.clipboardView ? Math.round(parent.width * 0.4) : parent.width + width: window.splitView ? Math.round(parent.width * 0.4) : parent.width anchors.topMargin: window.px(4) anchors.bottomMargin: window.px(4) visible: window.view.kind !== "alias" @@ -929,7 +935,7 @@ PanelWindow { } DetailPane { - visible: window.clipboardView && rows.count > 0 && !!detail + visible: window.splitView && rows.count > 0 && !!detail anchors.left: list.right anchors.right: parent.right anchors.top: parent.top From a0704c3ec32b7b37796d6cf52c69797c97f7220a Mon Sep 17 00:00:00 2001 From: superduck00 <294879536+superduck00@users.noreply.github.com> Date: Wed, 4 Nov 2026 23:22:39 -0500 Subject: [PATCH 3/4] Make Switch Theme replace Omarchy's theme picker Omarchy's Theme entry gives way to Switch Theme in results, a new omacast:theme shortcut toggles it, and theme rows drop their icon. Co-Authored-By: Claude Opus 5.5 --- Backend.qml | 4 ++++ CLAUDE.md | 2 +- Launcher.qml | 8 ++++++++ README.md | 9 ++++++++- core/RootSearch.js | 2 ++ core/Themes.js | 2 +- ui/LauncherWindow.qml | 4 +++- ui/ResultRow.qml | 5 +++-- 8 files changed, 30 insertions(+), 6 deletions(-) diff --git a/Backend.qml b/Backend.qml index be5a1a0..0eb5d55 100644 --- a/Backend.qml +++ b/Backend.qml @@ -98,6 +98,10 @@ Item { for (var i = 0; i < apps.length; i++) if (apps[i] && apps[i].id) next.push(RootSearch.appCandidate(apps[i])) next = next.concat(RootSearch.menuCandidates(menuSource.tree, menuSource.guards, menuSource.providerItems, backend.deps)) for (var b = 0; b < RootSearch.BUILTINS.length; b++) next.push(RootSearch.builtinCandidate(RootSearch.BUILTINS[b])) + // A built-in that replaces an Omarchy command hides that command. + var superseded = ({}) + RootSearch.BUILTINS.forEach(function(d) { (d.supersedes || []).forEach(function(id) { superseded[id] = true }) }) + next = next.filter(function(c) { return !superseded[c.id] }) var byId = ({}) for (var j = 0; j < next.length; j++) byId[next[j].id] = next[j] var entries = FleaSearch.entries(next, Ranker.transliterate) diff --git a/CLAUDE.md b/CLAUDE.md index 98abdb8..517f491 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -150,7 +150,7 @@ Works: ↵ copies the answer (HUD), Ctrl+↵ pastes it (wl-copy on stdin, then Shift+Insert), Ctrl+Shift+C copies "question = answer". Each one saves to **Calculator History** (`db/calculator.json`, 4096 rows / 90 days): a built-in command and pushed view, searchable, Ctrl+D deletes an entry, Ctrl+Shift+D deletes all. RPC: `calculator.calculate|historyGetAll|historyDelete|historyDeleteAll|refreshCurrencies|getCurrencies`. A bare number, word or quantity (`42`, `firefox`, `5 kg`) never produces a row, because the section takes ↵. - **Clipboard History** (Phase 3, SPEC §5.3/§8.2, `core/ClipboardHistory.js`, `builtin:clipboard-history`, global shortcut `omacast:clipboard`): `services/ClipboardWatcher.qml` runs one `wl-paste --watch` whose constant bash script skips `CLIPBOARD_STATE != data` and `x-kde-passwordManagerHint`, prefers an `image/*` offer, keeps `text/uri-list` for `file://` copies, and prints one JSON line per copy. Images and text ≥ 30,720 bytes go to `~/.local/share/omacast/clipboard/` by MD5; short text travels in the JSON line. The backend adds the source (`ToplevelManager.activeToplevel` + `DesktopEntries.heuristicLookup`), drops disabled apps (`clipboardDisabledApplications`), dedups by hash (copy count, last copied), and prunes by `clipboardHistoryDuration` (default `P1W`, pins kept) plus a 1000-unpinned cap [D], deleting orphaned files. The first line after the watcher starts is skipped if already known, and our own writes from the history (5 s hash window) don't count as copies. UI: 40/60 list/detail (`ui/DetailPane.qml`: text, image or colour swatch, then Information rows), sections Pinned/Today/Yesterday/Earlier [I], type dropdown on Ctrl+P. Actions: Paste (↵, via `ClipboardWriter.copyFile` for files), Copy (Ctrl+↵), Open link/file (Ctrl+O), Pin (Ctrl+Shift+P), Delete (Ctrl+D), Delete All unpinned (Ctrl+Shift+D, confirmed). RPC: `clipboard.getAll|getDetail|copy|paste|pin|unpin|delete|deleteAll`. Not done: OCR, Paste Sequentially, rich-text representations (only plain text, URI lists and one image type are kept), edit/rename, Save as Snippet, moving pins, image dimensions. `omacast:clipboard` toggles: a second press closes the view. -- **Switch Theme** (`builtin:switch-theme`, `core/Themes.js`, `services/ThemeSource.qml`; Raycast's command name, Omarchy's themes [D]): the list is Omarchy's own picker rows (`omarchy-theme-switcher --print-rows`, "\t", ~40 ms), reloaded on each push; the current theme is the watched `~/.local/state/omarchy/current/theme.name`. List/detail layout with the preview image (a video preview falls back to its thumbnail), opens on the current theme, ↵ runs `omarchy-theme-set `. RPC: `themes.getAll|getDetail`. Not done: live preview while moving through the list, palette swatches. +- **Switch Theme** (`builtin:switch-theme`, `core/Themes.js`, `services/ThemeSource.qml`; Raycast's command name, Omarchy's themes [D]): the list is Omarchy's own picker rows (`omarchy-theme-switcher --print-rows`, "\t", ~40 ms), reloaded on each push; the current theme is the watched `~/.local/state/omarchy/current/theme.name`. List/detail layout with the preview image (a video preview falls back to its thumbnail), opens on the current theme, ↵ runs `omarchy-theme-set `. Rows have no icon (`iconless` in ResultRow). BUILTINS `supersedes: ["omarchy:appearance.theme"]` drops Omarchy's theme picker from the candidates. Global shortcut `omacast:theme` toggles it (README shows binding it to Super+Ctrl+T in place of Omarchy's theme menu). RPC: `themes.getAll|getDetail`. Not done: live preview while moving through the list, palette swatches. - **Latency:** show → mapped in 59–66 ms, hide in 24–42 ms on Omarchy 4.0.6 (`tests/smoke.sh`; it was 37–44 ms on the old `OverlayWindow`). - **Tests:** root search, calculator and privacy coverage (`node --test tests/*.test.js`). diff --git a/Launcher.qml b/Launcher.qml index 039e02b..807853a 100644 --- a/Launcher.qml +++ b/Launcher.qml @@ -99,6 +99,14 @@ Item { onPressed: root.toggle() } + // Toggles Switch Theme directly: hl.dsp.global("omacast:theme"). + GlobalShortcut { + appid: "omacast" + name: "theme" + description: "Toggle omacast Switch Theme" + onPressed: root.toggleView("builtin:switch-theme", "theme") + } + // Toggles Clipboard History directly: hl.dsp.global("omacast:clipboard"). GlobalShortcut { appid: "omacast" diff --git a/README.md b/README.md index 4cddc4e..ba7af72 100644 --- a/README.md +++ b/README.md @@ -151,7 +151,14 @@ Pressing the binding again closes it. ## Switch Theme -**Switch Theme** lists your Omarchy themes, your own and Omarchy's, with the current one checked and a preview of the highlighted theme beside the list. `↵` applies it with `omarchy-theme-set`, and omacast changes with it. +**Switch Theme** lists your Omarchy themes, your own and Omarchy's, with the current one checked and a preview of the highlighted theme beside the list. `↵` applies it with `omarchy-theme-set`, and omacast changes with it. It replaces Omarchy's own theme picker in omacast's results. To open it from Omarchy's theme keys as well: + +```lua +hl.unbind("SUPER + CTRL + T") +o.bind("SUPER + CTRL + T", "omacast switch theme", hl.dsp.global("omacast:theme")) +``` + +Pressing the key again closes it. ## CLI diff --git a/core/RootSearch.js b/core/RootSearch.js index d6e2b43..29fdad1 100644 --- a/core/RootSearch.js +++ b/core/RootSearch.js @@ -47,6 +47,8 @@ var BUILTINS = [ glyph: "\uf1fc", // nf-fa-paint_brush keywords: ["theme", "themes", "appearance", "colors", "colours", "dark", "light", "omarchy"], run: { push: { kind: "theme", title: "Switch Theme" } }, + // Replaces Omarchy's theme picker in omacast's results. [D] + supersedes: ["omarchy:appearance.theme"], rootSearch: { when: "always" } }, { diff --git a/core/Themes.js b/core/Themes.js index 4c292b4..e5c5271 100644 --- a/core/Themes.js +++ b/core/Themes.js @@ -34,7 +34,7 @@ function sections(themes, current, query) { return { candidate: { id: "theme:" + t.slug, type: "command", kind: "theme", title: t.name, subtitle: "", typeLabel: t.slug === current ? "Current" : "", checked: t.slug === current, - glyph: "", rootSearch: { when: "always" } // nf-fa-palette + glyph: "", rootSearch: { when: "always" } } } }) return rows.length ? [{ id: "themes", title: "", rows: rows }] : [] diff --git a/ui/LauncherWindow.qml b/ui/LauncherWindow.qml index 4673a96..7a1277b 100644 --- a/ui/LauncherWindow.qml +++ b/ui/LauncherWindow.qml @@ -252,7 +252,9 @@ PanelWindow { calcResult: String(c.calcResult || ""), calcInputCaption: String(c.calcInputCaption || ""), calcResultCaption: String(c.calcResultCaption || ""), - argsJson: JSON.stringify(c.arguments || []) + argsJson: JSON.stringify(c.arguments || []), + // Switch Theme's rows are names only, flush left. + iconless: c.kind === "theme" } } diff --git a/ui/ResultRow.qml b/ui/ResultRow.qml index ab5a1ec..8fa691a 100644 --- a/ui/ResultRow.qml +++ b/ui/ResultRow.qml @@ -16,6 +16,7 @@ Item { required property string typeLabel required property string alias required property bool checked + required property bool iconless required property bool disabled required property bool favorite required property string calcInput @@ -150,7 +151,7 @@ Item { Item { id: iconBox - width: row.px(22) + width: row.iconless ? 0 : row.px(22) height: row.px(22) anchors.left: parent.left anchors.leftMargin: row.px(12) @@ -239,7 +240,7 @@ Item { Row { anchors.left: iconBox.right - anchors.leftMargin: row.px(10) + anchors.leftMargin: row.iconless ? 0 : row.px(10) anchors.right: accessories.left anchors.rightMargin: row.px(10) anchors.verticalCenter: parent.verticalCenter From 8416c17782f242f47be1c12eb74c4fa4dcbe736f Mon Sep 17 00:00:00 2001 From: superduck00 <294879536+superduck00@users.noreply.github.com> Date: Sat, 7 Nov 2026 13:26:34 -0500 Subject: [PATCH 4/4] Keep the detail preview up while the next image loads The detail pane's preview Image is asynchronous, so changing its source blanked the pane until the next image decoded, and moving through Switch Theme or Clipboard History flashed an empty pane on every step. Two Images now take turns: the new source decodes in the hidden one, and it comes to the front once ready. Co-Authored-By: Claude Opus 5.5 --- ui/DetailPane.qml | 36 ++++++++++++++++++++++++++---------- 1 file changed, 26 insertions(+), 10 deletions(-) diff --git a/ui/DetailPane.qml b/ui/DetailPane.qml index 0e7c051..4fd8714 100644 --- a/ui/DetailPane.qml +++ b/ui/DetailPane.qml @@ -42,16 +42,32 @@ Item { font.pixelSize: Math.round(Style.font.bodySmall * pane.zoom) } - Image { - anchors.fill: parent - visible: !!pane.detail && !!pane.detail.image - source: pane.detail && pane.detail.image ? Util.fileUrl(pane.detail.image) : "" - fillMode: Image.PreserveAspectFit - asynchronous: true - cache: false - // Decode at the pane's size: a 4K screenshot needn't cost 33 MB. - sourceSize.width: Math.max(1, Math.round(width * 2)) - sourceSize.height: Math.max(1, Math.round(height * 2)) + // Two images: the new one decodes hidden and the old one stays up until it's + // ready, so moving through a list doesn't flash an empty pane. + readonly property url wanted: pane.detail && pane.detail.image ? Util.fileUrl(pane.detail.image) : "" + property int front: 0 + onWantedChanged: { + var back = images.itemAt(1 - preview.front) + if (!back) return + back.source = preview.wanted + if (back.status === Image.Ready || !preview.wanted) preview.front = 1 - preview.front + } + + Repeater { + id: images + model: 2 + Image { + anchors.fill: parent + visible: index === preview.front && !!pane.detail && !!pane.detail.image + fillMode: Image.PreserveAspectFit + asynchronous: true + cache: false + // Decode at the pane's size: a 4K screenshot needn't cost 33 MB. + sourceSize.width: Math.max(1, Math.round(width * 2)) + sourceSize.height: Math.max(1, Math.round(height * 2)) + Component.onCompleted: if (index === 0) source = preview.wanted + onStatusChanged: if (index !== preview.front && source === preview.wanted && status !== Image.Loading) preview.front = index + } } Column {