initial commit
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user