pragma Singleton import QtQuick import Quickshell import Quickshell.Io import "UnitConverter.js" as UnitConverter QtObject { id: root property bool visible: false property string query: "" property string debouncedQuery: "" property int selectedIndex: 0 onQueryChanged: debounceTimer.restart() property Timer debounceTimer: Timer { interval: 80 repeat: false onTriggered: root.debouncedQuery = root.query } readonly property var allEntries: { const out = []; const apps = DesktopEntries.applications; const n = apps.values ? apps.values.length : apps.count; for (let i = 0; i < n; i++) { const e = apps.values ? apps.values[i] : apps.get(i); if (!e || e.noDisplay) continue; out.push(e); } out.sort((a, b) => (a.name || "").localeCompare(b.name || "")); return out; } readonly property var filtered: { const raw = (debouncedQuery || "").trim(); if (!raw) return allEntries; // Command-prefix dispatch. Each command returns synthetic entries // shaped like DesktopEntry (name, icon, comment) plus an internal // `_copy` field used by launch(). if (raw.startsWith("=")) return calcResults(raw.substring(1).trim()); if (raw.startsWith(".")) return unicodeResults(raw.substring(1).trim()); if (raw.startsWith("!")) return gifResults(raw.substring(1).trim()); const q = raw.toLowerCase(); const scored = []; for (let i = 0; i < allEntries.length; i++) { const e = allEntries[i]; const s = fuzzyScore(q, (e.name || "").toLowerCase(), (e.genericName || "").toLowerCase(), (e.comment || "").toLowerCase()); if (s > 0) scored.push({ entry: e, score: s }); } scored.sort((a, b) => b.score - a.score); return scored.map(x => x.entry); } // Unicode lookup: lazy-dumped once via Python's unicodedata, then filtered // in-memory by name substring. property var unicodeData: null // [{ ch, name }, ...] property bool unicodeLoading: false function ensureUnicodeLoaded() { if (unicodeData !== null || unicodeLoading) return; unicodeLoading = true; unicodeProc.running = false; unicodeProc.running = true; } function unicodeResults(q) { ensureUnicodeLoaded(); if (!unicodeData || unicodeData.length === 0 || q.length < 2) { const hint = unicodeLoading ? "Loading unicode database…" : (!q ? "Type to search unicode (min 2 chars)" : q.length < 2 ? "Type at least 2 characters…" : ""); return hint ? [{ name: hint, genericName: "", comment: "", icon: "accessories-character-map" }] : []; } const ql = q.toUpperCase(); const out = []; const limit = 50; const t0 = Date.now(); for (let i = 0; i < unicodeData.length && out.length < limit; i++) { const e = unicodeData[i]; if (e.name.indexOf(ql) >= 0) { const cp = e.ch.codePointAt(0).toString(16).toUpperCase(); out.push({ name: e.name, genericName: "U+" + cp.padStart(4, "0"), comment: "Press Enter to copy", icon: "", _noIcon: true, _glyph: e.ch, _copy: e.ch }); } } console.log("unicode filter '" + q + "' →", out.length, "results in", (Date.now() - t0), "ms (scanned", unicodeData.length, "entries)"); return out; } property Process unicodeProc: Process { command: ["python3", "-c", "import unicodedata,sys\n" + "for cp in range(0x110000):\n" + " try: n = unicodedata.name(chr(cp))\n" + " except ValueError: continue\n" + " sys.stdout.write(f'{cp:X}\\t{n}\\n')\n"] running: false stdout: StdioCollector { onStreamFinished: { const t0 = Date.now(); const lines = this.text.split("\n"); const out = []; for (let i = 0; i < lines.length; i++) { const tab = lines[i].indexOf("\t"); if (tab <= 0) continue; const cp = parseInt(lines[i].substring(0, tab), 16); if (!cp) continue; out.push({ ch: String.fromCodePoint(cp), name: lines[i].substring(tab + 1) }); } root.unicodeData = out; root.unicodeLoading = false; console.log("unicode load: parsed", out.length, "entries in", (Date.now() - t0), "ms"); } } } // In-memory currency rate cache: { "_": { TGT: rate, ... } } // Populated lazily when a currency query is typed; the same day's table is // reused for any further conversions from that base. property var currencyRates: ({}) property string currencyFetching: "" // "_" currently in flight, "" if idle function todayKey() { return new Date().toISOString().substring(0, 10); } function fetchCurrency(base) { const key = todayKey() + "_" + base; if (currencyRates[key] || currencyFetching === key) return; currencyFetching = key; currencyProc.command = ["curl", "-sSL", "--max-time", "5", "https://api.frankfurter.dev/v1/latest?base=" + base]; currencyProc.running = true; } property Process currencyProc: Process { command: [] running: false stdout: StdioCollector { onStreamFinished: { root.currencyFetching = ""; try { const data = JSON.parse(this.text); if (!data || !data.base || !data.rates) return; // Cache under today's date (the request-side key), not // data.date — Frankfurter reports the last *business* day, // which won't match today on weekends/holidays and would // cause a perpetual cache miss + refetch loop. const key = root.todayKey() + "_" + data.base; const updated = Object.assign({}, root.currencyRates); const rates = Object.assign({}, data.rates); rates[data.base] = 1; updated[key] = rates; root.currencyRates = updated; } catch (e) { console.log("currency: parse failed:", e); } } } } function formatConv(v) { return Number.isInteger(v) ? String(v) : String(+v.toPrecision(10)); } function formatMoney(v) { return (Math.round(v * 100) / 100).toFixed(2); } function convEntry(conv, expr) { const text = formatConv(conv.value); return { name: text + " " + conv.to, genericName: "= " + expr + (expr.match(/\s(to|in|->)(\s|$)/i) ? "" : " (" + conv.from + " → " + conv.to + ")"), comment: "Press Enter to copy", icon: "accessories-calculator", _copy: text }; } function currencyResults(parsed, expr) { const base = parsed.from.toUpperCase(); const key = todayKey() + "_" + base; const rates = currencyRates[key]; if (!rates) { fetchCurrency(base); return [{ name: "Fetching rates…", genericName: "= " + expr, comment: base + " — Frankfurter", icon: "accessories-calculator" }]; } const convs = UnitConverter.currencyConvert(parsed.amount, base, parsed.partial, rates); return convs.map(c => { const text = formatMoney(c.value); return { name: text + " " + c.to, genericName: "= " + expr, comment: "Press Enter to copy", icon: "accessories-calculator", _copy: text }; }); } function calcResults(expr) { if (!expr) return []; const conv = UnitConverter.convert(expr); if (conv) return [convEntry(conv, expr)]; const parsed = UnitConverter.parseQuery(expr); if (parsed && UnitConverter.isCurrency(parsed.from)) return currencyResults(parsed, expr); const suggestions = UnitConverter.suggest(expr); if (suggestions) return suggestions.map(c => convEntry(c, expr)); // Whitelist: digits, operators, parens, dot, comma, whitespace, // and identifiers (for Math.* / pi / e). Reject anything else. if (!/^[0-9a-zA-Z_+\-*/%().,\s^!]+$/.test(expr)) return []; let src = expr.replace(/\^/g, "**"); try { const fn = new Function("Math", "pi", "e", "\"use strict\"; return (" + src + ");"); const v = fn(Math, Math.PI, Math.E); if (typeof v !== "number" || !isFinite(v)) return []; const text = Number.isInteger(v) ? String(v) : String(+v.toFixed(10)); return [{ name: text, genericName: "= " + expr, comment: "Press Enter to copy", icon: "accessories-calculator", _copy: text }]; } catch (_) { return []; } } // GIF search (Giphy v1). Key comes from Config (config.json -> giphyApiKey). // Results are cached per-query for the session. Each result exposes // `_isGif`, `_previewUrl` (small animated preview), `_ratio` (h/w for // masonry packing), and `_copy` (the direct .gif URL — what FB Messenger // / Discord / etc. need to inline the animation). readonly property string gifApiKey: Config.giphyApiKey readonly property bool gifApiKeyLoaded: Config.loaded property var gifCache: ({}) property string gifFetching: "" readonly property bool gifMode: (debouncedQuery || "").trim().startsWith("!") function fetchGifs(q) { if (!gifApiKey || !q || gifCache[q] || gifFetching === q) return; gifFetching = q; gifProc.command = ["curl", "-sSL", "--max-time", "8", "https://api.giphy.com/v1/gifs/search?api_key=" + gifApiKey + "&q=" + encodeURIComponent(q) + "&limit=24&rating=pg-13&bundle=messaging_non_clips"]; gifProc.running = true; } property Process gifProc: Process { command: [] running: false stdout: StdioCollector { onStreamFinished: { const q = root.gifFetching; root.gifFetching = ""; if (!q) return; try { const data = JSON.parse(this.text); if (!data || !data.data) return; const out = []; for (let i = 0; i < data.data.length; i++) { const r = data.data[i]; const imgs = r.images || {}; // Preview: fixed_width (200px wide, animated) is the // standard small-grid asset. fixed_width_small (100px) // is even lighter but blurs at column widths > 160px. const prev = imgs.fixed_width || imgs.fixed_width_small || imgs.preview_gif || imgs.original; const full = imgs.original; if (!prev || !prev.url || !full || !full.url) continue; const w = parseFloat(full.width) || 1; const h = parseFloat(full.height) || 1; out.push({ name: r.title || "GIF", _isGif: true, _previewUrl: prev.url, _ratio: w > 0 ? h / w : 1, _copy: full.url }); } const updated = Object.assign({}, root.gifCache); updated[q] = out; root.gifCache = updated; } catch (e) { console.log("gif: parse failed:", e); } } } } function gifResults(q) { if (!gifApiKeyLoaded) { return [{ name: "Loading config…", genericName: "", comment: "~/.config/omni-launcher/config.json", icon: "image-x-generic" }]; } if (!gifApiKey) { return [{ name: "Missing Giphy API key", genericName: "~/.config/omni-launcher/config.json", comment: "Set \"giphyApiKey\" in config.json.", icon: "dialog-warning" }]; } if (!q) { return [{ name: "Type to search Giphy…", genericName: "", comment: "", icon: "image-x-generic" }]; } const cached = gifCache[q]; if (!cached) { fetchGifs(q); return [{ name: "Searching Giphy…", genericName: "", comment: q, icon: "image-x-generic" }]; } return cached; } // Masonry packing: 3 columns, each item appended to the leftmost // shortest column. Tracks cumulative h/w ratio per column — column // widths are equal so ratio sum is proportional to rendered height. // Adds `_globalIndex` to each entry so the delegate can compare against // `selectedIndex` regardless of column placement. readonly property var gifColumns: { const empty = [[], [], []]; if (!gifMode) return empty; const items = filtered; if (!items || items.length === 0 || !items[0]._isGif) return empty; const cols = [[], [], []]; const heights = [0, 0, 0]; for (let i = 0; i < items.length; i++) { const it = items[i]; if (!it._isGif) continue; let pick = 0; for (let j = 1; j < 3; j++) if (heights[j] < heights[pick]) pick = j; cols[pick].push(Object.assign({}, it, { _globalIndex: i })); heights[pick] += it._ratio || 1; } return cols; } function fuzzyScore(q, name, generic, comment) { const nameIdx = name.indexOf(q); if (nameIdx === 0) return 1000 - name.length; if (nameIdx > 0) return 500 - name.length + (name[nameIdx - 1] === ' ' ? 200 : 0); if (generic.indexOf(q) >= 0) return 200; if (comment.indexOf(q) >= 0) return 100; // Subsequence match in name with word-start bonuses. let qi = 0, score = 0, prev = ' '; for (let i = 0; i < name.length && qi < q.length; i++) { if (name[i] === q[qi]) { score += 1; if (prev === ' ' || prev === '-' || prev === '_' || prev === '.') score += 5; qi++; } prev = name[i]; } return qi === q.length ? score : 0; } function open() { query = ""; debouncedQuery = ""; debounceTimer.stop(); selectedIndex = 0; visible = true; } function close() { visible = false; query = ""; debouncedQuery = ""; debounceTimer.stop(); selectedIndex = 0; } function toggle() { if (visible) close(); else open(); } function launchSelected() { const list = filtered; if (selectedIndex < 0 || selectedIndex >= list.length) return; launch(list[selectedIndex]); } function launch(entry) { if (!entry) return; close(); if (entry._isGif && entry._copy) { // Save the .gif to a temp file and put a text/uri-list entry on // the clipboard. Web apps (Discord, Messenger) translate file-URI // pastes into the file-upload flow and preserve animation, which // they do NOT do for image/gif blob pastes. copyProc.running = false; copyProc.command = ["bash", "-c", "f=$(mktemp --suffix=.gif) && " + "curl -sSL --max-time 15 \"$1\" -o \"$f\" && " + "printf 'file://%s\\r\\n' \"$f\" | wl-copy --type text/uri-list", "_", String(entry._copy)]; copyProc.running = true; return; } if (entry._copy !== undefined) { copyProc.running = false; copyProc.command = ["wl-copy", "--", String(entry._copy)]; copyProc.running = true; return; } if (entry.execute) entry.execute(); } property Process copyProc: Process { command: [] running: false } }