diff --git a/.gitignore b/.gitignore index 7dda67a..fac905b 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md index c138ea1..2c01c57 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/config/dot/hypr/input.lua b/config/dot/hypr/input.lua index e27097c..cea64aa 100644 --- a/config/dot/hypr/input.lua +++ b/config/dot/hypr/input.lua @@ -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 diff --git a/config/dot/hypr/looks.lua b/config/dot/hypr/looks.lua index 3a97d90..7ac02f0 100644 --- a/config/dot/hypr/looks.lua +++ b/config/dot/hypr/looks.lua @@ -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 = { diff --git a/config/dot/quickshell/config/PreferenceSchema.qml b/config/dot/quickshell/config/PreferenceSchema.qml index cd7b4fc..618f5dc 100644 --- a/config/dot/quickshell/config/PreferenceSchema.qml +++ b/config/dot/quickshell/config/PreferenceSchema.qml @@ -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", diff --git a/config/dot/quickshell/modules/settings/DesktopPage.qml b/config/dot/quickshell/modules/settings/DesktopPage.qml index 1a111c3..e2524a4 100644 --- a/config/dot/quickshell/modules/settings/DesktopPage.qml +++ b/config/dot/quickshell/modules/settings/DesktopPage.qml @@ -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 { diff --git a/config/dot/quickshell/modules/settings/MousePage.qml b/config/dot/quickshell/modules/settings/MousePage.qml index bd9d552..7f3d1b1 100644 --- a/config/dot/quickshell/modules/settings/MousePage.qml +++ b/config/dot/quickshell/modules/settings/MousePage.qml @@ -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" diff --git a/config/local/share/vicinae/extensions/panama-search/assets/extension_icon.svg b/config/local/share/vicinae/extensions/panama-search/assets/extension_icon.svg new file mode 100644 index 0000000..b7fc2dd --- /dev/null +++ b/config/local/share/vicinae/extensions/panama-search/assets/extension_icon.svg @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/config/local/share/vicinae/extensions/panama-search/package.json b/config/local/share/vicinae/extensions/panama-search/package.json new file mode 100644 index 0000000..2d29a21 --- /dev/null +++ b/config/local/share/vicinae/extensions/panama-search/package.json @@ -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" + } +} diff --git a/config/local/share/vicinae/extensions/panama-search/src/search.tsx b/config/local/share/vicinae/extensions/panama-search/src/search.tsx new file mode 100644 index 0000000..23d894d --- /dev/null +++ b/config/local/share/vicinae/extensions/panama-search/src/search.tsx @@ -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([]); + 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(null); + const debounce = useRef | 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 ( + + {trimmed !== "" && ( + + + + } + /> + )} + + {suggestions + .filter((suggestion) => suggestion !== trimmed) + .map((suggestion) => ( + + + + } + /> + ))} + + + ); +} diff --git a/config/local/share/vicinae/extensions/panama-search/tsconfig.json b/config/local/share/vicinae/extensions/panama-search/tsconfig.json new file mode 100644 index 0000000..bc6a73e --- /dev/null +++ b/config/local/share/vicinae/extensions/panama-search/tsconfig.json @@ -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/**/*"] +} diff --git a/config/local/share/vicinae/scripts/search-web b/config/local/share/vicinae/scripts/search-web new file mode 100755 index 0000000..6557eea --- /dev/null +++ b/config/local/share/vicinae/scripts/search-web @@ -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" diff --git a/docs/settings.md b/docs/settings.md index 8ee886d..1aed787 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -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**
`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**
`allowWorkspaceCycles` `binds:allow_workspace_cycles` | false | Moving past the last workspace continues from the first | | **Let applications take focus**
`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**
`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**
`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**
`touchpadScrollFactor` `input:touchpad:scroll_factor` | 1.0 | Multiplies how far a two-finger scroll travels. Range 0.1–4.0. | | **Drag lock**
`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**
`touchpadMiddleButtonEmulation` `input:touchpad:middle_button_emulation` | false | Pressing left and right together acts as a middle click | +| **Swipe distance**
`swipeDistance` `gestures:workspace_swipe_distance` | 300 px | How far a three-finger swipe must travel to change workspace. Range 100–800. | +| **Natural swipe direction**
`swipeInvert` `gestures:workspace_swipe_invert` | true | Swiping left moves to the workspace on the right, as content follows your fingers | ## typography diff --git a/setup/scripts/link-vicinae-scripts b/setup/scripts/link-vicinae-scripts index f7c8982..fc5a19b 100755 --- a/setup/scripts/link-vicinae-scripts +++ b/setup/scripts/link-vicinae-scripts @@ -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 diff --git a/tests/hypr/gestures-contract b/tests/hypr/gestures-contract new file mode 100755 index 0000000..5af989f --- /dev/null +++ b/tests/hypr/gestures-contract @@ -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' diff --git a/tests/quickshell/panama-commands-contract b/tests/quickshell/panama-commands-contract index 34fd2bc..c222fd0 100755 --- a/tests/quickshell/panama-commands-contract +++ b/tests/quickshell/panama-commands-contract @@ -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" diff --git a/tests/setup/launcher-search-contract b/tests/setup/launcher-search-contract new file mode 100755 index 0000000..0191719 --- /dev/null +++ b/tests/setup/launcher-search-contract @@ -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'