A 3D window switcher for Hyprland/Quickshell. Running apps and windows are arranged on a Fibonacci sphere with drag-to-rotate, search, keyboard-driven selection animation, and a satellite detail view.
Runtime: Quickshell + Qt Quick
- Alt+Tab — open the sphere overlay with all running apps grouped by application ID. Pre-selects the previously focused app (MRU index 1).
- Tab / Shift+Tab — cycle forward/backward through sphere nodes while holding Alt. Wraps around at the edges.
;(semicolon) — drill down into an app's individual windows (layer 1). Press;again to return to the app list.- Type any letter while holding Alt — enters search mode (layer 2). Fuzzy-filters across all running apps, whitelisted apps, and window titles using Fuse.js. Results ordered: running apps → whitelisted apps → windows.
- Backspace — remove last search character. Empty field returns to app list (layer 0).
- Escape — close overlay without switching.
- Alt release — commit the selected app or window and focus it.
- Ctrl+C — close the selected window(s). At layer 0, closes all windows of the selected app. At layer 1, closes the specific window.
- Mouse drag — rotate the sphere.
- Click — select a node and center the sphere on it.
- Double-click — commit the selected node (same as Alt release).
- Hyprland 0.55+ (Lua config)
- Quickshell 0.3.0+
- Qt 6 + Qt5Compat (
qt5compat) - Fuse.js v7.0.0 (bundled in
lib/fuse.js)
On NixOS, Quickshell and Qt5Compat are typically provided by the system
or via nix-shell.
git clone <repo-url> /home/fireshark/hyprsphere
cd /home/fireshark/hyprsphereQuickshell requires the config file to be in ~/.config/quickshell/ for
IPC (qs ipc call) to work. The -p flag breaks IPC, so we use symlinks:
mkdir -p ~/.config/quickshell
ln -sf /path/to/hyprsphere/shell.qml ~/.config/quickshell/shell.qml
ln -sf /home/fireshark/hyprsphere/lib ~/.config/quickshell/libReplace /home/fireshark/hyprsphere with the actual path to your clone.
Quickshell needs the Qt5Compat QML module (specifically
Qt5Compat.GraphicalEffects) for certain visual effects. Set the
QML2_IMPORT_PATH environment variable to point to the Qt5Compat QML
directory before starting Quickshell:
export QML2_IMPORT_PATH="/path/to/qt5compat/lib/qt-6/qml"NixOS / Nix:
ls -d /nix/store/*qt5compat*/lib/qt-6/qml 2>/dev/nullThis lists all Qt5Compat QML paths in your Nix store. Pick one and set
it as QML2_IMPORT_PATH.
Arch Linux (qt5compat from AUR or extra):
pacman -Ql qt5compat 2>/dev/null | grep 'qt-6/qml' | head -1 | cut -d' ' -f2
# or find the installed path:
pkg-config --variable=libdir Qt5Compat 2>/dev/null
# common path:
ls -d /usr/lib/qt6/qml/Qt5Compat* 2>/dev/nullDebian/Ubuntu (qt6-base-dev or similar):
dpkg -L qt6-base-dev 2>/dev/null | grep 'qt5compat' | head -1
# or check common locations:
ls -d /usr/lib/*/qt6/qml/Qt5Compat* 2>/dev/null
ls -d /usr/lib/qt6/qml/Qt5Compat* 2>/dev/nullFedora (qt6-qt5compat):
rpm -ql qt6-qt5compat 2>/dev/null | grep qt-6/qml | head -1
# or:
ls -d /usr/lib64/qt6/qml/Qt5Compat* 2>/dev/nullManual search (any distro):
find /usr -path '*/Qt5Compat*' -type d 2>/dev/null | head -5If all else fails, look for any directory named Qt5Compat or
qt5compat under your Qt QML installation and point
QML2_IMPORT_PATH to its parent chain ending in qt-6/qml.
The manual_start.sh script included in this repo will attempt to
auto-detect the path on NixOS, but for other distros you'll need to
set it yourself.
In your Hyprland Lua config file (typically ~/.config/hypr/keymaps.lua
or similar), add the following:
-- =============================================================================
-- hyprsphere — Alt+Tab overlay window switcher
-- =============================================================================
-- Open overlay and enter submap (blocks global ALT+letter binds during search)
hl.bind("ALT + Tab", function()
hl.dispatch(hl.dsp.submap("hyprsphere"))
hl.dispatch(hl.dsp.exec_cmd("qs ipc call hyprsphere toggle"))
end)
-- Submap definition — active while hyprsphere overlay is open.
-- Inside the submap, only Alt release and Escape are bound.
-- All other keys (including letter keys for search) pass through to QML.
hl.define_submap("hyprsphere", function()
-- Alt release: commit the selected node (focus window or launch app)
-- Submap reset is handled by QML via hyprctl eval
hl.bind("ALT + Alt_L", function()
hl.dispatch(hl.dsp.exec_cmd("qs ipc call hyprsphere commit"))
end, { release = true })
hl.bind("ALT + Alt_R", function()
hl.dispatch(hl.dsp.exec_cmd("qs ipc call hyprsphere commit"))
end, { release = true })
-- Escape: close overlay without switching, reset submap
hl.bind("Escape", function()
hl.dispatch(hl.dsp.exec_cmd("qs ipc call hyprsphere cancel"))
hl.dispatch(hl.dsp.submap("reset"))
end)
end)
-- IMPORTANT: Remove or comment out any existing ALT + Alt_L / ALT + Alt_R
-- release binds from the global scope. They are now handled by the submap:
-- hl.bind("ALT + Alt_L", ..., { release = true }) -- REMOVE from global scope
-- hl.bind("ALT + Alt_R", ..., { release = true }) -- REMOVE from global scopeMake sure no other bind in your config uses ALT + Tab — it will conflict.
QML2_IMPORT_PATH="/path/to/qt5compat/lib/qt-6/qml" quickshell &Quickshell loads ~/.config/quickshell/shell.qml automatically. No flags
needed. The overlay starts invisible and waits for the first ALT + Tab.
Add to your Hyprland Lua config:
hl.on("hyprland.start", function()
hl.exec_cmd("bash -c '"
.. "ln -sf /path/to/hyprsphere/shell.qml $HOME/.config/quickshell/shell.qml; "
.. "ln -sf /path/to/hyprsphere/lib $HOME/.config/quickshell/lib; "
.. "export QML2_IMPORT_PATH=\"${QML2_IMPORT_PATH:+$QML2_IMPORT_PATH:}/path/to/qt5compat/lib/qt-6/qml\"; "
.. "quickshell'")
end)Or use your desktop environment's autostart mechanism with a desktop file or startup script.
The repository includes a manual_start.sh script that handles everything
automatically — killing old instances, creating symlinks, and launching
quickshell:
cd /path/to/hyprsphere
bash manual_start.shAfter running, the overlay is ready. Open it with:
qs ipc call hyprsphere toggleThe script performs these steps:
- Kills any existing quickshell processes
- Creates symlinks in
~/.config/quickshell/pointing to your repository files (shell.qml,hyprsphere.json,lib/) - Auto-detects the Qt5Compat QML import path on NixOS
- Starts quickshell and waits for it to register for IPC
NixOS note: This script replaces any Nix-managed symlinks in
~/.config/quickshell/ with direct symlinks to your repository.
To restore the Nix-generated symlinks (e.g., after rebuilding your
system or switching configurations), you need to:
rm -rf ~/.config/quickshell
# Then rebuild your Nix configuration:
nixos-rebuild switch # or: darwin-rebuild, home-manager switch, etc.All configurable settings are in hyprsphere.json. Below is every option
organized by section.
| Field | Default | Description |
|---|---|---|
base |
"#1e1e2e" |
Background color |
mantle |
"#181825" |
Slightly darker background variant |
crust |
"#11111b" |
Darkest background variant |
surface0 |
"#313244" |
Surface/raised element color |
surface1 |
"#45475a" |
Brighter surface variant |
surface2 |
"#585b70" |
Subtle/hover surface variant |
text |
"#cdd6f4" |
Primary text color |
subtext0 |
"#a6adc8" |
Subdued text color |
blue |
"#89b4fa" |
Blue accent |
mauve |
"#cba6f7" |
Mauve/purple accent |
teal |
"#94e2d5" |
Teal accent |
overlay0 |
"#6c7086" |
Overlay/subtle element color |
peach |
"#fab387" |
Peach/orange accent |
yellow |
"#f9e2af" |
Yellow accent |
sapphire |
"#74c7ec" |
Sapphire/blue accent |
| Field | Default | Description |
|---|---|---|
referenceWidth |
1920 |
Reference screen width for 1:1 scaling |
minRatio |
0.5 |
Minimum scale ratio (at very small screens) |
maxRatio |
2.0 |
Maximum scale ratio (at very large screens) |
| Field | Default | Description |
|---|---|---|
s2–s104 |
various | Named sizes (2, 3, 4, 5, 8, 9, 10, 11, 12, 15, 16, 18, 20, 28, 40, 50, 55, 56, 63, 74, 104) used throughout the UI. All scaled by the responsive scaler. |
| Field | Default | Description |
|---|---|---|
hullWidth |
216 |
Width of the satellite card background |
hullHeight |
148 |
Height of the satellite card background |
panelWidth |
64 |
Width of decorative solar panels |
panelHeight |
51 |
Height of decorative solar panels |
strutWidth |
10 |
Width of decorative struts |
strutHeight |
4 |
Height of decorative struts |
antennaHeight |
16 |
Height of decorative antenna |
thrusterHeight |
11 |
Height of decorative thruster |
radius12 |
10 |
Corner radius (12px base) for hull elements |
radius8 |
7 |
Corner radius (8px base) for panel elements |
radius4 |
3 |
Corner radius (4px base) for fine details |
antBall |
6 |
Diameter of the antenna tip ball |
antStick |
2 |
Width of the antenna mast |
antOffX |
14 |
Horizontal offset of the antenna from center |
screenMargin |
8 |
Margin inside the card for screen content |
innerMargin |
10 |
Inner margin for content layout |
iconSize |
160 |
Size of the satellite card's app icon |
fontSize |
10 |
Font size of the satellite card label |
thrusterBase |
16 |
Width of the thruster base |
spacing |
5 |
Spacing between satellite card elements |
extraHeight |
11 |
Additional height above the hull for decorative elements |
hullBorderWidth |
1.5 |
Border width of the satellite card hull |
selectedBackground |
false |
Show SVG decoration behind the satellite icon |
| Field | Default | Description |
|---|---|---|
baseRadius |
360 |
Base radius of the Fibonacci sphere in pixels |
initialZoom |
1.0 |
Initial zoom level when overlay opens |
zoomDurationMs |
400 |
Duration of zoom animations in ms |
zoomEasing |
"OutCubic" |
Easing curve for zoom animations |
initialRotX |
-0.2 |
Initial X-axis rotation (radians) |
initialRotY |
0 |
Initial Y-axis rotation (radians) |
maxRotationX |
2.5 |
Maximum X-axis rotation limit (drag) |
maxRotationY |
1.45 |
Maximum Y-axis rotation limit (drag) |
zoomFactorWeight |
0.45 |
How much zoom affects node scale (0-1) |
normalizationConstant |
310.5 |
Tilt calculation normalization factor |
autoRadius.enabled |
false |
Enable adaptive sphere radius based on node count |
autoRadius.minRadius |
160 |
Smallest sphere radius when adaptive (few nodes) |
autoRadius.maxNodeCount |
20 |
Node count at which adaptive radius reaches baseRadius |
| Field | Default | Description |
|---|---|---|
searchRotateDurationMs |
250 |
Duration of sphere rotation to center on a search result (ms) |
sphereAutoRotateIntervalMs |
16 |
Interval of the auto-rotation timer (ms, ~60 FPS) |
sphereRotateSpeed |
0.002 |
Radians per tick for auto-rotation |
cardFadeDurationMs |
200 |
Duration of card opacity transitions (ms) |
cardScaleDurationMs |
200 |
Duration of card scale transitions (ms) |
satelliteFadeDurationMs |
400 |
Duration of satellite card fade-in/out (ms) |
satelliteScaleDurationMs |
450 |
Duration of satellite card scale-up animation (ms) |
satelliteInitialScale |
0.4 |
Starting scale of the satellite card (animates up) |
satelliteTargetScale |
1.5 |
Final scale of the satellite card |
entranceFadeDurationMs |
800 |
Duration of the overlay's entrance fade animation (ms) |
exitFadeDurationMs |
400 |
Duration of the overlay's exit fade animation (ms) |
borderColorDurationMs |
150 |
Duration of card border color transitions (ms) |
| Field | Default | Description |
|---|---|---|
dragSensitivity |
0.005 |
Mouse drag rotation sensitivity |
| Field | Default | Description |
|---|---|---|
width |
560 |
Width of the search bar rectangle |
height |
56 |
Height of the search bar rectangle |
borderRadius |
28 |
Corner radius of the search bar (pill shape) |
bottomMargin |
63 |
Distance from the search bar to the bottom of the screen |
borderWidth |
1.5 |
Border width of the search bar |
backgroundColor |
"#ff4400" |
Background color of the search bar (used with backgroundOpacity) |
backgroundOpacity |
0.3 |
Background opacity of the search bar (0-1) |
borderColor |
"#2b2b2b" |
Border color when search is empty |
activeBorderColor |
"#ff4400" |
Border color when search has text |
textColor |
"#ff4400" |
Color of typed search text |
shadowOpacity |
0.4 |
Opacity of the drop shadow below the search bar |
shadowBlur |
1.5 |
Blur radius of the drop shadow |
placeholderText |
"Search apps and windows..." |
Placeholder text when search is empty |
placeholderColor |
"#ff4400" |
Color of the placeholder text |
| Field | Default | Description |
|---|---|---|
delayMs |
150 |
Debounce delay before executing search after keystroke (ms) |
maxResults |
30 |
Maximum results returned from Fuse.js |
fuseThreshold |
0.4 |
Fuse.js match threshold (0=perfect, 1=anything) |
fuseMinMatchCharLength |
1 |
Minimum character length for a Fuse.js match |
ignoreLocation |
true |
When true, search terms can match anywhere in the text — no penalty for distance from the start of the string. Crucial for long window titles. |
layer2Zoom |
1.5 |
Zoom level applied to the sphere during search (layer 2) |
| Field | Default | Description |
|---|---|---|
labelBgOpacity |
0.60 |
Background opacity of the label rectangle on non-selected cards |
nonSelectedIconSize |
110 |
Size of the app icon on non-selected sphere cards |
appIconOpacity |
1.0 |
Opacity of app group icons on non-selected cards (layer 0). Range 0–1. |
cardBorderColor |
"transparent" |
Border colour of non-selected cards on hover. Set to a hex like "#45475a" to show a coloured border on hover, or keep "transparent" for no border. |
windowIconOpacity |
1.0 |
Opacity of icons on window nodes (layer 1/layer 2) — app icons use appIconOpacity |
satelliteAppLabel |
false |
Show label on the satellite card for app nodes (window nodes always show) |
labelBgColor |
"#ff4400" |
Background color of the label rectangle on non-selected cards and satellite |
labelTextColor |
"#2b2b2b" |
Text color of the label rectangle |
labelBgOpacity |
0.5 |
Opacity of the label background pill (0-1) |
nonSelectedLayerLabels.layer_0 |
false |
Show labels on non-selected cards at layer 0 (app list) |
nonSelectedLayerLabels.layer_1 |
true |
Show labels on non-selected cards at layer 1 (window drill-down) |
nonSelectedLayerLabels.layer_2 |
true |
Show labels on non-selected cards at layer 2 (search results) |
windowCountBadge.satellite |
true |
Show badge on the satellite (selected) card |
windowCountBadge.nonSelected |
false |
Show badge on non-selected sphere cards |
windowCountBadge.offsetY |
55 |
Vertical offset of the badge from icon center (negative = up) |
windowCountBadge.offsetX |
0 |
Horizontal offset of the badge from icon center (negative = left) |
windowCountBadge.fontSize |
18 |
Font size of the badge text |
windowCountBadge.padding |
14 |
Total extra space around badge text (symmetric, keeps it circular) |
windowCountBadge.color |
"#ff4400" |
Foreground text color of app window-count badges (prepended with +) |
windowCountBadge.bgColor |
"#2b2b2b" |
Background pill color of app window-count badges |
windowCountBadge.bgOpacity |
1.0 |
Background opacity of app window-count badges (0-1) |
windowCountBadge.windowColor |
"#ff4400" |
Foreground text color of window index badges (plain number, no +) |
windowCountBadge.windowBgColor |
"#2b2b2b" |
Background pill color of window index badges |
windowCountBadge.windowBgOpacity |
1.0 |
Background opacity of window index badges (0-1) |
| Field | Default | Description |
|---|---|---|
maxAngleX |
45 |
Maximum X-axis tilt angle (degrees) for cards at sphere edges |
maxAngleY |
35 |
Maximum Y-axis tilt angle (degrees) for cards at sphere edges |
baseScaleAtEdge |
0.78 |
Scale of cards at the far edge of the sphere (z=0) |
scaleIncreaseTowardCenter |
0.22 |
Additional scale added as cards approach the front center (z=1) |
hoverScaleMultiplier |
1.12 |
Scale multiplier when hovering over a non-selected card |
depthOpacityMultiplier |
4.0 |
How quickly cards fade as they rotate behind the sphere (higher = sharper falloff) |
nonMatchOpacity |
0.15 |
Opacity of non-matching results during search |
Each entry is an object with the following fields. Whitelisted apps always appear on the sphere even when not running. If they ARE running, the entry is deduplicated and shown in its normal MRU position.
| Field | Description |
|---|---|
appId |
App identifier (must match Hyprland's wayland.appId for dedup) |
label |
Human-readable label displayed on the card |
icon |
Freedesktop icon name (fed to image://icon/...) |
exec |
Shell command to launch the app (e.g., "firefox"). This is the primary launch command — if the command contains double quotes, they must be escaped for Lua (\") in the JSON value (see Exec quoting for Lua dispatch below). |
{
"appId": "firefox",
"label": "Firefox",
"icon": "firefox",
"exec": "firefox"
}When a whitelisted entry is committed, the exec command is passed to
Hyprland's Lua API via exec_cmd("..."). This means the command string
appears inside Lua double-quote delimiters:
hl.dsp.exec_cmd("<command>", { maximize = true })If your exec command contains double quotes (e.g., "$XDG_RUNTIME_DIR"
or "(my/eat-new)"), they must be escaped so Lua treats them as literal
characters rather than string terminators. In the JSON value, write \\"
for each embedded double quote:
| Intended character | Write in JSON | Lua sees |
|---|---|---|
" (literal double quote) |
\\" |
\" (escaped, kept as part of string) |
Example: A desktop file Exec= line like:
Exec=ghostty -e bash -lc 'until [ -S "$XDG_RUNTIME_DIR/emacs/server" ]; ...'
Would be written in hyprsphere.json as:
"exec": "ghostty -e bash -lc 'until [ -S \\\"$XDG_RUNTIME_DIR/emacs/server\\\" ]; ...'"Where:
\\"in JSON →"in the parsed JavaScript string"in the JavaScript string → terminated Lua string (WRONG)\\"in JSON →\"in JavaScript → Lua sees\"as escaped quote inside string (CORRECT)
| Key | Action |
|---|---|
ALT + Tab |
Open overlay / cycle forward |
Shift + Tab (while Alt held) |
Cycle backward |
; |
Drill down into app's windows / toggle back |
| Any letter/digit | Search (layer 2) |
Backspace |
Remove last search char / return to layer 0 |
Ctrl + C |
Close selected window(s) |
Escape |
Close overlay |
Alt (release) |
Commit selection |
| Mouse click | Select node |
| Mouse double-click | Commit selection |
| Mouse drag | Rotate sphere |
This is harmless. It means the icon theme doesn't have an icon for a particular appId. The app still works — it just shows a generic fallback icon.
Make sure:
- Quickshell was started without the
-pflag - The symlink
~/.config/quickshell/shell.qmlexists and points to the repository'sshell.qml - Quickshell is running (
qs list --all)
The submap is not being entered. Check:
- The
ALT + Tabbind inkeymaps.luais a Lua function (not a direct dispatcher) - It calls
hl.dispatch(hl.dsp.submap("hyprsphere"))before the IPC command - The
hyprspheresubmap is defined withhl.define_submap("hyprsphere", function() ... end) - Run
hyprctl configerrorsto check for Lua syntax errors
The submap wasn't reset. This was a known bug fixed by adding hyprctl eval
calls in the QML commit paths. Make sure you're running the latest version
of the repository's shell.qml.
The peek feature (a snapshot of the selected window shown behind the sphere)
captures the window via Quickshell's ScreencopyView, which uses Hyprland's
hyprland-toplevel-export-v1 protocol. That capture API accepts a
foreign-toplevel handle — which exposes only appId + title, not the
window's unique 0x… address — so hyprsphere matches the selected node to a
capture handle by appId + title.
In the rare case that two windows of the same app have identical titles
(e.g. two Firefox windows both titled "New Tab"), the snapshot may show the
wrong one. This is a known limitation; see TODO.md for proposed fixes.
hyprsphere uses Fuse.js v7.0.0 (bundled at lib/fuse.js) for
client-side fuzzy matching. Fuse.js is a lightweight fuzzy-search library
tuned for approximate string matching — it handles typos, partial matches,
and out-of-order characters.
import "lib/fuse.js" as FuseJsThis is QML's script import syntax. The .pragma library directive at
the top of fuse.js tells the QML engine to load and cache the library
once, sharing it across all imports rather than re-executing the 2000-line
file on every access.
Quickshell resolves lib/fuse.js relative to the symlink parent
(~/.config/quickshell/), not the actual file location. The chain is:
~/.config/quickshell/shell.qml → /path/to/hyprsphere/shell.qml
~/.config/quickshell/lib → /path/to/hyprsphere/lib/
└── fuse.js ← resolved here
When the overlay opens, initFuseIndex() calls buildSearchDatabase() which
builds a flat array of every searchable item. It contains three types of
entries:
One entry per unique appId from Hyprland.toplevels (the same data source
as the layer 0 sphere). Special workspace windows (special:*) are excluded.
Apps are grouped so that typing the app name surfaces a single result rather
than every window:
{ type: "running-app", appId: "firefox", label: "Firefox",
icon: "firefox", windows: [{ address: "0x...", title: "Mozilla Firefox" }, ...] }The windows array stores every individual window's address and title — used
by drill-down but not for Fuse matching.
A second pass over the same toplevel array adds a separate entry per window for title-based search:
{ type: "window", appId: "firefox", label: "Firefox", icon: "firefox",
address: "0x63d341ce8290", title: "Mozilla Firefox" }This is what lets you type part of a window title and have that specific window appear in results.
Whitelist entries whose appId isn't currently running are added so they
appear in search results too:
{ type: "whitelisted-app", appId: "blender", label: "Blender",
icon: "blender", exec: "blender", windows: [], windowCount: 0 }Yes — the search matches against ALL windows currently alive in Hyprland
(plus whitelisted-but-dormant apps). The same j/clients data that powers
the sphere also feeds the search index.
The flat database is passed to Fuse.js with weighted keys:
| Key | Weight | Purpose |
|---|---|---|
label |
0.5 | Resolved display name (e.g. "Firefox", "Ghostty") |
title |
0.4 | Window title (e.g. "Mozilla Firefox", "π - hyprsphere") |
appId |
0.1 | Raw app identifier (e.g. "com.mitchellh.ghostty") |
fuseIndex = new FuseJs.Fuse(db, {
keys: [
{ name: "label", weight: 0.5 },
{ name: "title", weight: 0.4 },
{ name: "appId", weight: 0.1 }
],
threshold: 0.4,
minMatchCharLength: 1,
ignoreLocation: true, // ← match anywhere in the string, no distance penalty
includeScore: true,
shouldSort: true
});Key settings:
ignoreLocation: true— Crucial for window titles. Typing"fox"matches"Firefox"even though the match starts at character 4.threshold: 0.4— Fairly lenient. Allows typos and partial matches.weight— Labels are most important, then window titles, then raw appIds.
The Fuse index is built once when the overlay opens (in finishOpenSwitcher())
and survives for the entire session. It is only rebuilt when:
- A window opens or closes while the overlay is open and layer 2 is active
(
scheduleRebuild()callsinitFuseIndex()then re-runs the search) - The overlay is closed and reopened (
openSwitcher()→finishOpenSwitcher()→initFuseIndex())
Each keystroke calls _executeSearch() which runs:
function _executeSearch() {
if (!fuseIndex) {
initFuseIndex(); // safety net, usually skipped
if (!fuseIndex) return;
}
var results = fuseIndex.search(searchQuery);
// ... process results ...
}fuseIndex.search() is a lightweight in-memory fuzzy match against the
already-built index. No new Fuse object is created per keystroke — typing
"firefox" character-by-character runs 7 .search() calls on the same
cached index.
Results from Fuse arrive as [{ item, score }, ...] sorted by Fuse's
internal scoring. The pipeline transforms them into a layer 2 sphere:
fuseIndex.search("fox")
│
▼
Results are sliced to maxResults (default 30)
│
▼
Sorted into three buckets by item.type:
├── runApps[] (type === "running-app") ← app groups first
├── whitelistApps[] (type === "whitelisted-app") ← then dormant apps
└── winNodes[] (type === "window") ← individual windows last
│
▼
layer2Model = runApps.concat(whitelistApps).concat(winNodes)
│
▼
sphereModel = layer2Model ← overwrites the layer 0 sphere
sphereZoom = cfg.search.layer2Zoom ← zooms in (default 1.5×)
The sort order is strict: app groups first → whitelisted apps → individual
windows. Within each bucket, Fuse's score order is preserved (best match
first). The .score metadata is discarded — only the raw .item data is
carried into the layer 2 model.
Each search result node gets isSearchResult: true so the sphere delegate
can distinguish it from layer 0 nodes. The sphere zoom increases to
layer2Zoom (configurable, default 1.5×) so fewer nodes are visible at
once, making the results easier to scan.
Backspace to empty or Escape calls cancelSearch(), which rebuilds
buildLayer0(), re-sorts by MRU, restores sphereZoom to 1.0, and
centres the sphere on the pre-selected app.
After _executeSearch() returns, everything goes out of scope:
results(Fuse result wrappers) → garbage collectedtop(sliced copy) → garbage collectedrunApps,whitelistApps,winNodes(temporary buckets) → garbage collected- Previous
sphereModel→ garbage collected (replaced by new assignment)
The only persistent memory is the new sphereModel — roughly 30 small
node objects. On the next keystroke, a fresh pipeline runs and replaces
it.
The entire search pipeline runs inside QML's JavaScript engine. No background process, no Unix socket, no subprocess spawn — every keystroke is evaluated in-process.
Keybind: Ctrl+C while the overlay is open.
Closes all windows of the selected app. The function iterates through
every window in the app's windows array and sends a separate
hyprctl dispatch closewindow command for each one:
for (var w = 0; w < node.windows.length; w++) {
var a = node.windows[w].address;
var p = a.indexOf("0x") === 0 ? "" : "0x";
Quickshell.execDetached(["hyprctl", "dispatch",
'hl.dsp.window.close({window="address:' + p + a + '"})']);
}Each closewindow command triggers a Hyprland raw event, which the
onRawEvent handler picks up to:
- Remove the window address from
appWindowMru(per-app MRU) - If that was the app's last window, remove the app from
appMru - Remove the address from
_appOpeningOrder(compacting window indices) - Call
scheduleRebuild()to refresh the sphere
Closes only the single selected window using its address:
var p = node.address.indexOf("0x") === 0 ? "" : "0x";
Quickshell.execDetached(["hyprctl", "dispatch",
'hl.dsp.window.close({window="address:' + p + node.address + '"})']);After closing:
- The sphere automatically rebuilds — closed windows disappear, and if an app's last window is closed, the entire app node disappears from the sphere (unless it's in the whitelist, in which case it reverts to a placeholder)
- Window index badges compact — if window #3 is closed, windows #4–8 shift down to become #3–7
- The overlay stays open so you can continue cycling
- Guard:
closeSequence.runningprevents double-firing during the exit animation sequence
When spawning a new window with Ctrl+Enter, which window gets focus on Alt release depends on where you spawned from:
- Spawning from the app group (layer 0): The original MRU-most window receives focus when you commit (Alt release). The newly spawned window is added to the app's window list but is not set as the active MRU target for that app.
- Spawning from a specific window (layer 1): The newly spawned
window receives focus when you commit (Alt release). This is because
the new window becomes the most recent window in that app's MRU list,
and layer-1 commits use
appWindowMru[appId][0]to determine focus target.
This is a natural consequence of how MRU tracking works — the act of selecting a specific window and spawning from it makes the new window the MRU-most, while spawning from the app group leaves the existing MRU order intact.
Whitelisted apps that are NOT running have isWhitelistPlaceholder: true.
Committing them (Alt release) launches the app via their configured
exec command rather than focusing an existing window.
Some applications (notably Firefox) have multiple Exec= lines in their
.desktop file for different actions (normal launch, new window, private
window, profile manager). The icon reader only captures the first
Exec= line to avoid launching the wrong action.
Hyprland provides window addresses in two different formats depending on the API, which caused subtle, hard-to-reproduce bugs throughout development.
| Source | Format | Example |
|---|---|---|
t.address (toplevels / activeToplevel) |
Decimal string | "109758992184752" |
event.data (closewindow) |
Hex without 0x | "63d341b2fcf0" |
event.data (openwindow) |
Hex with 0x | "0x5cb8a4e2a040" |
hyprctl clients -j |
Hex | "0x63d341b2fcf0" |
The critical mismatch: t.address from Quickshell's j/clients parser returns
a decimal string, while all event-socket data uses hex. Simply
prepending "0x" to the decimal string (e.g., "0x109758992184752") produces
a different address than the correct hex ("0x63d341b2fcf0"), since
0x109758992184752 in hex equals 4663441176908114 in decimal — not the
original 109758992184752.
A single entry point (shell.qml line ~89) handles all address ingestion:
function normalizeAddress(addr) {
if (!addr) return "";
if (addr.indexOf("0x") === 0) return addr;
// Quickshell may return address as decimal string (from j/clients)
// OR as hex string without 0x prefix (from event-socket fallback).
// Try decimal first; if it fails, treat as raw hex.
var num = Number(addr);
if (!isNaN(num)) return "0x" + num.toString(16);
// Already hex without 0x — just add the prefix
return "0x" + addr;
}This properly converts decimal "109758992184752" → "0x63d341b2fcf0" by
parsing as a number and converting to hex, while also handling hex-without-0x
inputs gracefully via the isNaN fallback.
Every address from t.address (Quickshell's j/clients) now goes through
normalizeAddress(). The event-socket paths (openwindow / closewindow) keep
their simple "0x" + addr since they already receive hex data:
| # | Function | Before (broken) | After (fixed) |
|---|---|---|---|
| 1 | initWindowIndices() |
"0x" + addr |
normalizeAddress(addr) |
| 2 | buildLayer0() |
"0x" + wAddr |
normalizeAddress(wAddr) |
| 3 | buildSearchDatabase() (app) |
"0x" + sAddr1 |
normalizeAddress(sAddr1) |
| 4 | buildSearchDatabase() (window) |
ternary with "0x" |
normalizeAddress(t2.address) |
| 5 | onActiveToplevelChanged() |
"0x" + addr |
normalizeAddress(addr) |
Normalise on ingest, strip on dispatch. Every address is converted to
0x-prefixed hex as soon as it enters the system — at the assignment, not
at the comparison site. This means:
- All internal containers (
globalWindowMru,appWindowMru,sphereModel[].address,_appOpeningOrder) consistently store0x-prefixed hex addresses - All comparisons are direct
===matches with no ad-hoc normalisation - Addresses are only stripped of
0xwhen sent to Hyprland's IPC dispatcher (which expects raw addresses in certain contexts, though the dispatch calls in hyprsphere always ensure0xis present via theprefixpattern shown below)
// hyprctl dispatch expects the address WITH 0x prefix:
var prefix = addr.indexOf("0x") === 0 ? "" : "0x";
Quickshell.execDetached(["hyprctl", "dispatch",
'hl.dsp.focus({window="address:' + prefix + addr + '"})']);If address normalisation is missing or inconsistent, you may see:
- Stale windows accumulate as "unknown" — the most visible symptom.
Closewindow events can't match addresses in
appWindowMru, so cleanup never runs. Stale entries persist inglobalWindowMruandappWindowMru, whilebuildLayer0()sees windows in transitional closing states that fall through toappId = "unknown". These appear with the purple/black QML broken-image icon ("application-x-executable"not found in icon theme). - Window close doesn't update MRU — the closed address doesn't match any
entry in
appWindowMru, so the window is never removed from the per-app MRU list. Next overlay open may reference closed windows. - Drill-down selects the wrong window — the second-MRU window isn't found, falls back to index 0 (MRU-most)
- Opening-order badges are wrong — the badge lookup against
_appOpeningOrderfails due to format mismatch, showing incorrect window indices - Spawned window doesn't auto-select — the pending spawn address doesn't match the sphere model entry
All of these manifest as intermittent, hard-to-reproduce failures because the format an address arrives in depends on whether a window was opened vs. focused, and whether it was present at startup vs. spawned later.
After any sphere rebuild, auto-selection uses different matching logic by layer:
- Layer 0 (app groups): matches by
appId— app nodes don't have an.addressproperty - Layers 1/2 (window nodes): matches by
address— window nodes have.addressbut comparison must handle0xprefix