initial commit
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
result
|
||||
result-*
|
||||
.direnv/
|
||||
*.qmlc
|
||||
*.jsc
|
||||
@@ -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`.
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"];
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user