commit 30fdd5cb28940cc0d9f96410c771e7a4d8866a92 Author: Asmir A Date: Sun Jul 26 21:16:55 2026 +0200 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0075a25 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +result +result-* +.direnv/ +*.qmlc +*.jsc diff --git a/README.md b/README.md new file mode 100644 index 0000000..c07d967 --- /dev/null +++ b/README.md @@ -0,0 +1,254 @@ +# att_menu + +A bottom desktop-environment panel for wlroots-based compositors, built on +[Quickshell](https://quickshell.org). Three sections, nothing else: + +| Section | Contents | +| ------- | ----------------------------------------------------------------- | +| Left | Application menu built from `.desktop` entries, with search | +| Middle | Open windows (task list), click to focus, middle-click to close | +| Right | Wifi, bluetooth, and a session menu: log out, suspend, restart, shut down | + +No clock, no tray, no status readouts — it is designed to sit alongside a +separate status bar. + +## Requirements + +- A wlroots-based compositor supporting `wlr-layer-shell-unstable-v1` and + `wlr-foreign-toplevel-management-unstable-v1` (sway, Hyprland, river, niri, + labwc, Wayfire, dwl, …). On river 0.4, additionally attwm — the window + manager owns focus there, so the task list needs its socket + (`attwmctl focus-window`); everything else works on plain river +- Nix with flakes enabled +- BlueZ (`bluetoothd`) for the bluetooth section — optional; without it, or on a + machine with no adapter, the button is simply absent +- NetworkManager for the wifi section, likewise optional. A NetworkManager + configured with `wifi.backend=iwd` is fine — and is what this was built + against — since the connection still goes through iwd underneath + +The window list uses the foreign-toplevel protocol rather than any single +compositor's IPC, so it works the same everywhere — except under river 0.4, +where it uses attwm's socket instead, because foreign-toplevel cannot focus a +window there. See the note at the end. + +## Running + +Try it without installing: + +```sh +nix run . +``` + +Iterate on the config from the working tree, with hot reload on file save: + +```sh +nix develop +qs --path . +``` + +Install into a profile: + +```sh +nix profile install . +att_menu +``` + +### Autostart + +sway (`~/.config/sway/config`): + +``` +exec att_menu +bindsym $mod+d exec att_menu ipc call launcher toggle +``` + +Hyprland (`~/.config/hypr/hyprland.conf`): + +``` +exec-once = att_menu +bind = SUPER, D, exec, att_menu ipc call launcher toggle +``` + +river 0.4 with attwm (`~/.config/river/init`) — attwm owns key bindings, so the +launcher binding goes in its `config.zig` as a `spawn` action: + +```sh +att_menu & +exec attwm +``` + +Either order works: whichever starts first waits for the other. + +The `launcher`, `wifi`, `bluetooth` and `power` IPC targets each accept +`toggle`, `open` and `close`. When opened by keybind the menu appears on the screen holding the +focused window. Only one menu is open at a time, so `close` closes whichever it +is. + +## Behaviour + +**Placement.** The panel is a layer-shell surface anchored to the left, right and +bottom edges, on the `Top` layer. Because it is anchored rather than sized, the +compositor resizes it on any output change — resolution, scale, or **rotation** — +and it stays on the bottom edge in every orientation. It reserves an exclusive +zone so maximised windows do not sit underneath. + +One panel is created per screen and they follow monitor hotplug. + +**Window list.** Left click focuses a window, or minimises it if it is already +focused. Middle click closes it. The focused window is underlined, and a window +that is not on screen — minimised, or on an unviewed tag under attwm — is dimmed. +By default each panel shows only the windows on its own screen (see +`windowsPerScreen`). + +Under attwm there is nothing to minimise into: a window is on a tag or it is +not. Left click therefore always means focus, and clicking a window parked on +another tag brings that tag into view. + +**Launcher.** Type to search across name, generic name, keywords, comment and +desktop-entry id, ranked by match quality. Category chips filter the list when +the search box is empty. `Up`/`Down` move, `Enter` launches, `Escape` closes, and +clicking away closes. + +The launcher is a full-screen overlay layer surface rather than an +`xdg-popup`, which is what makes click-away dismissal and keyboard focus work on +any wlroots compositor without compositor-specific focus grabs. + +**Wifi.** The button shows signal strength in the arcs and the SSID in use next +to them, struck through when the radio is off. The menu lists the networks in +range — connected first, then saved, then by strength — with the switch in the +header powering the radio. Clicking a network connects it, clicking the +connected one disconnects, and joining a secured network that is not already +saved opens a passphrase field at the bottom of the menu (`Enter` joins, +`Escape` cancels). Right-click a saved network to forget it, which is also the +way out if a wrong passphrase gets saved. `Up`/`Down` move, `Enter` activates. + +Scanning only runs while the menu is open, so the panel is not keeping the radio +busy in the background. Networks are ordered by signal in quarters rather than by +the raw value, so rows do not reshuffle under the cursor as strength drifts. + +Quickshell speaks to NetworkManager, which is the only backend it implements. +On a system where NetworkManager is configured with `wifi.backend=iwd`, iwd is +still what associates — this just avoids racing NetworkManager's own policy by +driving iwd behind its back. + +**Bluetooth.** The button shows adapter state at a glance — struck through when +the radio is off, accented while something is connected — followed by the +battery level of the first connected device that reports one. The menu lists the +devices bluetoothd already knows about (paired or bonded), connected ones first; +clicking a row connects it, clicking a connected row disconnects it, and the +switch in the header powers the adapter. `Up`/`Down` move, `Enter` toggles the +selected device, `Escape` closes. + +It is deliberately not a pairing UI: discovery is never turned on, so nothing +transient appears in the list. Pair new devices with `bluetoothctl` or a +settings app once, and they show up here afterwards. Battery levels come from +BlueZ, so they appear only for devices that report them (most headsets, some +mice) and only while connected. + +The whole section hides itself on machines with no bluetooth adapter, or when +bluetoothd is not running. + +**Session actions.** One button opens a menu holding log out, suspend, restart +and shut down, in whatever order `powerActions` lists them. Actions marked +`dangerous` (log out, restart, shut down) ask for confirmation first; suspend +does not. `Up`/`Down` move, `Enter` runs, `Escape` closes — and the menu opens +with the first *non*-destructive action selected, so `Enter` straight after +opening can never shut the machine down. + +Logging out is compositor-specific and is detected at runtime — Hyprland, sway, +niri and river are recognised, with `loginctl terminate-session` as the +fallback. On river it goes through `attwmctl exit-session` when attwm is +answering, since river 0.4 has no `riverctl`. + +## Configuration + +Everything tunable lives in two singletons; edit and restart (or just save, when +running with `qs --path .`). + +- **`config/Theme.qml`** — colours, sizes, fonts, animation timings. `barHeight` + is here. +- **`config/Config.qml`** — behaviour: + - `layer`, `namespace`, `reserveSpace` — layer-shell placement. The namespace + (`att_menu`) is what compositor rules target, e.g. sway's `layer_effects`. + - `windowsPerScreen` — set `false` to mirror every window onto every panel. + - `windowButtonMinWidth` / `windowButtonMaxWidth` + - `launcherWidth` / `launcherHeight` — clamped to the screen, so a rotated or + small output still gets a usable menu. + - `terminal` — argv prefix for entries with `Terminal=true`. Honours + `$TERMINAL`; otherwise change the `["foot"]` fallback to your terminal. + - `showWifi` — set `false` to drop the wifi button entirely. + - `wifiLabelMaxWidth` — cap on the SSID shown on the panel. + - `wifiMenuWidth` / `wifiMenuHeight` — also clamped to the screen. + - `showBluetooth` — set `false` to drop the bluetooth button entirely. + - `bluetoothMenuWidth` / `bluetoothMenuHeight` — also clamped to the screen. + - `confirmDangerous` — set `false` to skip the confirmation dialog. + - `powerMenuWidth` — width of the session menu. + - `powerActions` — the rows in the session menu. Reorder, remove, or add entries; + each has a `label`, an icon name, a fallback `glyph`, an argv `command`, and + a `dangerous` flag. + +## Layout + +``` +shell.qml Root: one Bar per screen, plus the menu IPC handlers +config/Theme.qml Colours, sizing, fonts +config/Config.qml Behaviour, power actions, compositor detection +services/Session.qml Which menu is open, and on which screen +services/AppLauncher.qml Desktop-entry launching +services/Attwm.qml attwm's IPC socket, when there is one +modules/Bar.qml The panel window and its three sections +modules/AppMenu.qml Left: launcher button +modules/Launcher.qml The application menu overlay +modules/AppEntry.qml One row in the menu +modules/WindowList.qml Middle: task list +modules/WindowButton.qml One task button +modules/WifiMenu.qml Right: wifi button +modules/WifiPanel.qml The network list overlay +modules/WifiNetworkEntry.qml One network row +modules/BluetoothMenu.qml Right: bluetooth button +modules/BluetoothPanel.qml The device list overlay +modules/BluetoothDeviceEntry.qml One device row +modules/PowerMenu.qml Right: session button, and running the actions +modules/PowerPanel.qml The session menu overlay +modules/ConfirmDialog.qml Confirmation for destructive actions +components/AppIcon.qml Icon resolution with fallbacks +components/PanelButton.qml Shared hover/press surface +components/BluetoothGlyph.qml The bluetooth rune, drawn +components/WifiGlyph.qml The wifi fan, drawn +components/ToggleSwitch.qml On/off switch +``` + +## Notes + +- **Why the window list has two sources.** river 0.4 is non-monolithic: it ships + no window management policy and hands the job to an external client speaking + `river-window-management-v1`, which for this setup is attwm. river still + advertises `wlr-foreign-toplevel-management`, so a task list built on it shows + the right titles and tracks focus — but that protocol's `activate` request has + nowhere to go. Focus is set solely by the window manager through + `river_seat_v1.focus_window`, and the window management protocol has no + `activate_requested` event to forward the request through, so river drops it + and a click does nothing at all. `services/Attwm.qml` therefore takes over + under river, sourcing the list from attwm's socket and focusing with + `focus-window `. Matching the two protocols up is not needed: river's + window identifier is shared between them — which is just as well, since + Quickshell's `Toplevel` does not expose it, and app id plus title is not + unique (two terminals in the same directory). +- Quickshell's `Socket` cannot be reconnected in place. A connect attempt against + a socket that does not exist yet fails silently — no state change is reported — + and re-asserting `connected` afterwards does nothing. So the socket lives in a + `Loader` that gets thrown away and rebuilt on a timer, which is what lets the + panel and attwm be started in either order. +- Quickshell's `DesktopEntry.execute()` intentionally ignores `Terminal=true` + and does not strip `Exec` field codes, so entries would be launched with a + literal `%U` argument. `services/AppLauncher.qml` handles both instead. +- Icons are resolved through the Qt icon theme, falling back to the last segment + of a reverse-DNS app id (`org.qutebrowser.qutebrowser` → `qutebrowser`), then + to a lettered tile. The packaged build adds Adwaita and hicolor to + `XDG_DATA_DIRS` so the session-action icons resolve on minimal systems. +- The bluetooth rune and the wifi fan are drawn with `QtQuick.Shapes` instead of + pulled from the icon theme. Themes ship them only as *symbolic* + (black-on-transparent) SVGs, which Qt renders as-is — they would be invisible + on the dark panel. Drawing them also makes them recolourable, which is how + adapter state is shown, and lets the wifi arcs light up one at a time. +- The flake pins nixpkgs to the current NixOS stable release, `nixos-26.05`. diff --git a/components/AppIcon.qml b/components/AppIcon.qml new file mode 100644 index 0000000..e6e71b0 --- /dev/null +++ b/components/AppIcon.qml @@ -0,0 +1,74 @@ +import Quickshell +import Quickshell.Widgets +import QtQuick +import qs.config + +// Renders a desktop-entry icon, which may be either an icon-theme name or an +// absolute path. Falls back to a lettered tile so an entry with a broken or +// missing icon still gets something clickable. +Item { + id: root + + property string icon: "" + property string fallbackLabel: "" + // When set, shown instead of the lettered tile if the icon cannot resolve. + property string fallbackGlyph: "" + property color glyphColor: Theme.text + property int size: Theme.iconSize + + implicitWidth: size + implicitHeight: size + + readonly property string resolved: root.resolveIcon(root.icon) + + function resolveIcon(name: string): string { + if (!name) + return ""; + if (name.startsWith("file://") || name.startsWith("image://")) + return name; + if (name.startsWith("/")) + return "file://" + name; + + // iconPath with checkExists returns "" when the theme has no such icon, + // so these can be chained as fallbacks. The last segment of a + // reverse-DNS app id ("org.qutebrowser.qutebrowser") is very often the + // real icon name, so try that before giving up. + const lower = name.toLowerCase(); + const tail = lower.split(".").pop(); + + return Quickshell.iconPath(name, true) || Quickshell.iconPath(lower, true) || (tail !== lower ? Quickshell.iconPath(tail, true) : ""); + } + + IconImage { + id: image + + anchors.fill: parent + source: root.resolved + visible: root.resolved !== "" && image.status !== Image.Error + } + + Text { + anchors.centerIn: parent + visible: !image.visible && root.fallbackGlyph !== "" + text: root.fallbackGlyph + color: root.glyphColor + font.family: Theme.fontFamily + font.pixelSize: Math.max(10, root.size * 0.8) + } + + Rectangle { + anchors.fill: parent + visible: !image.visible && root.fallbackGlyph === "" + radius: Math.max(2, root.size / 5) + color: Theme.surfaceActive + + Text { + anchors.centerIn: parent + text: (root.fallbackLabel || "?").charAt(0).toUpperCase() + color: Theme.textDim + font.family: Theme.fontFamily + font.pixelSize: Math.max(8, root.size * 0.55) + font.bold: true + } + } +} diff --git a/components/BluetoothGlyph.qml b/components/BluetoothGlyph.qml new file mode 100644 index 0000000..ebda3e3 --- /dev/null +++ b/components/BluetoothGlyph.qml @@ -0,0 +1,87 @@ +import QtQuick +import QtQuick.Shapes +import qs.config + +// The bluetooth rune, drawn rather than themed: icon themes ship it only as a +// symbolic (black-on-transparent) SVG, which Qt renders as-is and which would +// be invisible on the dark panel. Drawing it also makes it recolourable to +// signal adapter state. +Item { + id: root + + property color color: Theme.text + property int size: Theme.iconSize + // Struck through with a diagonal, for "off". + property bool struck: false + + implicitWidth: root.size + implicitHeight: root.size + + readonly property real glyphWidth: root.size * 0.44 + readonly property real glyphHeight: root.size * 0.82 + readonly property real stroke: Math.max(1.5, root.size * 0.09) + + Shape { + anchors.centerIn: parent + width: root.glyphWidth + height: root.glyphHeight + preferredRendererType: Shape.CurveRenderer + + // One continuous stroke: up the left diagonal, over the top apex, down + // the spine, out to the right and back down the other diagonal. + ShapePath { + strokeColor: root.color + strokeWidth: root.stroke + fillColor: "transparent" + capStyle: ShapePath.RoundCap + joinStyle: ShapePath.RoundJoin + + startX: 0 + startY: root.glyphHeight * 0.25 + + PathLine { + x: root.glyphWidth + y: root.glyphHeight * 0.75 + } + PathLine { + x: root.glyphWidth * 0.5 + y: root.glyphHeight + } + PathLine { + x: root.glyphWidth * 0.5 + y: 0 + } + PathLine { + x: root.glyphWidth + y: root.glyphHeight * 0.25 + } + PathLine { + x: 0 + y: root.glyphHeight * 0.75 + } + } + } + + Shape { + anchors.centerIn: parent + width: root.size + height: root.size + visible: root.struck + preferredRendererType: Shape.CurveRenderer + + ShapePath { + strokeColor: root.color + strokeWidth: root.stroke + fillColor: "transparent" + capStyle: ShapePath.RoundCap + + startX: root.size * 0.12 + startY: root.size * 0.88 + + PathLine { + x: root.size * 0.88 + y: root.size * 0.12 + } + } + } +} diff --git a/components/PanelButton.qml b/components/PanelButton.qml new file mode 100644 index 0000000..b865fda --- /dev/null +++ b/components/PanelButton.qml @@ -0,0 +1,63 @@ +import QtQuick +import qs.config + +// Shared hover/press surface used by every clickable thing on the panel. +// +// Content is placed at its natural size inside `holder`, which then drives the +// button's implicit size. Content must not anchor to the holder or the size +// binding becomes circular. +Rectangle { + id: root + + property bool active: false + property int minContentWidth: 0 + property alias acceptedButtons: mouse.acceptedButtons + property alias containsMouse: mouse.containsMouse + property color activeColor: Theme.surfaceActive + + default property alias content: holder.data + + // Decorations that should stretch across the whole button (underlines, + // badges) go here instead of `content`, so they stay out of the size + // calculation above. + property alias overlay: overlayHolder.data + + signal clicked(var event) + + implicitWidth: Math.max(root.minContentWidth, holder.childrenRect.width) + Theme.padding * 2 + implicitHeight: holder.childrenRect.height + Theme.padding + + radius: Theme.radius + color: root.active ? root.activeColor : mouse.containsMouse ? Theme.surfaceHover : "transparent" + + Behavior on color { + ColorAnimation { + duration: Theme.animDuration + easing.type: Theme.animEasing + } + } + + Item { + id: holder + + anchors.centerIn: parent + width: childrenRect.width + height: childrenRect.height + } + + Item { + id: overlayHolder + + anchors.fill: parent + } + + MouseArea { + id: mouse + + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + acceptedButtons: Qt.LeftButton + onClicked: event => root.clicked(event) + } +} diff --git a/components/ToggleSwitch.qml b/components/ToggleSwitch.qml new file mode 100644 index 0000000..dc0c107 --- /dev/null +++ b/components/ToggleSwitch.qml @@ -0,0 +1,64 @@ +import QtQuick +import qs.config + +// A themed on/off switch. Reports intent through `toggled` rather than driving +// `checked` itself, so the caller can keep it bound to the real state and let +// the switch follow whatever actually happened. +Rectangle { + id: root + + property bool checked: false + // Shown mid-flight, while the backing state is still catching up. + property bool busy: false + + signal toggled(bool checked) + + implicitWidth: 38 + implicitHeight: 20 + radius: height / 2 + color: root.checked ? Theme.accent : Theme.background + border.width: Theme.borderWidth + border.color: root.checked ? Theme.accent : Theme.border + opacity: root.busy ? 0.6 : 1 + + Behavior on color { + ColorAnimation { + duration: Theme.animDuration + easing.type: Theme.animEasing + } + } + + Rectangle { + id: knob + + y: (parent.height - height) / 2 + x: root.checked ? root.width - width - 3 : 3 + width: root.height - 6 + height: width + radius: height / 2 + color: root.checked ? Theme.background : mouse.containsMouse ? Theme.text : Theme.textDim + + Behavior on x { + NumberAnimation { + duration: Theme.animDuration + easing.type: Theme.animEasing + } + } + + Behavior on color { + ColorAnimation { + duration: Theme.animDuration + easing.type: Theme.animEasing + } + } + } + + MouseArea { + id: mouse + + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: root.toggled(!root.checked) + } +} diff --git a/components/WifiGlyph.qml b/components/WifiGlyph.qml new file mode 100644 index 0000000..71e672f --- /dev/null +++ b/components/WifiGlyph.qml @@ -0,0 +1,88 @@ +import QtQuick +import QtQuick.Shapes +import qs.config + +// The wifi fan, drawn for the same reason as the bluetooth rune: themes ship it +// symbolic-only, which Qt renders black-on-transparent. Drawing it also lets +// each arc be lit separately, which is how signal strength is shown. +Item { + id: root + + property color color: Theme.text + // Unlit arcs stay drawn, faintly, so the glyph keeps one silhouette at + // every strength instead of changing shape as the signal moves. + property color dimColor: Theme.textDim + property int size: Theme.iconSize + // Lit arcs, 0-3. + property int level: 3 + // Struck through with a diagonal, for "off". + property bool struck: false + + implicitWidth: root.size + implicitHeight: root.size + + readonly property real cx: root.size / 2 + readonly property real cy: root.size * 0.74 + readonly property real stroke: Math.max(1.5, root.size * 0.085) + + Rectangle { + x: root.cx - width / 2 + y: root.cy - height / 2 + width: Math.max(2, root.size * 0.14) + height: width + radius: width / 2 + color: root.level > 0 ? root.color : root.dimColor + } + + Repeater { + model: 3 + + Shape { + id: arc + + required property int index + + anchors.fill: parent + preferredRendererType: Shape.CurveRenderer + + ShapePath { + strokeColor: root.level > arc.index ? root.color : root.dimColor + strokeWidth: root.stroke + fillColor: "transparent" + capStyle: ShapePath.RoundCap + + // Centred on the dot, opening upwards: 225° to 315°, measured + // clockwise from three o'clock. + PathAngleArc { + centerX: root.cx + centerY: root.cy + radiusX: root.size * (0.2 + arc.index * 0.115) + radiusY: root.size * (0.2 + arc.index * 0.115) + startAngle: 225 + sweepAngle: 90 + } + } + } + } + + Shape { + anchors.fill: parent + visible: root.struck + preferredRendererType: Shape.CurveRenderer + + ShapePath { + strokeColor: root.color + strokeWidth: root.stroke + fillColor: "transparent" + capStyle: ShapePath.RoundCap + + startX: root.size * 0.12 + startY: root.size * 0.88 + + PathLine { + x: root.size * 0.88 + y: root.size * 0.12 + } + } + } +} diff --git a/config/Config.qml b/config/Config.qml new file mode 100644 index 0000000..91b1e1c --- /dev/null +++ b/config/Config.qml @@ -0,0 +1,136 @@ +pragma Singleton + +import Quickshell +import Quickshell.Wayland +import qs.services + +Singleton { + id: root + + // ---- Panel placement ------------------------------------------------- + + // Bottom of the screen, on the Top layer so the panel sits above normal + // windows but below fullscreen overlays and lockscreens. + readonly property int layer: WlrLayer.Top + + // Layershell namespace. Compositors can target the panel with this, e.g. + // sway: `layer_effects "att_menu" blur enable` + readonly property string namespace: "att_menu" + + // Reserve space so maximised windows do not sit under the panel. + readonly property bool reserveSpace: true + + // ---- Window list ----------------------------------------------------- + + // Show only windows present on the panel's own screen. Set false to mirror + // every window onto every panel. + readonly property bool windowsPerScreen: true + + readonly property int windowButtonMinWidth: 40 + readonly property int windowButtonMaxWidth: 220 + + // ---- Launcher -------------------------------------------------------- + + readonly property int launcherWidth: 460 + readonly property int launcherHeight: 520 + + // argv prefix used to run desktop entries with `Terminal=true`. + // $TERMINAL is honoured if set; otherwise change the fallback to your + // terminal of choice. + readonly property var terminal: { + const env = Quickshell.env("TERMINAL"); + if (!env) + return ["foot"]; + + // Terminals that need an explicit flag before the command to run. + const needsExecFlag = ["alacritty", "ghostty", "xterm", "urxvt", "st", "konsole"]; + return needsExecFlag.includes(env.split("/").pop()) ? [env, "-e"] : [env]; + } + + // ---- Wifi ------------------------------------------------------------ + + // Set false to drop the wifi button. It also hides itself where there is no + // wifi adapter, or no NetworkManager to talk to. + readonly property bool showWifi: true + + // Cap on the SSID shown next to the panel icon, so a long name cannot + // squeeze the window list. + readonly property int wifiLabelMaxWidth: 110 + + readonly property int wifiMenuWidth: 300 + readonly property int wifiMenuHeight: 400 + + // ---- Bluetooth ------------------------------------------------------- + + // Set false to drop the bluetooth button. It also hides itself on machines + // with no bluetooth adapter, so leaving this on costs nothing there. + readonly property bool showBluetooth: true + + readonly property int bluetoothMenuWidth: 300 + readonly property int bluetoothMenuHeight: 400 + + // ---- Power actions --------------------------------------------------- + + // Ask before running the actions marked `dangerous` below. + readonly property bool confirmDangerous: true + + readonly property int powerMenuWidth: 190 + + // `command` is argv, run detached. Reorder or trim this list to change + // which rows appear in the session menu. + readonly property var powerActions: [ + { + id: "logout", + label: "Log out", + icon: "system-log-out", + glyph: "⏻", + command: root.sessionQuitCommand, + dangerous: true + }, + { + id: "suspend", + label: "Suspend", + icon: "system-suspend", + glyph: "☽", + command: ["systemctl", "suspend"], + dangerous: false + }, + { + id: "reboot", + label: "Restart", + icon: "system-reboot", + glyph: "↻", + command: ["systemctl", "reboot"], + dangerous: true + }, + { + id: "shutdown", + label: "Shut down", + icon: "system-shutdown", + glyph: "⏼", + command: ["systemctl", "poweroff"], + dangerous: true + } + ] + + // Logging out is compositor specific, so detect the running one. Falls back + // to asking logind to end the session, which works anywhere. + readonly property var sessionQuitCommand: { + const desktop = (Quickshell.env("XDG_CURRENT_DESKTOP") || "").toLowerCase(); + + if (Quickshell.env("HYPRLAND_INSTANCE_SIGNATURE")) + return ["hyprctl", "dispatch", "exit"]; + if (Quickshell.env("SWAYSOCK") || desktop.includes("sway")) + return ["swaymsg", "exit"]; + if (Quickshell.env("NIRI_SOCKET") || desktop.includes("niri")) + return ["niri", "msg", "action", "quit", "--skip-confirmation"]; + // river 0.4 has no riverctl: the external window manager owns session + // exit, and attwm offers it over its socket. Only river-classic (0.3.x) + // is the riverctl one. + if (desktop.includes("river")) + return Attwm.available ? ["attwmctl", "exit-session"] : ["riverctl", "exit"]; + + const session = Quickshell.env("XDG_SESSION_ID"); + return session ? ["loginctl", "terminate-session", session] : ["loginctl", "terminate-session", "self"]; + } +} diff --git a/config/Theme.qml b/config/Theme.qml new file mode 100644 index 0000000..3bfdec4 --- /dev/null +++ b/config/Theme.qml @@ -0,0 +1,34 @@ +pragma Singleton + +import Quickshell +import QtQuick + +Singleton { + // Sizing + readonly property int barHeight: 40 + readonly property int radius: 6 + readonly property int spacing: 4 + readonly property int padding: 6 + readonly property int iconSize: 22 + readonly property int borderWidth: 1 + + // Palette + readonly property color background: "#14161b" + readonly property color surface: "#1c1f26" + readonly property color surfaceHover: "#272b34" + readonly property color surfaceActive: "#323845" + readonly property color border: "#2c313c" + readonly property color text: "#d6dae2" + readonly property color textDim: "#8b93a3" + readonly property color accent: "#6ea8fe" + readonly property color danger: "#e4677a" + + // Type + readonly property string fontFamily: "sans-serif" + readonly property int fontSize: 12 + readonly property int fontSizeSmall: 10 + + // Motion + readonly property int animDuration: 110 + readonly property int animEasing: Easing.OutQuad +} diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..a1b3161 --- /dev/null +++ b/flake.lock @@ -0,0 +1,27 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1784856561, + "narHash": "sha256-J+Bx1Z6Oeoj2FgnBhRMKyUhhtDoOpTgXYaVLZpDjW4A=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "597283ad8aa0b331c788e97c4c262d58877074ef", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-26.05", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..c8ac8f0 --- /dev/null +++ b/flake.nix @@ -0,0 +1,87 @@ +{ + description = "att_menu — a bottom desktop panel for wlroots compositors, built on Quickshell"; + + inputs = { + # Pinned to the current NixOS stable release. + nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05"; + }; + + outputs = + { self, nixpkgs }: + let + systems = [ + "x86_64-linux" + "aarch64-linux" + ]; + + forAllSystems = fn: nixpkgs.lib.genAttrs systems (system: fn nixpkgs.legacyPackages.${system}); + in + { + packages = forAllSystems (pkgs: rec { + default = att_menu; + + att_menu = pkgs.stdenvNoCC.mkDerivation { + pname = "att_menu"; + version = "0.1.0"; + + src = ./.; + + nativeBuildInputs = [ pkgs.makeWrapper ]; + + dontConfigure = true; + dontBuild = true; + + installPhase = '' + runHook preInstall + + mkdir -p $out/share/att_menu + cp -r shell.qml config components modules services $out/share/att_menu/ + + makeWrapper ${pkgs.lib.getExe' pkgs.quickshell "qs"} $out/bin/att_menu \ + --add-flags "--path $out/share/att_menu" \ + --suffix PATH : ${ + pkgs.lib.makeBinPath [ + # loginctl and systemctl back the default power actions. The + # session's own PATH is searched first, so a compositor's + # swaymsg/hyprctl/niri is still picked up for logging out. + pkgs.systemd + ] + } \ + --suffix XDG_DATA_DIRS : "${pkgs.adwaita-icon-theme}/share:${pkgs.hicolor-icon-theme}/share" + + runHook postInstall + ''; + + meta = { + description = "Bottom desktop panel for wlroots compositors, built on Quickshell"; + mainProgram = "att_menu"; + platforms = pkgs.lib.platforms.linux; + }; + }; + }); + + apps = forAllSystems (pkgs: { + default = { + type = "app"; + program = pkgs.lib.getExe self.packages.${pkgs.stdenv.hostPlatform.system}.att_menu; + }; + }); + + devShells = forAllSystems (pkgs: { + default = pkgs.mkShell { + packages = [ + pkgs.quickshell + # qmlls / qmlformat for editing the QML in place. + pkgs.qt6.qtdeclarative + ]; + + shellHook = '' + echo "att_menu dev shell — run the panel from the working tree with:" + echo " qs --path ." + ''; + }; + }); + + formatter = forAllSystems (pkgs: pkgs.nixfmt-tree); + }; +} diff --git a/modules/AppEntry.qml b/modules/AppEntry.qml new file mode 100644 index 0000000..cb3e293 --- /dev/null +++ b/modules/AppEntry.qml @@ -0,0 +1,76 @@ +import QtQuick +import qs.config +import qs.components + +// One row in the launcher's result list. +Rectangle { + id: root + + required property var entry + property bool selected: false + + signal activated + signal entered + + implicitHeight: 40 + radius: Theme.radius + color: root.selected ? Theme.surfaceActive : "transparent" + + Behavior on color { + ColorAnimation { + duration: Theme.animDuration + easing.type: Theme.animEasing + } + } + + AppIcon { + id: icon + + anchors { + left: parent.left + leftMargin: Theme.padding + verticalCenter: parent.verticalCenter + } + icon: root.entry.icon + fallbackLabel: root.entry.name + size: 26 + } + + Column { + anchors { + left: icon.right + right: parent.right + leftMargin: Theme.padding * 2 + rightMargin: Theme.padding + verticalCenter: parent.verticalCenter + } + spacing: 1 + + Text { + width: parent.width + elide: Text.ElideRight + text: root.entry.name || root.entry.id + color: Theme.text + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize + } + + Text { + width: parent.width + elide: Text.ElideRight + visible: text !== "" + text: root.entry.comment || root.entry.genericName || "" + color: Theme.textDim + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + } + } + + MouseArea { + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: root.activated() + onEntered: root.entered() + } +} diff --git a/modules/AppMenu.qml b/modules/AppMenu.qml new file mode 100644 index 0000000..1566a7a --- /dev/null +++ b/modules/AppMenu.qml @@ -0,0 +1,70 @@ +import QtQuick +import qs.config +import qs.components +import qs.services + +// Left section: the launcher button. The menu itself lives in Launcher.qml as a +// separate layer surface. +Item { + id: root + + required property var bar + + implicitWidth: button.implicitWidth + + PanelButton { + id: button + + anchors.verticalCenter: parent.verticalCenter + height: root.height - Theme.spacing + active: launcher.visible + activeColor: Theme.accent + + onClicked: Session.toggle("launcher", launcher.screenName) + + Row { + spacing: Theme.spacing + 2 + + // Drawn rather than themed: a 2x2 grid needs no icon theme to be + // installed and always renders. + Item { + implicitWidth: Theme.iconSize + implicitHeight: Theme.iconSize + + Grid { + anchors.centerIn: parent + columns: 2 + rows: 2 + spacing: 3 + + Repeater { + model: 4 + + Rectangle { + implicitWidth: 4 + implicitHeight: 4 + radius: 1 + color: button.active ? Theme.background : Theme.text + } + } + } + } + + Text { + height: Theme.iconSize + verticalAlignment: Text.AlignVCenter + text: "Apps" + color: button.active ? Theme.background : Theme.text + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize + font.bold: true + } + } + } + + Launcher { + id: launcher + + bar: root.bar + } +} diff --git a/modules/Bar.qml b/modules/Bar.qml new file mode 100644 index 0000000..d359228 --- /dev/null +++ b/modules/Bar.qml @@ -0,0 +1,78 @@ +import Quickshell +import Quickshell.Wayland +import QtQuick +import QtQuick.Layouts +import qs.config + +// The panel. Anchored to the bottom edge on three sides, so the compositor +// resizes it for us whenever the output geometry changes — including rotation, +// mode changes and scale changes. Nothing here depends on a fixed width. +PanelWindow { + id: bar + + required property var modelData + + screen: bar.modelData + + WlrLayershell.layer: Config.layer + WlrLayershell.namespace: Config.namespace + + anchors { + left: true + right: true + bottom: true + } + + implicitHeight: Theme.barHeight + exclusionMode: Config.reserveSpace ? ExclusionMode.Auto : ExclusionMode.Ignore + color: "transparent" + + Rectangle { + anchors.fill: parent + color: Theme.background + + Rectangle { + anchors { + left: parent.left + right: parent.right + top: parent.top + } + height: Theme.borderWidth + color: Theme.border + } + + RowLayout { + anchors.fill: parent + anchors.topMargin: Theme.borderWidth + anchors.leftMargin: Theme.padding + anchors.rightMargin: Theme.padding + spacing: Theme.spacing + + AppMenu { + bar: bar + Layout.fillHeight: true + } + + WindowList { + bar: bar + Layout.fillWidth: true + Layout.fillHeight: true + } + + WifiMenu { + bar: bar + Layout.fillHeight: true + } + + BluetoothMenu { + bar: bar + Layout.fillHeight: true + } + + PowerMenu { + bar: bar + Layout.fillHeight: true + } + } + } +} diff --git a/modules/BluetoothDeviceEntry.qml b/modules/BluetoothDeviceEntry.qml new file mode 100644 index 0000000..ed0b0a7 --- /dev/null +++ b/modules/BluetoothDeviceEntry.qml @@ -0,0 +1,117 @@ +import QtQuick +import Quickshell.Bluetooth +import qs.config +import qs.components + +// One known device in the bluetooth menu. +Rectangle { + id: root + + required property BluetoothDevice device + property bool selected: false + + signal activated + signal entered + + readonly property bool busy: root.device.state === BluetoothDeviceState.Connecting || root.device.state === BluetoothDeviceState.Disconnecting || root.device.pairing + + readonly property string status: { + if (root.device.pairing) + return "Pairing…"; + + switch (root.device.state) { + case BluetoothDeviceState.Connecting: + return "Connecting…"; + case BluetoothDeviceState.Disconnecting: + return "Disconnecting…"; + case BluetoothDeviceState.Connected: + return "Connected"; + default: + return root.device.blocked ? "Blocked" : "Not connected"; + } + } + + implicitHeight: 40 + radius: Theme.radius + color: root.selected ? Theme.surfaceActive : "transparent" + + Behavior on color { + ColorAnimation { + duration: Theme.animDuration + easing.type: Theme.animEasing + } + } + + AppIcon { + id: icon + + anchors { + left: parent.left + leftMargin: Theme.padding + verticalCenter: parent.verticalCenter + } + // BlueZ reports a freedesktop icon name for the device class + // ("audio-headset", "input-mouse", …); the lettered tile covers the + // classes a theme happens to be missing. + icon: root.device.icon + fallbackLabel: root.device.name + size: 24 + opacity: root.device.connected ? 1 : 0.65 + } + + Column { + anchors { + left: icon.right + right: battery.left + leftMargin: Theme.padding * 2 + rightMargin: Theme.padding + verticalCenter: parent.verticalCenter + } + spacing: 1 + + Text { + width: parent.width + elide: Text.ElideRight + text: root.device.name || root.device.deviceName || root.device.address + color: Theme.text + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize + } + + Text { + width: parent.width + elide: Text.ElideRight + text: root.status + color: root.device.connected ? Theme.accent : Theme.textDim + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + } + } + + // Battery is only published by devices that report it, and only while they + // are connected. + Text { + id: battery + + anchors { + right: parent.right + rightMargin: Theme.padding * 2 + verticalCenter: parent.verticalCenter + } + visible: root.device.batteryAvailable + width: visible ? implicitWidth : 0 + text: Math.round(root.device.battery * 100) + "%" + color: root.device.battery <= 0.2 ? Theme.danger : Theme.textDim + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + font.bold: true + } + + MouseArea { + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: root.activated() + onEntered: root.entered() + } +} diff --git a/modules/BluetoothMenu.qml b/modules/BluetoothMenu.qml new file mode 100644 index 0000000..1f5f72d --- /dev/null +++ b/modules/BluetoothMenu.qml @@ -0,0 +1,67 @@ +import QtQuick +import Quickshell.Bluetooth +import qs.config +import qs.components +import qs.services + +// Right section: the bluetooth button. The device list lives in +// BluetoothPanel.qml as a separate layer surface. +Item { + id: root + + required property var bar + + readonly property BluetoothAdapter adapter: Bluetooth.defaultAdapter + readonly property bool adapterOn: root.adapter ? root.adapter.enabled : false + + readonly property var connected: root.adapter ? root.adapter.devices.values.filter(d => d.connected) : [] + + // The first connected device that reports a battery. With a headset paired + // that is the one number worth carrying on the panel; the rest are a click + // away. + readonly property var batteryDevice: root.connected.find(d => d.batteryAvailable) ?? null + + // Machines without a bluetooth radio get no button at all. + visible: Config.showBluetooth && root.adapter !== null + + implicitWidth: button.implicitWidth + + PanelButton { + id: button + + anchors.verticalCenter: parent.verticalCenter + height: root.height - Theme.spacing + minContentWidth: Theme.iconSize + active: panel.visible + activeColor: Theme.accent + + onClicked: Session.toggle("bluetooth", panel.screenName) + + Row { + spacing: Theme.spacing + + BluetoothGlyph { + size: Theme.iconSize + struck: !root.adapterOn + color: button.active ? Theme.background : !root.adapterOn ? Theme.textDim : root.connected.length > 0 ? Theme.accent : Theme.text + } + + Text { + height: Theme.iconSize + verticalAlignment: Text.AlignVCenter + visible: root.batteryDevice !== null + text: root.batteryDevice ? Math.round(root.batteryDevice.battery * 100) + "%" : "" + color: button.active ? Theme.background : root.batteryDevice && root.batteryDevice.battery <= 0.2 ? Theme.danger : Theme.textDim + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + font.bold: true + } + } + } + + BluetoothPanel { + id: panel + + bar: root.bar + } +} diff --git a/modules/BluetoothPanel.qml b/modules/BluetoothPanel.qml new file mode 100644 index 0000000..acad8fa --- /dev/null +++ b/modules/BluetoothPanel.qml @@ -0,0 +1,241 @@ +import Quickshell +import Quickshell.Wayland +import Quickshell.Bluetooth +import QtQuick +import qs.config +import qs.components +import qs.services + +// The bluetooth menu: adapter power and the devices bluetoothd already knows +// about. Same full-screen layer surface as the launcher, so click-away and +// Escape work on any wlroots compositor. +// +// Deliberately not a pairing UI — discovery is never turned on, so nothing +// transient appears in the list and the panel stays a one-click connect. +PanelWindow { + id: root + + required property var bar + + readonly property string screenName: root.bar.screen ? root.bar.screen.name : "" + + visible: Session.isOpen("bluetooth", root.screenName) + + screen: root.bar.screen + WlrLayershell.layer: WlrLayer.Overlay + WlrLayershell.namespace: Config.namespace + "-bluetooth" + WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive + + anchors { + left: true + right: true + top: true + bottom: true + } + exclusionMode: ExclusionMode.Ignore + color: "transparent" + + // ---- Data ------------------------------------------------------------ + + readonly property BluetoothAdapter adapter: Bluetooth.defaultAdapter + readonly property bool adapterOn: root.adapter ? root.adapter.enabled : false + readonly property bool adapterBusy: root.adapter ? root.adapter.state === BluetoothAdapterState.Enabling || root.adapter.state === BluetoothAdapterState.Disabling : false + + // Known devices only: paired or bonded, i.e. what bluetoothd remembers. + // Connected ones first, then alphabetical, so the list does not reshuffle + // under the cursor for any other reason. + readonly property var devices: { + if (!root.adapter) + return []; + + return [...root.adapter.devices.values].filter(d => d.paired || d.bonded).sort((a, b) => { + if (a.connected !== b.connected) + return a.connected ? -1 : 1; + return (a.name || "").localeCompare(b.name || ""); + }); + } + + function toggleDevice(device: BluetoothDevice): void { + if (!device) + return; + + if (device.connected) + device.disconnect(); + else + device.connect(); + } + + onVisibleChanged: { + if (root.visible) { + list.currentIndex = 0; + keyHandler.forceActiveFocus(); + } + } + + // ---- Chrome ---------------------------------------------------------- + + // Click-away scrim. + MouseArea { + anchors.fill: parent + onClicked: Session.close() + } + + Item { + id: keyHandler + + anchors.fill: parent + focus: true + + Keys.onEscapePressed: Session.close() + Keys.onDownPressed: list.currentIndex = Math.min(list.currentIndex + 1, list.count - 1) + Keys.onUpPressed: list.currentIndex = Math.max(list.currentIndex - 1, 0) + Keys.onReturnPressed: root.toggleDevice(root.devices[list.currentIndex]) + Keys.onEnterPressed: root.toggleDevice(root.devices[list.currentIndex]) + } + + Rectangle { + id: menu + + // Anchored to the panel's right edge, under the button that opens it. + anchors { + right: parent.right + bottom: parent.bottom + rightMargin: Theme.padding + bottomMargin: Theme.barHeight + Theme.padding + } + + readonly property int maxHeight: Math.min(Config.bluetoothMenuHeight, root.height - Theme.barHeight - Theme.padding * 2) + readonly property int bodyHeight: root.adapterOn && list.count > 0 ? list.contentHeight : 48 + + width: Math.min(Config.bluetoothMenuWidth, root.width - Theme.padding * 2) + height: Math.min(menu.maxHeight, Theme.padding * 4 + header.height + Theme.padding * 2 + menu.bodyHeight) + + color: Theme.surface + radius: Theme.radius * 2 + border.width: Theme.borderWidth + border.color: Theme.border + + // Swallow clicks so they do not reach the scrim behind. + MouseArea { + anchors.fill: parent + } + + Item { + id: header + + anchors { + left: parent.left + right: parent.right + top: parent.top + margins: Theme.padding * 2 + } + height: 24 + + BluetoothGlyph { + id: headerGlyph + + anchors.verticalCenter: parent.verticalCenter + color: root.adapterOn ? Theme.text : Theme.textDim + struck: !root.adapterOn + size: 18 + } + + Text { + anchors { + left: headerGlyph.right + right: toggle.left + leftMargin: Theme.padding + rightMargin: Theme.padding + verticalCenter: parent.verticalCenter + } + elide: Text.ElideRight + text: "Bluetooth" + color: Theme.text + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize + font.bold: true + } + + ToggleSwitch { + id: toggle + + anchors { + right: parent.right + verticalCenter: parent.verticalCenter + } + checked: root.adapterOn + busy: root.adapterBusy + enabled: root.adapter !== null + + // BlueZ reports the new state back, so `checked` stays bound to + // it rather than to what was clicked. + onToggled: on => { + if (root.adapter) + root.adapter.enabled = on; + } + } + } + + Rectangle { + id: separator + + anchors { + left: parent.left + right: parent.right + top: header.bottom + topMargin: Theme.padding * 2 + } + height: Theme.borderWidth + color: Theme.border + } + + ListView { + id: list + + anchors { + left: parent.left + right: parent.right + top: separator.bottom + bottom: parent.bottom + margins: Theme.padding + topMargin: Theme.padding + } + visible: root.adapterOn + clip: true + spacing: 1 + boundsBehavior: Flickable.StopAtBounds + keyNavigationEnabled: false + model: root.devices + + delegate: BluetoothDeviceEntry { + required property var modelData + required property int index + + device: modelData + width: list.width + selected: list.currentIndex === index + + onActivated: root.toggleDevice(modelData) + onEntered: list.currentIndex = index + } + } + + Text { + anchors { + left: parent.left + right: parent.right + top: separator.bottom + bottom: parent.bottom + margins: Theme.padding * 2 + } + visible: !root.adapterOn || list.count === 0 + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + wrapMode: Text.WordWrap + text: !root.adapter ? "No bluetooth adapter" : !root.adapterOn ? "Bluetooth is off" : "No paired devices" + color: Theme.textDim + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize + } + } +} diff --git a/modules/ConfirmDialog.qml b/modules/ConfirmDialog.qml new file mode 100644 index 0000000..64720a1 --- /dev/null +++ b/modules/ConfirmDialog.qml @@ -0,0 +1,148 @@ +import Quickshell +import Quickshell.Wayland +import QtQuick +import qs.config + +// Confirmation for session actions. Same full-screen layer surface approach as +// the launcher, so Escape and click-away work everywhere. +PanelWindow { + id: root + + required property var bar + property string actionLabel: "" + + signal accepted + + visible: false + + screen: root.bar.screen + WlrLayershell.layer: WlrLayer.Overlay + WlrLayershell.namespace: Config.namespace + "-confirm" + WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive + + anchors { + left: true + right: true + top: true + bottom: true + } + exclusionMode: ExclusionMode.Ignore + color: "#66000000" + + function accept(): void { + root.visible = false; + root.accepted(); + } + + onVisibleChanged: if (root.visible) + keyHandler.forceActiveFocus() + + MouseArea { + anchors.fill: parent + onClicked: root.visible = false + } + + Item { + id: keyHandler + + anchors.fill: parent + focus: true + + Keys.onEscapePressed: root.visible = false + Keys.onReturnPressed: root.accept() + Keys.onEnterPressed: root.accept() + } + + Rectangle { + anchors.centerIn: parent + width: Math.min(320, root.width - Theme.padding * 4) + implicitHeight: dialogColumn.implicitHeight + Theme.padding * 6 + radius: Theme.radius * 2 + color: Theme.surface + border.width: Theme.borderWidth + border.color: Theme.border + + MouseArea { + anchors.fill: parent + } + + Column { + id: dialogColumn + + anchors.centerIn: parent + width: parent.width - Theme.padding * 6 + spacing: Theme.padding * 3 + + Text { + width: parent.width + horizontalAlignment: Text.AlignHCenter + wrapMode: Text.WordWrap + text: root.actionLabel + "?" + color: Theme.text + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize + 2 + font.bold: true + } + + Row { + anchors.horizontalCenter: parent.horizontalCenter + spacing: Theme.padding * 2 + + Rectangle { + id: cancel + + implicitWidth: 100 + implicitHeight: 32 + radius: Theme.radius + color: cancelMouse.containsMouse ? Theme.surfaceHover : Theme.background + border.width: Theme.borderWidth + border.color: Theme.border + + Text { + anchors.centerIn: parent + text: "Cancel" + color: Theme.text + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize + } + + MouseArea { + id: cancelMouse + + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: root.visible = false + } + } + + Rectangle { + id: accept + + implicitWidth: 100 + implicitHeight: 32 + radius: Theme.radius + color: acceptMouse.containsMouse ? Qt.lighter(Theme.danger, 1.15) : Theme.danger + + Text { + anchors.centerIn: parent + text: "Confirm" + color: Theme.background + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize + font.bold: true + } + + MouseArea { + id: acceptMouse + + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: root.accept() + } + } + } + } + } +} diff --git a/modules/Launcher.qml b/modules/Launcher.qml new file mode 100644 index 0000000..4ec1d9a --- /dev/null +++ b/modules/Launcher.qml @@ -0,0 +1,288 @@ +import Quickshell +import Quickshell.Wayland +import QtQuick +import qs.config +import qs.components +import qs.services + +// The application menu, implemented as a full-screen layer surface rather than +// a PopupWindow. That gives click-outside-to-dismiss and reliable keyboard +// focus on any wlroots compositor, without compositor-specific focus grabs. +PanelWindow { + id: root + + required property var bar + + readonly property string screenName: root.bar.screen ? root.bar.screen.name : "" + + visible: Session.isOpen("launcher", root.screenName) + + screen: root.bar.screen + WlrLayershell.layer: WlrLayer.Overlay + WlrLayershell.namespace: Config.namespace + "-launcher" + // Exclusive so the search field receives keys immediately on open; the + // surface is unmapped while closed, so nothing is held hostage. + WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive + + anchors { + left: true + right: true + top: true + bottom: true + } + exclusionMode: ExclusionMode.Ignore + color: "transparent" + + // ---- Data ------------------------------------------------------------ + + readonly property var categories: ["All", "Development", "Games", "Graphics", "Internet", "Multimedia", "Office", "Science", "System", "Utility", "Other"] + property string category: "All" + + function groupFor(entry: var): string { + const c = entry.categories || []; + + // Ordered: many entries carry several categories, and the first match + // wins, so the more specific groups are tested first. + if (c.includes("Game")) + return "Games"; + if (c.includes("Development")) + return "Development"; + if (c.includes("Graphics")) + return "Graphics"; + if (c.includes("Network")) + return "Internet"; + if (c.includes("AudioVideo") || c.includes("Audio") || c.includes("Video")) + return "Multimedia"; + if (c.includes("Office")) + return "Office"; + if (c.includes("Science") || c.includes("Education")) + return "Science"; + if (c.includes("Settings") || c.includes("System")) + return "System"; + if (c.includes("Utility")) + return "Utility"; + return "Other"; + } + + function score(entry: var, needle: string): int { + const name = (entry.name || "").toLowerCase(); + if (name === needle) + return 100; + if (name.startsWith(needle)) + return 80; + if (name.includes(needle)) + return 60; + if ((entry.genericName || "").toLowerCase().includes(needle)) + return 40; + if ((entry.keywords || []).some(k => k.toLowerCase().includes(needle))) + return 30; + if ((entry.comment || "").toLowerCase().includes(needle)) + return 20; + if ((entry.id || "").toLowerCase().includes(needle)) + return 10; + return 0; + } + + readonly property var allApps: [...DesktopEntries.applications.values].filter(e => !e.noDisplay) + + readonly property var results: { + const needle = search.text.trim().toLowerCase(); + + if (needle === "") { + return root.allApps.filter(e => root.category === "All" || root.groupFor(e) === root.category).sort((a, b) => (a.name || "").localeCompare(b.name || "")); + } + + // While searching, ignore the category selection — searching the whole + // menu is what people expect. + return root.allApps.map(e => ({ + entry: e, + score: root.score(e, needle) + })).filter(r => r.score > 0).sort((a, b) => b.score - a.score || (a.entry.name || "").localeCompare(b.entry.name || "")).map(r => r.entry); + } + + function launch(entry: var): void { + Session.close(); + AppLauncher.launch(entry); + } + + onVisibleChanged: { + if (root.visible) { + search.text = ""; + root.category = "All"; + list.currentIndex = 0; + search.forceActiveFocus(); + } + } + + // ---- Chrome ---------------------------------------------------------- + + // Click-away scrim. + MouseArea { + anchors.fill: parent + onClicked: Session.close() + } + + Rectangle { + id: menu + + anchors { + left: parent.left + bottom: parent.bottom + leftMargin: Theme.padding + bottomMargin: Theme.barHeight + Theme.padding + } + + // Never taller or wider than the screen, so a rotated or small output + // still shows a usable menu. + width: Math.min(Config.launcherWidth, root.width - Theme.padding * 2) + height: Math.min(Config.launcherHeight, root.height - Theme.barHeight - Theme.padding * 2) + + color: Theme.surface + radius: Theme.radius * 2 + border.width: Theme.borderWidth + border.color: Theme.border + + // Swallow clicks so they do not reach the scrim behind. + MouseArea { + anchors.fill: parent + } + + Keys.onEscapePressed: Session.close() + + Column { + anchors.fill: parent + anchors.margins: Theme.padding * 2 + spacing: Theme.padding * 2 + + // Search field + Rectangle { + width: parent.width + height: 34 + radius: Theme.radius + color: Theme.background + border.width: Theme.borderWidth + border.color: search.activeFocus ? Theme.accent : Theme.border + + TextInput { + id: search + + anchors.fill: parent + anchors.leftMargin: Theme.padding * 2 + anchors.rightMargin: Theme.padding * 2 + verticalAlignment: TextInput.AlignVCenter + color: Theme.text + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize + selectByMouse: true + selectionColor: Theme.accent + focus: true + + Keys.onEscapePressed: Session.close() + Keys.onDownPressed: list.currentIndex = Math.min(list.currentIndex + 1, list.count - 1) + Keys.onUpPressed: list.currentIndex = Math.max(list.currentIndex - 1, 0) + Keys.onReturnPressed: list.activateCurrent() + Keys.onEnterPressed: list.activateCurrent() + + Text { + anchors.fill: parent + verticalAlignment: Text.AlignVCenter + visible: search.text === "" + text: "Search applications…" + color: Theme.textDim + font: search.font + } + } + } + + // Category selector. Wraps rather than scrolls so every category + // stays reachable at any launcher width. + Flow { + id: categoryFlow + + width: parent.width + spacing: Theme.spacing + + Repeater { + model: root.categories + + Rectangle { + id: chip + + required property string modelData + + readonly property bool selected: root.category === chip.modelData + + implicitWidth: categoryLabel.implicitWidth + Theme.padding * 3 + implicitHeight: 24 + radius: Theme.radius + color: chip.selected ? Theme.accent : categoryMouse.containsMouse ? Theme.surfaceHover : Theme.background + + Text { + id: categoryLabel + + anchors.centerIn: parent + text: chip.modelData + color: chip.selected ? Theme.background : Theme.textDim + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + } + + MouseArea { + id: categoryMouse + + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + root.category = chip.modelData; + list.currentIndex = 0; + } + } + } + } + } + + // Results + ListView { + id: list + + width: parent.width + height: parent.height - y + clip: true + spacing: 1 + boundsBehavior: Flickable.StopAtBounds + keyNavigationEnabled: false + model: root.results + + function activateCurrent(): void { + const entry = root.results[list.currentIndex]; + if (entry) + root.launch(entry); + } + + highlightMoveDuration: Theme.animDuration + + delegate: AppEntry { + required property var modelData + required property int index + + entry: modelData + width: list.width + selected: list.currentIndex === index + + onActivated: root.launch(modelData) + onEntered: list.currentIndex = index + } + + Text { + anchors.centerIn: parent + visible: list.count === 0 + text: "No matching applications" + color: Theme.textDim + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize + } + } + } + } +} diff --git a/modules/PowerMenu.qml b/modules/PowerMenu.qml new file mode 100644 index 0000000..d388a4d --- /dev/null +++ b/modules/PowerMenu.qml @@ -0,0 +1,72 @@ +import Quickshell +import QtQuick +import qs.config +import qs.components +import qs.services + +// Right section: the session button. The actions live in PowerPanel.qml as a +// separate layer surface; running them, and confirming the destructive ones, +// stays here so the confirmation outlives the menu closing. +Item { + id: root + + required property var bar + + implicitWidth: button.implicitWidth + + property var pendingAction: null + + function trigger(action: var): void { + if (action.dangerous && Config.confirmDangerous) { + root.pendingAction = action; + confirm.visible = true; + } else { + root.run(action); + } + } + + function run(action: var): void { + Quickshell.execDetached(action.command); + } + + PanelButton { + id: button + + anchors.verticalCenter: parent.verticalCenter + height: root.height - Theme.spacing + minContentWidth: Theme.iconSize + active: panel.visible + activeColor: Theme.accent + + onClicked: Session.toggle("power", panel.screenName) + + AppIcon { + icon: "system-shutdown" + fallbackGlyph: "⏻" + glyphColor: button.active ? Theme.background : Theme.text + size: Theme.iconSize + } + } + + PowerPanel { + id: panel + + bar: root.bar + + onTriggered: action => root.trigger(action) + } + + ConfirmDialog { + id: confirm + + bar: root.bar + actionLabel: root.pendingAction ? root.pendingAction.label : "" + + onAccepted: { + const action = root.pendingAction; + root.pendingAction = null; + if (action) + root.run(action); + } + } +} diff --git a/modules/PowerPanel.qml b/modules/PowerPanel.qml new file mode 100644 index 0000000..80e5ea5 --- /dev/null +++ b/modules/PowerPanel.qml @@ -0,0 +1,182 @@ +import Quickshell +import Quickshell.Wayland +import QtQuick +import qs.config +import qs.components +import qs.services + +// The session menu: the actions from Config.powerActions, one per row. Same +// full-screen layer surface as the launcher, so click-away and Escape work +// everywhere. +// +// Running them is left to PowerMenu, which owns the confirmation dialog — this +// surface is unmapped the moment something is picked. +PanelWindow { + id: root + + required property var bar + + signal triggered(var action) + + readonly property string screenName: root.bar.screen ? root.bar.screen.name : "" + + visible: Session.isOpen("power", root.screenName) + + screen: root.bar.screen + WlrLayershell.layer: WlrLayer.Overlay + WlrLayershell.namespace: Config.namespace + "-power" + WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive + + anchors { + left: true + right: true + top: true + bottom: true + } + exclusionMode: ExclusionMode.Ignore + color: "transparent" + + readonly property var actions: Config.powerActions + property int current: 0 + + function activate(index: int): void { + const action = root.actions[index]; + if (!action) + return; + + Session.close(); + root.triggered(action); + } + + onVisibleChanged: { + if (root.visible) { + // Never start on a destructive action: Enter on open should not be + // able to shut the machine down. + root.current = root.actions.findIndex(a => !a.dangerous); + if (root.current < 0) + root.current = 0; + + keyHandler.forceActiveFocus(); + } + } + + // Click-away scrim. + MouseArea { + anchors.fill: parent + onClicked: Session.close() + } + + Item { + id: keyHandler + + anchors.fill: parent + focus: true + + Keys.onEscapePressed: Session.close() + Keys.onDownPressed: root.current = Math.min(root.current + 1, root.actions.length - 1) + Keys.onUpPressed: root.current = Math.max(root.current - 1, 0) + Keys.onReturnPressed: root.activate(root.current) + Keys.onEnterPressed: root.activate(root.current) + } + + Rectangle { + id: menu + + // Anchored to the panel's right edge, under the button that opens it. + anchors { + right: parent.right + bottom: parent.bottom + rightMargin: Theme.padding + bottomMargin: Theme.barHeight + Theme.padding + } + + width: Math.min(Config.powerMenuWidth, root.width - Theme.padding * 2) + height: column.implicitHeight + Theme.padding * 2 + + color: Theme.surface + radius: Theme.radius * 2 + border.width: Theme.borderWidth + border.color: Theme.border + + // Swallow clicks so they do not reach the scrim behind. + MouseArea { + anchors.fill: parent + } + + Column { + id: column + + anchors { + left: parent.left + right: parent.right + verticalCenter: parent.verticalCenter + margins: Theme.padding + } + spacing: 1 + + Repeater { + model: root.actions + + Rectangle { + id: row + + required property var modelData + required property int index + + readonly property bool destructive: row.modelData.dangerous + readonly property bool selected: root.current === row.index + readonly property color tint: row.destructive && row.selected ? Theme.danger : Theme.text + + width: column.width + implicitHeight: 34 + radius: Theme.radius + color: row.selected ? Theme.surfaceActive : "transparent" + + Behavior on color { + ColorAnimation { + duration: Theme.animDuration + easing.type: Theme.animEasing + } + } + + AppIcon { + id: icon + + anchors { + left: parent.left + leftMargin: Theme.padding * 2 + verticalCenter: parent.verticalCenter + } + icon: row.modelData.icon + fallbackGlyph: row.modelData.glyph + glyphColor: row.tint + size: 18 + } + + Text { + anchors { + left: icon.right + right: parent.right + leftMargin: Theme.padding * 2 + rightMargin: Theme.padding * 2 + verticalCenter: parent.verticalCenter + } + elide: Text.ElideRight + text: row.modelData.label + color: row.tint + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize + } + + MouseArea { + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onEntered: root.current = row.index + onClicked: root.activate(row.index) + } + } + } + } + } +} diff --git a/modules/WifiMenu.qml b/modules/WifiMenu.qml new file mode 100644 index 0000000..5b7415e --- /dev/null +++ b/modules/WifiMenu.qml @@ -0,0 +1,69 @@ +import QtQuick +import Quickshell.Networking +import qs.config +import qs.components +import qs.services + +// Right section: the wifi button. The network list lives in WifiPanel.qml as a +// separate layer surface. +Item { + id: root + + required property var bar + + readonly property WifiDevice device: Networking.devices.values.find(d => d.type === DeviceType.Wifi) ?? null + readonly property bool wifiOn: Networking.wifiEnabled && Networking.wifiHardwareEnabled + + readonly property var network: root.device ? root.device.networks.values.find(n => n.connected) ?? null : null + + // Machines with no wifi adapter, and sessions with no NetworkManager, get + // no button at all. + visible: Config.showWifi && root.device !== null + + implicitWidth: button.implicitWidth + + PanelButton { + id: button + + anchors.verticalCenter: parent.verticalCenter + height: root.height - Theme.spacing + minContentWidth: Theme.iconSize + active: panel.visible + activeColor: Theme.accent + + onClicked: Session.toggle("wifi", panel.screenName) + + Row { + spacing: Theme.spacing + + WifiGlyph { + size: Theme.iconSize + struck: !root.wifiOn + level: root.network ? Math.min(3, Math.floor(root.network.signalStrength * 4)) : 0 + color: button.active ? Theme.background : !root.wifiOn ? Theme.textDim : root.network ? Theme.accent : Theme.text + dimColor: button.active ? Qt.rgba(Theme.background.r, Theme.background.g, Theme.background.b, 0.4) : Theme.border + } + + // The network in use, which is the thing worth carrying on the + // panel. Capped so a long SSID cannot squeeze the window list. + Text { + height: Theme.iconSize + width: Math.min(implicitWidth, Config.wifiLabelMaxWidth) + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + visible: root.network !== null + text: root.network ? root.network.name : "" + color: button.active ? Theme.background : Theme.textDim + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + font.bold: true + } + } + } + + WifiPanel { + id: panel + + bar: root.bar + } +} diff --git a/modules/WifiNetworkEntry.qml b/modules/WifiNetworkEntry.qml new file mode 100644 index 0000000..3957c2e --- /dev/null +++ b/modules/WifiNetworkEntry.qml @@ -0,0 +1,157 @@ +import QtQuick +import Quickshell.Networking +import qs.config +import qs.components + +// One network in the wifi menu. +Rectangle { + id: root + + required property WifiNetwork network + property bool selected: false + + signal activated + signal forgetRequested + signal entered + + // Owe is opportunistic encryption: no passphrase to ask for. + readonly property bool secured: root.network.security !== WifiSecurityType.Open && root.network.security !== WifiSecurityType.Owe + + readonly property string securityLabel: { + switch (root.network.security) { + case WifiSecurityType.Open: + return "Open"; + case WifiSecurityType.Owe: + return "Open, encrypted"; + case WifiSecurityType.Sae: + case WifiSecurityType.Wpa3SuiteB192: + return "WPA3"; + case WifiSecurityType.Wpa2Psk: + case WifiSecurityType.Wpa2Eap: + return "WPA2"; + case WifiSecurityType.WpaPsk: + case WifiSecurityType.WpaEap: + return "WPA"; + case WifiSecurityType.StaticWep: + case WifiSecurityType.DynamicWep: + return "WEP"; + default: + return "Secured"; + } + } + + readonly property string status: { + switch (root.network.state) { + case ConnectionState.Connecting: + return "Connecting…"; + case ConnectionState.Disconnecting: + return "Disconnecting…"; + case ConnectionState.Connected: + return "Connected"; + default: + return root.network.known ? "Saved · " + root.securityLabel : root.securityLabel; + } + } + + implicitHeight: 40 + radius: Theme.radius + color: root.selected ? Theme.surfaceActive : "transparent" + + Behavior on color { + ColorAnimation { + duration: Theme.animDuration + easing.type: Theme.animEasing + } + } + + WifiGlyph { + id: glyph + + anchors { + left: parent.left + leftMargin: Theme.padding + 2 + verticalCenter: parent.verticalCenter + } + size: 20 + // Quarters, so a network only changes bars on a real move rather than + // on every scan. + level: Math.min(3, Math.floor(root.network.signalStrength * 4)) + color: root.network.connected ? Theme.accent : Theme.text + dimColor: Theme.border + } + + Column { + anchors { + left: glyph.right + right: lock.left + leftMargin: Theme.padding * 2 + rightMargin: Theme.padding + verticalCenter: parent.verticalCenter + } + spacing: 1 + + Text { + width: parent.width + elide: Text.ElideRight + text: root.network.name + color: Theme.text + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize + } + + Text { + width: parent.width + elide: Text.ElideRight + text: root.status + color: root.network.connected ? Theme.accent : Theme.textDim + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + } + } + + // A padlock, drawn: same reasoning as the glyphs. + Item { + id: lock + + anchors { + right: parent.right + rightMargin: Theme.padding * 2 + verticalCenter: parent.verticalCenter + } + visible: root.secured + width: visible ? 9 : 0 + height: 11 + + Rectangle { + width: parent.width + height: 6 + y: parent.height - height + radius: 1 + color: Theme.textDim + } + + Rectangle { + x: 1.5 + width: parent.width - 3 + height: 7 + radius: width / 2 + color: "transparent" + border.width: 1.5 + border.color: Theme.textDim + } + } + + MouseArea { + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + acceptedButtons: Qt.LeftButton | Qt.RightButton + onEntered: root.entered() + onClicked: event => { + if (event.button === Qt.RightButton) + root.forgetRequested(); + else + root.activated(); + } + } +} diff --git a/modules/WifiPanel.qml b/modules/WifiPanel.qml new file mode 100644 index 0000000..6ab39b9 --- /dev/null +++ b/modules/WifiPanel.qml @@ -0,0 +1,402 @@ +import Quickshell +import Quickshell.Wayland +import Quickshell.Networking +import QtQuick +import qs.config +import qs.components +import qs.services + +// The wifi menu: radio power and the networks in range. Same full-screen layer +// surface as the launcher, so click-away and Escape work everywhere. +// +// NetworkManager is the backend Quickshell speaks; on a machine configured with +// `wifi.backend=iwd` the connections still go through iwd underneath, without +// this having to race NetworkManager's own policy. +PanelWindow { + id: root + + required property var bar + + readonly property string screenName: root.bar.screen ? root.bar.screen.name : "" + + visible: Session.isOpen("wifi", root.screenName) + + screen: root.bar.screen + WlrLayershell.layer: WlrLayer.Overlay + WlrLayershell.namespace: Config.namespace + "-wifi" + // Exclusive so the passphrase field receives keys immediately; the surface + // is unmapped while closed, so nothing is held hostage. + WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive + + anchors { + left: true + right: true + top: true + bottom: true + } + exclusionMode: ExclusionMode.Ignore + color: "transparent" + + // ---- Data ------------------------------------------------------------ + + readonly property WifiDevice device: Networking.devices.values.find(d => d.type === DeviceType.Wifi) ?? null + readonly property bool wifiOn: Networking.wifiEnabled && Networking.wifiHardwareEnabled + + readonly property var networks: { + if (!root.device || !root.wifiOn) + return []; + + // Sorted by signal in quarters rather than by the raw value: strength + // moves a little on every scan, and rows must not reshuffle under the + // cursor for that. Connected first, then saved, then strongest. + const bars = n => Math.min(3, Math.floor(n.signalStrength * 4)); + + return [...root.device.networks.values].sort((a, b) => { + if (a.connected !== b.connected) + return a.connected ? -1 : 1; + if (a.known !== b.known) + return a.known ? -1 : 1; + if (bars(a) !== bars(b)) + return bars(b) - bars(a); + return a.name.localeCompare(b.name); + }); + } + + // Network waiting on a passphrase, or null. + property var pskNetwork: null + // Network we last asked to connect, watched for a failure report. + property var pendingNetwork: null + property string error: "" + + function secured(network: var): bool { + // Owe is opportunistic encryption: nothing to ask the user for. + return network.security !== WifiSecurityType.Open && network.security !== WifiSecurityType.Owe; + } + + function activate(network: var): void { + if (!network) + return; + + root.error = ""; + + if (network.connected || network.state === ConnectionState.Connecting) { + network.disconnect(); + return; + } + + // A saved network has its secrets in NetworkManager already. + if (!network.known && root.secured(network)) { + root.promptFor(network); + return; + } + + root.pendingNetwork = network; + network.connect(); + } + + function promptFor(network: var): void { + root.pskNetwork = network; + psk.text = ""; + psk.forceActiveFocus(); + } + + function submitPsk(): void { + const network = root.pskNetwork; + if (!network || psk.text === "") + return; + + root.pskNetwork = null; + root.pendingNetwork = network; + network.connectWithPsk(psk.text); + psk.text = ""; + keyHandler.forceActiveFocus(); + } + + function cancelPsk(): void { + root.pskNetwork = null; + psk.text = ""; + keyHandler.forceActiveFocus(); + } + + // Right click drops a saved network. Without it a mistyped passphrase would + // be saved by NetworkManager and retried forever with no way out from here. + function forget(network: var): void { + if (!network || !network.known) + return; + + root.error = ""; + network.forget(); + } + + Connections { + target: root.pendingNetwork + + function onConnectionFailed(reason: int): void { + switch (reason) { + case ConnectionFailReason.NoSecrets: + root.error = "Wrong password for " + root.pendingNetwork.name; + root.promptFor(root.pendingNetwork); + break; + case ConnectionFailReason.WifiAuthTimeout: + root.error = "Authentication timed out"; + break; + case ConnectionFailReason.WifiNetworkLost: + root.error = "Network went out of range"; + break; + default: + root.error = "Could not connect to " + root.pendingNetwork.name; + } + } + } + + // Scanning costs power, so it only runs while the menu is on screen. As a + // Binding rather than a one-shot, an adapter that appears later still gets + // switched on. + Binding { + target: root.device + property: "scannerEnabled" + value: root.visible + when: root.device !== null + } + + onVisibleChanged: { + if (root.visible) { + list.currentIndex = 0; + root.pskNetwork = null; + root.error = ""; + keyHandler.forceActiveFocus(); + } + } + + // ---- Chrome ---------------------------------------------------------- + + // Click-away scrim. + MouseArea { + anchors.fill: parent + onClicked: Session.close() + } + + Item { + id: keyHandler + + anchors.fill: parent + focus: true + + Keys.onEscapePressed: Session.close() + Keys.onDownPressed: list.currentIndex = Math.min(list.currentIndex + 1, list.count - 1) + Keys.onUpPressed: list.currentIndex = Math.max(list.currentIndex - 1, 0) + Keys.onReturnPressed: root.activate(root.networks[list.currentIndex]) + Keys.onEnterPressed: root.activate(root.networks[list.currentIndex]) + } + + Rectangle { + id: menu + + // Anchored to the panel's right edge, under the button that opens it. + anchors { + right: parent.right + bottom: parent.bottom + rightMargin: Theme.padding + bottomMargin: Theme.barHeight + Theme.padding + } + + readonly property int maxHeight: Math.min(Config.wifiMenuHeight, root.height - Theme.barHeight - Theme.padding * 2) + readonly property int bodyHeight: root.wifiOn && list.count > 0 ? list.contentHeight : 48 + readonly property int footerHeight: footer.visible ? footer.height + Theme.padding : 0 + + width: Math.min(Config.wifiMenuWidth, root.width - Theme.padding * 2) + height: Math.min(menu.maxHeight, Theme.padding * 4 + header.height + Theme.padding * 2 + menu.bodyHeight + menu.footerHeight) + + color: Theme.surface + radius: Theme.radius * 2 + border.width: Theme.borderWidth + border.color: Theme.border + + // Swallow clicks so they do not reach the scrim behind. + MouseArea { + anchors.fill: parent + } + + Item { + id: header + + anchors { + left: parent.left + right: parent.right + top: parent.top + margins: Theme.padding * 2 + } + height: 24 + + WifiGlyph { + id: headerGlyph + + anchors.verticalCenter: parent.verticalCenter + size: 18 + struck: !root.wifiOn + level: root.wifiOn ? 3 : 0 + color: root.wifiOn ? Theme.text : Theme.textDim + dimColor: Theme.border + } + + Text { + anchors { + left: headerGlyph.right + right: toggle.left + leftMargin: Theme.padding + rightMargin: Theme.padding + verticalCenter: parent.verticalCenter + } + elide: Text.ElideRight + text: Networking.wifiHardwareEnabled ? "Wi-Fi" : "Wi-Fi (blocked)" + color: Theme.text + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize + font.bold: true + } + + ToggleSwitch { + id: toggle + + anchors { + right: parent.right + verticalCenter: parent.verticalCenter + } + checked: Networking.wifiEnabled + enabled: Networking.wifiHardwareEnabled + + // NetworkManager reports the new state back, so `checked` stays + // bound to it rather than to what was clicked. + onToggled: on => Networking.wifiEnabled = on + } + } + + Rectangle { + id: separator + + anchors { + left: parent.left + right: parent.right + top: header.bottom + topMargin: Theme.padding * 2 + } + height: Theme.borderWidth + color: Theme.border + } + + ListView { + id: list + + anchors { + left: parent.left + right: parent.right + top: separator.bottom + bottom: footer.visible ? footer.top : parent.bottom + margins: Theme.padding + topMargin: Theme.padding + } + visible: root.wifiOn + clip: true + spacing: 1 + boundsBehavior: Flickable.StopAtBounds + keyNavigationEnabled: false + model: root.networks + + delegate: WifiNetworkEntry { + required property var modelData + required property int index + + network: modelData + width: list.width + selected: list.currentIndex === index + + onActivated: root.activate(modelData) + onForgetRequested: root.forget(modelData) + onEntered: list.currentIndex = index + } + } + + Text { + anchors { + left: parent.left + right: parent.right + top: separator.bottom + bottom: parent.bottom + margins: Theme.padding * 2 + } + visible: !root.wifiOn || list.count === 0 + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + wrapMode: Text.WordWrap + text: !root.device ? "No wifi adapter" : !Networking.wifiHardwareEnabled ? "Wi-Fi is blocked by hardware" : !Networking.wifiEnabled ? "Wi-Fi is off" : "Scanning…" + color: Theme.textDim + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize + } + + // Passphrase prompt, and where failures are reported. + Item { + id: footer + + anchors { + left: parent.left + right: parent.right + bottom: parent.bottom + margins: Theme.padding * 2 + } + visible: root.pskNetwork !== null || root.error !== "" + height: root.pskNetwork !== null ? 30 : errorText.implicitHeight + + Rectangle { + anchors.fill: parent + visible: root.pskNetwork !== null + radius: Theme.radius + color: Theme.background + border.width: Theme.borderWidth + border.color: psk.activeFocus ? Theme.accent : Theme.border + + TextInput { + id: psk + + anchors.fill: parent + anchors.leftMargin: Theme.padding * 2 + anchors.rightMargin: Theme.padding * 2 + verticalAlignment: TextInput.AlignVCenter + echoMode: TextInput.Password + color: Theme.text + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize + selectByMouse: true + selectionColor: Theme.accent + + Keys.onEscapePressed: root.cancelPsk() + Keys.onReturnPressed: root.submitPsk() + Keys.onEnterPressed: root.submitPsk() + + Text { + anchors.fill: parent + verticalAlignment: Text.AlignVCenter + visible: psk.text === "" + text: root.pskNetwork ? "Password for " + root.pskNetwork.name : "" + color: Theme.textDim + font: psk.font + elide: Text.ElideRight + } + } + } + + Text { + id: errorText + + anchors.fill: parent + visible: root.pskNetwork === null && root.error !== "" + verticalAlignment: Text.AlignVCenter + wrapMode: Text.WordWrap + text: root.error + color: Theme.danger + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + } + } + } +} diff --git a/modules/WindowButton.qml b/modules/WindowButton.qml new file mode 100644 index 0000000..6aae388 --- /dev/null +++ b/modules/WindowButton.qml @@ -0,0 +1,110 @@ +import Quickshell +import QtQuick +import qs.config +import qs.components +import qs.services + +// Left click focuses the window, or minimises it if it is already focused — +// except under attwm, which has no minimised state. Middle click closes it. +PanelButton { + id: root + + // The window this button stands for: either an attwm window object from its + // IPC state, or a Toplevel handle from wlr-foreign-toplevel-management. + // Both carry the same facts under different names, so `attwm` says which is + // in hand rather than each read having to guess. + required property var win + required property bool attwm + + readonly property string appId: (root.attwm ? root.win.app_id : root.win.appId) || "" + readonly property bool activated: root.attwm ? root.win.focused : root.win.activated + + // Not on screen right now: minimised elsewhere, and under attwm parked on a + // tag nobody is viewing, which is that model's equivalent. + readonly property bool hidden: root.attwm ? !root.win.visible : root.win.minimized + + // Desktop entries carry a proper themed icon; appId alone often works as an + // icon name too, which is the fallback AppIcon ends up using. + readonly property var entry: DesktopEntries.heuristicLookup(root.appId) + readonly property string title: root.win.title || root.appId || "Window" + + active: root.activated + minContentWidth: Config.windowButtonMinWidth + acceptedButtons: Qt.LeftButton | Qt.MiddleButton + + opacity: root.hidden ? 0.55 : 1 + + Behavior on opacity { + NumberAnimation { + duration: Theme.animDuration + easing.type: Theme.animEasing + } + } + + onClicked: event => { + if (event.button === Qt.MiddleButton) { + if (root.attwm) + Attwm.closeWindow(root.win.id); + else + root.win.close(); + return; + } + + if (root.attwm) { + // attwm has nothing to minimise into — a window is on a tag or it + // is not — so a click always means focus, and attwm brings the + // window's tags into view if they are not already. + Attwm.focusWindow(root.win.id); + return; + } + + if (root.win.activated) { + root.win.minimized = true; + } else { + root.win.minimized = false; + root.win.activate(); + } + } + + // Both children are given the same height so the Row needs no anchors, + // which positioners disallow. + Row { + spacing: Theme.spacing + 2 + + AppIcon { + icon: (root.entry && root.entry.icon) || root.appId + fallbackLabel: root.appId || root.title + size: Theme.iconSize + } + + Text { + id: label + + // Clamp to the natural width so the button never depends on its own + // width, which would loop through PanelButton's implicit sizing. + width: Math.min(implicitWidth, Config.windowButtonMaxWidth) + height: Theme.iconSize + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + text: root.title + color: root.activated ? Theme.text : Theme.textDim + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize + } + } + + // Active-window underline, the usual taskbar affordance. + overlay: Rectangle { + anchors { + left: parent.left + right: parent.right + bottom: parent.bottom + leftMargin: Theme.radius + rightMargin: Theme.radius + } + height: 2 + radius: 1 + color: Theme.accent + visible: root.activated + } +} diff --git a/modules/WindowList.qml b/modules/WindowList.qml new file mode 100644 index 0000000..70cea05 --- /dev/null +++ b/modules/WindowList.qml @@ -0,0 +1,72 @@ +import Quickshell +import Quickshell.Wayland +import QtQuick +import qs.config +import qs.services + +// Middle section: one button per open window. +// +// Two sources, because no one protocol does the job everywhere: +// +// - wlr-foreign-toplevel-management, which works on any wlroots-based +// compositor without talking to a single window manager's IPC. +// - attwm's socket, whenever it answers. Under river 0.4 the foreign-toplevel +// protocol can list windows but cannot focus one — see services/Attwm.qml — +// and its window ids are what make the click land on the right window. +Item { + id: root + + required property var bar + + readonly property bool attwm: Attwm.available + + readonly property string screenName: root.bar.screen ? root.bar.screen.name : "" + + readonly property var windows: { + if (root.attwm) { + if (!Config.windowsPerScreen) + return Attwm.allWindows; + + // attwm reports windows under the output they are on, so no + // filtering is needed — and none of them is screenless. + const out = Attwm.outputFor(root.screenName); + return out ? out.windows : []; + } + + const all = [...ToplevelManager.toplevels.values]; + if (!Config.windowsPerScreen) + return all; + + // A toplevel with no reported screens yet is still shown, otherwise + // freshly mapped windows would flicker in late. + return all.filter(t => t.screens.length === 0 || t.screens.some(s => s && s.name === root.screenName)); + } + + ListView { + id: list + + anchors.fill: parent + orientation: ListView.Horizontal + spacing: Theme.spacing + clip: true + boundsBehavior: Flickable.StopAtBounds + + model: ScriptModel { + values: root.windows + + // Toplevels are long-lived objects and diff by identity. attwm's + // are plain objects re-parsed on every state change, so they need a + // key, or each title change would rebuild every delegate. + objectProp: root.attwm ? "id" : "" + } + + delegate: WindowButton { + required property var modelData + + win: modelData + attwm: root.attwm + height: list.height - Theme.spacing + anchors.verticalCenter: parent ? parent.verticalCenter : undefined + } + } +} diff --git a/services/AppLauncher.qml b/services/AppLauncher.qml new file mode 100644 index 0000000..4100204 --- /dev/null +++ b/services/AppLauncher.qml @@ -0,0 +1,48 @@ +pragma Singleton + +import Quickshell +// Required: execDetached's context overload takes a processContext value type +// from this module, and without the import the call fails to resolve. +import Quickshell.Io +import qs.config + +// Quickshell's DesktopEntry.execute() deliberately ignores both Terminal=true +// and Exec field codes, so entries are launched here instead. Without this, +// entries like `firefox %U` are started with a literal "%U" argument. +Singleton { + id: root + + function withoutFieldCodes(command: var): var { + const out = []; + + for (const arg of command) { + // "%%" is an escaped percent and collapses to one. Every other + // % is a field code the spec says to substitute with files, + // URLs or icons — launching from a menu has nothing to substitute, + // so they are removed. + const cleaned = arg.replace(/%(.)/g, (match, char) => char === "%" ? "%" : ""); + + // An argument that was nothing but a field code is dropped. + if (cleaned !== "") + out.push(cleaned); + } + + return out; + } + + function launch(entry: var): void { + const command = root.withoutFieldCodes(entry.command); + if (command.length === 0) + return; + + const argv = entry.runInTerminal ? [...Config.terminal, ...command] : command; + const options = { + command: argv + }; + + if (entry.workingDirectory) + options.workingDirectory = entry.workingDirectory; + + Quickshell.execDetached(options); + } +} diff --git a/services/Attwm.qml b/services/Attwm.qml new file mode 100644 index 0000000..531ac47 --- /dev/null +++ b/services/Attwm.qml @@ -0,0 +1,123 @@ +pragma Singleton + +import QtQuick +import Quickshell +import Quickshell.Io + +// attwm's IPC socket: the window list, and the commands that act on a window. +// +// river 0.4 ships no window management policy of its own — an external client +// speaking river-window-management-v1 owns it, which here is attwm. That +// protocol has no `activate_requested` event and focus is set solely by the +// window manager, so the `activate` request wlr-foreign-toplevel-management +// sends on a task-list click reaches nobody at all and focus never moves. This +// socket is the way in. See modules/WindowList.qml. +// +// One connection does both jobs: it subscribes to state and commands go back +// down the same socket, so a click costs no process spawn. +Singleton { + id: root + + // True once attwm has answered. Everything here is inert otherwise, which + // is what keeps the panel working unchanged on other compositors. + readonly property bool available: link.item ? link.item.connected : false + + // Only worth trying under river — anywhere else the socket will never + // exist and the retry below would spend the session failing to connect. + readonly property bool enabled: !!Quickshell.env("ATTWM_SOCKET") || (Quickshell.env("XDG_CURRENT_DESKTOP") || "").toLowerCase().includes("river") + + // Latest state object from attwm, parsed. + property var state: ({}) + + readonly property var outputs: root.state.outputs ?? [] + + readonly property var allWindows: { + const all = []; + for (const out of root.outputs) + all.push(...out.windows); + return all; + } + + function outputFor(name: string): var { + for (const out of root.outputs) + if (out.name === name) + return out; + + return null; + } + + // Both take a window id — the `id` field of a window in the state above, + // which is river's own identifier for it. + function focusWindow(id: string): void { + root.send(`focus-window ${id}`); + } + + function closeWindow(id: string): void { + root.send(`close-window ${id}`); + } + + function send(command: string): void { + if (root.available) + link.item.write(`${command}\n`); + } + + // A Socket that fails to connect stays dead: it reports no state change and + // re-asserting `connected` does not make it try again. Reconnecting + // therefore means building a new one, so the socket lives in a Loader that + // can be thrown away. The panel and attwm start independently and either + // may restart, so giving up after the first attempt is not an option. + Loader { + id: link + + active: root.enabled + sourceComponent: Socket { + // attwm names its socket after the Wayland display, so several + // river sessions can run side by side without colliding. + path: { + const explicit = Quickshell.env("ATTWM_SOCKET"); + if (explicit) + return explicit; + + const dir = Quickshell.env("XDG_RUNTIME_DIR"); + const display = Quickshell.env("WAYLAND_DISPLAY") || "wayland-0"; + return `${dir}/att_wm-${display}.sock`; + } + + connected: true + + // attwm replies with a full state object immediately, so the window + // list does not flash empty on connect. + onConnectionStateChanged: if (connected) + write("subscribe\n") + + parser: SplitParser { + onRead: line => { + // Command acknowledgements ("ok" / "err ...") share the + // stream with state objects; only the latter are JSON. + if (!line.startsWith("{")) + return; + + try { + root.state = JSON.parse(line); + } catch (e) { + console.warn("attwm: bad state line:", e); + } + } + } + } + } + + Timer { + interval: 1000 + repeat: true + running: root.enabled && !root.available + + onTriggered: { + // Drop whatever the dead connection last said, so a reconnect + // cannot briefly show a window list from before the restart. + root.state = ({}); + link.active = false; + link.active = true; + } + } +} diff --git a/services/Session.qml b/services/Session.qml new file mode 100644 index 0000000..7b83c1b --- /dev/null +++ b/services/Session.qml @@ -0,0 +1,53 @@ +pragma Singleton + +import Quickshell +import Quickshell.Wayland + +// Shell-wide UI state. The menus are per-screen surfaces, but only one may be +// open at a time and only on one screen, so ownership lives here rather than in +// the panel instances. +Singleton { + id: root + + // Id of the open menu ("launcher", "bluetooth"), or "" when none is open. + property string openMenu: "" + + // Name of the screen it is open on, or "" when closed. + property string menuScreen: "" + + // Best guess at the screen the user is looking at, used when a menu is + // opened by keybind rather than by clicking a specific panel. + function focusedScreenName(): string { + const active = ToplevelManager.activeToplevel; + if (active && active.screens.length > 0) + return active.screens[0].name; + + return Quickshell.screens.length > 0 ? Quickshell.screens[0].name : ""; + } + + function isOpen(menu: string, screenName: string): bool { + return screenName !== "" && root.openMenu === menu && root.menuScreen === screenName; + } + + function open(menu: string, screenName: string): void { + // Closed first: moving a menu between screens would otherwise map the + // outgoing menu onto the incoming screen for one binding pass. + root.openMenu = ""; + root.menuScreen = screenName || root.focusedScreenName(); + root.openMenu = menu; + } + + function close(): void { + root.openMenu = ""; + root.menuScreen = ""; + } + + function toggle(menu: string, screenName: string): void { + const target = screenName || root.focusedScreenName(); + + if (root.openMenu === menu && root.menuScreen === target) + root.close(); + else + root.open(menu, target); + } +} diff --git a/shell.qml b/shell.qml new file mode 100644 index 0000000..b49c287 --- /dev/null +++ b/shell.qml @@ -0,0 +1,81 @@ +import Quickshell +import Quickshell.Io +import qs.modules +import qs.services + +ShellRoot { + // One panel per screen. Variants tracks screen hotplug, so panels appear and + // disappear with monitors without a reload. + Variants { + model: Quickshell.screens + + Bar {} + } + + // Lets the menus be bound to keys in the compositor, e.g. + // bindsym $mod+d exec qs -c att_menu ipc call launcher toggle + // `close` closes whichever menu is open — only one ever is. + IpcHandler { + target: "launcher" + + function toggle(): void { + Session.toggle("launcher", ""); + } + + function open(): void { + Session.open("launcher", ""); + } + + function close(): void { + Session.close(); + } + } + + IpcHandler { + target: "power" + + function toggle(): void { + Session.toggle("power", ""); + } + + function open(): void { + Session.open("power", ""); + } + + function close(): void { + Session.close(); + } + } + + IpcHandler { + target: "wifi" + + function toggle(): void { + Session.toggle("wifi", ""); + } + + function open(): void { + Session.open("wifi", ""); + } + + function close(): void { + Session.close(); + } + } + + IpcHandler { + target: "bluetooth" + + function toggle(): void { + Session.toggle("bluetooth", ""); + } + + function open(): void { + Session.open("bluetooth", ""); + } + + function close(): void { + Session.close(); + } + } +}