Search from the launcher, and give the touchpad something to do

Four things a Hyprland desktop can do that this one was not.

Searching from the launcher needed no launcher work at all: Vicinae already
models it, so this is a script command with one percent-encoded argument. Make
it the fallback command and anything typed that matches nothing else offers to
search it. Bangs come free -- they are a property of where the query is sent,
not of the launcher -- so !yt reaches YouTube without a line of bang parsing.

Suggestions could not be a script command. They need a view that reacts as you
type, which is an extension: TypeScript, compiled, querying the same endpoint
Firefox's address bar uses. It debounces, and aborts the request in flight on
every keystroke -- typing is faster than the network, and an older answer
landing after a newer one leaves the list describing a query that is no longer
on screen. A bang skips suggestions entirely, because Google has no useful
guesses about "!yt".

The engine is now written down twice, once in each. The contract pins that they
agree, since searching from the fallback and searching from the suggestions
reaching different places is the kind of wrong that looks fine.

Gestures mirror GNOME: three fingers sideways for workspaces, up for the
overview, down to dismiss it. Open and close rather than toggle both ways --
toggling means swiping up from an open overview closes it, which is not what the
fingers meant. Hyprland reads gesture registrations at startup so they cannot be
a setting, but distance and direction can be, and are.

Window swallowing is off by default and a preference like every other misc
setting here. A terminal that vanishes when you did not ask for it is confusing
rather than broken, which is worse.

