initial commit

This commit is contained in:
2026-07-26 21:16:55 +02:00
commit 30fdd5cb28
30 changed files with 3373 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
result
result-*
.direnv/
*.qmlc
*.jsc
+254
View File
@@ -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 <id>`. 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`.
+74
View File
@@ -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
}
}
}
+87
View File
@@ -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
}
}
}
}
+63
View File
@@ -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)
}
}
+64
View File
@@ -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)
}
}
+88
View File
@@ -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
}
}
}
}
+136
View File
@@ -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"];
}
}
+34
View File
@@ -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
}
Generated
+27
View File
@@ -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
}
+87
View File
@@ -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);
};
}
+76
View File
@@ -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()
}
}
+70
View File
@@ -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
}
}
+78
View File
@@ -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
}
}
}
}
+117
View File
@@ -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()
}
}
+67
View File
@@ -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
}
}
+241
View File
@@ -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
}
}
}
+148
View File
@@ -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()
}
}
}
}
}
}
+288
View File
@@ -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
}
}
}
}
}
+72
View File
@@ -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);
}
}
}
+182
View File
@@ -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)
}
}
}
}
}
}
+69
View File
@@ -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
}
}
+157
View File
@@ -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();
}
}
}
+402
View File
@@ -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
}
}
}
}
+110
View File
@@ -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
}
}
+72
View File
@@ -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
}
}
}
+48
View File
@@ -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
// %<char> 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);
}
}
+123
View File
@@ -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;
}
}
}
+53
View File
@@ -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);
}
}
+81
View File
@@ -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();
}
}
}