feat(quickshell): add application launcher + hover affordance
Update PKGBUILD version / update-pkgver (push) Successful in 6s

New Launcher bar widget (first in the left cluster) opening a LauncherPopout
over DesktopEntries — search field + keyboard-navigable app list, launches via
entry.execute(). Icon-click only for now; Mod+Space stays on walker (drun-only
scope, walker keeps dmenu/run/calc).

Placing a popout in the left cluster forced the shared host anchor to become
side-aware: left-cluster triggers left-align (body grows rightward), right
cluster keeps the original right-align — otherwise a left-anchored popout
renders off-screen. The anchor rect now carries the trigger's left edge + width
(PopoutState.openByName, Tray).

Clickable bar widgets gain a hover affordance via opacity (0.8), not color:
the icon color already encodes status (muted/off/active/critical), so a
color-based hover would clobber that signal. Launcher keeps an accent hover as
it carries no status color.
This commit is contained in:
2026-07-08 11:09:28 +02:00
parent c1c2af1abd
commit 456f2ade99
21 changed files with 357 additions and 10 deletions
+7 -3
View File
@@ -70,13 +70,17 @@ quickshell is the **default bar and notification daemon**, spawned by niri as `q
cd defaults/xdg/quickshell/moonarch && for f in *.qml; do [ "$f" = shell.qml ] && continue; n=${f%.qml}; grep -q 'pragma Singleton' "$f" && echo "singleton $n $f" || echo "$n $f"; done | sort > qmldir
```
- **Own popouts:** a composed widget carries its own popout as a `Component` and renders it in the shared morphing host. Pattern (see any indicator, e.g. `AudioIndicator.qml`): an inline `Component { AudioPopout { popouts: root.popouts } }`; the widget declares a `popoutName` and registers on completion (`popouts.register(popoutName, popoutComponent, root, payloadFn)`), then its click handler calls `popouts.toggleByName(popoutName)`. The widget file needs `pragma ComponentBehavior: Bound` so the popout resolves `root` when the shared host instantiates it. The low-level `popouts.open(component, rect, payload)` still exists for non-registered popouts (Tray).
- **Own popouts:** a composed widget carries its own popout as a `Component` and renders it in the shared morphing host. Pattern (see any indicator, e.g. `AudioIndicator.qml`): an inline `Component { AudioPopout { popouts: root.popouts } }`; the widget declares a `popoutName` and registers on completion (`popouts.register(popoutName, popoutComponent, root, payloadFn)`), then its click handler calls `popouts.toggleByName(popoutName)`. The widget file needs `pragma ComponentBehavior: Bound` so the popout resolves `root` when the shared host instantiates it. The low-level `popouts.open(component, rect, payload)` still exists for non-registered popouts (Tray). The host anchors the popout body to the trigger's on-screen box and picks the side from it: **left-aligned** when the trigger sits in the left half of the screen (so left-cluster popouts grow rightward and stay on-screen), **right-aligned** otherwise (`PopoutHost.qml` `frame.leftAnchored`). The anchor rect carries the trigger's left edge + width; both `openByName` and Tray's direct `open()` pass that box.
- **Popouts by keybind:** registered popouts are addressable by name through the `PopoutRouter` singleton, which exposes an `IpcHandler { target: "popout" }` with `toggle`/`open`/`close`. A niri keybind fires `qs -c moonarch ipc call popout toggle <name>` (e.g. `Mod+A` → `audio`, in `defaults/xdg/niri/config.kdl`). The router routes to the focused monitor's `PopoutState` via `Niri.activeOutput` (workspace `is_focused` → `output`, matched against `screen.name`); an open popout grabs the keyboard (Escape closes). Names: `audio backlight network bluetooth nightlight cpugov battery notifications media`. Tray is not keybind-addressable.
- **Popouts by keybind:** registered popouts are addressable by name through the `PopoutRouter` singleton, which exposes an `IpcHandler { target: "popout" }` with `toggle`/`open`/`close`. A niri keybind fires `qs -c moonarch ipc call popout toggle <name>` (e.g. `Mod+A` → `audio`, in `defaults/xdg/niri/config.kdl`). The router routes to the focused monitor's `PopoutState` via `Niri.activeOutput` (workspace `is_focused` → `output`, matched against `screen.name`); an open popout grabs the keyboard (Escape closes). Names: `audio backlight network bluetooth nightlight cpugov battery notifications media launcher`. Tray is not keybind-addressable.
- **Launcher:** `Launcher.qml` is the first widget in the left cluster (an apps glyph) and owns `LauncherPopout.qml` — an application launcher over `DesktopEntries.applications` (search field + keyboard-navigable icon+name list; ↑/↓ select, Enter/click launch via `entry.execute()`, Esc close; app icons via `Quickshell.iconPath`). It registers under `launcher` (so it is IPC-addressable) but is **icon-click only** for now — `Mod+Space` stays on `walker`, which remains the full dmenu/run/calc launcher. Binding a key later is one line in `config.kdl`. Scope is app-launch (drun) only; walker keeps the other modes.
- **Hover affordance:** clickable bar widgets dim to `opacity: 0.8` on hover (110ms `animDurationShort` fade) so they read as interactive. Deliberately **opacity, not color** — the icon color already encodes status (muted/off → `subtext0`, active → `accent`, battery critical → red), so a color-based hover would clobber the state signal. The Launcher is the exception: no status color, so it uses an `accent` hover instead. Applied per widget (no shared parent to hoist it to); `Workspaces`/`Tray` keep their own per-element hover, non-clickable `PrivacyIndicator`/`WindowTitle` get none.
- **Keyboard navigation:** an open popout grabs the keyboard, so it is fully operable without the mouse. Controls (`Toggle`, `ListRow`, `VolumeSlider`, `IconButton`) set `activeFocusOnTab` and show a focus ring; `PopoutPanel` focuses the first control on open and maps ↑/↓ onto Qt's native Tab chain (`nextItemInFocusChain` + an `activeFocusOnTab` filter). Keys: Tab/↑/↓ move focus, ←/→ nudge a slider ±5%, Space/Enter activate, Esc closes. Icon actions use the shared `IconButton` (destructive ones set `activeColor: Theme.red`); Rectangle-shaped buttons (clear-all, notification actions) are made focusable inline.
Rationale + spike results: `DECISIONS.md` (2026-07-05 pilot + user-extension, 2026-07-06 composition + composable popouts, 2026-07-08 keybind registry + IPC router, keyboard-navigable controls).
Rationale + spike results: `DECISIONS.md` (2026-07-05 pilot + user-extension, 2026-07-06 composition + composable popouts, 2026-07-08 keybind registry + IPC router, keyboard-navigable controls; launcher widget + side-aware popout anchoring).
## mpv + ModernZ OSC
+6
View File
@@ -1,5 +1,11 @@
# Decisions
## 2026-07-08 Quickshell launcher popout + side-aware popout anchoring
- **Who**: Dominik, ClaudeCode
- **Why**: Wanted an application launcher living in the quickshell bar rather than as a separate overlay. Explored the options together: a bar popout (like the existing audio/network popouts) vs. a keybind-only centered overlay. Chose a bar-popout launcher with a click icon as the **first** widget in the left cluster.
- **Tradeoffs**: (1) Placement — keybind-only + centered was deferred as the "complicated" path (centering needs a host change because the body width isn't known until content loads); icon + bar popout reuses the existing register/host/keyboard machinery for free. (2) Keybind — `Mod+Space` stays on `walker`; the quickshell launcher is icon-click only for now. walker remains the full dmenu/run/calc launcher; the quickshell one is app-launch (drun) only. It still registers under `launcher` so wiring a key later is one line. (3) **Side-aware anchoring (forced correctness fix)** — every existing popout sits in the right cluster and the host anchored the body *right-aligned* to the trigger (`x = anchorRect.x - width + flareR`). A left-cluster trigger anchored that way grows leftward off-screen regardless of width. So the far-left placement required generalizing the host anchor: it now derives the side from the trigger's on-screen box (left-align when the trigger is in the left screen half, right-align otherwise). Right-cluster popouts are pixel-identical (recomputed); only the anchor-rect semantics changed (now left-edge + width instead of right-edge). Considered avoiding the host change — impossible, a right-aligned left-edge trigger is always off-screen.
- **How**: New `Launcher.qml` (apps glyph, owns `LauncherPopout` as a `Component`, registers `launcher`, click → `toggleByName`) added first in `shell.qml` `leftContent`. New `LauncherPopout.qml` — fixed-size (so the frame doesn't morph per keystroke) `Item` with `focusFirst()`; a `TextInput` search field over `DesktopEntries.applications.values` (filter on name/genericName/comment/keywords, drop `noDisplay`, sort by name), a `ListView` of icon+name rows (`Quickshell.iconPath` for icons); ↑/↓ move selection, Enter/click `entry.execute()` + close, Esc close. Side-aware anchor: `PopoutHost.qml` `frame.leftAnchored` (`anchorMid < root.width/2`) picks left/right x; `PopoutState.openByName` and `Tray.qml`'s direct `open()` now pass the trigger's left-edge+width box. `qmldir` regenerated. Verified via a repo test instance and a throwaway harness that instantiates `LauncherPopout` directly (`quickshell -p`): config + popout load clean, result list + delegates + icon lookup evaluate without QML errors (a few apps lack a themed icon → blank icon, cosmetic). Follow-on UX tweak in the same commit: clickable bar widgets now give a hover affordance via `opacity: 0.8` (not a color change — icon color is the status signal, so a color hover would clobber muted/active/critical state; the Launcher keeps an `accent` hover as it carries no status color). Applied per widget as there is no shared icon parent; `Workspaces`/`Tray` keep their existing per-element hover.
## 2026-07-08 Quickshell popouts: keyboard-navigable controls
- **Who**: Dominik, ClaudeCode
- **Why**: Popouts open by keybind (Mod+A) and grab the keyboard, but nothing inside was operable without a mouse — the shared controls (`Toggle`/`ListRow`/`VolumeSlider`) and ad-hoc glyph buttons were pure `MouseArea`, took no focus, reacted to no key. Opening by key is pointless if you then need the mouse. Goal: full mouse-free operation.
@@ -6,6 +6,13 @@ import Quickshell.Services.Pipewire
Item {
id: root
// Hover affordance — dim slightly on hover so the clickable icon feels interactive (state color untouched).
opacity: hover.hovered ? 0.8 : 1.0
Behavior on opacity {
NumberAnimation {
duration: Theme.animDurationShort
}
}
property var popouts: null
property string popoutName: "audio"
@@ -5,6 +5,13 @@ import QtQuick
Item {
id: root
// Hover affordance — dim slightly on hover so the clickable icon feels interactive (state color untouched).
opacity: hover.hovered ? 0.8 : 1.0
Behavior on opacity {
NumberAnimation {
duration: Theme.animDurationShort
}
}
property var popouts: null
property string popoutName: "backlight"
@@ -6,6 +6,13 @@ import Quickshell.Services.UPower
Row {
id: root
// Hover affordance — dim slightly on hover so the clickable icon feels interactive (state color untouched).
opacity: hover.hovered ? 0.8 : 1.0
Behavior on opacity {
NumberAnimation {
duration: Theme.animDurationShort
}
}
property var popouts: null
property string popoutName: "battery"
@@ -6,6 +6,13 @@ import Quickshell.Bluetooth
Item {
id: root
// Hover affordance — dim slightly on hover so the clickable icon feels interactive (state color untouched).
opacity: hover.hovered ? 0.8 : 1.0
Behavior on opacity {
NumberAnimation {
duration: Theme.animDurationShort
}
}
property var popouts: null
property string popoutName: "bluetooth"
@@ -5,6 +5,13 @@ import Quickshell
Text {
id: root
// Hover affordance — dim slightly on hover so the clickable icon feels interactive (state color untouched).
opacity: hover.hovered ? 0.8 : 1.0
Behavior on opacity {
NumberAnimation {
duration: Theme.animDurationShort
}
}
property bool showYear: false
SystemClock {
@@ -5,6 +5,13 @@ import QtQuick
Item {
id: root
// Hover affordance — dim slightly on hover so the clickable icon feels interactive (state color untouched).
opacity: hover.hovered ? 0.8 : 1.0
Behavior on opacity {
NumberAnimation {
duration: Theme.animDurationShort
}
}
property var popouts: null
property string popoutName: "cpugov"
@@ -4,6 +4,13 @@ import QtQuick
Item {
id: root
// Hover affordance — dim slightly on hover so the clickable icon feels interactive (state color untouched).
opacity: hover.hovered ? 0.8 : 1.0
Behavior on opacity {
NumberAnimation {
duration: Theme.animDurationShort
}
}
implicitWidth: iconText.implicitWidth
implicitHeight: Theme.barHeight
@@ -0,0 +1,49 @@
// ABOUTME: Launcher bar widget — an apps glyph that opens the application-launcher popout on click.
// ABOUTME: Registers under "launcher" so it is also addressable by keybind via the niri IPC router.
pragma ComponentBehavior: Bound
import QtQuick
Item {
id: root
property var popouts: null
property string popoutName: "launcher"
// Register with the shared popout state so this popout is addressable by keybind (niri IPC).
Component.onCompleted: if (root.popouts)
root.popouts.register(popoutName, popoutComponent, root, null)
// This widget's popout — handed to the shared animated host on click.
Component {
id: popoutComponent
LauncherPopout {
popouts: root.popouts
}
}
implicitWidth: Theme.barHeight
implicitHeight: Theme.barHeight
readonly property bool open: root.popouts && root.popouts.currentName === root.popoutName
Text {
anchors.centerIn: parent
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize + 5
color: (hover.hovered || root.open) ? Theme.accent : Theme.text
text: String.fromCodePoint(0xf14de) // md-rocket-launch
}
HoverHandler {
id: hover
}
MouseArea {
anchors.fill: parent
onClicked: if (root.popouts)
root.popouts.toggleByName(root.popoutName)
}
Tooltip {
anchorItem: root
shown: hover.hovered
text: qsTr("Applications")
}
}
@@ -0,0 +1,189 @@
// ABOUTME: Application launcher popout — a search field over DesktopEntries with a keyboard-navigable
// ABOUTME: result list (icon + name); Enter/click launches the selected app and closes the popout.
pragma ComponentBehavior: Bound
import QtQuick
import Quickshell
Item {
id: root
property var popouts: null
// Fixed size so the frame does not morph on every keystroke (the host sizes from implicit size).
readonly property int panelWidth: 460
readonly property int rowHeight: 40
readonly property int visibleRows: 8
implicitWidth: col.width
implicitHeight: col.height
property string query: ""
// Case-insensitive substring match over name/genericName/comment/keywords.
function matches(app, q) {
if (!q)
return true;
const hay = [app.name, app.genericName, app.comment, (app.keywords || []).join(" ")].filter(Boolean).join(" ").toLowerCase();
return hay.includes(q);
}
readonly property var results: {
const all = DesktopEntries.applications ? DesktopEntries.applications.values : [];
const q = root.query.trim().toLowerCase();
const out = all.filter(a => a && !a.noDisplay && root.matches(a, q));
out.sort((a, b) => (a.name || "").localeCompare(b.name || ""));
return out;
}
function launch(app) {
if (!app)
return;
app.execute();
if (root.popouts)
root.popouts.close();
}
// The host focuses this on open (PopoutHost's focusFirst path) — hand focus to the search field.
function focusFirst() {
input.forceActiveFocus();
}
// Reset the selection to the top whenever the filtered set changes.
onQueryChanged: list.currentIndex = 0
Column {
id: col
width: root.panelWidth
leftPadding: 12
rightPadding: 12
topPadding: 12
bottomPadding: 12
spacing: 8
readonly property int innerWidth: width - leftPadding - rightPadding
// --- Search field ---
Rectangle {
width: col.innerWidth
height: 34
radius: Theme.radius
color: Theme.surface0
border.width: input.activeFocus ? 1 : 0
border.color: Theme.accent
Row {
anchors.fill: parent
anchors.leftMargin: 10
anchors.rightMargin: 10
spacing: 8
Text {
anchors.verticalCenter: parent.verticalCenter
text: String.fromCodePoint(0xf0349) // md-magnify
color: Theme.subtext0
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize + 2
}
TextInput {
id: input
anchors.verticalCenter: parent.verticalCenter
width: parent.width - parent.spacing - 20
color: Theme.text
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
clip: true
selectByMouse: true
activeFocusOnTab: true
onTextChanged: root.query = text
// Placeholder — shown only while empty.
Text {
anchors.verticalCenter: parent.verticalCenter
visible: input.text.length === 0
text: qsTr("Search applications…")
color: Theme.subtext0
font: input.font
}
Keys.onDownPressed: list.incrementCurrentIndex()
Keys.onUpPressed: list.decrementCurrentIndex()
Keys.onReturnPressed: root.launch(root.results[list.currentIndex])
Keys.onEnterPressed: root.launch(root.results[list.currentIndex])
Keys.onEscapePressed: if (root.popouts)
root.popouts.close()
}
}
}
// --- Results ---
Item {
width: col.innerWidth
height: root.rowHeight * root.visibleRows
ListView {
id: list
anchors.fill: parent
clip: true
model: root.results
currentIndex: 0
boundsBehavior: Flickable.StopAtBounds
delegate: Rectangle {
id: appRow
required property var modelData
required property int index
width: ListView.view.width
height: root.rowHeight
radius: Theme.radius
color: (appRow.ListView.isCurrentItem || rowHover.hovered) ? Theme.surface0 : "transparent"
Row {
anchors.fill: parent
anchors.leftMargin: 8
anchors.rightMargin: 8
spacing: 10
Image {
anchors.verticalCenter: parent.verticalCenter
width: 24
height: 24
sourceSize.width: 24
sourceSize.height: 24
fillMode: Image.PreserveAspectFit
source: Quickshell.iconPath(appRow.modelData.icon || "", "application-x-executable")
}
Text {
anchors.verticalCenter: parent.verticalCenter
width: parent.width - 24 - parent.spacing
text: appRow.modelData.name || appRow.modelData.id
elide: Text.ElideRight
color: Theme.text
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
}
}
HoverHandler {
id: rowHover
}
MouseArea {
anchors.fill: parent
onClicked: {
list.currentIndex = appRow.index;
root.launch(appRow.modelData);
}
}
}
}
// Empty state — overlays the (empty) list area.
Text {
anchors.centerIn: parent
visible: root.results.length === 0
text: qsTr("No matches")
color: Theme.subtext0
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
}
}
}
}
@@ -6,6 +6,13 @@ import Quickshell.Services.Mpris
Item {
id: root
// Hover affordance — dim slightly on hover so the clickable icon feels interactive (state color untouched).
opacity: hover.hovered ? 0.8 : 1.0
Behavior on opacity {
NumberAnimation {
duration: Theme.animDurationShort
}
}
property var popouts: null
property int maxWidth: 220
property string popoutName: "media"
@@ -6,6 +6,13 @@ import Quickshell.Networking
Item {
id: root
// Hover affordance — dim slightly on hover so the clickable icon feels interactive (state color untouched).
opacity: hover.hovered ? 0.8 : 1.0
Behavior on opacity {
NumberAnimation {
duration: Theme.animDurationShort
}
}
property var popouts: null
property string popoutName: "network"
@@ -5,6 +5,13 @@ import QtQuick
Item {
id: root
// Hover affordance — dim slightly on hover so the clickable icon feels interactive (state color untouched).
opacity: hover.hovered ? 0.8 : 1.0
Behavior on opacity {
NumberAnimation {
duration: Theme.animDurationShort
}
}
property var popouts: null
property string popoutName: "nightlight"
@@ -5,6 +5,13 @@ import QtQuick
Item {
id: root
// Hover affordance — dim slightly on hover so the clickable icon feels interactive (state color untouched).
opacity: hover.hovered ? 0.8 : 1.0
Behavior on opacity {
NumberAnimation {
duration: Theme.animDurationShort
}
}
property var popouts: null
property string popoutName: "notifications"
@@ -75,9 +75,15 @@ PanelWindow {
readonly property real bodyH: content.implicitHeight + pad * 2
property real offset: root.wantOpen ? 0 : 1
// Anchor side: left-align when the trigger sits in the left half of the screen (left-cluster
// popouts grow rightward and stay on-screen), right-align otherwise (the original behavior for
// right-cluster widgets). anchorRect is the trigger's on-screen box in this surface's coords.
readonly property real anchorMid: root.popouts.anchorRect.x + root.popouts.anchorRect.width / 2
readonly property bool leftAnchored: anchorMid < root.width / 2
clip: true
// Body right edge aligns to the trigger icon; the panel is wider by the flare radius each side.
x: root.popouts.anchorRect.x - width + flareR
// The panel is wider than the body by the flare radius each side; align the body edge to the trigger.
x: frame.leftAnchored ? root.popouts.anchorRect.x - flareR : root.popouts.anchorRect.x + root.popouts.anchorRect.width - width + flareR
y: 0
implicitWidth: bodyW + flareR * 2
implicitHeight: bodyH * (1 - offset)
@@ -58,8 +58,10 @@ QtObject {
if (!e || !e.anchorItem)
return;
const it = e.anchorItem;
const pr = it.mapToItem(null, it.width, it.height + 4);
root.open(e.component, Qt.rect(pr.x, pr.y, 0, 0), e.payloadFn ? e.payloadFn() : null);
// Anchor rect = the widget's on-screen box (top-left below it + its width). The host picks the
// alignment side from this, so a left-cluster widget's popout grows rightward and stays on-screen.
const tl = it.mapToItem(null, 0, it.height + 4);
root.open(e.component, Qt.rect(tl.x, tl.y, it.width, 0), e.payloadFn ? e.payloadFn() : null);
root.currentName = name;
}
+4 -3
View File
@@ -45,9 +45,10 @@ Row {
else if (mouse.button === Qt.MiddleButton)
iconItem.modelData.secondaryActivate();
else if (mouse.button === Qt.RightButton && iconItem.modelData.hasMenu && root.popouts) {
// Icon bottom-right in screen coords (the bar window origin equals the screen origin).
const p = iconItem.mapToItem(null, iconItem.width, iconItem.height + 4);
root.popouts.open(popoutComponent, Qt.rect(p.x, p.y, 0, 0), iconItem.modelData.menu);
// Icon bottom-left + width in screen coords (the bar window origin equals the screen
// origin); the host derives the anchor side from this box.
const p = iconItem.mapToItem(null, 0, iconItem.height + 4);
root.popouts.open(popoutComponent, Qt.rect(p.x, p.y, iconItem.width, 0), iconItem.modelData.menu);
}
}
}
@@ -5,6 +5,13 @@ import Quickshell.Io
Item {
id: root
// Hover affordance — dim slightly on hover so the clickable icon feels interactive (state color untouched).
opacity: hover.hovered ? 0.8 : 1.0
Behavior on opacity {
NumberAnimation {
duration: Theme.animDurationShort
}
}
property int count: 0
property string tip: ""
+2
View File
@@ -13,6 +13,8 @@ CpuGovPopout CpuGovPopout.qml
Divider Divider.qml
IconButton IconButton.qml
IdleInhibitorIndicator IdleInhibitorIndicator.qml
Launcher Launcher.qml
LauncherPopout LauncherPopout.qml
ListRow ListRow.qml
ModuleBox ModuleBox.qml
MprisPopout MprisPopout.qml
@@ -14,6 +14,10 @@ ShellRoot {
// Left cluster.
leftContent: [
Launcher {
Layout.alignment: Qt.AlignVCenter
popouts: bar.popouts
},
ModuleBox {
Layout.alignment: Qt.AlignVCenter
Clock {}