initial commit

This commit is contained in:
2026-07-26 21:16:55 +02:00
commit 30fdd5cb28
30 changed files with 3373 additions and 0 deletions
+76
View File
@@ -0,0 +1,76 @@
import QtQuick
import qs.config
import qs.components
// One row in the launcher's result list.
Rectangle {
id: root
required property var entry
property bool selected: false
signal activated
signal entered
implicitHeight: 40
radius: Theme.radius
color: root.selected ? Theme.surfaceActive : "transparent"
Behavior on color {
ColorAnimation {
duration: Theme.animDuration
easing.type: Theme.animEasing
}
}
AppIcon {
id: icon
anchors {
left: parent.left
leftMargin: Theme.padding
verticalCenter: parent.verticalCenter
}
icon: root.entry.icon
fallbackLabel: root.entry.name
size: 26
}
Column {
anchors {
left: icon.right
right: parent.right
leftMargin: Theme.padding * 2
rightMargin: Theme.padding
verticalCenter: parent.verticalCenter
}
spacing: 1
Text {
width: parent.width
elide: Text.ElideRight
text: root.entry.name || root.entry.id
color: Theme.text
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
}
Text {
width: parent.width
elide: Text.ElideRight
visible: text !== ""
text: root.entry.comment || root.entry.genericName || ""
color: Theme.textDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
}
MouseArea {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.activated()
onEntered: root.entered()
}
}
+70
View File
@@ -0,0 +1,70 @@
import QtQuick
import qs.config
import qs.components
import qs.services
// Left section: the launcher button. The menu itself lives in Launcher.qml as a
// separate layer surface.
Item {
id: root
required property var bar
implicitWidth: button.implicitWidth
PanelButton {
id: button
anchors.verticalCenter: parent.verticalCenter
height: root.height - Theme.spacing
active: launcher.visible
activeColor: Theme.accent
onClicked: Session.toggle("launcher", launcher.screenName)
Row {
spacing: Theme.spacing + 2
// Drawn rather than themed: a 2x2 grid needs no icon theme to be
// installed and always renders.
Item {
implicitWidth: Theme.iconSize
implicitHeight: Theme.iconSize
Grid {
anchors.centerIn: parent
columns: 2
rows: 2
spacing: 3
Repeater {
model: 4
Rectangle {
implicitWidth: 4
implicitHeight: 4
radius: 1
color: button.active ? Theme.background : Theme.text
}
}
}
}
Text {
height: Theme.iconSize
verticalAlignment: Text.AlignVCenter
text: "Apps"
color: button.active ? Theme.background : Theme.text
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.bold: true
}
}
}
Launcher {
id: launcher
bar: root.bar
}
}
+78
View File
@@ -0,0 +1,78 @@
import Quickshell
import Quickshell.Wayland
import QtQuick
import QtQuick.Layouts
import qs.config
// The panel. Anchored to the bottom edge on three sides, so the compositor
// resizes it for us whenever the output geometry changes — including rotation,
// mode changes and scale changes. Nothing here depends on a fixed width.
PanelWindow {
id: bar
required property var modelData
screen: bar.modelData
WlrLayershell.layer: Config.layer
WlrLayershell.namespace: Config.namespace
anchors {
left: true
right: true
bottom: true
}
implicitHeight: Theme.barHeight
exclusionMode: Config.reserveSpace ? ExclusionMode.Auto : ExclusionMode.Ignore
color: "transparent"
Rectangle {
anchors.fill: parent
color: Theme.background
Rectangle {
anchors {
left: parent.left
right: parent.right
top: parent.top
}
height: Theme.borderWidth
color: Theme.border
}
RowLayout {
anchors.fill: parent
anchors.topMargin: Theme.borderWidth
anchors.leftMargin: Theme.padding
anchors.rightMargin: Theme.padding
spacing: Theme.spacing
AppMenu {
bar: bar
Layout.fillHeight: true
}
WindowList {
bar: bar
Layout.fillWidth: true
Layout.fillHeight: true
}
WifiMenu {
bar: bar
Layout.fillHeight: true
}
BluetoothMenu {
bar: bar
Layout.fillHeight: true
}
PowerMenu {
bar: bar
Layout.fillHeight: true
}
}
}
}
+117
View File
@@ -0,0 +1,117 @@
import QtQuick
import Quickshell.Bluetooth
import qs.config
import qs.components
// One known device in the bluetooth menu.
Rectangle {
id: root
required property BluetoothDevice device
property bool selected: false
signal activated
signal entered
readonly property bool busy: root.device.state === BluetoothDeviceState.Connecting || root.device.state === BluetoothDeviceState.Disconnecting || root.device.pairing
readonly property string status: {
if (root.device.pairing)
return "Pairing…";
switch (root.device.state) {
case BluetoothDeviceState.Connecting:
return "Connecting…";
case BluetoothDeviceState.Disconnecting:
return "Disconnecting…";
case BluetoothDeviceState.Connected:
return "Connected";
default:
return root.device.blocked ? "Blocked" : "Not connected";
}
}
implicitHeight: 40
radius: Theme.radius
color: root.selected ? Theme.surfaceActive : "transparent"
Behavior on color {
ColorAnimation {
duration: Theme.animDuration
easing.type: Theme.animEasing
}
}
AppIcon {
id: icon
anchors {
left: parent.left
leftMargin: Theme.padding
verticalCenter: parent.verticalCenter
}
// BlueZ reports a freedesktop icon name for the device class
// ("audio-headset", "input-mouse", …); the lettered tile covers the
// classes a theme happens to be missing.
icon: root.device.icon
fallbackLabel: root.device.name
size: 24
opacity: root.device.connected ? 1 : 0.65
}
Column {
anchors {
left: icon.right
right: battery.left
leftMargin: Theme.padding * 2
rightMargin: Theme.padding
verticalCenter: parent.verticalCenter
}
spacing: 1
Text {
width: parent.width
elide: Text.ElideRight
text: root.device.name || root.device.deviceName || root.device.address
color: Theme.text
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
}
Text {
width: parent.width
elide: Text.ElideRight
text: root.status
color: root.device.connected ? Theme.accent : Theme.textDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
}
// Battery is only published by devices that report it, and only while they
// are connected.
Text {
id: battery
anchors {
right: parent.right
rightMargin: Theme.padding * 2
verticalCenter: parent.verticalCenter
}
visible: root.device.batteryAvailable
width: visible ? implicitWidth : 0
text: Math.round(root.device.battery * 100) + "%"
color: root.device.battery <= 0.2 ? Theme.danger : Theme.textDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
font.bold: true
}
MouseArea {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.activated()
onEntered: root.entered()
}
}
+67
View File
@@ -0,0 +1,67 @@
import QtQuick
import Quickshell.Bluetooth
import qs.config
import qs.components
import qs.services
// Right section: the bluetooth button. The device list lives in
// BluetoothPanel.qml as a separate layer surface.
Item {
id: root
required property var bar
readonly property BluetoothAdapter adapter: Bluetooth.defaultAdapter
readonly property bool adapterOn: root.adapter ? root.adapter.enabled : false
readonly property var connected: root.adapter ? root.adapter.devices.values.filter(d => d.connected) : []
// The first connected device that reports a battery. With a headset paired
// that is the one number worth carrying on the panel; the rest are a click
// away.
readonly property var batteryDevice: root.connected.find(d => d.batteryAvailable) ?? null
// Machines without a bluetooth radio get no button at all.
visible: Config.showBluetooth && root.adapter !== null
implicitWidth: button.implicitWidth
PanelButton {
id: button
anchors.verticalCenter: parent.verticalCenter
height: root.height - Theme.spacing
minContentWidth: Theme.iconSize
active: panel.visible
activeColor: Theme.accent
onClicked: Session.toggle("bluetooth", panel.screenName)
Row {
spacing: Theme.spacing
BluetoothGlyph {
size: Theme.iconSize
struck: !root.adapterOn
color: button.active ? Theme.background : !root.adapterOn ? Theme.textDim : root.connected.length > 0 ? Theme.accent : Theme.text
}
Text {
height: Theme.iconSize
verticalAlignment: Text.AlignVCenter
visible: root.batteryDevice !== null
text: root.batteryDevice ? Math.round(root.batteryDevice.battery * 100) + "%" : ""
color: button.active ? Theme.background : root.batteryDevice && root.batteryDevice.battery <= 0.2 ? Theme.danger : Theme.textDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
font.bold: true
}
}
}
BluetoothPanel {
id: panel
bar: root.bar
}
}
+241
View File
@@ -0,0 +1,241 @@
import Quickshell
import Quickshell.Wayland
import Quickshell.Bluetooth
import QtQuick
import qs.config
import qs.components
import qs.services
// The bluetooth menu: adapter power and the devices bluetoothd already knows
// about. Same full-screen layer surface as the launcher, so click-away and
// Escape work on any wlroots compositor.
//
// Deliberately not a pairing UI — discovery is never turned on, so nothing
// transient appears in the list and the panel stays a one-click connect.
PanelWindow {
id: root
required property var bar
readonly property string screenName: root.bar.screen ? root.bar.screen.name : ""
visible: Session.isOpen("bluetooth", root.screenName)
screen: root.bar.screen
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.namespace: Config.namespace + "-bluetooth"
WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive
anchors {
left: true
right: true
top: true
bottom: true
}
exclusionMode: ExclusionMode.Ignore
color: "transparent"
// ---- Data ------------------------------------------------------------
readonly property BluetoothAdapter adapter: Bluetooth.defaultAdapter
readonly property bool adapterOn: root.adapter ? root.adapter.enabled : false
readonly property bool adapterBusy: root.adapter ? root.adapter.state === BluetoothAdapterState.Enabling || root.adapter.state === BluetoothAdapterState.Disabling : false
// Known devices only: paired or bonded, i.e. what bluetoothd remembers.
// Connected ones first, then alphabetical, so the list does not reshuffle
// under the cursor for any other reason.
readonly property var devices: {
if (!root.adapter)
return [];
return [...root.adapter.devices.values].filter(d => d.paired || d.bonded).sort((a, b) => {
if (a.connected !== b.connected)
return a.connected ? -1 : 1;
return (a.name || "").localeCompare(b.name || "");
});
}
function toggleDevice(device: BluetoothDevice): void {
if (!device)
return;
if (device.connected)
device.disconnect();
else
device.connect();
}
onVisibleChanged: {
if (root.visible) {
list.currentIndex = 0;
keyHandler.forceActiveFocus();
}
}
// ---- Chrome ----------------------------------------------------------
// Click-away scrim.
MouseArea {
anchors.fill: parent
onClicked: Session.close()
}
Item {
id: keyHandler
anchors.fill: parent
focus: true
Keys.onEscapePressed: Session.close()
Keys.onDownPressed: list.currentIndex = Math.min(list.currentIndex + 1, list.count - 1)
Keys.onUpPressed: list.currentIndex = Math.max(list.currentIndex - 1, 0)
Keys.onReturnPressed: root.toggleDevice(root.devices[list.currentIndex])
Keys.onEnterPressed: root.toggleDevice(root.devices[list.currentIndex])
}
Rectangle {
id: menu
// Anchored to the panel's right edge, under the button that opens it.
anchors {
right: parent.right
bottom: parent.bottom
rightMargin: Theme.padding
bottomMargin: Theme.barHeight + Theme.padding
}
readonly property int maxHeight: Math.min(Config.bluetoothMenuHeight, root.height - Theme.barHeight - Theme.padding * 2)
readonly property int bodyHeight: root.adapterOn && list.count > 0 ? list.contentHeight : 48
width: Math.min(Config.bluetoothMenuWidth, root.width - Theme.padding * 2)
height: Math.min(menu.maxHeight, Theme.padding * 4 + header.height + Theme.padding * 2 + menu.bodyHeight)
color: Theme.surface
radius: Theme.radius * 2
border.width: Theme.borderWidth
border.color: Theme.border
// Swallow clicks so they do not reach the scrim behind.
MouseArea {
anchors.fill: parent
}
Item {
id: header
anchors {
left: parent.left
right: parent.right
top: parent.top
margins: Theme.padding * 2
}
height: 24
BluetoothGlyph {
id: headerGlyph
anchors.verticalCenter: parent.verticalCenter
color: root.adapterOn ? Theme.text : Theme.textDim
struck: !root.adapterOn
size: 18
}
Text {
anchors {
left: headerGlyph.right
right: toggle.left
leftMargin: Theme.padding
rightMargin: Theme.padding
verticalCenter: parent.verticalCenter
}
elide: Text.ElideRight
text: "Bluetooth"
color: Theme.text
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.bold: true
}
ToggleSwitch {
id: toggle
anchors {
right: parent.right
verticalCenter: parent.verticalCenter
}
checked: root.adapterOn
busy: root.adapterBusy
enabled: root.adapter !== null
// BlueZ reports the new state back, so `checked` stays bound to
// it rather than to what was clicked.
onToggled: on => {
if (root.adapter)
root.adapter.enabled = on;
}
}
}
Rectangle {
id: separator
anchors {
left: parent.left
right: parent.right
top: header.bottom
topMargin: Theme.padding * 2
}
height: Theme.borderWidth
color: Theme.border
}
ListView {
id: list
anchors {
left: parent.left
right: parent.right
top: separator.bottom
bottom: parent.bottom
margins: Theme.padding
topMargin: Theme.padding
}
visible: root.adapterOn
clip: true
spacing: 1
boundsBehavior: Flickable.StopAtBounds
keyNavigationEnabled: false
model: root.devices
delegate: BluetoothDeviceEntry {
required property var modelData
required property int index
device: modelData
width: list.width
selected: list.currentIndex === index
onActivated: root.toggleDevice(modelData)
onEntered: list.currentIndex = index
}
}
Text {
anchors {
left: parent.left
right: parent.right
top: separator.bottom
bottom: parent.bottom
margins: Theme.padding * 2
}
visible: !root.adapterOn || list.count === 0
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
wrapMode: Text.WordWrap
text: !root.adapter ? "No bluetooth adapter" : !root.adapterOn ? "Bluetooth is off" : "No paired devices"
color: Theme.textDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
}
}
}
+148
View File
@@ -0,0 +1,148 @@
import Quickshell
import Quickshell.Wayland
import QtQuick
import qs.config
// Confirmation for session actions. Same full-screen layer surface approach as
// the launcher, so Escape and click-away work everywhere.
PanelWindow {
id: root
required property var bar
property string actionLabel: ""
signal accepted
visible: false
screen: root.bar.screen
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.namespace: Config.namespace + "-confirm"
WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive
anchors {
left: true
right: true
top: true
bottom: true
}
exclusionMode: ExclusionMode.Ignore
color: "#66000000"
function accept(): void {
root.visible = false;
root.accepted();
}
onVisibleChanged: if (root.visible)
keyHandler.forceActiveFocus()
MouseArea {
anchors.fill: parent
onClicked: root.visible = false
}
Item {
id: keyHandler
anchors.fill: parent
focus: true
Keys.onEscapePressed: root.visible = false
Keys.onReturnPressed: root.accept()
Keys.onEnterPressed: root.accept()
}
Rectangle {
anchors.centerIn: parent
width: Math.min(320, root.width - Theme.padding * 4)
implicitHeight: dialogColumn.implicitHeight + Theme.padding * 6
radius: Theme.radius * 2
color: Theme.surface
border.width: Theme.borderWidth
border.color: Theme.border
MouseArea {
anchors.fill: parent
}
Column {
id: dialogColumn
anchors.centerIn: parent
width: parent.width - Theme.padding * 6
spacing: Theme.padding * 3
Text {
width: parent.width
horizontalAlignment: Text.AlignHCenter
wrapMode: Text.WordWrap
text: root.actionLabel + "?"
color: Theme.text
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize + 2
font.bold: true
}
Row {
anchors.horizontalCenter: parent.horizontalCenter
spacing: Theme.padding * 2
Rectangle {
id: cancel
implicitWidth: 100
implicitHeight: 32
radius: Theme.radius
color: cancelMouse.containsMouse ? Theme.surfaceHover : Theme.background
border.width: Theme.borderWidth
border.color: Theme.border
Text {
anchors.centerIn: parent
text: "Cancel"
color: Theme.text
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
}
MouseArea {
id: cancelMouse
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.visible = false
}
}
Rectangle {
id: accept
implicitWidth: 100
implicitHeight: 32
radius: Theme.radius
color: acceptMouse.containsMouse ? Qt.lighter(Theme.danger, 1.15) : Theme.danger
Text {
anchors.centerIn: parent
text: "Confirm"
color: Theme.background
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.bold: true
}
MouseArea {
id: acceptMouse
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.accept()
}
}
}
}
}
}
+288
View File
@@ -0,0 +1,288 @@
import Quickshell
import Quickshell.Wayland
import QtQuick
import qs.config
import qs.components
import qs.services
// The application menu, implemented as a full-screen layer surface rather than
// a PopupWindow. That gives click-outside-to-dismiss and reliable keyboard
// focus on any wlroots compositor, without compositor-specific focus grabs.
PanelWindow {
id: root
required property var bar
readonly property string screenName: root.bar.screen ? root.bar.screen.name : ""
visible: Session.isOpen("launcher", root.screenName)
screen: root.bar.screen
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.namespace: Config.namespace + "-launcher"
// Exclusive so the search field receives keys immediately on open; the
// surface is unmapped while closed, so nothing is held hostage.
WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive
anchors {
left: true
right: true
top: true
bottom: true
}
exclusionMode: ExclusionMode.Ignore
color: "transparent"
// ---- Data ------------------------------------------------------------
readonly property var categories: ["All", "Development", "Games", "Graphics", "Internet", "Multimedia", "Office", "Science", "System", "Utility", "Other"]
property string category: "All"
function groupFor(entry: var): string {
const c = entry.categories || [];
// Ordered: many entries carry several categories, and the first match
// wins, so the more specific groups are tested first.
if (c.includes("Game"))
return "Games";
if (c.includes("Development"))
return "Development";
if (c.includes("Graphics"))
return "Graphics";
if (c.includes("Network"))
return "Internet";
if (c.includes("AudioVideo") || c.includes("Audio") || c.includes("Video"))
return "Multimedia";
if (c.includes("Office"))
return "Office";
if (c.includes("Science") || c.includes("Education"))
return "Science";
if (c.includes("Settings") || c.includes("System"))
return "System";
if (c.includes("Utility"))
return "Utility";
return "Other";
}
function score(entry: var, needle: string): int {
const name = (entry.name || "").toLowerCase();
if (name === needle)
return 100;
if (name.startsWith(needle))
return 80;
if (name.includes(needle))
return 60;
if ((entry.genericName || "").toLowerCase().includes(needle))
return 40;
if ((entry.keywords || []).some(k => k.toLowerCase().includes(needle)))
return 30;
if ((entry.comment || "").toLowerCase().includes(needle))
return 20;
if ((entry.id || "").toLowerCase().includes(needle))
return 10;
return 0;
}
readonly property var allApps: [...DesktopEntries.applications.values].filter(e => !e.noDisplay)
readonly property var results: {
const needle = search.text.trim().toLowerCase();
if (needle === "") {
return root.allApps.filter(e => root.category === "All" || root.groupFor(e) === root.category).sort((a, b) => (a.name || "").localeCompare(b.name || ""));
}
// While searching, ignore the category selection — searching the whole
// menu is what people expect.
return root.allApps.map(e => ({
entry: e,
score: root.score(e, needle)
})).filter(r => r.score > 0).sort((a, b) => b.score - a.score || (a.entry.name || "").localeCompare(b.entry.name || "")).map(r => r.entry);
}
function launch(entry: var): void {
Session.close();
AppLauncher.launch(entry);
}
onVisibleChanged: {
if (root.visible) {
search.text = "";
root.category = "All";
list.currentIndex = 0;
search.forceActiveFocus();
}
}
// ---- Chrome ----------------------------------------------------------
// Click-away scrim.
MouseArea {
anchors.fill: parent
onClicked: Session.close()
}
Rectangle {
id: menu
anchors {
left: parent.left
bottom: parent.bottom
leftMargin: Theme.padding
bottomMargin: Theme.barHeight + Theme.padding
}
// Never taller or wider than the screen, so a rotated or small output
// still shows a usable menu.
width: Math.min(Config.launcherWidth, root.width - Theme.padding * 2)
height: Math.min(Config.launcherHeight, root.height - Theme.barHeight - Theme.padding * 2)
color: Theme.surface
radius: Theme.radius * 2
border.width: Theme.borderWidth
border.color: Theme.border
// Swallow clicks so they do not reach the scrim behind.
MouseArea {
anchors.fill: parent
}
Keys.onEscapePressed: Session.close()
Column {
anchors.fill: parent
anchors.margins: Theme.padding * 2
spacing: Theme.padding * 2
// Search field
Rectangle {
width: parent.width
height: 34
radius: Theme.radius
color: Theme.background
border.width: Theme.borderWidth
border.color: search.activeFocus ? Theme.accent : Theme.border
TextInput {
id: search
anchors.fill: parent
anchors.leftMargin: Theme.padding * 2
anchors.rightMargin: Theme.padding * 2
verticalAlignment: TextInput.AlignVCenter
color: Theme.text
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
selectByMouse: true
selectionColor: Theme.accent
focus: true
Keys.onEscapePressed: Session.close()
Keys.onDownPressed: list.currentIndex = Math.min(list.currentIndex + 1, list.count - 1)
Keys.onUpPressed: list.currentIndex = Math.max(list.currentIndex - 1, 0)
Keys.onReturnPressed: list.activateCurrent()
Keys.onEnterPressed: list.activateCurrent()
Text {
anchors.fill: parent
verticalAlignment: Text.AlignVCenter
visible: search.text === ""
text: "Search applications…"
color: Theme.textDim
font: search.font
}
}
}
// Category selector. Wraps rather than scrolls so every category
// stays reachable at any launcher width.
Flow {
id: categoryFlow
width: parent.width
spacing: Theme.spacing
Repeater {
model: root.categories
Rectangle {
id: chip
required property string modelData
readonly property bool selected: root.category === chip.modelData
implicitWidth: categoryLabel.implicitWidth + Theme.padding * 3
implicitHeight: 24
radius: Theme.radius
color: chip.selected ? Theme.accent : categoryMouse.containsMouse ? Theme.surfaceHover : Theme.background
Text {
id: categoryLabel
anchors.centerIn: parent
text: chip.modelData
color: chip.selected ? Theme.background : Theme.textDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
MouseArea {
id: categoryMouse
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
root.category = chip.modelData;
list.currentIndex = 0;
}
}
}
}
}
// Results
ListView {
id: list
width: parent.width
height: parent.height - y
clip: true
spacing: 1
boundsBehavior: Flickable.StopAtBounds
keyNavigationEnabled: false
model: root.results
function activateCurrent(): void {
const entry = root.results[list.currentIndex];
if (entry)
root.launch(entry);
}
highlightMoveDuration: Theme.animDuration
delegate: AppEntry {
required property var modelData
required property int index
entry: modelData
width: list.width
selected: list.currentIndex === index
onActivated: root.launch(modelData)
onEntered: list.currentIndex = index
}
Text {
anchors.centerIn: parent
visible: list.count === 0
text: "No matching applications"
color: Theme.textDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
}
}
}
}
}
+72
View File
@@ -0,0 +1,72 @@
import Quickshell
import QtQuick
import qs.config
import qs.components
import qs.services
// Right section: the session button. The actions live in PowerPanel.qml as a
// separate layer surface; running them, and confirming the destructive ones,
// stays here so the confirmation outlives the menu closing.
Item {
id: root
required property var bar
implicitWidth: button.implicitWidth
property var pendingAction: null
function trigger(action: var): void {
if (action.dangerous && Config.confirmDangerous) {
root.pendingAction = action;
confirm.visible = true;
} else {
root.run(action);
}
}
function run(action: var): void {
Quickshell.execDetached(action.command);
}
PanelButton {
id: button
anchors.verticalCenter: parent.verticalCenter
height: root.height - Theme.spacing
minContentWidth: Theme.iconSize
active: panel.visible
activeColor: Theme.accent
onClicked: Session.toggle("power", panel.screenName)
AppIcon {
icon: "system-shutdown"
fallbackGlyph: "⏻"
glyphColor: button.active ? Theme.background : Theme.text
size: Theme.iconSize
}
}
PowerPanel {
id: panel
bar: root.bar
onTriggered: action => root.trigger(action)
}
ConfirmDialog {
id: confirm
bar: root.bar
actionLabel: root.pendingAction ? root.pendingAction.label : ""
onAccepted: {
const action = root.pendingAction;
root.pendingAction = null;
if (action)
root.run(action);
}
}
}
+182
View File
@@ -0,0 +1,182 @@
import Quickshell
import Quickshell.Wayland
import QtQuick
import qs.config
import qs.components
import qs.services
// The session menu: the actions from Config.powerActions, one per row. Same
// full-screen layer surface as the launcher, so click-away and Escape work
// everywhere.
//
// Running them is left to PowerMenu, which owns the confirmation dialog — this
// surface is unmapped the moment something is picked.
PanelWindow {
id: root
required property var bar
signal triggered(var action)
readonly property string screenName: root.bar.screen ? root.bar.screen.name : ""
visible: Session.isOpen("power", root.screenName)
screen: root.bar.screen
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.namespace: Config.namespace + "-power"
WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive
anchors {
left: true
right: true
top: true
bottom: true
}
exclusionMode: ExclusionMode.Ignore
color: "transparent"
readonly property var actions: Config.powerActions
property int current: 0
function activate(index: int): void {
const action = root.actions[index];
if (!action)
return;
Session.close();
root.triggered(action);
}
onVisibleChanged: {
if (root.visible) {
// Never start on a destructive action: Enter on open should not be
// able to shut the machine down.
root.current = root.actions.findIndex(a => !a.dangerous);
if (root.current < 0)
root.current = 0;
keyHandler.forceActiveFocus();
}
}
// Click-away scrim.
MouseArea {
anchors.fill: parent
onClicked: Session.close()
}
Item {
id: keyHandler
anchors.fill: parent
focus: true
Keys.onEscapePressed: Session.close()
Keys.onDownPressed: root.current = Math.min(root.current + 1, root.actions.length - 1)
Keys.onUpPressed: root.current = Math.max(root.current - 1, 0)
Keys.onReturnPressed: root.activate(root.current)
Keys.onEnterPressed: root.activate(root.current)
}
Rectangle {
id: menu
// Anchored to the panel's right edge, under the button that opens it.
anchors {
right: parent.right
bottom: parent.bottom
rightMargin: Theme.padding
bottomMargin: Theme.barHeight + Theme.padding
}
width: Math.min(Config.powerMenuWidth, root.width - Theme.padding * 2)
height: column.implicitHeight + Theme.padding * 2
color: Theme.surface
radius: Theme.radius * 2
border.width: Theme.borderWidth
border.color: Theme.border
// Swallow clicks so they do not reach the scrim behind.
MouseArea {
anchors.fill: parent
}
Column {
id: column
anchors {
left: parent.left
right: parent.right
verticalCenter: parent.verticalCenter
margins: Theme.padding
}
spacing: 1
Repeater {
model: root.actions
Rectangle {
id: row
required property var modelData
required property int index
readonly property bool destructive: row.modelData.dangerous
readonly property bool selected: root.current === row.index
readonly property color tint: row.destructive && row.selected ? Theme.danger : Theme.text
width: column.width
implicitHeight: 34
radius: Theme.radius
color: row.selected ? Theme.surfaceActive : "transparent"
Behavior on color {
ColorAnimation {
duration: Theme.animDuration
easing.type: Theme.animEasing
}
}
AppIcon {
id: icon
anchors {
left: parent.left
leftMargin: Theme.padding * 2
verticalCenter: parent.verticalCenter
}
icon: row.modelData.icon
fallbackGlyph: row.modelData.glyph
glyphColor: row.tint
size: 18
}
Text {
anchors {
left: icon.right
right: parent.right
leftMargin: Theme.padding * 2
rightMargin: Theme.padding * 2
verticalCenter: parent.verticalCenter
}
elide: Text.ElideRight
text: row.modelData.label
color: row.tint
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
}
MouseArea {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onEntered: root.current = row.index
onClicked: root.activate(row.index)
}
}
}
}
}
}
+69
View File
@@ -0,0 +1,69 @@
import QtQuick
import Quickshell.Networking
import qs.config
import qs.components
import qs.services
// Right section: the wifi button. The network list lives in WifiPanel.qml as a
// separate layer surface.
Item {
id: root
required property var bar
readonly property WifiDevice device: Networking.devices.values.find(d => d.type === DeviceType.Wifi) ?? null
readonly property bool wifiOn: Networking.wifiEnabled && Networking.wifiHardwareEnabled
readonly property var network: root.device ? root.device.networks.values.find(n => n.connected) ?? null : null
// Machines with no wifi adapter, and sessions with no NetworkManager, get
// no button at all.
visible: Config.showWifi && root.device !== null
implicitWidth: button.implicitWidth
PanelButton {
id: button
anchors.verticalCenter: parent.verticalCenter
height: root.height - Theme.spacing
minContentWidth: Theme.iconSize
active: panel.visible
activeColor: Theme.accent
onClicked: Session.toggle("wifi", panel.screenName)
Row {
spacing: Theme.spacing
WifiGlyph {
size: Theme.iconSize
struck: !root.wifiOn
level: root.network ? Math.min(3, Math.floor(root.network.signalStrength * 4)) : 0
color: button.active ? Theme.background : !root.wifiOn ? Theme.textDim : root.network ? Theme.accent : Theme.text
dimColor: button.active ? Qt.rgba(Theme.background.r, Theme.background.g, Theme.background.b, 0.4) : Theme.border
}
// The network in use, which is the thing worth carrying on the
// panel. Capped so a long SSID cannot squeeze the window list.
Text {
height: Theme.iconSize
width: Math.min(implicitWidth, Config.wifiLabelMaxWidth)
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
visible: root.network !== null
text: root.network ? root.network.name : ""
color: button.active ? Theme.background : Theme.textDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
font.bold: true
}
}
}
WifiPanel {
id: panel
bar: root.bar
}
}
+157
View File
@@ -0,0 +1,157 @@
import QtQuick
import Quickshell.Networking
import qs.config
import qs.components
// One network in the wifi menu.
Rectangle {
id: root
required property WifiNetwork network
property bool selected: false
signal activated
signal forgetRequested
signal entered
// Owe is opportunistic encryption: no passphrase to ask for.
readonly property bool secured: root.network.security !== WifiSecurityType.Open && root.network.security !== WifiSecurityType.Owe
readonly property string securityLabel: {
switch (root.network.security) {
case WifiSecurityType.Open:
return "Open";
case WifiSecurityType.Owe:
return "Open, encrypted";
case WifiSecurityType.Sae:
case WifiSecurityType.Wpa3SuiteB192:
return "WPA3";
case WifiSecurityType.Wpa2Psk:
case WifiSecurityType.Wpa2Eap:
return "WPA2";
case WifiSecurityType.WpaPsk:
case WifiSecurityType.WpaEap:
return "WPA";
case WifiSecurityType.StaticWep:
case WifiSecurityType.DynamicWep:
return "WEP";
default:
return "Secured";
}
}
readonly property string status: {
switch (root.network.state) {
case ConnectionState.Connecting:
return "Connecting…";
case ConnectionState.Disconnecting:
return "Disconnecting…";
case ConnectionState.Connected:
return "Connected";
default:
return root.network.known ? "Saved · " + root.securityLabel : root.securityLabel;
}
}
implicitHeight: 40
radius: Theme.radius
color: root.selected ? Theme.surfaceActive : "transparent"
Behavior on color {
ColorAnimation {
duration: Theme.animDuration
easing.type: Theme.animEasing
}
}
WifiGlyph {
id: glyph
anchors {
left: parent.left
leftMargin: Theme.padding + 2
verticalCenter: parent.verticalCenter
}
size: 20
// Quarters, so a network only changes bars on a real move rather than
// on every scan.
level: Math.min(3, Math.floor(root.network.signalStrength * 4))
color: root.network.connected ? Theme.accent : Theme.text
dimColor: Theme.border
}
Column {
anchors {
left: glyph.right
right: lock.left
leftMargin: Theme.padding * 2
rightMargin: Theme.padding
verticalCenter: parent.verticalCenter
}
spacing: 1
Text {
width: parent.width
elide: Text.ElideRight
text: root.network.name
color: Theme.text
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
}
Text {
width: parent.width
elide: Text.ElideRight
text: root.status
color: root.network.connected ? Theme.accent : Theme.textDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
}
// A padlock, drawn: same reasoning as the glyphs.
Item {
id: lock
anchors {
right: parent.right
rightMargin: Theme.padding * 2
verticalCenter: parent.verticalCenter
}
visible: root.secured
width: visible ? 9 : 0
height: 11
Rectangle {
width: parent.width
height: 6
y: parent.height - height
radius: 1
color: Theme.textDim
}
Rectangle {
x: 1.5
width: parent.width - 3
height: 7
radius: width / 2
color: "transparent"
border.width: 1.5
border.color: Theme.textDim
}
}
MouseArea {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
acceptedButtons: Qt.LeftButton | Qt.RightButton
onEntered: root.entered()
onClicked: event => {
if (event.button === Qt.RightButton)
root.forgetRequested();
else
root.activated();
}
}
}
+402
View File
@@ -0,0 +1,402 @@
import Quickshell
import Quickshell.Wayland
import Quickshell.Networking
import QtQuick
import qs.config
import qs.components
import qs.services
// The wifi menu: radio power and the networks in range. Same full-screen layer
// surface as the launcher, so click-away and Escape work everywhere.
//
// NetworkManager is the backend Quickshell speaks; on a machine configured with
// `wifi.backend=iwd` the connections still go through iwd underneath, without
// this having to race NetworkManager's own policy.
PanelWindow {
id: root
required property var bar
readonly property string screenName: root.bar.screen ? root.bar.screen.name : ""
visible: Session.isOpen("wifi", root.screenName)
screen: root.bar.screen
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.namespace: Config.namespace + "-wifi"
// Exclusive so the passphrase field receives keys immediately; the surface
// is unmapped while closed, so nothing is held hostage.
WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive
anchors {
left: true
right: true
top: true
bottom: true
}
exclusionMode: ExclusionMode.Ignore
color: "transparent"
// ---- Data ------------------------------------------------------------
readonly property WifiDevice device: Networking.devices.values.find(d => d.type === DeviceType.Wifi) ?? null
readonly property bool wifiOn: Networking.wifiEnabled && Networking.wifiHardwareEnabled
readonly property var networks: {
if (!root.device || !root.wifiOn)
return [];
// Sorted by signal in quarters rather than by the raw value: strength
// moves a little on every scan, and rows must not reshuffle under the
// cursor for that. Connected first, then saved, then strongest.
const bars = n => Math.min(3, Math.floor(n.signalStrength * 4));
return [...root.device.networks.values].sort((a, b) => {
if (a.connected !== b.connected)
return a.connected ? -1 : 1;
if (a.known !== b.known)
return a.known ? -1 : 1;
if (bars(a) !== bars(b))
return bars(b) - bars(a);
return a.name.localeCompare(b.name);
});
}
// Network waiting on a passphrase, or null.
property var pskNetwork: null
// Network we last asked to connect, watched for a failure report.
property var pendingNetwork: null
property string error: ""
function secured(network: var): bool {
// Owe is opportunistic encryption: nothing to ask the user for.
return network.security !== WifiSecurityType.Open && network.security !== WifiSecurityType.Owe;
}
function activate(network: var): void {
if (!network)
return;
root.error = "";
if (network.connected || network.state === ConnectionState.Connecting) {
network.disconnect();
return;
}
// A saved network has its secrets in NetworkManager already.
if (!network.known && root.secured(network)) {
root.promptFor(network);
return;
}
root.pendingNetwork = network;
network.connect();
}
function promptFor(network: var): void {
root.pskNetwork = network;
psk.text = "";
psk.forceActiveFocus();
}
function submitPsk(): void {
const network = root.pskNetwork;
if (!network || psk.text === "")
return;
root.pskNetwork = null;
root.pendingNetwork = network;
network.connectWithPsk(psk.text);
psk.text = "";
keyHandler.forceActiveFocus();
}
function cancelPsk(): void {
root.pskNetwork = null;
psk.text = "";
keyHandler.forceActiveFocus();
}
// Right click drops a saved network. Without it a mistyped passphrase would
// be saved by NetworkManager and retried forever with no way out from here.
function forget(network: var): void {
if (!network || !network.known)
return;
root.error = "";
network.forget();
}
Connections {
target: root.pendingNetwork
function onConnectionFailed(reason: int): void {
switch (reason) {
case ConnectionFailReason.NoSecrets:
root.error = "Wrong password for " + root.pendingNetwork.name;
root.promptFor(root.pendingNetwork);
break;
case ConnectionFailReason.WifiAuthTimeout:
root.error = "Authentication timed out";
break;
case ConnectionFailReason.WifiNetworkLost:
root.error = "Network went out of range";
break;
default:
root.error = "Could not connect to " + root.pendingNetwork.name;
}
}
}
// Scanning costs power, so it only runs while the menu is on screen. As a
// Binding rather than a one-shot, an adapter that appears later still gets
// switched on.
Binding {
target: root.device
property: "scannerEnabled"
value: root.visible
when: root.device !== null
}
onVisibleChanged: {
if (root.visible) {
list.currentIndex = 0;
root.pskNetwork = null;
root.error = "";
keyHandler.forceActiveFocus();
}
}
// ---- Chrome ----------------------------------------------------------
// Click-away scrim.
MouseArea {
anchors.fill: parent
onClicked: Session.close()
}
Item {
id: keyHandler
anchors.fill: parent
focus: true
Keys.onEscapePressed: Session.close()
Keys.onDownPressed: list.currentIndex = Math.min(list.currentIndex + 1, list.count - 1)
Keys.onUpPressed: list.currentIndex = Math.max(list.currentIndex - 1, 0)
Keys.onReturnPressed: root.activate(root.networks[list.currentIndex])
Keys.onEnterPressed: root.activate(root.networks[list.currentIndex])
}
Rectangle {
id: menu
// Anchored to the panel's right edge, under the button that opens it.
anchors {
right: parent.right
bottom: parent.bottom
rightMargin: Theme.padding
bottomMargin: Theme.barHeight + Theme.padding
}
readonly property int maxHeight: Math.min(Config.wifiMenuHeight, root.height - Theme.barHeight - Theme.padding * 2)
readonly property int bodyHeight: root.wifiOn && list.count > 0 ? list.contentHeight : 48
readonly property int footerHeight: footer.visible ? footer.height + Theme.padding : 0
width: Math.min(Config.wifiMenuWidth, root.width - Theme.padding * 2)
height: Math.min(menu.maxHeight, Theme.padding * 4 + header.height + Theme.padding * 2 + menu.bodyHeight + menu.footerHeight)
color: Theme.surface
radius: Theme.radius * 2
border.width: Theme.borderWidth
border.color: Theme.border
// Swallow clicks so they do not reach the scrim behind.
MouseArea {
anchors.fill: parent
}
Item {
id: header
anchors {
left: parent.left
right: parent.right
top: parent.top
margins: Theme.padding * 2
}
height: 24
WifiGlyph {
id: headerGlyph
anchors.verticalCenter: parent.verticalCenter
size: 18
struck: !root.wifiOn
level: root.wifiOn ? 3 : 0
color: root.wifiOn ? Theme.text : Theme.textDim
dimColor: Theme.border
}
Text {
anchors {
left: headerGlyph.right
right: toggle.left
leftMargin: Theme.padding
rightMargin: Theme.padding
verticalCenter: parent.verticalCenter
}
elide: Text.ElideRight
text: Networking.wifiHardwareEnabled ? "Wi-Fi" : "Wi-Fi (blocked)"
color: Theme.text
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.bold: true
}
ToggleSwitch {
id: toggle
anchors {
right: parent.right
verticalCenter: parent.verticalCenter
}
checked: Networking.wifiEnabled
enabled: Networking.wifiHardwareEnabled
// NetworkManager reports the new state back, so `checked` stays
// bound to it rather than to what was clicked.
onToggled: on => Networking.wifiEnabled = on
}
}
Rectangle {
id: separator
anchors {
left: parent.left
right: parent.right
top: header.bottom
topMargin: Theme.padding * 2
}
height: Theme.borderWidth
color: Theme.border
}
ListView {
id: list
anchors {
left: parent.left
right: parent.right
top: separator.bottom
bottom: footer.visible ? footer.top : parent.bottom
margins: Theme.padding
topMargin: Theme.padding
}
visible: root.wifiOn
clip: true
spacing: 1
boundsBehavior: Flickable.StopAtBounds
keyNavigationEnabled: false
model: root.networks
delegate: WifiNetworkEntry {
required property var modelData
required property int index
network: modelData
width: list.width
selected: list.currentIndex === index
onActivated: root.activate(modelData)
onForgetRequested: root.forget(modelData)
onEntered: list.currentIndex = index
}
}
Text {
anchors {
left: parent.left
right: parent.right
top: separator.bottom
bottom: parent.bottom
margins: Theme.padding * 2
}
visible: !root.wifiOn || list.count === 0
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
wrapMode: Text.WordWrap
text: !root.device ? "No wifi adapter" : !Networking.wifiHardwareEnabled ? "Wi-Fi is blocked by hardware" : !Networking.wifiEnabled ? "Wi-Fi is off" : "Scanning…"
color: Theme.textDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
}
// Passphrase prompt, and where failures are reported.
Item {
id: footer
anchors {
left: parent.left
right: parent.right
bottom: parent.bottom
margins: Theme.padding * 2
}
visible: root.pskNetwork !== null || root.error !== ""
height: root.pskNetwork !== null ? 30 : errorText.implicitHeight
Rectangle {
anchors.fill: parent
visible: root.pskNetwork !== null
radius: Theme.radius
color: Theme.background
border.width: Theme.borderWidth
border.color: psk.activeFocus ? Theme.accent : Theme.border
TextInput {
id: psk
anchors.fill: parent
anchors.leftMargin: Theme.padding * 2
anchors.rightMargin: Theme.padding * 2
verticalAlignment: TextInput.AlignVCenter
echoMode: TextInput.Password
color: Theme.text
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
selectByMouse: true
selectionColor: Theme.accent
Keys.onEscapePressed: root.cancelPsk()
Keys.onReturnPressed: root.submitPsk()
Keys.onEnterPressed: root.submitPsk()
Text {
anchors.fill: parent
verticalAlignment: Text.AlignVCenter
visible: psk.text === ""
text: root.pskNetwork ? "Password for " + root.pskNetwork.name : ""
color: Theme.textDim
font: psk.font
elide: Text.ElideRight
}
}
}
Text {
id: errorText
anchors.fill: parent
visible: root.pskNetwork === null && root.error !== ""
verticalAlignment: Text.AlignVCenter
wrapMode: Text.WordWrap
text: root.error
color: Theme.danger
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
}
}
}
+110
View File
@@ -0,0 +1,110 @@
import Quickshell
import QtQuick
import qs.config
import qs.components
import qs.services
// Left click focuses the window, or minimises it if it is already focused —
// except under attwm, which has no minimised state. Middle click closes it.
PanelButton {
id: root
// The window this button stands for: either an attwm window object from its
// IPC state, or a Toplevel handle from wlr-foreign-toplevel-management.
// Both carry the same facts under different names, so `attwm` says which is
// in hand rather than each read having to guess.
required property var win
required property bool attwm
readonly property string appId: (root.attwm ? root.win.app_id : root.win.appId) || ""
readonly property bool activated: root.attwm ? root.win.focused : root.win.activated
// Not on screen right now: minimised elsewhere, and under attwm parked on a
// tag nobody is viewing, which is that model's equivalent.
readonly property bool hidden: root.attwm ? !root.win.visible : root.win.minimized
// Desktop entries carry a proper themed icon; appId alone often works as an
// icon name too, which is the fallback AppIcon ends up using.
readonly property var entry: DesktopEntries.heuristicLookup(root.appId)
readonly property string title: root.win.title || root.appId || "Window"
active: root.activated
minContentWidth: Config.windowButtonMinWidth
acceptedButtons: Qt.LeftButton | Qt.MiddleButton
opacity: root.hidden ? 0.55 : 1
Behavior on opacity {
NumberAnimation {
duration: Theme.animDuration
easing.type: Theme.animEasing
}
}
onClicked: event => {
if (event.button === Qt.MiddleButton) {
if (root.attwm)
Attwm.closeWindow(root.win.id);
else
root.win.close();
return;
}
if (root.attwm) {
// attwm has nothing to minimise into — a window is on a tag or it
// is not — so a click always means focus, and attwm brings the
// window's tags into view if they are not already.
Attwm.focusWindow(root.win.id);
return;
}
if (root.win.activated) {
root.win.minimized = true;
} else {
root.win.minimized = false;
root.win.activate();
}
}
// Both children are given the same height so the Row needs no anchors,
// which positioners disallow.
Row {
spacing: Theme.spacing + 2
AppIcon {
icon: (root.entry && root.entry.icon) || root.appId
fallbackLabel: root.appId || root.title
size: Theme.iconSize
}
Text {
id: label
// Clamp to the natural width so the button never depends on its own
// width, which would loop through PanelButton's implicit sizing.
width: Math.min(implicitWidth, Config.windowButtonMaxWidth)
height: Theme.iconSize
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
text: root.title
color: root.activated ? Theme.text : Theme.textDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
}
}
// Active-window underline, the usual taskbar affordance.
overlay: Rectangle {
anchors {
left: parent.left
right: parent.right
bottom: parent.bottom
leftMargin: Theme.radius
rightMargin: Theme.radius
}
height: 2
radius: 1
color: Theme.accent
visible: root.activated
}
}
+72
View File
@@ -0,0 +1,72 @@
import Quickshell
import Quickshell.Wayland
import QtQuick
import qs.config
import qs.services
// Middle section: one button per open window.
//
// Two sources, because no one protocol does the job everywhere:
//
// - wlr-foreign-toplevel-management, which works on any wlroots-based
// compositor without talking to a single window manager's IPC.
// - attwm's socket, whenever it answers. Under river 0.4 the foreign-toplevel
// protocol can list windows but cannot focus one — see services/Attwm.qml —
// and its window ids are what make the click land on the right window.
Item {
id: root
required property var bar
readonly property bool attwm: Attwm.available
readonly property string screenName: root.bar.screen ? root.bar.screen.name : ""
readonly property var windows: {
if (root.attwm) {
if (!Config.windowsPerScreen)
return Attwm.allWindows;
// attwm reports windows under the output they are on, so no
// filtering is needed — and none of them is screenless.
const out = Attwm.outputFor(root.screenName);
return out ? out.windows : [];
}
const all = [...ToplevelManager.toplevels.values];
if (!Config.windowsPerScreen)
return all;
// A toplevel with no reported screens yet is still shown, otherwise
// freshly mapped windows would flicker in late.
return all.filter(t => t.screens.length === 0 || t.screens.some(s => s && s.name === root.screenName));
}
ListView {
id: list
anchors.fill: parent
orientation: ListView.Horizontal
spacing: Theme.spacing
clip: true
boundsBehavior: Flickable.StopAtBounds
model: ScriptModel {
values: root.windows
// Toplevels are long-lived objects and diff by identity. attwm's
// are plain objects re-parsed on every state change, so they need a
// key, or each title change would rebuild every delegate.
objectProp: root.attwm ? "id" : ""
}
delegate: WindowButton {
required property var modelData
win: modelData
attwm: root.attwm
height: list.height - Theme.spacing
anchors.verticalCenter: parent ? parent.verticalCenter : undefined
}
}
}