Claude-Session: https://claude.ai/code/session_01Q84axqUE5inJhf5Jz9CFy1
This commit is contained in:
Gabriel Brown
2026-08-21 01:49:29 -04:00
parent 62dce86b4e
commit 9092a80f66
17 changed files with 594 additions and 11 deletions
+8
View File
@@ -22,3 +22,11 @@ __pycache__/
/config/dot/gtk-4.0/settings.ini
/config/dot/tmux/current-theme.conf
/config/dot/hypr/hyprlock.conf
# Build products of the Vicinae extension. The source is the repository's; the
# dependency tree and the bundle it produces are machine state, rebuilt by
# `panama apps`.
/config/local/share/vicinae/extensions/*/node_modules/
/config/local/share/vicinae/extensions/*/dist/
/config/local/share/vicinae/extensions/*/build/
/config/local/share/vicinae/extensions/*/package-lock.json
+5 -3
View File
@@ -64,7 +64,7 @@ is what decides; anything not on it is a panel Panama owns itself.
|---|---|
| `config/dot/hypr/` | Compositor config. **Lua, not hyprlang** — see its README |
| `config/dot/quickshell/` | The shell: bar, dock, Continuum overview, Settings, Screen Intelligence, focus sessions, quick settings, notifications, screenshot UI |
| `config/dot/vicinae/` | Raycast-style launcher, themed |
| `config/dot/vicinae/` | Raycast-style launcher, themed. Its commands live in `config/local/share/vicinae/` — script commands, and one compiled extension that adds web search with live suggestions |
| `config/dot/uwsm/` | Session environment (see the uwsm caveat in the hypr README) |
| `config/dot/wofi/` | Fallback launcher, in case the shell fails to start |
| `config/dot/xdg-desktop-portal/` | Portal backend routing |
@@ -84,11 +84,13 @@ config/
copy/ Files copied verbatim over / (needs sudo)
dot/ Symlinked into ~/.config
firefox/ Vendored Firefox chrome, linked into the browser profile
local/ Icons and the cursor theme, linked into ~/.local/share
local/ Icons, the cursor theme, and the launcher's commands and
extensions, linked into ~/.local/share
old/ Backups of whatever was replaced (gitignored)
wallpapers/ Copied into ~/Pictures/Wallpapers when absent
setup/
apps/ Applications built from source, one file each
lib/ Shared by more than one stage; the extras catalog reader
packages/ One package per line; extras/ holds the optional categories
scripts/ Run in order by ./install
tests/ Contracts. See below
@@ -97,7 +99,7 @@ docs/ Settings reference, and the design specs behind the work
## Tests
126 of them, under `tests/`. Run the lot, or a subset by pattern:
128 of them, under `tests/`. Run the lot, or a subset by pattern:
```sh
panama test # everything
+23 -2
View File
@@ -44,7 +44,28 @@ hl.config({
},
})
-- Desktop machine: no touchpad, no gestures worth wiring. If a laptop ever
-- runs this config, add touchpad settings in overrides.lua.
-- ── Touchpad gestures ───────────────────────────────────────────────────────
--
-- GNOME's gestures, reproduced: three fingers sideways moves between
-- workspaces, three fingers up opens the overview, three fingers down closes
-- it. That is the same muscle memory the keybinds were built to preserve.
--
-- Registered unconditionally rather than behind a preference. Hyprland 0.56
-- dropped `gestures:workspace_swipe` in favour of this `gesture` keyword, and a
-- registration is read at config time -- so a toggle would need a reload to
-- take effect, which is worse than the nothing these cost on a machine with no
-- touchpad. What IS tunable at runtime lives in Settings: how far a swipe has
-- to travel, and which way round it goes.
--
-- open and close rather than toggle twice: with a toggle on both directions,
-- swiping up from an already-open overview would close it, and swiping down
-- would reopen it. GNOME does not do that, and neither does this.
local overview = function(fn)
return function() hl.exec_cmd("qs ipc call overview " .. fn) end
end
hl.gesture({ fingers = 3, direction = "horizontal", action = "workspace" })
hl.gesture({ fingers = 3, direction = "up", action = overview("open") })
hl.gesture({ fingers = 3, direction = "down", action = overview("close") })
return true
+12
View File
@@ -195,6 +195,18 @@ hl.config({
-- Don't let apps steal focus by shouting; matches GNOME's behavior.
focus_on_activate = false,
-- Window swallowing: a terminal hides itself while a graphical
-- application launched from it is open, and comes back when that
-- application exits. Off by default -- it is a real change in how the
-- desktop behaves, and one that is confusing rather than broken if you
-- did not ask for it: your terminal appears to vanish.
--
-- The regex is narrow on purpose. Anything matching it can swallow, so
-- a permissive pattern means windows disappearing in cases nobody
-- intended. Only the two terminals this desktop actually ships.
enable_swallow = prefs.get("windowSwallow", false),
swallow_regex = "^(kitty|com\\.mitchellh\\.ghostty)$",
},
render = {
@@ -672,6 +672,24 @@ Singleton {
hypr: { path: ["input", "touchpad", "middle_button_emulation"], option: "input:touchpad:middle_button_emulation", readAs: "bool" }
},
// Tuning for the three-finger gestures registered in hypr/input.lua.
// The gestures themselves are not settings: Hyprland reads a gesture
// registration at config time, so switching one on would need a reload,
// and these two are the parts it will accept at runtime.
{
key: "swipeDistance", type: "int", def: 300, min: 100, max: 800, step: 20,
unit: "px", group: "touchpad",
label: "Swipe distance",
detail: "How far a three-finger swipe must travel to change workspace",
hypr: { path: ["gestures", "workspace_swipe_distance"], option: "gestures:workspace_swipe_distance", readAs: "int" }
},
{
key: "swipeInvert", type: "bool", def: true, group: "touchpad",
label: "Natural swipe direction",
detail: "Swiping left moves to the workspace on the right, as content follows your fingers",
hypr: { path: ["gestures", "workspace_swipe_invert"], option: "gestures:workspace_swipe_invert", readAs: "bool" }
},
// ── Multitasking ────────────────────────────────────────────────────
//
// GNOME's Multitasking panel, in Hyprland's terms. The Desktop page
@@ -733,6 +751,12 @@ Singleton {
detail: "An application asking for attention is switched to, rather than only highlighted",
hypr: { path: ["misc", "focus_on_activate"], option: "misc:focus_on_activate", readAs: "bool" }
},
{
key: "windowSwallow", type: "bool", def: false, group: "multitasking",
label: "Hide the terminal that launched a window",
detail: "A terminal disappears while an application started from it is open, and returns when it closes",
hypr: { path: ["misc", "enable_swallow"], option: "misc:enable_swallow", readAs: "bool" }
},
{
key: "mouseMoveFocusesMonitor", type: "bool", def: true, group: "multitasking",
label: "Pointer changes active display",
@@ -192,7 +192,8 @@ SettingsPage {
ToggleRow { setting: "workspaceBackAndForth" }
ToggleRow { setting: "allowWorkspaceCycles" }
ToggleRow { setting: "focusOnActivate" }
ToggleRow { setting: "mouseMoveFocusesMonitor"; divider: false }
ToggleRow { setting: "mouseMoveFocusesMonitor" }
ToggleRow { setting: "windowSwallow"; divider: false }
}
SettingsCard {
@@ -52,6 +52,15 @@ SettingsPage {
ToggleRow { setting: "touchpadMiddleButtonEmulation"; divider: false }
}
SettingsCard {
visible: InputDevices.hasTouchpad
title: "Gestures"
subtitle: "Three fingers sideways moves between workspaces, up opens the overview, and down closes it — the same gestures GNOME used. Which gestures exist is fixed by the compositor at startup; what they feel like is here."
SliderRow { setting: "swipeDistance" }
ToggleRow { setting: "swipeInvert"; divider: false }
}
SettingsCard {
title: "Pointer"
@@ -0,0 +1,42 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Panama Settings.
A gear, because a settings icon has to be recognizable at 48px in a dock
before it is anything else - but rendered in the Prism identity rather than
the flat monochrome symbolic icon this replaces. Blue leads into orchid, the
same pair that marks the focused window, the active workspace, and the
hairline along every glass surface.
The gear is a real toothed outline. An earlier version drew a ring with radial
strokes; at dock size the strokes merged into the ring and it read as an X.
-->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" width="128" height="128">
<defs>
<linearGradient id="prism" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#82aaff"/>
<stop offset="55%" stop-color="#9b8ed8"/>
<stop offset="100%" stop-color="#b172b0"/>
</linearGradient>
<linearGradient id="tile" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#2b2e45"/>
<stop offset="100%" stop-color="#1e2030"/>
</linearGradient>
<linearGradient id="edge" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#82aaff" stop-opacity="0"/>
<stop offset="22%" stop-color="#82aaff" stop-opacity="0.9"/>
<stop offset="78%" stop-color="#b172b0" stop-opacity="0.9"/>
<stop offset="100%" stop-color="#b172b0" stop-opacity="0"/>
</linearGradient>
</defs>
<rect x="4" y="4" width="120" height="120" rx="28" fill="url(#tile)"/>
<rect x="4.5" y="4.5" width="119" height="119" rx="27.5" fill="none"
stroke="#c8d3f5" stroke-opacity="0.10" stroke-width="1"/>
<path d="M34 6.5 H94" stroke="url(#edge)" stroke-width="1.6" stroke-linecap="round"/>
<path d="M 52.53 29.88 L 56.45 18.62 L 71.55 18.62 L 75.47 29.88 L 80.02 31.76 L 90.75 26.57 L 101.43 37.25 L 96.24 47.98 L 98.12 52.53 L 109.38 56.45 L 109.38 71.55 L 98.12 75.47 L 96.24 80.02 L 101.43 90.75 L 90.75 101.43 L 80.02 96.24 L 75.47 98.12 L 71.55 109.38 L 56.45 109.38 L 52.53 98.12 L 47.98 96.24 L 37.25 101.43 L 26.57 90.75 L 31.76 80.02 L 29.88 75.47 L 18.62 71.55 L 18.62 56.45 L 29.88 52.53 L 31.76 47.98 L 26.57 37.25 L 37.25 26.57 L 47.98 31.76 Z" fill="url(#prism)" stroke="url(#prism)" stroke-width="5"
stroke-linejoin="round"/>
<circle cx="64" cy="64" r="15" fill="#1e2030"/>
<circle cx="64" cy="64" r="15" fill="none" stroke="#c8d3f5" stroke-opacity="0.18" stroke-width="1.5"/>
</svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

@@ -0,0 +1,30 @@
{
"name": "panama-search",
"title": "Panama Search",
"description": "Web search from the launcher, with live suggestions and bang syntax",
"categories": ["Web"],
"license": "MIT",
"author": "gib",
"icon": "extension_icon.svg",
"commands": [
{
"name": "search",
"title": "Search the web",
"subtitle": "Panama",
"description": "Search with live suggestions, opened in the default browser",
"mode": "view"
}
],
"preferences": [],
"scripts": {
"build": "vici build",
"dev": "vici develop"
},
"dependencies": {
"@vicinae/api": "^0.8.2"
},
"devDependencies": {
"@types/react": "^19.0.0",
"typescript": "^5.9.2"
}
}
@@ -0,0 +1,121 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { Action, ActionPanel, Icon, List } from "@vicinae/api";
// Where a search goes. A client-side bang redirector: it resolves
// DuckDuckGo-style bangs in the browser rather than round-tripping through a
// search engine to be bounced, and falls through to an ordinary search when
// there is no bang. Bang support is a property of this URL, not of this
// extension, which is why there is no bang parsing below.
const ENGINE = "https://bang.gibbyb.com/?q=";
// The suggestion endpoint Firefox's address bar uses. Answers with
// [query, [suggestion, ...], ...] and needs no key.
const SUGGEST = "https://suggestqueries.google.com/complete/search?client=firefox&q=";
// Long enough that typing a word is one request rather than one per keystroke,
// short enough that the list still feels attached to the keyboard.
const DEBOUNCE_MS = 150;
const searchUrl = (query: string) => ENGINE + encodeURIComponent(query);
export default function SearchCommand() {
const [query, setQuery] = useState("");
const [suggestions, setSuggestions] = useState<string[]>([]);
const [loading, setLoading] = useState(false);
// Both are refs rather than state: changing them must not re-render, and the
// cleanup below needs whatever the latest one is, not the one captured when
// an effect happened to run.
const inFlight = useRef<AbortController | null>(null);
const debounce = useRef<ReturnType<typeof setTimeout> | null>(null);
// Typing is faster than the network, so responses can arrive out of order. An
// older, slower answer landing after a newer one would leave the list showing
// suggestions for a query that is no longer on screen -- so each new keystroke
// aborts the request before it.
useEffect(() => () => {
inFlight.current?.abort();
if (debounce.current) clearTimeout(debounce.current);
}, []);
const onSearchTextChange = useCallback((text: string) => {
setQuery(text);
if (debounce.current) clearTimeout(debounce.current);
inFlight.current?.abort();
const trimmed = text.trim();
// A bang says *where* to search rather than what for, so Google's guesses
// about it are noise: "!yt" suggests nothing anybody wants.
if (trimmed === "" || trimmed.startsWith("!")) {
setSuggestions([]);
setLoading(false);
return;
}
setLoading(true);
debounce.current = setTimeout(async () => {
const controller = new AbortController();
inFlight.current = controller;
try {
const response = await fetch(SUGGEST + encodeURIComponent(trimmed), {
signal: controller.signal,
});
const body = (await response.json()) as unknown;
const returned = Array.isArray(body) ? body[1] : undefined;
setSuggestions(
Array.isArray(returned)
? returned.filter((entry): entry is string => typeof entry === "string")
: [],
);
} catch {
// Offline, rate-limited, or aborted by the next keystroke. The typed
// query is still searchable, so this costs suggestions rather than the
// command -- which is the right way round for something you reach for
// when you already know what you want.
setSuggestions([]);
} finally {
if (inFlight.current === controller) setLoading(false);
}
}, DEBOUNCE_MS);
}, []);
const trimmed = query.trim();
return (
<List
isLoading={loading}
onSearchTextChange={onSearchTextChange}
searchBarPlaceholder="Search, or !bang to jump straight there"
>
{trimmed !== "" && (
<List.Item
title={trimmed}
subtitle={trimmed.startsWith("!") ? "Bang" : "Search"}
icon={Icon.MagnifyingGlass}
actions={
<ActionPanel>
<Action.OpenInBrowser title="Search" url={searchUrl(trimmed)} />
</ActionPanel>
}
/>
)}
<List.Section title="Suggestions">
{suggestions
.filter((suggestion) => suggestion !== trimmed)
.map((suggestion) => (
<List.Item
key={suggestion}
title={suggestion}
icon={Icon.MagnifyingGlass}
actions={
<ActionPanel>
<Action.OpenInBrowser title="Search" url={searchUrl(suggestion)} />
</ActionPanel>
}
/>
))}
</List.Section>
</List>
);
}
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"lib": ["ES2023"],
"module": "ESNext",
"target": "ES2022",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"isolatedModules": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src/**/*"]
}
+29
View File
@@ -0,0 +1,29 @@
#!/usr/bin/env bash
# @vicinae.schemaVersion 1
# @vicinae.title Search the web
# @vicinae.mode silent
# @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg
# @vicinae.description Search from the launcher, with bang syntax, in the default browser.
# @vicinae.keywords ["search", "web", "google", "bang", "find"]
# @vicinae.argument1 { "type": "text", "placeholder": "search, or !bang to jump", "percentEncoded": true }
# Search without leaving the launcher.
#
# Make this the fallback command -- "Manage fallback commands" in Vicinae -- and
# anything typed that matches nothing else offers this at the bottom of the
# results. That designation is per-machine state Vicinae keeps in its own
# database, so it is one click rather than something Panama ships.
#
# The engine is a client-side bang redirector: it resolves DuckDuckGo-style
# bangs in the browser rather than round-tripping through a search engine to be
# redirected, and falls through to a normal search when there is no bang. So
# `!yt tiling` reaches YouTube directly, and bang support costs nothing here --
# it is a property of where this points, not of the launcher.
#
# percentEncoded on the argument means Vicinae URL-encodes the query before it
# arrives, which is what keeps `&`, `#` and spaces from truncating the search.
#
# xdg-open rather than a named browser: the default browser is already a setting
# this desktop owns, on the Applications page, and naming one here would quietly
# outrank it.
exec xdg-open "https://bang.gibbyb.com/?q=$1"
+4 -1
View File
@@ -4,7 +4,7 @@
Do not edit this file. Run `quickshell/scripts/panama-settings-docs`
after changing the schema; a contract fails when this copy is stale.
133 settings across 27 groups. 67 of them are applied to the compositor and confirmed by reading the value back.
136 settings across 27 groups. 70 of them are applied to the compositor and confirmed by reading the value back.
## accessibility
@@ -181,6 +181,7 @@ Found on **Desktop & Dock**.
| **Switch back and forth**<br>`workspaceBackAndForth` `binds:workspace_back_and_forth` | false | Selecting the workspace you are already on returns you to the previous one |
| **Wrap around at the ends**<br>`allowWorkspaceCycles` `binds:allow_workspace_cycles` | false | Moving past the last workspace continues from the first |
| **Let applications take focus**<br>`focusOnActivate` `misc:focus_on_activate` | false | An application asking for attention is switched to, rather than only highlighted |
| **Hide the terminal that launched a window**<br>`windowSwallow` `misc:enable_swallow` | false | A terminal disappears while an application started from it is open, and returns when it closes |
| **Pointer changes active display**<br>`mouseMoveFocusesMonitor` `misc:mouse_move_focuses_monitor` | true | Moving the pointer to another display makes it the active one |
## nightLight
@@ -263,6 +264,8 @@ Found on **Mouse & Touchpad**.
| **Scroll speed**<br>`touchpadScrollFactor` `input:touchpad:scroll_factor` | 1.0 | Multiplies how far a two-finger scroll travels. Range 0.14.0. |
| **Drag lock**<br>`touchpadDragLock` `input:touchpad:drag_lock` | 0 | Keeps a tap-and-drag active when you lift a finger mid-drag Choices: Off, On, On, until you tap again. |
| **Middle-click by pressing both buttons**<br>`touchpadMiddleButtonEmulation` `input:touchpad:middle_button_emulation` | false | Pressing left and right together acts as a middle click |
| **Swipe distance**<br>`swipeDistance` `gestures:workspace_swipe_distance` | 300 px | How far a three-finger swipe must travel to change workspace. Range 100800. |
| **Natural swipe direction**<br>`swipeInvert` `gestures:workspace_swipe_invert` | true | Swiping left moves to the workspace on the right, as content follows your fingers |
## typography
+37
View File
@@ -46,6 +46,43 @@ fi
ln -s "$source_dir" "$target_dir"
# ── Extensions ───────────────────────────────────────────────────────────────
#
# Script commands are a file and a shebang, so they are linked. Extensions are
# not: they are TypeScript that has to be compiled, and `vici build` writes its
# output straight into Vicinae's data directory rather than leaving a bundle to
# link. So the source lives in this repository and the build is what installs
# it.
#
# Never fatal, and never a reason to fail a stage. A build wants npm and the
# network, and neither is guaranteed at this point in an install -- npm arrives
# with nvm earlier in install-packages, which can itself be skipped. An
# extension that did not build is a launcher missing one command, not a desktop
# that failed to install.
extensions_source="$panama_path/config/local/share/vicinae/extensions"
if [[ -d "$extensions_source" ]] && command -v npm >/dev/null 2>&1; then
for extension in "$extensions_source"/*/; do
[[ -f "$extension/package.json" ]] || continue
name="$(basename "$extension")"
# Skip a build that would produce what is already there. `npm install`
# alone takes long enough to be worth not repeating on every re-run of
# a stage that is otherwise nearly instant.
built="$vicinae_data_dir/extensions/$name"
if [[ -d "$built" && "$extension/src" -ot "$built" ]]; then
printf 'Vicinae extension %s is already built\n' "$name"
continue
fi
printf 'Building Vicinae extension %s\n' "$name"
if ! (cd "$extension" && npm install --silent >/dev/null 2>&1 && npm run build >/dev/null 2>&1); then
printf 'Vicinae extension %s did not build; skipping\n' "$name" >&2
fi
done
elif [[ -d "$extensions_source" ]]; then
printf 'npm is not available, so Vicinae extensions were not built\n' >&2
fi
# The server also rescans periodically, but an explicit reload makes a setup
# run deterministic. If Vicinae is not active yet, its startup scan is enough.
if command -v vicinae >/dev/null 2>&1 && vicinae ping >/dev/null 2>&1; then
+88
View File
@@ -0,0 +1,88 @@
#!/usr/bin/env bash
# The touchpad gestures, and the window swallowing beside them.
#
# Both are behaviour that only exists on hardware this machine may not have, so
# neither can be checked by running it. What can be pinned is the shape:
#
# * Three gestures, mirroring GNOME. Sideways moves workspaces, up opens the
# overview, down closes it.
# * Up and down do not both toggle. That is the obvious way to write it and it
# is wrong: swiping up from an open overview would close it, and swiping
# down would reopen it, which is the opposite of what the fingers mean.
# * Swallowing is off by default and driven by a preference. Turning it on for
# everybody would make terminals appear to vanish on a machine nobody asked.
# * The swallow regex names only terminals this desktop ships. Anything the
# pattern matches can swallow, so a broad pattern is windows disappearing in
# cases nobody intended.
set -uo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
input="$repo_dir/config/dot/hypr/input.lua"
looks="$repo_dir/config/dot/hypr/looks.lua"
schema="$repo_dir/config/dot/quickshell/config/PreferenceSchema.qml"
findings=()
note() { findings+=("$1"); }
# ── Gestures ─────────────────────────────────────────────────────────────────
gestures="$(grep -c 'hl\.gesture({' "$input" || true)"
(( gestures == 3 )) || note "input.lua registers $gestures gestures, not the 3 that mirror GNOME"
grep -qE 'fingers = 3, direction = "horizontal",[[:space:]]*action = "workspace"' "$input" \
|| note 'three fingers sideways does not switch workspaces'
grep -qE 'fingers = 3, direction = "up",[[:space:]]*action = overview\("open"\)' "$input" \
|| note 'three fingers up does not open the overview'
grep -qE 'fingers = 3, direction = "down",[[:space:]]*action = overview\("close"\)' "$input" \
|| note 'three fingers down does not close the overview'
# The specific mistake worth a test of its own.
if grep -qE 'direction = "(up|down)",[[:space:]]*action = overview\("toggle"\)' "$input"; then
note 'a vertical gesture toggles the overview, so swiping the same way twice undoes itself'
fi
# The tuning that Hyprland does accept at runtime has to be reachable, or the
# gestures are unadjustable without editing this file -- which is the thing
# Settings exists to avoid.
for key in swipeDistance swipeInvert; do
grep -q "key: \"$key\"" "$schema" \
|| note "$key is not a preference, so the gestures cannot be tuned from Settings"
done
# ── Swallowing ───────────────────────────────────────────────────────────────
grep -qE 'enable_swallow = prefs\.get\("windowSwallow", false\)' "$looks" \
|| note 'window swallowing is not preference-driven and off by default'
regex_line="$(grep -E '^\s*swallow_regex' "$looks" || true)"
[[ -n "$regex_line" ]] || note 'swallowing is enabled with no regex, so nothing can ever swallow'
# Anchored at both ends: an unanchored pattern matches any class containing the
# name, which is a much larger set than the one intended.
grep -qE 'swallow_regex\s*=\s*"\^\(.*\)\$"' "$looks" \
|| note 'the swallow regex is not anchored, so it matches more window classes than it names'
for terminal in kitty ghostty; do
grep -q "$terminal" <<<"$regex_line" \
|| note "the swallow regex does not cover $terminal, which this desktop ships"
done
# Whatever the regex names has to be something the machine will actually have.
declared="$(cat "$repo_dir"/setup/packages/* 2>/dev/null | sed 's/#.*//' | tr -d ' ' | grep -v '^$')"
for terminal in kitty ghostty; do
grep -qix "$terminal" <<<"$declared" \
|| note "the swallow regex names $terminal, which no package list installs"
done
# ── Report ───────────────────────────────────────────────────────────────────
if (( ${#findings[@]} > 0 )); then
mapfile -t findings < <(printf '%s\n' "${findings[@]}" | sort -u)
printf 'gestures contract: %d finding(s)\n' "${#findings[@]}" >&2
printf ' - %s\n' "${findings[@]}" >&2
exit 1
fi
printf 'gestures contract: PASS\n'
+27 -4
View File
@@ -51,6 +51,19 @@ done < <(grep -oE '\{ page: "[a-z-]+"' "$sidebar" | sed 's/.*"\([a-z-]*\)"/\1/')
(( ${#expected[@]} > 18 )) || fail 'no generated per-page commands were found; run scripts/panama-settings-commands'
# Commands that do not go through panama-action, and should not.
#
# Every command above asks the shell to do something, so routing them through
# one dispatcher is what keeps that surface small. search-web is a different
# animal: it takes a query and opens a browser, and neither half needs the
# shell. Sending it through panama-action would mean a web search stops working
# when Quickshell is down -- which is exactly when somebody is reaching for the
# launcher to look up what went wrong.
#
# They are still commands, so everything else below applies to them: a title,
# a description, search vocabulary, the Panama icon, and closing quietly.
declare -a standalone=(search-web)
# Generated commands must match their source. A stale command dispatches to a
# page that has been renamed or removed, and the launcher reports nothing wrong.
"$repo_dir/config/dot/quickshell/scripts/panama-settings-commands" --check >/dev/null \
@@ -69,11 +82,12 @@ chmod +x "$work/home/.config/quickshell/scripts/panama-action"
# none: a shebang and the executable bit already select the interpreter, and an
# extension is one more thing that has to stay in sync -- which it did not.
mapfile -t actual_files < <(find "$commands_dir" -maxdepth 1 -type f -printf '%f\n' | sort)
[[ ${#actual_files[@]} -eq ${#expected[@]} ]] \
|| fail "expected ${#expected[@]} commands, found ${#actual_files[@]}"
declared=$(( ${#expected[@]} + ${#standalone[@]} ))
[[ ${#actual_files[@]} -eq $declared ]] \
|| fail "expected $declared commands, found ${#actual_files[@]}"
declare -A seen_titles=()
for script_name in "${!expected[@]}"; do
for script_name in "${!expected[@]}" "${standalone[@]}"; do
script="$commands_dir/$script_name"
[[ -x "$script" ]] || fail "$script_name is missing or not executable"
@@ -109,6 +123,15 @@ for script_name in "${!expected[@]}"; do
|| fail 'health command bypasses the stable dispatcher path'
fi
# A standalone command has nothing to dispatch, and running it would open a
# browser at whoever is running the tests.
if [[ -z ${expected[$script_name]+x} ]]; then
if grep -Fq 'panama-action' "$script"; then
fail "$script_name is listed as standalone but goes through panama-action"
fi
continue
fi
: >"$dispatch_log"
HOME="$work/home" PANAMA_COMMAND_TEST_LOG="$dispatch_log" "$script"
dispatched="$(cat "$dispatch_log")"
@@ -116,4 +139,4 @@ for script_name in "${!expected[@]}"; do
|| fail "$script_name dispatched [$dispatched], expected [${expected[$script_name]}]"
done
printf 'Panama commands contract: PASS (%d commands)\n' "${#expected[@]}"
printf 'Panama commands contract: PASS (%d commands)\n' "$declared"
+119
View File
@@ -0,0 +1,119 @@
#!/usr/bin/env bash
# Searching the web from the launcher, in both the forms Panama ships it.
#
# There are two, deliberately: a script command that needs nothing but bash, and
# an extension that adds live suggestions but has to be compiled. That is also
# the risk this pins -- the search engine is written down twice, and two copies
# of a URL drift.
#
# `vicinae script check` is the validator for the first, and it exits 0 even when
# it rejects a file. Checking its exit status would pass on a script Vicinae
# refuses to load, so its output is what counts.
set -uo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
script="$repo_dir/config/local/share/vicinae/scripts/search-web"
extension="$repo_dir/config/local/share/vicinae/extensions/panama-search"
stage="$repo_dir/setup/scripts/link-vicinae-scripts"
findings=()
note() { findings+=("$1"); }
# ── The script command ───────────────────────────────────────────────────────
if [[ ! -x "$script" ]]; then
note 'search-web is missing or not executable, so Vicinae will not load it'
else
if command -v vicinae >/dev/null 2>&1; then
output="$(vicinae script check "$script" 2>&1)"
[[ "$output" == *Error* ]] && note "Vicinae rejects search-web: $output"
fi
# Exactly one argument. Vicinae only offers a no-view command as a fallback
# -- type anything, get "Search" at the bottom -- when it takes a single
# text argument, which is the whole point of shipping this one.
arguments="$(grep -c '@vicinae.argument' "$script" || true)"
(( arguments == 1 )) \
|| note "search-web declares $arguments arguments; a fallback command takes exactly 1"
grep -q '"percentEncoded": true' "$script" \
|| note 'the argument is not percent-encoded, so a query containing & or # is truncated'
grep -qE '@vicinae\.mode silent' "$script" \
|| note 'search-web is not a silent command, so it would render a view it has nothing to put in'
# The default browser is a setting this desktop already owns. Naming a
# browser here would quietly outrank the Applications page.
grep -q 'xdg-open' "$script" \
|| note 'search-web does not open through xdg-open, so it ignores the default browser'
grep -qE '\b(helium|firefox|chromium|google-chrome)\b' "$script" \
&& note 'search-web names a specific browser instead of following the default'
fi
# ── The extension ────────────────────────────────────────────────────────────
manifest="$extension/package.json"
if [[ ! -f "$manifest" ]]; then
note 'the panama-search extension has no manifest'
else
if command -v jq >/dev/null 2>&1; then
jq -e . "$manifest" >/dev/null 2>&1 || note 'the extension manifest is not valid JSON'
for field in name title description commands; do
jq -e "has(\"$field\")" "$manifest" >/dev/null 2>&1 \
|| note "the extension manifest has no \"$field\", which Vicinae requires"
done
# A command's name is its source file. Getting this wrong builds an
# extension with a command that cannot be opened.
while read -r command_name; do
[[ -n "$command_name" ]] || continue
[[ -f "$extension/src/$command_name.tsx" || -f "$extension/src/$command_name.ts" ]] \
|| note "the manifest declares command \"$command_name\" with no matching file in src/"
done < <(jq -r '.commands[]?.name // empty' "$manifest" 2>/dev/null)
fi
fi
# ── One engine, written twice ────────────────────────────────────────────────
#
# The script command and the extension both have to know where a search goes.
# Nothing makes them agree, so this does: searching from the fallback and
# searching from the suggestions list must not reach different places.
engine_in_script="$(grep -oE 'https://[^"]+\?q=' "$script" 2>/dev/null | head -1)"
engine_in_extension="$(grep -oE 'https://[^"]+\?q=' "$extension/src/search.tsx" 2>/dev/null | head -1)"
if [[ -z "$engine_in_script" ]]; then
note 'no search engine URL found in search-web'
elif [[ -z "$engine_in_extension" ]]; then
note 'no search engine URL found in the extension'
elif [[ "$engine_in_script" != "$engine_in_extension" ]]; then
note "the script searches $engine_in_script but the extension searches $engine_in_extension"
fi
# ── Provisioning ─────────────────────────────────────────────────────────────
#
# An extension is compiled, so unlike a script command it cannot simply be
# linked. If nothing builds it, it ships as source nobody can run.
grep -q 'npm run build' "$stage" \
|| note 'no stage builds the Vicinae extensions, so they never reach the launcher'
grep -q 'command -v npm' "$stage" \
|| note 'the extension build does not check for npm, so a machine without it fails the stage'
# node_modules is a dependency tree, not configuration.
git -C "$repo_dir" check-ignore -q "$extension/node_modules" 2>/dev/null \
|| note 'the extension node_modules is not gitignored'
# ── Report ───────────────────────────────────────────────────────────────────
if (( ${#findings[@]} > 0 )); then
mapfile -t findings < <(printf '%s\n' "${findings[@]}" | sort -u)
printf 'launcher search contract: %d finding(s)\n' "${#findings[@]}" >&2
printf ' - %s\n' "${findings[@]}" >&2
exit 1
fi
printf 'launcher search contract: PASS\n'