From 95637d91c81790cb79022cac46040b8dfcaa61cf Mon Sep 17 00:00:00 2001 From: rope Date: Sat, 1 Aug 2026 16:06:00 +0100 Subject: [PATCH] quickshell: instant answers in the launcher MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wikipedia search+extract in one request gives the article for a natural-language query, then the intro's sentences are ranked so the card leads with the one that actually answers: +3 per query term, +4 for a digit when a number was asked for, ties to the earliest sentence. Title words are dropped from the terms unless they're the attribute asked about, which is what makes "mount everest height" return 8,848.86 m instead of a sentence restating the subject. Fires on a 600ms debounce, only for query shapes that aren't app searches, cached per query — Wikipedia throttles bursts, so restraint is correctness here. Every triggered query goes to Wikipedia. Co-Authored-By: Claude Opus 5 --- settings/quickshell.nix | 285 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 284 insertions(+), 1 deletion(-) 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