diff --git a/Backend.qml b/Backend.qml index 8895670..0eb5d55 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,8 @@ 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 "core/Themes.js" as Themes import "services" Item { @@ -35,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 @@ -95,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) @@ -202,6 +209,129 @@ 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) + } + + // ----------------------------------------------------------------- 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) { @@ -292,6 +422,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 } @@ -428,6 +559,9 @@ 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 { ctx.filter = String(data && data.filter || "all") @@ -442,6 +576,17 @@ 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") + 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 +606,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 +619,14 @@ 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. if (!(data && data.confirmed)) { var confirm = RootSearch.confirmationFor(String(data && data.id || ""), String(data && data.action || "open")) @@ -520,6 +677,30 @@ 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, "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) { @@ -548,11 +729,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 +746,18 @@ 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) } + } + + ThemeSource { + id: themeSource + onLoaded: backend.emit("themes.changed", null) + } + AppFocuser { id: appFocuser onLaunchRequested: function(candidate) { backend.launchApp(candidate) } @@ -574,7 +768,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 +806,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..517f491 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -149,6 +149,8 @@ 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. +- **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 f14679f..807853a 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,22 @@ 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" + 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..ba7af72 100644 --- a/README.md +++ b/README.md @@ -132,6 +132,34 @@ 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. + +## 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. 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 ```sh @@ -147,7 +175,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..29fdad1 100644 --- a/core/RootSearch.js +++ b/core/RootSearch.js @@ -31,6 +31,26 @@ 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: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" } }, + // Replaces Omarchy's theme picker in omacast's results. [D] + supersedes: ["omarchy:appearance.theme"], + 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/core/Themes.js b/core/Themes.js new file mode 100644 index 0000000..e5c5271 --- /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" } + } } + }) + 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/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/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/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/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/DetailPane.qml b/ui/DetailPane.qml new file mode 100644 index 0000000..4fd8714 --- /dev/null +++ b/ui/DetailPane.qml @@ -0,0 +1,156 @@ +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) + } + + // 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 { + 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..7a1277b 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,14 @@ 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" + // 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" property bool dropdownOpen: false property bool toastPanelOpen: false property bool aboutOpen: false @@ -98,6 +107,8 @@ 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…" + : window.view.kind === "theme" ? "Search themes…" : "Search for apps and commands…" readonly property int cardWidth: Math.min(px(750), width - Style.gapsOut * 2) @@ -107,6 +118,14 @@ PanelWindow { ListModel { id: rows } + property int clipboardRevision: 0 + readonly property var clipboardDetail: { + window.clipboardRevision + 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 + } + PointerMoveGate { id: pointerGate referenceItem: card @@ -150,6 +169,7 @@ PanelWindow { window.stack = [{ kind: "root", title: "omacast", query: "" }] window.query = "" window.rootFilter = "all" + window.clipboardFilter = "all" window.rebuild(true) } @@ -158,6 +178,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 +202,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 @@ -205,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)) @@ -223,12 +247,14 @@ 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 || ""), 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" } } @@ -360,17 +386,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 +649,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 +681,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" && method !== "themes.changed") return + window.clipboardRevision++ + if (window.shown && window.splitView && !window.actionPanelOpen && !window.alert) window.rebuild(false) + } } @@ -812,7 +847,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 +866,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 +911,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.splitView ? 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 +936,17 @@ PanelWindow { } } + DetailPane { + visible: window.splitView && 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 +965,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 +975,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 +1127,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 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