diff --git a/.gitignore b/.gitignore index 6c7c6c5..34d9682 100644 --- a/.gitignore +++ b/.gitignore @@ -12,7 +12,6 @@ !waybar/ !zathura/ !mimeapps.list -!fuzzel/ !fish/ !packages !brave-flags.conf diff --git a/.gitmodules b/.gitmodules index 64eb05e..afcc75c 100644 --- a/.gitmodules +++ b/.gitmodules @@ -19,9 +19,6 @@ [submodule "btop/catppuccin-btop"] path = btop/catppuccin-btop url = https://github.com/catppuccin/btop.git -[submodule "fuzzel/catppuccin-fuzzel"] - path = fuzzel/catppuccin-fuzzel - url = https://github.com/catppuccin/fuzzel.git [submodule "nvim"] path = nvim url = https://git.narl.io/nvrl/nvim-config.git diff --git a/fluxo/config.toml b/fluxo/config.toml index 39f8447..1674203 100644 --- a/fluxo/config.toml +++ b/fluxo/config.toml @@ -14,7 +14,7 @@ # marquee ticker firing twice a second, all feeding a bar that does not exist. [general] -menu_command = "fuzzel --dmenu --prompt \"$FLUXO_PROMPT\"" +menu_command = "takemi-dmenu --prompt \"$FLUXO_PROMPT\"" [signals] network = 1 diff --git a/fuzzel/catppuccin-fuzzel b/fuzzel/catppuccin-fuzzel deleted file mode 160000 index 0af0e26..0000000 --- a/fuzzel/catppuccin-fuzzel +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 0af0e26901b60ada4b20522df739f032797b07c3 diff --git a/fuzzel/fuzzel.ini b/fuzzel/fuzzel.ini deleted file mode 100644 index 8a23f12..0000000 --- a/fuzzel/fuzzel.ini +++ /dev/null @@ -1,19 +0,0 @@ -include=~/.config/fuzzel/takemi.ini - -[main] -# JetBrainsMono to match Theme.fontMono, so the launcher and the bar are -# visibly the same typeface. -font=JetBrainsMono Nerd Font:size=14 -terminal=alacritty -dpi-aware=no - -width=45 -lines=8 -horizontal-pad=20 -vertical-pad=20 -inner-pad=10 - -prompt=" " - -show-actions=no -layer=overlay diff --git a/fuzzel/takemi.ini b/fuzzel/takemi.ini deleted file mode 100644 index bcfb587..0000000 --- a/fuzzel/takemi.ini +++ /dev/null @@ -1,37 +0,0 @@ -# Takemi — matched to ~/.config/quickshell/config/Theme.qml, -# ~/.config/alacritty/takemi.toml and ~/.config/nvim/lua/takemi.lua. -# -# Near-black ground, white text, crimson as the only saturated colour. The -# selection is a solid crimson block with the text punched out of it in black, -# which is the same "active" signal the bar and the terminal use. - -[colors] -# Flat and fully opaque. The Persona surfaces are ink on ground — translucency -# and blur read as a different design language, so the alpha stays at ff. -background=0a0a0cff -text=c8c8ceff -input=ffffffff -placeholder=6b6b70ff -prompt=ff2d40ff - -# Fuzzy-match characters in an unselected row: crimson, since they are the -# reason the row is on screen at all. -match=ff2d40ff - -selection=ff2d40ff -selection-text=000000ff -# White rather than black, so the matched characters still read as marked once -# the row is already crimson. -selection-match=ffffffff - -# Deliberately the same as the background — hides the match counter. -counter=0a0a0cff - -border=ff2d40ff - -[border] -# Square. Every corner in this style is either a right angle or a hard chamfer; -# nothing in the shell is rounded. -radius=0 -# Theme.stroke. -width=2 diff --git a/hypr/lua/apps.lua b/hypr/lua/apps.lua index 47f2412..fced52e 100644 --- a/hypr/lua/apps.lua +++ b/hypr/lua/apps.lua @@ -3,13 +3,21 @@ return { terminal = "alacritty", fileManager = "nautilus", - menu = "fuzzel", + -- The launcher lives in the Quickshell process (quickshell/launcher/), so + -- the bind toggles it over IPC instead of spawning anything. Nothing to + -- start up means it is on screen on the same frame as the keypress. + menu = "qs ipc call shell launcher", - -- clipboard history picker - clipboard = "cliphist list | fuzzel --dmenu | cliphist decode | wl-copy", + -- Clipboard history. Opens the launcher already in its ":" mode, which + -- reads cliphist itself and copies the chosen entry back — so the picker is + -- the same window, with the same keys, as everything else. + clipboard = "qs ipc call shell prompt :", - -- pdf picker - pdfs = "pdfs-prompt --dmenu --menu 'fuzzel --dmenu --width 60'", + -- Proton Drive search. --fzf re-queries the daemon on every keystroke, so + -- results appear as you type; a dmenu could only filter a list it was + -- handed once, which made searching a two-step. It spawns its own terminal + -- (class pdfs-prompt, floated in lua/rules/windows.lua). + pdfs = "pdfs-prompt --fzf", -- bluetooth menu bluetooth = "fluxo bt menu", diff --git a/hypr/lua/rules/layers.lua b/hypr/lua/rules/layers.lua index 53080f8..b83ca94 100644 --- a/hypr/lua/rules/layers.lua +++ b/hypr/lua/rules/layers.lua @@ -1,10 +1,10 @@ -- https://wiki.hypr.land/Configuring/Basics/Window-Rules/#layer-rules -- --- Quickshell surfaces (bar, popouts, OSD) and the fuzzel launcher. Note +-- Quickshell surfaces: the bar, popouts, the OSD and the launcher. Note -- decoration.blur is off in lua/look.lua, so these only take effect if blur is -- switched back on. -for _, namespace in ipairs({ "bottom", "top", "launcher" }) do +for _, namespace in ipairs({ "bottom", "top", "takemi-launcher" }) do hl.layer_rule({ match = { namespace = namespace }, blur = true, diff --git a/hypr/scripts/powermenu.sh b/hypr/scripts/powermenu.sh deleted file mode 100755 index f6c40b2..0000000 --- a/hypr/scripts/powermenu.sh +++ /dev/null @@ -1,18 +0,0 @@ -#!/bin/bash -options="Shutdown\nReboot\nLock\nLogout" -selected=$(echo -e $options | fuzzel --dmenu --prompt='Power:') - -case $selected in - "Shutdown") - systemctl poweroff - ;; - "Reboot") - systemctl reboot - ;; - "Lock") - hyprlock - ;; - "Logout") - hyprctl dispatch exit - ;; -esac diff --git a/packages b/packages index fb58aa4..c04ae2e 100644 --- a/packages +++ b/packages @@ -178,7 +178,6 @@ fribidi fuse-common fuse2 fuse3 -fuzzel fzf gawk gc diff --git a/quickshell/README.md b/quickshell/README.md index 5126c24..9ac12c1 100644 --- a/quickshell/README.md +++ b/quickshell/README.md @@ -199,8 +199,9 @@ Persona 3's blue and nothing else needs touching. `fish_frozen_theme.fish` that fish 4.3 generated on upgrade. The prompt arrow is white and turns crimson on a non-zero exit — the shell's one "wrong" signal, and so the one place that earns the accent. -- **Fuzzel** — `fuzzel/takemi.ini`, selected by the `include` in `fuzzel.ini`. - Square corners rather than the stock radius, and a fully opaque ground. +- **The launcher** — no external theming to keep in step, because it is part of + this shell (`launcher/`). Fuzzel is gone; `takemi-dmenu` stands in for + `fuzzel --dmenu` where a script needs a picker. - **No glass anywhere.** Alacritty is `opacity = 1.0, blur = false`, and Hyprland's `decoration:blur` is disabled with both opacities at `1.0`. Blur is the signature of the frosted-glass look, which is the opposite of flat ink on diff --git a/quickshell/bar/Sigil.qml b/quickshell/bar/Sigil.qml index 5361b1b..10c00ac 100644 --- a/quickshell/bar/Sigil.qml +++ b/quickshell/bar/Sigil.qml @@ -17,7 +17,7 @@ BarPill { lean: Theme.skew implicitWidth: Theme.barHeight + contentPad - onClicked: Actions.launch("fuzzel") + onClicked: Actions.toggleLauncher() onRightClicked: Actions.reloadShell() // The wordmark. Was the Death arcana skull (nf-md-skull, U+F068C) — a diff --git a/quickshell/launcher/Launcher.qml b/quickshell/launcher/Launcher.qml new file mode 100644 index 0000000..1794ff9 --- /dev/null +++ b/quickshell/launcher/Launcher.qml @@ -0,0 +1,526 @@ +import QtQuick +import Quickshell +import Quickshell.Wayland +import Quickshell.Widgets +import "root:/config" +import "root:/components" +import "root:/popouts" +import "root:/services" + +// The launcher. One input, one list, and a small amount of grammar. +// +// TYPING is always typing. Navigation lives on the arrows and on Ctrl+J/K so +// that nothing you can type is ever stolen from you — except in select mode, +// which you enter deliberately (the first arrow press does it) and which hands +// h/j/k/l over to navigation until you press Escape or Backspace. That is the +// compromise the vim keys need: a launcher whose input eats `j` is a launcher +// you cannot use to find Jellyfin. +// +// The other half of the grammar is the argument. Type a URL and the list turns +// into "open this with…", already sorted so the system default is row zero — +// so the fast path is type, Enter, done, and the slow path is one arrow key. +// Tab pins whatever is selected, which does the same thing for arguments that +// do not announce themselves as arguments. +PanelWindow { + id: root + + required property var modelData + screen: modelData + + readonly property bool open: Actions.launcherOpen + visible: root.open || closeAnim.running + + WlrLayershell.layer: WlrLayer.Overlay + WlrLayershell.namespace: "takemi-launcher" + WlrLayershell.keyboardFocus: root.open + ? WlrKeyboardFocus.Exclusive + : WlrKeyboardFocus.None + + anchors { top: true; bottom: true; left: true; right: true } + color: "transparent" + exclusionMode: ExclusionMode.Ignore + + // ----------------------------------------------------------------- state + property string query: "" + property var pinned: null // an index item, frozen as the verb + property bool selectMode: false + property int selected: 0 + + readonly property int maxRows: 8 + readonly property int rowHeight: 46 + + // --------------------------------------------------------------- grammar + readonly property string prefix: root.pinned ? "" : root.query.slice(0, 1) + // A dmenu request owns the window outright — there is no app search to + // fall back to, because the caller is waiting on a choice from its own + // list. + readonly property string mode: Apps.dmenuActive ? "dmenu" + : root.prefix === ">" ? "shell" + : root.prefix === "=" ? "calc" + : root.prefix === ":" ? "clip" + : "app" + + readonly property bool prefixed: root.mode === "shell" + || root.mode === "calc" || root.mode === "clip" + + readonly property string body: root.prefixed + ? root.query.slice(1) + : root.query + + // With an app pinned, everything you type is the argument. Without one, an + // argument only exists if what you typed is self-evidently an object. + readonly property string argKind: root.pinned + ? (Apps.classify(root.body) || "text") + : Apps.classify(root.body) + + readonly property string arg: root.mode !== "app" || root.body.trim() === "" + ? "" + : (root.pinned || root.argKind !== "") + ? Apps.normalizeArg(root.body, root.argKind) + : "" + + // What is left to match app names against. When the whole query has been + // claimed as an argument there is nothing left, and the list falls back to + // "every app, best handler first". + readonly property string term: root.mode !== "app" || root.pinned || root.arg !== "" + ? "" + : root.body + + readonly property var results: { + if (!root.open) return []; + + if (root.mode === "calc") { + const v = Apps.calc(root.body); + if (v === "") return []; + return [{ kind: "calc", name: v, sub: "copy to clipboard", + icon: "accessories-calculator", value: v }]; + } + + if (root.mode === "dmenu") return Apps.searchDmenu(root.body); + if (root.mode === "clip") return Apps.searchClips(root.body); + + if (root.mode === "shell") { + if (root.body.trim() === "") return []; + return [ + { kind: "shell", name: root.body, sub: "run", icon: "utilities-terminal" }, + { kind: "shellterm", name: root.body, sub: "run in a terminal", + icon: "utilities-terminal" } + ]; + } + + // Pinned: the verb is decided, so the list narrows to that app and the + // things it can do — which is where "Brave, but a private window" + // lives. Same list, one step further in. + if (root.pinned) { + const id = root.pinned.id; + return Apps.index.filter(e => e.id === id) + .sort((a, b) => (a.kind === "app" ? 0 : 1) - (b.kind === "app" ? 0 : 1)); + } + + return Apps.search(root.term, root.arg, root.argKind); + } + + onResultsChanged: root.selected = 0 + + // Typing the ":" prefix is the other way into the clipboard, and the + // history may have moved on since the window opened. + onModeChanged: if (root.mode === "clip") Apps.refreshClips() + onOpenChanged: { + if (root.open) { + PopupCoordinator.dismissAll(); + root.reset(); + openAnim.restart(); + input.forceActiveFocus(); + input.cursorPosition = input.text.length; + Apps.refreshClips(); + } else { + // Dismissing a dmenu is a valid answer — an empty one. Without this + // the caller blocks until its own timeout. + Apps.finishDmenu(""); + closeAnim.restart(); + } + } + + function reset(): void { + root.query = Actions.launcherSeed; + root.pinned = null; + root.selectMode = false; + root.selected = 0; + } + + function move(delta: int): void { + const n = root.results.length; + if (n === 0) return; + root.selected = (root.selected + delta + n) % n; + list.positionViewAtIndex(root.selected, ListView.Contain); + } + + function pin(): void { + const item = root.results[root.selected]; + if (!item || (item.kind !== "app" && item.kind !== "action")) return; + root.pinned = item; + root.query = ""; + root.selectMode = false; + input.forceActiveFocus(); + } + + function unpin(): void { + if (!root.pinned) return; + root.query = root.pinned.name.split(" · ")[0]; + root.pinned = null; + input.forceActiveFocus(); + } + + function activate(): void { + const item = root.results[root.selected]; + if (!item) return; + + switch (item.kind) { + case "dmenu": Apps.finishDmenu(item.name); break; + case "clip": Apps.pasteClip(item); break; + case "calc": Apps.copy(item.value); break; + case "shell": Apps.runShell(root.body, false); break; + case "shellterm": Apps.runShell(root.body, true); break; + default: Apps.launch(item, root.arg); break; + } + Actions.closeLauncher(); + } + + // -------------------------------------------------------------- backdrop + Rectangle { + id: backdrop + anchors.fill: parent + color: Theme.alpha(Theme.base, 0.72) + opacity: 0 + + TapHandler { onTapped: Actions.closeLauncher() } + } + + // ----------------------------------------------------------------- panel + Item { + id: shell + + width: 680 + height: header.height + divider.height + listBox.height + hint.height + anchors.horizontalCenter: parent.horizontalCenter + // Sits above centre. A launcher pinned to the exact middle of a 1440p + // screen puts the list below the eyeline and the input above it; the + // input is the thing you are looking at. + y: parent.height * 0.22 + + opacity: 0 + transform: Translate { id: rise; y: 24 } + + PopoutSurface { + anchors.fill: parent + tone: root.arg !== "" ? Theme.accent : Theme.primary + padding: 0 + slashWidth: root.arg !== "" ? 8 : 5 + + // ------------------------------------------------------- header + Item { + id: header + width: parent.width + height: 66 + anchors.top: parent.top + + Row { + anchors.fill: parent + anchors.leftMargin: Theme.padL + anchors.rightMargin: Theme.padL + spacing: Theme.padM + + Icon { + anchors.verticalCenter: parent.verticalCenter + text: root.mode === "shell" ? "" : root.mode === "calc" ? "󰃬" + : root.mode === "clip" ? "󰅇" + : root.mode === "dmenu" ? "󰘳" + : root.arg !== "" ? "󰏌" : "󰍉" + color: root.arg !== "" ? Theme.accent : Theme.primary + font.pixelSize: Theme.fsTitle + } + + // The pin chip. Once this is on screen the input means + // "argument", and the chip is the only thing saying so. + Item { + anchors.verticalCenter: parent.verticalCenter + visible: root.pinned !== null + width: visible ? chipText.width + Theme.padM * 2 : 0 + height: 26 + + Skew { + anchors.fill: parent + color: Theme.accent + } + + Text { + id: chipText + anchors.centerIn: parent + text: root.pinned ? root.pinned.name.toUpperCase() : "" + color: Theme.treatmentDestructiveText + font.family: Theme.fontMono + font.pixelSize: Theme.fsMicro + font.weight: Font.Bold + font.letterSpacing: 2 + renderType: Text.NativeRendering + } + } + + TextInput { + id: input + anchors.verticalCenter: parent.verticalCenter + width: parent.width - x + text: root.query + onTextChanged: root.query = text + color: root.selectMode ? Theme.muted : Theme.text + font.family: Theme.fontMono + font.pixelSize: Theme.fsLarge + selectionColor: Theme.accent + selectedTextColor: Theme.ink + clip: true + focus: true + renderType: Text.NativeRendering + + // Ours must run before TextInput's, or select mode's + // h/j/k/l would be typed into the field before we ever + // saw them. + Keys.priority: Keys.BeforeItem + Keys.onPressed: event => root.handleKey(event) + + Text { + anchors.verticalCenter: parent.verticalCenter + visible: input.text === "" + text: root.pinned ? "argument…" + : root.mode === "shell" ? "command…" + : root.mode === "calc" ? "expression…" + : root.mode === "clip" ? "clipboard history…" + : root.mode === "dmenu" + ? (Apps.dmenuPrompt || "choose…") + : "search, or paste a link" + color: Theme.muted + font: input.font + renderType: Text.NativeRendering + } + } + } + + // Count, right-aligned over the input's tail. + Text { + anchors.right: parent.right + anchors.rightMargin: Theme.padL + anchors.verticalCenter: parent.verticalCenter + text: root.results.length > 0 + ? (root.selected + 1) + "/" + root.results.length + : "" + color: Theme.muted + font.family: Theme.fontMono + font.pixelSize: Theme.fsMicro + font.letterSpacing: 1 + renderType: Text.NativeRendering + } + } + + Skew { + id: divider + anchors.top: header.bottom + width: parent.width + height: Theme.stroke + color: Theme.alpha(Theme.outline, 0.8) + lean: 0 + } + + // -------------------------------------------------------- list + Item { + id: listBox + anchors.top: divider.bottom + width: parent.width + height: Math.min(root.results.length, root.maxRows) * root.rowHeight + + (root.results.length > 0 ? Theme.padS * 2 : 0) + clip: true + + Behavior on height { + NumberAnimation { duration: Theme.durBase; easing.type: Easing.OutExpo } + } + + ListView { + id: list + anchors.fill: parent + anchors.topMargin: Theme.padS + anchors.bottomMargin: Theme.padS + anchors.leftMargin: Theme.padM + anchors.rightMargin: Theme.padM + model: root.results + currentIndex: root.selected + boundsBehavior: Flickable.StopAtBounds + reuseItems: true + + delegate: ResultRow { + required property var modelData + required property int index + + width: list.width + entry: modelData + selected: index === root.selected + badge: root.arg !== "" ? "OPEN ⏎" : "⏎" + onActivated: { + root.selected = index; + root.activate(); + } + onPinRequested: { + root.selected = index; + root.pin(); + } + } + } + + // Empty state. Says which of the two ways to be empty it is, + // because "no results" and "nothing typed" want different + // things from you. + Text { + anchors.centerIn: parent + visible: root.results.length === 0 && root.query !== "" + text: root.mode === "calc" ? "NOT AN EXPRESSION" : "NO MATCH" + color: Theme.muted + font.family: Theme.fontMono + font.pixelSize: Theme.fsSmall + font.letterSpacing: 4 + renderType: Text.NativeRendering + } + } + + // -------------------------------------------------------- hints + Item { + id: hint + anchors.top: listBox.bottom + width: parent.width + height: 30 + + Text { + anchors.centerIn: parent + color: Theme.muted + font.family: Theme.fontMono + font.pixelSize: Theme.fsMicro + font.letterSpacing: 3 + renderType: Text.NativeRendering + text: root.selectMode + ? "H J K L MOVE L PIN ESC TYPE" + : root.mode === "dmenu" + ? "⏎ CHOOSE ESC CANCEL ↑↓ SELECT" + : root.mode === "clip" + ? "⏎ COPY ESC BACK ↑↓ SELECT" + : root.pinned + ? "⏎ OPEN ESC UNPIN ↑↓ PICK" + : "TAB PIN > SHELL = CALC : CLIPBOARD" + } + } + } + } + + // ------------------------------------------------------------------ keys + function handleKey(event: var): void { + if (!root.open) return; + + const ctrl = (event.modifiers & Qt.ControlModifier) !== 0; + + // Escape peels one layer at a time: select mode, then the pin, then + // the launcher. Nothing about it is ever a surprise. + if (event.key === Qt.Key_Escape) { + if (root.selectMode) root.selectMode = false; + else if (root.pinned) root.unpin(); + else Actions.closeLauncher(); + event.accepted = true; + return; + } + + if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) { + root.activate(); + event.accepted = true; + return; + } + + if (event.key === Qt.Key_Tab) { + root.pin(); + event.accepted = true; + return; + } + + if (event.key === Qt.Key_Down || (ctrl && event.key === Qt.Key_J) + || (ctrl && event.key === Qt.Key_N)) { + root.selectMode = true; + root.move(1); + event.accepted = true; + return; + } + + if (event.key === Qt.Key_Up || (ctrl && event.key === Qt.Key_K) + || (ctrl && event.key === Qt.Key_P)) { + root.selectMode = true; + root.move(-1); + event.accepted = true; + return; + } + + if (ctrl && event.key === Qt.Key_U) { + root.query = ""; + event.accepted = true; + return; + } + + if (!root.selectMode) return; + + // Select mode. The letters navigate; anything else drops you back into + // the input, having typed nothing, so a mistyped `d` costs one press + // rather than a mangled query. + switch (event.key) { + case Qt.Key_J: root.move(1); event.accepted = true; return; + case Qt.Key_K: root.move(-1); event.accepted = true; return; + case Qt.Key_L: root.pin(); event.accepted = true; return; + case Qt.Key_H: root.unpin(); event.accepted = true; return; + case Qt.Key_G: + root.selected = (event.modifiers & Qt.ShiftModifier) + ? root.results.length - 1 : 0; + list.positionViewAtIndex(root.selected, ListView.Contain); + event.accepted = true; + return; + case Qt.Key_Backspace: + root.selectMode = false; + event.accepted = true; + return; + } + + if (event.text !== "") root.selectMode = false; + } + + // ------------------------------------------------------------- animation + ParallelAnimation { + id: openAnim + NumberAnimation { + target: backdrop; property: "opacity" + to: 1; duration: Theme.durBase; easing.type: Easing.OutQuad + } + NumberAnimation { + target: shell; property: "opacity" + to: 1; duration: Theme.durBase + } + NumberAnimation { + target: rise; property: "y" + from: 24; to: 0; duration: Theme.durSlow; easing.type: Easing.OutBack + } + } + + ParallelAnimation { + id: closeAnim + NumberAnimation { + target: backdrop; property: "opacity" + to: 0; duration: Theme.durFast + } + NumberAnimation { + target: shell; property: "opacity" + to: 0; duration: Theme.durFast + } + NumberAnimation { + target: rise; property: "y" + to: 16; duration: Theme.durFast; easing.type: Easing.InQuad + } + } +} diff --git a/quickshell/launcher/ResultRow.qml b/quickshell/launcher/ResultRow.qml new file mode 100644 index 0000000..eeaa18d --- /dev/null +++ b/quickshell/launcher/ResultRow.qml @@ -0,0 +1,132 @@ +import QtQuick +import Quickshell +import Quickshell.Widgets +import "root:/config" +import "root:/components" + +// One line in the result list. Rest is bare — no fill, no border — so a list of +// forty apps reads as a column of text rather than forty boxes. Only the +// selected row gets a slab, and it takes the full inverted treatment: white +// fill, black text. That is the strongest contrast the palette has, which is +// what the row you are about to launch should be wearing. +Item { + id: root + + required property var entry + required property bool selected + property bool hovered: false + property string badge: "" + + signal activated + signal pinRequested + + implicitHeight: 46 + + // Selection slab. Leans like everything else; drawn as a Skew so the + // diagonals get analytic antialiasing instead of a pixel staircase. + Skew { + anchors.fill: parent + anchors.leftMargin: -Theme.padS + anchors.rightMargin: -Theme.padS + color: root.selected ? Theme.treatmentSelectedFill : "transparent" + opacity: root.selected ? 1 : 0 + + Behavior on opacity { + NumberAnimation { duration: Theme.durFast; easing.type: Easing.OutQuad } + } + } + + // Hover is a crimson tick in the gutter rather than a second slab, so + // pointing at a row cannot be mistaken for selecting it. + Skew { + anchors.verticalCenter: parent.verticalCenter + x: -Theme.padS + width: 3 + height: root.hovered && !root.selected ? parent.height - 10 : 0 + color: Theme.accent + + Behavior on height { + NumberAnimation { duration: Theme.durFast; easing.type: Easing.OutBack } + } + } + + Row { + anchors.fill: parent + anchors.leftMargin: Theme.padM + anchors.rightMargin: Theme.padM + spacing: Theme.padM + + IconImage { + anchors.verticalCenter: parent.verticalCenter + implicitSize: 26 + asynchronous: true + source: root.entry.icon ? Quickshell.iconPath(root.entry.icon, true) : "" + visible: source !== "" + } + + // Entries with no icon still need to occupy the same gutter, or their + // labels wander left and the column stops being a column. + Icon { + anchors.verticalCenter: parent.verticalCenter + width: 26 + text: "󰣆" + color: root.selected ? Theme.treatmentSelectedText : Theme.muted + visible: !root.entry.icon && root.entry.kind !== "dmenu" + } + + Column { + anchors.verticalCenter: parent.verticalCenter + width: parent.width - 26 - Theme.padM * 2 - trailing.width + spacing: -1 + + Text { + width: parent.width + text: root.entry.name + elide: Text.ElideRight + color: root.selected ? Theme.treatmentSelectedText : Theme.text + font.family: Theme.fontMono + font.pixelSize: Theme.fsBody + font.weight: root.selected ? Font.Bold : Theme.weightBody + renderType: Text.NativeRendering + } + + Text { + width: parent.width + text: root.entry.sub + visible: text !== "" + elide: Text.ElideRight + color: root.selected ? Theme.alpha(Theme.ink, 0.6) : Theme.muted + font.family: Theme.fontMono + font.pixelSize: Theme.fsMicro + renderType: Text.NativeRendering + } + } + } + + // Trailing hint: the key that fires this row. Only the selected row shows + // one, because a column of ⏎ glyphs tells you nothing. + Text { + id: trailing + anchors.verticalCenter: parent.verticalCenter + anchors.right: parent.right + anchors.rightMargin: Theme.padM + text: root.selected ? root.badge : "" + color: Theme.alpha(Theme.ink, 0.55) + font.family: Theme.fontMono + font.pixelSize: Theme.fsMicro + font.letterSpacing: 2 + renderType: Text.NativeRendering + } + + HoverHandler { + onHoveredChanged: root.hovered = hovered + } + + TapHandler { + acceptedButtons: Qt.LeftButton | Qt.RightButton + onTapped: (point, button) => { + if (button === Qt.RightButton) root.pinRequested(); + else root.activated(); + } + } +} diff --git a/quickshell/scripts/takemi-dmenu b/quickshell/scripts/takemi-dmenu new file mode 100755 index 0000000..5bb7631 --- /dev/null +++ b/quickshell/scripts/takemi-dmenu @@ -0,0 +1,39 @@ +#!/bin/sh +# fuzzel --dmenu, backed by the Takemi launcher in Quickshell. +# +# Reads choices on stdin, prints the chosen line on stdout, prints nothing if +# the launcher was dismissed. Accepts the handful of fuzzel flags the callers +# in this config actually pass so they did not have to be rewritten. +# +# The launcher lives in the already-running shell process, so unlike fuzzel +# there is nothing to start; the cost here is the two-file handshake, which +# exists because `qs ipc call` returns immediately and a dmenu has to block. +set -eu + +prompt="" +while [ $# -gt 0 ]; do + case "$1" in + --dmenu|-d) ;; + --prompt) prompt="${2-}"; shift ;; + --prompt=*) prompt="${1#--prompt=}" ;; + *) ;; + esac + shift +done + +dir=$(mktemp -d) +trap 'rm -rf "$dir"' EXIT INT TERM +cat > "$dir/in" + +qs ipc call shell dmenu "$dir/in" "$dir/out" "$prompt" >/dev/null + +# Give up after two minutes rather than wedging a caller forever if the shell +# is reloaded out from under us mid-choice. +i=0 +while [ ! -e "$dir/out" ]; do + i=$((i + 1)) + [ "$i" -gt 2400 ] && exit 1 + sleep 0.05 +done + +cat "$dir/out" diff --git a/quickshell/services/Actions.qml b/quickshell/services/Actions.qml index 34ba949..1eb4aae 100644 --- a/quickshell/services/Actions.qml +++ b/quickshell/services/Actions.qml @@ -15,6 +15,12 @@ Singleton { property bool powerMenuOpen: false property bool lockRequested: false property bool sidebarOpen: false + property bool launcherOpen: false + + // Seed text for the next launcher open. Lets a keybind or a script hand the + // launcher a query it did not have to type — `qs ipc call shell prompt + // "https://…"` is an open-with dialog for anything that can run a command. + property string launcherSeed: "" signal osdRequested(string kind) @@ -22,6 +28,26 @@ Singleton { function closePowerMenu() { root.powerMenuOpen = false; } function lock() { root.powerMenuOpen = false; root.lockRequested = true; } + function toggleLauncher() { + if (!root.launcherOpen) root.launcherSeed = ""; + root.launcherOpen = !root.launcherOpen; + } + function closeLauncher() { root.launcherOpen = false; } + + // Stand in for `fuzzel --dmenu`. See Apps.beginDmenu for the transport. + function dmenuLauncher(inPath: string, outPath: string, prompt: string) { + Apps.beginDmenu(inPath, outPath, prompt); + root.launcherSeed = ""; + root.launcherOpen = false; + root.launcherOpen = true; + } + + function promptLauncher(seed: string) { + root.launcherSeed = seed; + root.launcherOpen = false; + root.launcherOpen = true; + } + // ------------------------------------------------------------- commands // Each slot gets its own Process so a slow one cannot cancel another. Process { id: slotA } @@ -62,6 +88,11 @@ Singleton { function lock(): void { root.lock(); } function power(): void { root.togglePowerMenu(); } + function launcher(): void { root.toggleLauncher(); } + function prompt(seed: string): void { root.promptLauncher(seed); } + function dmenu(inPath: string, outPath: string, prompt: string): void { + root.dmenuLauncher(inPath, outPath, prompt); + } function reloadConfig(): void { root.reloadShell(); } function toggleDnd(): void { Notifs.toggleDnd(); } function clearNotifications(): void { Notifs.clearAll(); } diff --git a/quickshell/services/Apps.qml b/quickshell/services/Apps.qml new file mode 100644 index 0000000..db7ebaf --- /dev/null +++ b/quickshell/services/Apps.qml @@ -0,0 +1,530 @@ +pragma Singleton + +import QtQuick +import Quickshell +import Quickshell.Io + +// The launcher's brain: the application index, the query language, ranking, +// and the actual spawning. The UI in launcher/ owns no logic beyond keys and +// animation — everything it shows comes out of `results`. +// +// The one idea the launcher is built around is that a query is a *verb plus an +// object*. Fuzzel only ever had the verb: you typed a name and it ran the app +// with no arguments. Here the object is first class, so `gitlab.enqs.de` is a +// complete thought — the object is the URL, and the list becomes the set of +// verbs that can act on it, with the system default already selected. Hitting +// Enter the moment you stop typing is the fast path; arrowing down to a +// different verb is the slow one. +Singleton { + id: root + + // ------------------------------------------------------------- the index + // Every launchable thing, flattened. A desktop entry with actions + // contributes one item per action as well as itself, so "Brave (Private)" + // is a peer of "Brave" in the list rather than something you have to drill + // into. That is the whole reason fuzzel's `show-actions` was off: nested + // disclosure in a launcher is a step too many. + readonly property var index: { + const out = []; + const apps = DesktopEntries.applications.values; + + for (let i = 0; i < apps.length; i++) { + const e = apps[i]; + if (e.noDisplay) continue; + + out.push({ + key: root._entryKey(e.id), + id: e.id, + actionId: "", + name: e.name, + sub: e.genericName || e.comment || "", + icon: e.icon, + exec: e.execString, + categories: root._bag(e.categories), + keywords: root._bag(e.keywords), + terminal: e.runInTerminal, + kind: "app" + }); + + const actions = e.actions; + for (let j = 0; j < actions.length; j++) { + const a = actions[j]; + out.push({ + key: root._entryKey(e.id) + ":" + a.id, + id: e.id, + actionId: a.id, + name: e.name + " · " + a.name, + sub: a.name, + icon: a.icon || e.icon, + exec: a.execString, + categories: root._bag(e.categories), + keywords: root._bag(e.keywords), + terminal: e.runInTerminal, + kind: "action" + }); + } + } + return out; + } + + function _entryKey(id: string): string { + return id.endsWith(".desktop") ? id : id + ".desktop"; + } + + // Categories and Keywords come across as string *lists*, not strings, and + // an absent one is undefined rather than empty. Both get flattened to one + // lowercase haystack so the scoring below can just call `includes`. + function _bag(v: var): string { + if (!v) return ""; + return (Array.isArray(v) ? v.join(" ") : String(v)).toLowerCase(); + } + + // --------------------------------------------------------- query parsing + // A query resolves to a mode and, in app mode, an optional argument. There + // are two ways to end up with an argument: + // + // pinned you pressed Tab on a result, which froze that app as the verb + // and handed the rest of the input to it. Unambiguous, works + // for arguments that look like nothing in particular. + // implicit what you typed is *obviously* an object — a URL, a path, an + // email. Nothing is pinned, so every app is a candidate verb + // and the ranking decides who goes first. + // + // Implicit detection is deliberately conservative. A false positive turns + // an app search into an open-with list, which is jarring; a false negative + // just means you press Tab. + readonly property var urlRe: /^[a-z][a-z0-9+.-]*:\/\//i + readonly property var hostRe: /^([a-z0-9-]+\.)+[a-z]{2,}(\/\S*)?$/i + readonly property var mailRe: /^[^\s@]+@[^\s@]+\.[a-z]{2,}$/i + readonly property var pathRe: /^(~|\/|\.\/)\S*$/ + + function classify(text: string): string { + const t = text.trim(); + if (t === "") return ""; + if (root.urlRe.test(t) || root.hostRe.test(t)) return "url"; + if (root.mailRe.test(t)) return "mail"; + if (root.pathRe.test(t)) return "path"; + return ""; + } + + // Turn a bare host into something a browser will accept. `gitlab.enqs.de` + // on its own is a relative path as far as xdg-open is concerned. + function normalizeArg(text: string, kind: string): string { + const t = text.trim(); + if (kind === "url") return root.urlRe.test(t) ? t : "https://" + t; + if (kind === "mail") return "mailto:" + t; + if (kind === "path" && t.startsWith("~")) + return Quickshell.env("HOME") + t.slice(1); + return t; + } + + // ------------------------------------------------------------- defaults + // Which app the system would have picked. This is what puts Brave at row + // zero without the launcher having an opinion about browsers. + property string defaultBrowser: "" + property string defaultMail: "" + + Process { + running: true + command: ["xdg-mime", "query", "default", "x-scheme-handler/https"] + stdout: StdioCollector { + onStreamFinished: root.defaultBrowser = this.text.trim() + } + } + + Process { + running: true + command: ["xdg-mime", "query", "default", "x-scheme-handler/mailto"] + stdout: StdioCollector { + onStreamFinished: root.defaultMail = this.text.trim() + } + } + + // --------------------------------------------------------------- ranking + // Subsequence match with a bias toward matches that start a word. Returns + // -1 for no match. The numbers are only meaningful relative to each other. + function fuzzy(hay: string, needle: string): real { + if (needle === "") return 0; + const h = hay.toLowerCase(); + const n = needle.toLowerCase(); + + if (h === n) return 1000; + if (h.startsWith(n)) return 700 - h.length; + + // Start of any word inside the name — "code" hitting "Visual Studio + // Code" should beat "code" scattered through "Chromium Dev Tools". + const word = h.indexOf(" " + n); + if (word !== -1) return 500 - word; + if (h.includes(n)) return 300 - h.indexOf(n); + + // Plain subsequence, penalised by how far apart the letters are. + let score = 120; + let at = -1; + for (let i = 0; i < n.length; i++) { + const next = h.indexOf(n[i], at + 1); + if (next === -1) return -1; + score -= (next - at - 1) * 0.5; + at = next; + } + return Math.max(score, 1); + } + + function _appScore(item: var, term: string): real { + if (term === "") return 0; + + let best = root.fuzzy(item.name, term); + + // Secondary fields are worth less than the name on purpose: matching a + // Keywords= line should break a tie, not win outright. + const sub = root.fuzzy(item.sub, term); + if (sub > 0) best = Math.max(best, sub * 0.45); + + const id = root.fuzzy(item.id, term); + if (id > 0) best = Math.max(best, id * 0.6); + + if (item.keywords.includes(term.toLowerCase())) + best = Math.max(best, 140); + + // An action only surfaces on a free-text search once you have typed + // enough to plausibly mean it, otherwise every browser triples the + // length of the list for a one-letter query. + if (item.kind === "action" && term.length < 2) return -1; + + return best; + } + + // How well an app suits an object it did not ask for. Everything stays in + // the list — the point is that you can open a URL with anything — but the + // system default and then the plausible handlers float up. + function _affinity(item: var, kind: string): real { + let a = 0; + + if (kind === "url" || kind === "mail") { + const wanted = kind === "url" ? root.defaultBrowser : root.defaultMail; + if (wanted !== "" && item.key.startsWith(wanted)) a += 4000; + if (item.categories.includes("webbrowser")) a += 2000; + if (kind === "mail" && item.categories.includes("email")) a += 2500; + if (item.categories.includes("network")) a += 400; + } else if (kind === "path") { + if (item.categories.includes("filemanager")) a += 2500; + if (item.categories.includes("texteditor")) a += 1800; + if (item.categories.includes("development")) a += 600; + if (item.terminal) a += 300; + } + + // The main entry outranks its own actions for an unasked-for object: + // "open this link in Brave" is the common case, "…in a private window" + // is the deliberate one. + if (item.kind === "action") a -= 250; + return a; + } + + // The single entry point the UI binds to. + // + // term what to match apps against ("" means "everything") + // arg the object, if any + // argKind "" | url | mail | path + function search(term: string, arg: string, argKind: string): var { + const scored = []; + const idx = root.index; + const open = arg !== ""; + + for (let i = 0; i < idx.length; i++) { + const item = idx[i]; + let s = root._appScore(item, term); + if (term !== "" && s < 0) continue; + + // With nothing typed, actions would double the length of a list + // that is already just "your apps, by how often you use them". + if (term === "" && !open && item.kind === "action") continue; + + if (open) s += root._affinity(item, argKind); + s += root.frecency(item.key); + scored.push({ item: item, score: s }); + } + + scored.sort((a, b) => b.score - a.score || a.item.name.localeCompare(b.item.name)); + return scored.slice(0, 60).map(e => e.item); + } + + // -------------------------------------------------------------- frecency + // Usage decays: something you opened forty times last year should not + // outrank what you opened twice this morning. Half-life is a week, which + // is long enough to be stable and short enough to follow a change of + // project. + property var usage: ({}) + readonly property real halfLife: 7 * 24 * 3600 + + function frecency(key: string): real { + const u = root.usage[key]; + if (!u) return 0; + const age = Date.now() / 1000 - u.t; + return u.n * 240 * Math.pow(0.5, age / root.halfLife); + } + + function _remember(key: string): void { + const u = root.usage[key]; + const now = Date.now() / 1000; + + // Decay the stored count on write rather than letting it grow without + // bound, so `n` stays a decayed weight instead of a lifetime total. + const decayed = u ? u.n * Math.pow(0.5, (now - u.t) / root.halfLife) : 0; + root.usage[key] = { n: decayed + 1, t: now }; + root.usageChanged(); + store.setText(JSON.stringify(root.usage)); + } + + FileView { + id: store + path: Quickshell.env("HOME") + "/.local/state/takemi/launcher.json" + preload: true + printErrors: false + atomicWrites: true + onLoaded: { + try { + root.usage = JSON.parse(store.text()) || ({}); + } catch (e) { + root.usage = ({}); + } + } + } + + // ----------------------------------------------------------------- spawn + // Handing uwsm a desktop ID is the good path: it expands the Exec field + // codes around our argument, picks the terminal for Terminal=true entries, + // and drops the process in its own scope. + // + // It refuses one thing, and it is exactly the thing this launcher is for. + // `Exec=brave --incognito` declares no field code, so uwsm rejects + // `brave-browser.desktop:new-private-window ` with "does not support + // arguments" — which would make "open this link in a private window", the + // motivating example for the whole feature, the one case that does not + // work. Most Desktop Action entries are written that way. + // + // So when the target declares no placeholder we build the command line + // ourselves and hand uwsm an argv instead of an ID. It is still scoped and + // still supervised; we just did the expansion. + readonly property var fieldRe: /%[fFuU]/ + readonly property var anyFieldRe: /^%[a-zA-Z]$/ + + function launch(item: var, arg: string): void { + root._remember(item.key); + + if (arg === "") { + proc.exec(["uwsm", "app", "--", item.key]); + return; + } + + if (root.fieldRe.test(item.exec || "")) { + proc.exec(["uwsm", "app", "--", item.key, arg]); + return; + } + + const argv = root._tokenize(item.exec || "") + .filter(t => !root.anyFieldRe.test(t)); + if (argv.length === 0) { + proc.exec(["uwsm", "app", "--", item.key]); + return; + } + argv.push(arg); + + // Without this the unit is named after the bare executable and every + // app launched this way collides in the unit namespace. + const name = item.key.replace(".desktop", "").replace(/[^a-zA-Z0-9_-]/g, "-"); + const head = ["uwsm", "app", "-a", name]; + if (item.terminal) head.push("-T"); + proc.exec(head.concat(["--"]).concat(argv)); + } + + // Exec lines are shell-ish but not shell: the spec allows double quotes and + // backslash escapes, and nothing else. Splitting on whitespace would break + // every entry with a quoted path, and running it through `sh -c` would make + // a desktop file a code-execution surface. + function _tokenize(line: string): var { + const out = []; + let cur = ""; + let quoted = false; + let has = false; + + for (let i = 0; i < line.length; i++) { + const c = line[i]; + if (c === "\\" && i + 1 < line.length) { + cur += line[++i]; + has = true; + } else if (c === '"') { + quoted = !quoted; + has = true; + } else if (!quoted && (c === " " || c === "\t")) { + if (has) { out.push(cur); cur = ""; has = false; } + } else { + cur += c; + has = true; + } + } + if (has) out.push(cur); + return out; + } + + function runShell(line: string, inTerminal: bool): void { + if (line.trim() === "") return; + if (inTerminal) proc.exec(["uwsm", "app", "-T", "--", "sh", "-c", line]); + else proc.exec(["uwsm", "app", "--", "sh", "-c", line]); + } + + function copy(text: string): void { + proc.exec(["wl-copy", "--", text]); + } + + // Fire-and-forget. Each call replaces the last, which is fine because uwsm + // has already handed the app off to systemd by the time it exits. + Process { id: proc } + + // ----------------------------------------------------------------- dmenu + // The launcher can stand in for `fuzzel --dmenu`, which is what lets fuzzel + // leave the system entirely: fluxo's bluetooth menu and anything else that + // pipes a list through a picker go through here instead. + // + // The transport is two files, because IPC calls return immediately and a + // dmenu has to block until someone chooses. `takemi-dmenu` writes the + // choices, calls in, then waits for the answer file to appear. If the + // launcher is dismissed the answer file is written empty, so the caller + // sees an empty selection rather than hanging. + property var dmenuItems: [] + property string dmenuPrompt: "" + property string dmenuOut: "" + readonly property bool dmenuActive: root.dmenuOut !== "" + + function beginDmenu(inPath: string, outPath: string, prompt: string): void { + root.dmenuPrompt = prompt; + root.dmenuOut = outPath; + dmenuIn.path = ""; + dmenuIn.path = inPath; + } + + // Writing the answer with a FileView does not work: assigning `path` kicks + // off a read of a file that does not exist yet, and the write never lands — + // which leaves the caller blocked until its own timeout. A one-shot + // redirect is both simpler and actually synchronous from the caller's point + // of view, since the file appears complete or not at all. + // + // The choice and the path go in as positional arguments rather than being + // interpolated into the script, so a clipboard entry containing a quote or + // a `$(…)` is data and stays data. + function finishDmenu(choice: string): void { + if (root.dmenuOut === "") return; + // Write beside the target and rename, so the waiting script can treat + // "the file exists" as "the answer is complete". + dmenuWriter.exec(["sh", "-c", + 'printf "%s" "$1" > "$2.part" && mv "$2.part" "$2"', + "sh", choice, root.dmenuOut]); + root.dmenuOut = ""; + root.dmenuItems = []; + root.dmenuPrompt = ""; + } + + Process { id: dmenuWriter } + + FileView { + id: dmenuIn + onLoaded: { + const out = []; + const lines = dmenuIn.text().split("\n"); + for (let i = 0; i < lines.length; i++) { + if (lines[i] === "") continue; + out.push({ kind: "dmenu", name: lines[i], sub: "", icon: "" }); + } + root.dmenuItems = out; + } + } + + function searchDmenu(term: string): var { + if (term === "") return root.dmenuItems.slice(0, 200); + const scored = []; + for (let i = 0; i < root.dmenuItems.length; i++) { + const c = root.dmenuItems[i]; + const s = root.fuzzy(c.name, term); + if (s < 0) continue; + scored.push({ item: c, score: s }); + } + scored.sort((a, b) => b.score - a.score); + return scored.slice(0, 200).map(e => e.item); + } + + // ------------------------------------------------------------- clipboard + // cliphist keeps the history; this just reads and replays it. `cliphist + // list` emits "\t" per line, where the preview is already + // single-line and truncated — binary entries come through as a + // "[[ binary data … ]]" placeholder, which is exactly what should be shown + // for them anyway. + property var clips: [] + + function refreshClips(): void { + clipList.running = false; + clipList.running = true; + } + + Process { + id: clipList + command: ["cliphist", "list"] + stdout: StdioCollector { + onStreamFinished: { + const out = []; + const lines = this.text.split("\n"); + for (let i = 0; i < lines.length; i++) { + const tab = lines[i].indexOf("\t"); + if (tab <= 0) continue; + const id = lines[i].slice(0, tab); + if (!/^[0-9]+$/.test(id)) continue; + out.push({ + kind: "clip", + clipId: id, + name: lines[i].slice(tab + 1), + sub: "", + icon: "edit-paste" + }); + } + root.clips = out; + } + } + } + + // The id is checked against /^[0-9]+$/ on the way in, above, which is what + // makes it safe to put on a shell command line. Decoding needs the pipe: + // entries can be binary, so the bytes must go straight from cliphist into + // wl-copy without being round-tripped through a QML string. + function pasteClip(item: var): void { + if (!/^[0-9]+$/.test(item.clipId)) return; + proc.exec(["sh", "-c", "cliphist decode " + item.clipId + " | wl-copy"]); + } + + function searchClips(term: string): var { + if (term === "") return root.clips.slice(0, 60); + const scored = []; + for (let i = 0; i < root.clips.length; i++) { + const c = root.clips[i]; + const s = root.fuzzy(c.name, term); + if (s < 0) continue; + scored.push({ item: c, score: s }); + } + scored.sort((a, b) => b.score - a.score); + return scored.slice(0, 60).map(e => e.item); + } + + // ------------------------------------------------------------ calculator + // Arithmetic only. The expression is checked against a whitelist before it + // goes anywhere near the JS engine — a launcher that evals whatever you + // typed is a launcher that runs whatever a stray paste contained. + readonly property var mathRe: /^[0-9+\-*/%(). ,e]+$/i + + function calc(expr: string): string { + const e = expr.trim(); + if (e === "" || !root.mathRe.test(e)) return ""; + try { + const v = Function('"use strict"; return (' + e + ')')(); + if (typeof v !== "number" || !isFinite(v)) return ""; + return String(Math.round(v * 1e10) / 1e10); + } catch (err) { + return ""; + } + } +} diff --git a/quickshell/shell.qml b/quickshell/shell.qml index 1a25566..7aba288 100644 --- a/quickshell/shell.qml +++ b/quickshell/shell.qml @@ -3,6 +3,7 @@ import Quickshell import "root:/config" import "root:/services" import "root:/bar" +import "root:/launcher" import "root:/overlays" import "root:/lock" @@ -10,6 +11,7 @@ import "root:/lock" // // bar/ the top bar and its modules // popouts/ hover panels hanging off bar modules +// launcher/ the application launcher // overlays/ power menu, notification toasts, OSD // lock/ session lock // services/ data sources (fluxo, pipewire, bluez, upower, mpris, …) @@ -28,6 +30,7 @@ ShellRoot { void Net.wifiEnabled; void Bt.available; void Actions.powerMenuOpen; + void Apps.index; } Variants { @@ -42,6 +45,12 @@ ShellRoot { Rail {} } + Variants { + model: Quickshell.screens + + Launcher {} + } + Variants { model: Quickshell.screens