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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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"
|
||||
Reference in New Issue
Block a user