diff --git a/settings/quickshell.nix b/settings/quickshell.nix index 5a3077e..69611d5 100644 --- a/settings/quickshell.nix +++ b/settings/quickshell.nix @@ -226,6 +226,24 @@ in esac ''; + # Launcher instant answers. Wikipedia's full-text search and the intro + # extract in ONE request: generator=search picks the article, so a + # natural-language query ("mount everest height") works where the + # title-only endpoints return nothing. --data-urlencode keeps the query + # out of the URL literal. + # + # Prints nothing on any failure, which the QML treats as "no answer" — + # including the empty 4xx/5xx that Wikipedia returns when a burst of + # requests gets throttled. + answerFetchScript = pkgs.writeShellScript "answer-fetch" '' + q="$1" + [ -z "$q" ] && exit 0 + ${pkgs.curl}/bin/curl -sf --max-time 6 -G \ + --data-urlencode "gsrsearch=$q" \ + -H 'User-Agent: quickshell-launcher/1.0 (personal desktop use)' \ + 'https://en.wikipedia.org/w/api.php?action=query&format=json&generator=search&gsrlimit=1&prop=extracts&exintro&explaintext&redirects=1' + ''; + # cava in raw mode: one newline-terminated frame per tick, bars as # semicolon-separated 0-100 ints — trivially parsed by SplitParser. # Drives the media card's spectrum ring. Only spawned while the @@ -733,6 +751,7 @@ in readonly property string cavaConfig: "${cavaConfig}" readonly property string wlCopy: "${pkgs.wl-clipboard}/bin/wl-copy" readonly property string fd: "${pkgs.fd}/bin/fd" + readonly property string answerFetch: "${answerFetchScript}" readonly property string xdgOpen: "${pkgs.xdg-utils}/bin/xdg-open" } ''; @@ -1648,6 +1667,189 @@ in rebuild(false); } + // ── Instant answers: Wikipedia's article intro for queries + // that read like a question rather than an app name. + // + // This sends the query to Wikipedia, so it deliberately does + // NOT fire per keystroke — long debounce, a shape test that + // skips app-sized queries, and a cache of what's already been + // asked. Bursts get throttled (empty 202/4xx) anyway, so + // restraint is correctness here, not just politeness. + property string answerFact: "" + property string answerLead: "" + property string answerTitle: "" + property string answerUrl: "" + property string answerQuery: "" + property bool answerDropNext: false + property var answerCache: ({}) + + readonly property var answerStop: [ + "how", "what", "when", "where", "who", "whom", "why", "which", + "is", "are", "was", "were", "be", "the", "a", "an", "of", "in", + "on", "at", "to", "for", "and", "or", "does", "do", "did", + "much", "many", "its", "it" + ] + // Asking for one of these means the answer is a number, which + // is what makes a digit in the sentence worth points. + readonly property var answerAttrs: [ + "height", "tall", "high", "population", "long", "length", + "deep", "depth", "old", "age", "born", "died", "weight", + "heavy", "distance", "far", "area", "size", "wide", "width", + "speed", "fast", "temperature", "cost", "price", "year", + "when", "many", "much", "capital", "big" + ] + + // "firefox" and "vs code" are app searches; "mount everest + // height" is a question. Two words plus some length sorts + // them, and a "?" anywhere forces a lookup either way. + function wantsAnswer(raw) { + const q = raw.trim(); + if (q === "" || calcResult !== "") return false; + if (q.indexOf("?") !== -1) return true; + return q.split(/\s+/).length >= 2 && q.length >= 8; + } + + // The "?" is a trigger, not a search term. + function answerKey(s) { + return s.replace(/\?/g, " ").replace(/\s+/g, " ").trim(); + } + + Timer { + id: answerTimer + interval: 600 + onTriggered: launcherPanel.runAnswer() + } + + Process { + id: answerProc + stdout: StdioCollector { + onStreamFinished: launcherPanel.takeAnswer(text) + } + } + + function killAnswer() { + if (!answerProc.running) return; + answerDropNext = true; + answerProc.running = false; + } + + function clearAnswer() { + answerTimer.stop(); + killAnswer(); + answerFact = ""; + answerLead = ""; + answerTitle = ""; + answerUrl = ""; + } + + function runAnswer() { + const q = answerKey(searchInput.text); + if (q === "" || !wantsAnswer(searchInput.text)) { + clearAnswer(); + return; + } + answerQuery = q; + const hit = answerCache[q]; + if (hit !== undefined) { + showAnswer(hit); + return; + } + killAnswer(); + answerProc.command = [Commands.answerFetch, q]; + answerProc.running = true; + } + + function showAnswer(res) { + answerTitle = res.title; + answerFact = res.fact; + answerLead = res.lead; + answerUrl = res.url; + } + + // Wikipedia hands back a whole intro; the sentence that + // actually answers the question is often buried in it + // ("The tower is 330 metres tall" is nine sentences down). + // So score every sentence and lead with the best one: + // +3 per query term present + // +4 for containing a digit when a number was asked for + // ties keep the earliest, which favours the definitional + // sentence over a later aside that restates the subject. + // Title words are dropped from the terms — otherwise a + // sentence that merely repeats "Eiffel Tower" outscores the + // one with the height — EXCEPT when the title word is the + // attribute being asked about ("Lunar distance" for "distance + // to the moon"), where it's the whole question. + function pickFact(extract, query, title) { + // Alias and pronunciation parentheticals are long and + // bury the fact. Short ones like "(29,031 ft)" ARE the + // fact, so only the long ones go. + const body = extract.replace(/\s*\([^()]{25,}\)/g, "").replace(/\s+/g, " ").trim(); + if (body === "") return { fact: "", lead: "" }; + // Sentence split by lookahead only — V4 has no lookbehind. + // Marking the break keeps the full stop on the sentence + // rather than consuming it as the delimiter. + const sep = String.fromCharCode(1); + const sents = body.replace(/([.!?])\s+(?=[A-Z"'])/g, "$1" + sep) + .split(sep) + .filter(s => s.length > 20); + if (sents.length === 0) return { fact: body.slice(0, 260), lead: "" }; + + const ql = query.toLowerCase(); + const tw = title.toLowerCase().split(/[^a-z0-9]+/); + const terms = ql.split(/[^a-z0-9]+/).filter(w => + w.length > 2 && answerStop.indexOf(w) === -1 + && (answerAttrs.indexOf(w) !== -1 || tw.indexOf(w) === -1)); + const wantsNumber = answerAttrs.some(a => ql.indexOf(a) !== -1); + + let best = 0; + let bestScore = -1; + for (let i = 0; i < sents.length; i++) { + const s = sents[i].toLowerCase(); + let sc = 0; + for (let t = 0; t < terms.length; t++) + if (s.indexOf(terms[t]) !== -1) sc += 3; + if (wantsNumber && /\d/.test(sents[i])) sc += 4; + if (sc > bestScore) { bestScore = sc; best = i; } + } + return { + fact: sents[best].slice(0, 260), + // Context for a fact pulled from mid-article. Nothing + // to add when the fact IS the opening line. + lead: best === 0 ? "" : sents[0].slice(0, 170) + }; + } + + function takeAnswer(raw) { + if (answerDropNext) { answerDropNext = false; return; } + let title = ""; + let extract = ""; + try { + const pages = JSON.parse(raw).query.pages; + for (const k in pages) { // single page under an unpredictable key + title = pages[k].title || ""; + extract = pages[k].extract || ""; + break; + } + } catch (e) { + title = ""; // no match, or a throttled empty reply + extract = ""; + } + const picked = title === "" ? { fact: "", lead: "" } + : pickFact(extract, answerQuery, title); + const res = { + title: title, + fact: picked.fact, + lead: picked.lead, + url: title === "" ? "" + : "https://en.wikipedia.org/wiki/" + encodeURIComponent(title.replace(/ /g, "_")) + }; + if (Object.keys(answerCache).length > 40) answerCache = ({}); + answerCache[answerQuery] = res; + // Typing may have moved on while curl was out. + if (answerQuery !== answerKey(searchInput.text)) return; + showAnswer(res); + } + // ── Web search: last row, always. A leading bang retargets // it; anything else goes to the default engine. readonly property string webEngine: "https://duckduckgo.com/?q=" @@ -1762,10 +1964,11 @@ in let list = activeCat === "" ? [] : apps.filter(a => inCat(a, activeCat)); list.sort((a, b) => a.name.localeCompare(b.name)); appHits = list.map(a => appItem(a)); - // Nothing typed: no files to look for. + // Nothing typed: no files to look for, nothing to ask. fdTimer.stop(); killFd(); fileHits = []; + clearAnswer(); } else { let scored = []; for (let i = 0; i < apps.length; i++) { @@ -1778,6 +1981,12 @@ in scored.sort((a, b) => b.s - a.s || a.app.name.localeCompare(b.app.name)); appHits = scored.slice(0, 8).map(x => appItem(x.app)); fdTimer.restart(); + // A previous answer stays put while the next one is in + // flight — it's captioned with its own article title, + // so a lagging card reads as stale, not as wrong. + // Clearing per keystroke just made it blink. + if (wantsAnswer(searchInput.text)) answerTimer.restart(); + else clearAnswer(); } rebuild(true); } @@ -1997,6 +2206,80 @@ in } } + // Instant answer. A fact isn't something you launch, + // so it's a card like the calculator's rather than a + // row in the results list — and it needs the height + // to wrap. Click opens the article. + Rectangle { + width: parent.width + height: answerCol.height + 28 + radius: Theme.radiusCard + color: Theme.cardBg + visible: launcherPanel.answerFact !== "" + + Column { + id: answerCol + anchors.top: parent.top + anchors.topMargin: 14 + anchors.left: parent.left + anchors.leftMargin: 14 + anchors.right: parent.right + anchors.rightMargin: 14 + spacing: 4 + + Row { + spacing: 8 + SIcon { + anchors.verticalCenter: parent.verticalCenter + text: "sparkles" + font.pixelSize: 14 + color: Theme.base0D + } + SText { + anchors.verticalCenter: parent.verticalCenter + text: launcherPanel.answerTitle + font.pixelSize: 13 + font.weight: Font.Medium + } + } + + // The answer itself, then the article's + // opening line as context — omitted when the + // answer already came from it. + SText { + width: parent.width + text: launcherPanel.answerFact + font.pixelSize: 13 + wrapMode: Text.WordWrap + } + + SText { + width: parent.width + visible: launcherPanel.answerLead !== "" + text: launcherPanel.answerLead + font.pixelSize: 11 + color: Theme.base04 + wrapMode: Text.WordWrap + } + + SText { + text: "Wikipedia" + font.pixelSize: 10 + color: Theme.base03 + } + } + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: { + if (launcherPanel.answerUrl === "") return; + Quickshell.execDetached([Commands.xdgOpen, launcherPanel.answerUrl]); + launcherPanel.open = false; + } + } + } + // Pinned tiles, three across. Drag a tile onto // another slot to reorder — the id list is rewritten // on release, not mid-drag, so nothing jitters under