From 77fca92c5c5e749fd1083489e50319c52809e152 Mon Sep 17 00:00:00 2001 From: rope Date: Thu, 11 Jun 2026 10:47:20 +0100 Subject: [PATCH] quickshell: native launcher + power menu, drop anyrun Co-Authored-By: Claude Fable 5 --- settings/hyprland.nix | 108 +----------------- settings/quickshell.nix | 237 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 240 insertions(+), 105 deletions(-) diff --git a/settings/hyprland.nix b/settings/hyprland.nix index 0afef3a..3fd9f97 100644 --- a/settings/hyprland.nix +++ b/settings/hyprland.nix @@ -55,7 +55,6 @@ in networkmanagerapplet pavucontrol polkit_gnome - anyrun zenity libcanberra-gtk3 ]; @@ -167,30 +166,6 @@ in extraConfig = let - powerMenu = pkgs.writeShellScript "power-menu" '' - # Stop the daemon so standalone stdin mode can run cleanly. - # systemd restarts it automatically afterwards (Restart=on-failure). - systemctl --user stop anyrun.service 2>/dev/null || true - choice=$(printf '%s\n' \ - $'\uf023 Lock' \ - $'\uf08b Logout' \ - $'\uf01e Reboot' \ - $'\uf011 Shutdown' \ - | ${pkgs.anyrun}/bin/anyrun \ - --plugins "${pkgs.anyrun}/lib/libstdin.so" \ - --show-results-immediately true \ - --hide-plugin-info true \ - --close-on-click true) - # Restart the daemon service (reset-failed clears the start-rate limiter). - systemctl --user reset-failed anyrun.service 2>/dev/null - systemctl --user start anyrun.service 2>/dev/null - case "$choice" in - *Lock) ${pkgs.hyprlock}/bin/hyprlock ;; - *Logout) hyprctl dispatch exit ;; - *Reboot) systemctl reboot ;; - *Shutdown) systemctl poweroff ;; - esac - ''; kbdBrightUp = pkgs.writeShellScript "kbd-bright-up" '' ${pkgs.brightnessctl}/bin/brightnessctl -d smc::kbd_backlight set +10% brightness=$(${pkgs.brightnessctl}/bin/brightnessctl -d smc::kbd_backlight get) @@ -260,7 +235,7 @@ in -- Apps hl.bind(mod .. " + T", hl.dsp.exec_cmd("ghostty")) hl.bind(mod .. " + E", hl.dsp.exec_cmd("nemo")) - hl.bind(mod .. " + R", hl.dsp.exec_cmd("hyprctl layers -j | grep -q anyrun && anyrun close || anyrun")) + hl.bind(mod .. " + R", hl.dsp.exec_cmd("${pkgs.quickshell}/bin/qs ipc call launcher toggle")) hl.bind(mod .. " + Q", hl.dsp.window.close()) hl.bind(mod .. " + SHIFT + E", hl.dsp.exit()) @@ -279,8 +254,8 @@ in hl.bind(mod .. " + K", hl.dsp.focus({ direction = "up" })) hl.bind(mod .. " + J", hl.dsp.focus({ direction = "down" })) - -- Power menu — dismiss launcher if open, then show menu - hl.bind(mod .. " + L", hl.dsp.exec_cmd("anyrun close 2>/dev/null; ${powerMenu}")) + -- Power menu (quickshell launcher in power mode) + hl.bind(mod .. " + L", hl.dsp.exec_cmd("${pkgs.quickshell}/bin/qs ipc call launcher powermenu")) -- Move windows hl.bind(mod .. " + SHIFT + left", hl.dsp.window.move({ direction = "left" })) @@ -407,83 +382,6 @@ in # Hyprland session so they don't crash-loop in a GNOME session. wayland.systemd.target = "hyprland-session.target"; - systemd.user.services.anyrun = { - Unit = { - Description = "Anyrun launcher daemon"; - PartOf = [ "graphical-session.target" ]; - After = [ "graphical-session.target" ]; - }; - Service = { - ExecStart = "${pkgs.anyrun}/bin/anyrun daemon"; - Restart = "on-failure"; - RestartSec = 2; - }; - Install.WantedBy = [ "hyprland-session.target" ]; - }; - - xdg.configFile = { - # anyrun config — written manually since HM 26.05 has no anyrun module. - "anyrun/config.ron".text = '' - Config( - x: Fraction(0.5), - y: Fraction(0.25), - width: Absolute(350), - height: Absolute(0), - hide_icons: false, - ignore_exclusive_zones: false, - layer: Overlay, - hide_plugin_info: true, - close_on_click: true, - max_entries: Some(8), - plugins: [ - "${pkgs.anyrun}/lib/libapplications.so", - ], - ) - ''; - "anyrun/style.css".text = '' - * { all: unset; font-family: "FiraMono Nerd Font", monospace; font-size: 13px; } - window { background: transparent; } - box.main { - background: #${c.base00}; - border: 1px solid #${c.base03}; - border-radius: 10px; - padding: 8px; - margin: 16px; - } - text { - background: #${c.base01}; - color: #${c.base05}; - caret-color: #${c.base0D}; - padding: 8px 16px; - border-radius: 6px; - min-height: 0; - } - list.plugin { background: transparent; } - .matches { background: transparent; } - .match { - padding: 4px 16px; - border-radius: 6px; - color: #${c.base05}; - background: transparent; - } - .match:selected { - background: #${c.base02}; - border: none; - } - label.match.description { color: #${c.base04}; font-size: 11px; } - ''; - "anyrun/applications.ron".text = '' - ( - desktop_actions: false, - max_entries: 8, - terminal: Some(( - command: "ghostty", - args: "-e {}", - )), - ) - ''; - }; - }; }; } diff --git a/settings/quickshell.nix b/settings/quickshell.nix index 59e97cf..75e4040 100644 --- a/settings/quickshell.nix +++ b/settings/quickshell.nix @@ -49,6 +49,7 @@ in singleton Theme 1.0 Theme.qml singleton Commands 1.0 Commands.qml Bar 1.0 Bar.qml + Launcher 1.0 Launcher.qml ''; }; @@ -87,6 +88,8 @@ in readonly property string wifiConnect: "${wifiConnectScript}" readonly property string powerprofilesctl: "${powerprofilesctl}" readonly property string notifSound: "${pkgs.libcanberra-gtk3}/bin/canberra-gtk-play" + readonly property string hyprlock: "${pkgs.hyprlock}/bin/hyprlock" + readonly property string systemctl: "${pkgs.systemd}/bin/systemctl" } ''; }; @@ -96,6 +99,7 @@ in text = '' //@ pragma UseQApplication import Quickshell + import Quickshell.Io import Quickshell.Services.Notifications import QtQuick @@ -104,6 +108,17 @@ in property var latestNotification: null signal notificationReceived() + Launcher { + id: launcher + } + + // Bound in hyprland.nix: Super+R → toggle, Super+L → powermenu + IpcHandler { + target: "launcher" + function toggle(): void { launcher.toggleMode("apps"); } + function powermenu(): void { launcher.toggleMode("power"); } + } + NotificationServer { id: _notifServer bodySupported: true @@ -130,6 +145,228 @@ in ''; }; + # App launcher + power menu (replaces anyrun). Full-screen transparent + # overlay with exclusive keyboard focus while open; Esc / click-outside + # closes. Apps come from Quickshell's DesktopEntries service. + "quickshell/Launcher.qml" = { + onChange = qsRestart; + text = '' + import Quickshell + import Quickshell.Wayland + import Quickshell.Hyprland + import QtQuick + + PanelWindow { + id: root + + // "apps" (Super+R) or "power" (Super+L) + property string mode: "apps" + + visible: false + screen: Quickshell.screens[0] + WlrLayershell.namespace: "quickshell-launcher" + WlrLayershell.layer: WlrLayer.Overlay + WlrLayershell.keyboardFocus: visible ? WlrKeyboardFocus.Exclusive : WlrKeyboardFocus.None + exclusionMode: ExclusionMode.Ignore + color: "transparent" + + anchors { + top: true + bottom: true + left: true + right: true + } + + function toggleMode(m) { + if (visible && mode === m) { close(); return; } + mode = m; + search.text = ""; + list.currentIndex = 0; + visible = true; + search.forceActiveFocus(); + } + function close() { + visible = false; + } + + // Power actions go through Hyprland's exec dispatcher so they + // are NOT children of quickshell — a quickshell restart must + // never kill a running hyprlock. + readonly property var powerActions: [ + { name: "Lock", glyph: "", dispatch: "exec " + Commands.hyprlock }, + { name: "Logout", glyph: "", dispatch: "exit" }, + { name: "Reboot", glyph: "", dispatch: "exec " + Commands.systemctl + " reboot" }, + { name: "Shutdown", glyph: "", dispatch: "exec " + Commands.systemctl + " poweroff" } + ] + + function score(name, extra, q) { + let n = name.toLowerCase(); + if (n.startsWith(q)) return 3; + if (n.includes(" " + q)) return 2; + if (n.includes(q)) return 1; + if (extra && extra.toLowerCase().includes(q)) return 1; + return 0; + } + + property var entries: { + let q = search.text.toLowerCase().trim(); + if (mode === "power") { + return powerActions.filter(a => q === "" || score(a.name, "", q) > 0); + } + let apps = DesktopEntries.applications.values.filter(a => !a.noDisplay); + if (q === "") { + apps.sort((a, b) => a.name.localeCompare(b.name)); + return apps.slice(0, 8); + } + let scored = []; + for (let i = 0; i < apps.length; i++) { + let s = score(apps[i].name, apps[i].genericName + " " + apps[i].comment, q); + if (s > 0) scored.push({ app: apps[i], s: s }); + } + scored.sort((a, b) => b.s - a.s || a.app.name.localeCompare(b.app.name)); + return scored.slice(0, 8).map(x => x.app); + } + + function activate(item) { + if (!item) return; + if (mode === "power") { + Hyprland.dispatch(item.dispatch); + } else { + item.execute(); + } + close(); + } + + // Click outside the box closes + MouseArea { + anchors.fill: parent + onClicked: root.close() + } + + Rectangle { + id: box + x: Math.round((parent.width - width) / 2) + y: Math.round(parent.height * 0.25) + width: 350 + height: col.height + 16 + radius: 10 + color: Theme.base00 + border.width: 1 + border.color: Theme.base03 + + // Swallow clicks inside the box + MouseArea { anchors.fill: parent } + + Column { + id: col + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + anchors.margins: 8 + spacing: 6 + + Rectangle { + width: parent.width + height: 36 + radius: 6 + color: Theme.base01 + + TextInput { + id: search + anchors.fill: parent + anchors.leftMargin: 12 + anchors.rightMargin: 12 + verticalAlignment: TextInput.AlignVCenter + color: Theme.base05 + font.family: "FiraMono Nerd Font" + font.pixelSize: 13 + clip: true + onTextChanged: list.currentIndex = 0 + + Keys.onEscapePressed: root.close() + Keys.onUpPressed: list.currentIndex = Math.max(0, list.currentIndex - 1) + Keys.onDownPressed: list.currentIndex = Math.min(root.entries.length - 1, list.currentIndex + 1) + Keys.onReturnPressed: root.activate(root.entries[list.currentIndex]) + Keys.onEnterPressed: root.activate(root.entries[list.currentIndex]) + Keys.onTabPressed: list.currentIndex = (list.currentIndex + 1) % Math.max(1, root.entries.length) + } + + Text { + anchors.fill: search + verticalAlignment: Text.AlignVCenter + visible: search.text === "" + text: root.mode === "power" ? "Power" : "Search" + color: Theme.base03 + font.family: "FiraMono Nerd Font" + font.pixelSize: 13 + } + } + + ListView { + id: list + width: parent.width + height: contentHeight + interactive: false + model: root.entries + + delegate: Rectangle { + required property var modelData + required property int index + width: list.width + height: 32 + radius: 6 + color: list.currentIndex === index ? Theme.base02 : "transparent" + + Row { + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left + anchors.leftMargin: 10 + spacing: 10 + + Image { + visible: root.mode === "apps" && source != "" + anchors.verticalCenter: parent.verticalCenter + width: 18 + height: 18 + sourceSize.width: 18 + sourceSize.height: 18 + source: root.mode === "apps" ? Quickshell.iconPath(modelData.icon, true) : "" + } + + Text { + visible: root.mode === "power" + anchors.verticalCenter: parent.verticalCenter + text: root.mode === "power" ? modelData.glyph : "" + color: Theme.base0D + font.family: "FiraMono Nerd Font" + font.pixelSize: 14 + } + + Text { + anchors.verticalCenter: parent.verticalCenter + text: modelData.name + color: Theme.base05 + font.family: "FiraMono Nerd Font" + font.pixelSize: 13 + elide: Text.ElideRight + width: 270 + } + } + + MouseArea { + anchors.fill: parent + hoverEnabled: true + onEntered: list.currentIndex = index + onClicked: root.activate(modelData) + } + } + } + } + } + } + ''; + }; + "quickshell/Bar.qml" = { onChange = qsRestart; text = ''