init
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import Quickshell
|
||||
import Quickshell.Services.Pipewire
|
||||
import "./"
|
||||
|
||||
LazyLoader {
|
||||
required property bool visible
|
||||
|
||||
active: visible
|
||||
|
||||
ShellRoot {
|
||||
|
||||
PanelWindow {
|
||||
// match the system theme background color
|
||||
color: contentItem.palette.active.window
|
||||
|
||||
ScrollView {
|
||||
anchors.fill: parent
|
||||
contentWidth: availableWidth
|
||||
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
|
||||
anchors.margins: 10
|
||||
|
||||
// get a list of nodes that output to the default sink
|
||||
PwNodeLinkTracker {
|
||||
id: linkTracker
|
||||
node: Pipewire.defaultAudioSink
|
||||
}
|
||||
|
||||
MixerEntry {
|
||||
node: Pipewire.defaultAudioSink
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
color: palette.active.text
|
||||
implicitHeight: 1
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: linkTracker.linkGroups
|
||||
|
||||
MixerEntry {
|
||||
required property PwLinkGroup modelData
|
||||
// Each link group contains a source and a target.
|
||||
// Since the target is the default sink, we want the source.
|
||||
node: modelData.source
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
|
||||
import Quickshell
|
||||
import Quickshell.Hyprland
|
||||
|
||||
import "./components"
|
||||
|
||||
PanelWindow {
|
||||
id: root
|
||||
|
||||
// WlrLayershell.layer: WlrLayer.Bottom
|
||||
|
||||
Component.onCompleted: {
|
||||
if (this.WlrLayershell != null) {
|
||||
this.WlrLayershell.layer = WlrLayer.Bottom;
|
||||
}
|
||||
}
|
||||
|
||||
anchors {
|
||||
top: true
|
||||
left: true
|
||||
right: true
|
||||
}
|
||||
|
||||
implicitHeight: 30
|
||||
color: '#28000000'
|
||||
|
||||
// Shared popup manager: only one click-popup (network / bluetooth / clock)
|
||||
// may be open at a time. Setting `current` to a name opens that popup and
|
||||
// implicitly closes any other.
|
||||
QtObject {
|
||||
id: popups
|
||||
|
||||
property string current: ""
|
||||
|
||||
function toggle(name: string): void {
|
||||
current = current === name ? "" : name;
|
||||
}
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
|
||||
spacing: 12
|
||||
|
||||
anchors {
|
||||
fill: parent
|
||||
leftMargin: 16
|
||||
rightMargin: 16
|
||||
}
|
||||
|
||||
Desktop {}
|
||||
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
|
||||
NetworkControl {
|
||||
popups: popups
|
||||
}
|
||||
|
||||
BluetoothControl {
|
||||
popups: popups
|
||||
}
|
||||
|
||||
Battery {}
|
||||
|
||||
Clock {
|
||||
popups: popups
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
|
||||
Scope {
|
||||
id: root
|
||||
|
||||
// Backlight device under /sys/class/backlight, auto-detected at startup.
|
||||
property string device: ""
|
||||
// Current brightness as a 0..1 fraction of max_brightness.
|
||||
property real brightness: 0
|
||||
// Suppresses the OSD for the very first reading (startup, not a real change).
|
||||
property bool initialised: false
|
||||
property bool shouldShowOsd: false
|
||||
|
||||
// Nerd Font glyph matching the brightness level (matches config icon style).
|
||||
function brightnessGlyph(): string {
|
||||
return root.brightness < 0.5 ? "" : "";
|
||||
}
|
||||
|
||||
// Pick the first backlight device present on the machine.
|
||||
Process {
|
||||
running: true
|
||||
command: ["sh", "-c", "ls -1 /sys/class/backlight/ | head -n1"]
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: root.device = text.trim()
|
||||
}
|
||||
}
|
||||
|
||||
// max_brightness is static; a single blocking read is fine.
|
||||
FileView {
|
||||
id: maxBrightness
|
||||
path: root.device ? `/sys/class/backlight/${root.device}/max_brightness` : ""
|
||||
blockLoading: true
|
||||
printErrors: false
|
||||
onLoaded: root.updateBrightness()
|
||||
}
|
||||
|
||||
// brightness changes whenever brightnessctl (Hyprland keybinds) writes it.
|
||||
// inotify fires on sysfs writes, so watchChanges is enough - no polling.
|
||||
FileView {
|
||||
id: curBrightness
|
||||
path: root.device ? `/sys/class/backlight/${root.device}/brightness` : ""
|
||||
watchChanges: true
|
||||
printErrors: false
|
||||
onFileChanged: reload()
|
||||
onLoaded: root.updateBrightness()
|
||||
}
|
||||
|
||||
function updateBrightness(): void {
|
||||
const max = parseInt(maxBrightness.text());
|
||||
const cur = parseInt(curBrightness.text());
|
||||
if (isNaN(max) || isNaN(cur) || max <= 0)
|
||||
return;
|
||||
|
||||
const value = Math.max(0, Math.min(1, cur / max));
|
||||
const changed = Math.abs(value - root.brightness) > 0.0001;
|
||||
root.brightness = value;
|
||||
|
||||
if (root.initialised && changed) {
|
||||
root.shouldShowOsd = true;
|
||||
hideTimer.restart();
|
||||
}
|
||||
root.initialised = true;
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: hideTimer
|
||||
interval: 1000
|
||||
onTriggered: root.shouldShowOsd = false
|
||||
}
|
||||
|
||||
LazyLoader {
|
||||
active: root.shouldShowOsd
|
||||
|
||||
PanelWindow {
|
||||
anchors.bottom: true
|
||||
margins.bottom: screen.height / 5
|
||||
exclusiveZone: 0
|
||||
|
||||
implicitWidth: 400
|
||||
implicitHeight: 50
|
||||
color: "transparent"
|
||||
|
||||
// An empty click mask prevents the window from blocking mouse events.
|
||||
mask: Region {}
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
radius: height / 2
|
||||
color: "#80000000"
|
||||
|
||||
RowLayout {
|
||||
anchors {
|
||||
fill: parent
|
||||
leftMargin: 10
|
||||
rightMargin: 15
|
||||
}
|
||||
|
||||
Text {
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
font.family: "JetBrainsMono Nerd Font"
|
||||
font.pixelSize: 24
|
||||
color: "white"
|
||||
text: root.brightnessGlyph()
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
// Stretches to fill all left-over space
|
||||
Layout.fillWidth: true
|
||||
|
||||
implicitHeight: 10
|
||||
radius: 20
|
||||
color: "#50ffffff"
|
||||
|
||||
Rectangle {
|
||||
anchors {
|
||||
left: parent.left
|
||||
top: parent.top
|
||||
bottom: parent.bottom
|
||||
}
|
||||
|
||||
color: "white"
|
||||
implicitWidth: parent.width * root.brightness
|
||||
radius: parent.radius
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import QtQuick.Controls
|
||||
import Quickshell.Services.Pipewire
|
||||
|
||||
ColumnLayout {
|
||||
required property PwNode node
|
||||
|
||||
// bind the node so we can read its properties
|
||||
PwObjectTracker {
|
||||
objects: [node]
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
Image {
|
||||
visible: source != ""
|
||||
source: {
|
||||
const icon = node.properties["application.icon-name"] ?? "audio-volume-high-symbolic";
|
||||
return `image://icon/${icon}`;
|
||||
}
|
||||
|
||||
sourceSize.width: 20
|
||||
sourceSize.height: 20
|
||||
}
|
||||
|
||||
Label {
|
||||
text: {
|
||||
// application.name -> description -> name
|
||||
const app = node.properties["application.name"] ?? (node.description != "" ? node.description : node.name);
|
||||
const media = node.properties["media.name"];
|
||||
return media != undefined ? `${app} - ${media}` : app;
|
||||
}
|
||||
}
|
||||
|
||||
Button {
|
||||
text: node.audio.muted ? "unmute" : "mute"
|
||||
onClicked: node.audio.muted = !node.audio.muted
|
||||
}
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
Label {
|
||||
Layout.preferredWidth: 50
|
||||
text: `${Math.floor(node.audio.volume * 100)}%`
|
||||
}
|
||||
|
||||
Slider {
|
||||
Layout.fillWidth: true
|
||||
value: node.audio.volume
|
||||
onValueChanged: node.audio.volume = value
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import Quickshell
|
||||
import Quickshell.Services.Pipewire
|
||||
|
||||
Scope {
|
||||
id: root
|
||||
|
||||
// Pick a Nerd Font glyph matching the current level / mute state, matching
|
||||
// the glyph-based icon style used elsewhere in this config (e.g. Battery).
|
||||
function volumeGlyph(): string {
|
||||
const sink = Pipewire.defaultAudioSink;
|
||||
if (!sink || !sink.audio || sink.audio.muted)
|
||||
return ""; // muted
|
||||
|
||||
const v = sink.audio.volume;
|
||||
if (v <= 0.0)
|
||||
return ""; // muted / zero
|
||||
if (v < 0.34)
|
||||
return ""; // low
|
||||
if (v < 0.67)
|
||||
return ""; // medium
|
||||
return ""; // high
|
||||
}
|
||||
|
||||
// Bind the pipewire node so its volume will be tracked
|
||||
PwObjectTracker {
|
||||
objects: [Pipewire.defaultAudioSink]
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: Pipewire.defaultAudioSink?.audio
|
||||
|
||||
function onVolumesChanged() {
|
||||
root.shouldShowOsd = true;
|
||||
hideTimer.restart();
|
||||
}
|
||||
|
||||
function onMutedChanged() {
|
||||
root.shouldShowOsd = true;
|
||||
hideTimer.restart();
|
||||
}
|
||||
}
|
||||
|
||||
property bool shouldShowOsd: false
|
||||
|
||||
Timer {
|
||||
id: hideTimer
|
||||
interval: 1000
|
||||
onTriggered: root.shouldShowOsd = false
|
||||
}
|
||||
|
||||
// The OSD window will be created and destroyed based on shouldShowOsd.
|
||||
// PanelWindow.visible could be set instead of using a loader, but using
|
||||
// a loader will reduce the memory overhead when the window isn't open.
|
||||
LazyLoader {
|
||||
active: root.shouldShowOsd
|
||||
|
||||
PanelWindow {
|
||||
// Since the panel's screen is unset, it will be picked by the compositor
|
||||
// when the window is created. Most compositors pick the current active monitor.
|
||||
|
||||
anchors.bottom: true
|
||||
margins.bottom: screen.height / 5
|
||||
exclusiveZone: 0
|
||||
|
||||
implicitWidth: 400
|
||||
implicitHeight: 50
|
||||
color: "transparent"
|
||||
|
||||
// An empty click mask prevents the window from blocking mouse events.
|
||||
mask: Region {}
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
radius: height / 2
|
||||
color: "#80000000"
|
||||
|
||||
RowLayout {
|
||||
anchors {
|
||||
fill: parent
|
||||
leftMargin: 10
|
||||
rightMargin: 15
|
||||
}
|
||||
|
||||
Text {
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
font.family: "JetBrainsMono Nerd Font"
|
||||
font.pixelSize: 24
|
||||
color: "white"
|
||||
text: root.volumeGlyph()
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
// Stretches to fill all left-over space
|
||||
Layout.fillWidth: true
|
||||
|
||||
implicitHeight: 10
|
||||
radius: 20
|
||||
color: "#50ffffff"
|
||||
|
||||
Rectangle {
|
||||
anchors {
|
||||
left: parent.left
|
||||
top: parent.top
|
||||
bottom: parent.bottom
|
||||
}
|
||||
|
||||
// Dim the fill while muted as an extra visual cue.
|
||||
color: (Pipewire.defaultAudioSink?.audio.muted ?? false) ? "#80ffffff" : "white"
|
||||
implicitWidth: parent.width * (Pipewire.defaultAudioSink?.audio.volume ?? 0)
|
||||
radius: parent.radius
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
|
||||
import Quickshell
|
||||
import Quickshell.Services.UPower
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
readonly property var battery: UPower.displayDevice
|
||||
readonly property bool charging: !UPower.onBattery
|
||||
|
||||
implicitHeight: label.implicitHeight
|
||||
implicitWidth: label.implicitWidth
|
||||
|
||||
function fmtTime(seconds: real): string {
|
||||
if (!seconds || seconds <= 0)
|
||||
return "\u2014";
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
return h > 0 ? `${h}h ${m}m` : `${m}m`;
|
||||
}
|
||||
|
||||
function batteryGlyph(): string {
|
||||
if (!root.battery)
|
||||
return "\u{F0091}";
|
||||
const percent = Math.round(root.battery.percentage * 100);
|
||||
if (root.charging)
|
||||
return "\u{F0084}";
|
||||
if (percent > 80)
|
||||
return "\u{F0079}";
|
||||
if (percent > 60)
|
||||
return "\u{F0080}";
|
||||
if (percent > 40)
|
||||
return "\u{F007F}";
|
||||
if (percent > 20)
|
||||
return "\u{F007E}";
|
||||
return "\u{F007A}";
|
||||
}
|
||||
|
||||
function stateText(s: int): string {
|
||||
switch (s) {
|
||||
case UPowerDeviceState.Charging:
|
||||
return "Charging";
|
||||
case UPowerDeviceState.Discharging:
|
||||
return "Discharging";
|
||||
case UPowerDeviceState.Empty:
|
||||
return "Empty";
|
||||
case UPowerDeviceState.FullyCharged:
|
||||
return "Fully charged";
|
||||
case UPowerDeviceState.PendingCharge:
|
||||
return "Pending charge";
|
||||
case UPowerDeviceState.PendingDischarge:
|
||||
return "Pending discharge";
|
||||
default:
|
||||
return "Unknown";
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: mousearea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
|
||||
Text {
|
||||
id: label
|
||||
anchors.centerIn: parent
|
||||
font.family: "JetBrainsMono Nerd Font"
|
||||
color: "#cdd6f4"
|
||||
text: {
|
||||
if (!root.battery)
|
||||
return "No Battery";
|
||||
return root.batteryGlyph() + " " + Math.round(root.battery.percentage * 100) + "%";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
PopupWindow {
|
||||
id: popup
|
||||
|
||||
visible: mousearea.containsMouse && root.battery
|
||||
|
||||
anchor {
|
||||
item: label
|
||||
edges: Edges.Bottom
|
||||
rect {
|
||||
y: 30
|
||||
}
|
||||
}
|
||||
|
||||
color: "transparent"
|
||||
implicitWidth: 270
|
||||
implicitHeight: card.height
|
||||
|
||||
Rectangle {
|
||||
id: card
|
||||
|
||||
width: 270
|
||||
height: col.implicitHeight + 24
|
||||
radius: 16
|
||||
color: "#f21e1e2e"
|
||||
border.color: "#45475a"
|
||||
border.width: 1
|
||||
|
||||
ColumnLayout {
|
||||
id: col
|
||||
|
||||
anchors {
|
||||
left: parent.left
|
||||
right: parent.right
|
||||
top: parent.top
|
||||
margins: 12
|
||||
}
|
||||
|
||||
spacing: 8
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 12
|
||||
|
||||
Text {
|
||||
font.family: "JetBrainsMono Nerd Font"
|
||||
font.pixelSize: 30
|
||||
color: root.charging ? "#a6e3a1" : "#89b4fa"
|
||||
text: root.batteryGlyph()
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 0
|
||||
|
||||
Text {
|
||||
color: "#cdd6f4"
|
||||
font.pixelSize: 22
|
||||
font.bold: true
|
||||
text: root.battery ? Math.round(root.battery.percentage * 100) + "%" : "\u2014"
|
||||
}
|
||||
|
||||
Text {
|
||||
color: root.charging ? "#a6e3a1" : "#a6adc8"
|
||||
font.pixelSize: 12
|
||||
text: root.battery ? root.stateText(root.battery.state) : ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
implicitHeight: 1
|
||||
color: "#313244"
|
||||
}
|
||||
|
||||
InfoRow {
|
||||
label: "Power draw"
|
||||
value: root.battery ? Math.abs(root.battery.changeRate).toFixed(1) + " W" : "\u2014"
|
||||
}
|
||||
|
||||
InfoRow {
|
||||
visible: root.charging
|
||||
label: "Time to full"
|
||||
value: root.battery ? root.fmtTime(root.battery.timeToFull) : "\u2014"
|
||||
}
|
||||
|
||||
InfoRow {
|
||||
visible: !root.charging
|
||||
label: "Time to empty"
|
||||
value: root.battery ? root.fmtTime(root.battery.timeToEmpty) : "\u2014"
|
||||
}
|
||||
|
||||
InfoRow {
|
||||
label: "Energy"
|
||||
value: root.battery ? root.battery.energy.toFixed(1) + " / " + root.battery.energyCapacity.toFixed(1) + " Wh" : "\u2014"
|
||||
}
|
||||
|
||||
InfoRow {
|
||||
visible: root.battery && root.battery.healthSupported
|
||||
label: "Health"
|
||||
value: root.battery ? Math.round(root.battery.healthPercentage) + "%" : "\u2014"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
component InfoRow: RowLayout {
|
||||
id: infoRow
|
||||
|
||||
property string label: ""
|
||||
property string value: ""
|
||||
|
||||
Layout.fillWidth: true
|
||||
spacing: 8
|
||||
|
||||
Text {
|
||||
color: "#a6adc8"
|
||||
font.pixelSize: 12
|
||||
text: infoRow.label
|
||||
}
|
||||
|
||||
Item {
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
|
||||
Text {
|
||||
color: "#cdd6f4"
|
||||
font.pixelSize: 12
|
||||
text: infoRow.value
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import Quickshell
|
||||
import Quickshell.Bluetooth
|
||||
import Quickshell.Hyprland
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property var popups: null
|
||||
readonly property bool expanded: popups ? popups.current === "bluetooth" : false
|
||||
readonly property var adapter: Bluetooth.defaultAdapter
|
||||
readonly property bool enabled: adapter?.enabled ?? false
|
||||
|
||||
// Reactive: reading each device's `connected` inside the binding makes it
|
||||
// re-evaluate whenever any device connects/disconnects.
|
||||
readonly property bool anyConnected: {
|
||||
const ds = Bluetooth.devices?.values ?? [];
|
||||
return ds.some(d => d.connected);
|
||||
}
|
||||
|
||||
implicitWidth: icon.implicitWidth
|
||||
implicitHeight: 30
|
||||
|
||||
Text {
|
||||
id: icon
|
||||
anchors.centerIn: parent
|
||||
font.family: "JetBrainsMono Nerd Font"
|
||||
font.pixelSize: 17
|
||||
color: root.enabled ? (root.anyConnected ? "#a6e3a1" : "#89b4fa") : "#6c7086"
|
||||
text: !root.enabled ? "\u{F00B2}" : (root.anyConnected ? "\u{F00B1}" : "\u{F00AF}")
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: if (root.popups) root.popups.toggle("bluetooth")
|
||||
}
|
||||
|
||||
LazyLoader {
|
||||
active: root.expanded
|
||||
|
||||
PanelWindow {
|
||||
id: win
|
||||
anchors.top: true
|
||||
anchors.right: true
|
||||
margins.top: 6
|
||||
margins.right: 8
|
||||
implicitWidth: 340
|
||||
implicitHeight: card.implicitHeight
|
||||
color: "transparent"
|
||||
exclusiveZone: 0
|
||||
|
||||
HyprlandFocusGrab {
|
||||
active: true
|
||||
windows: [win]
|
||||
onCleared: if (root.popups) root.popups.current = ""
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: card
|
||||
width: parent.width
|
||||
implicitHeight: col.implicitHeight + 24
|
||||
radius: 16
|
||||
color: "#f21e1e2e"
|
||||
border.color: "#45475a"
|
||||
border.width: 1
|
||||
|
||||
ColumnLayout {
|
||||
id: col
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.top: parent.top
|
||||
anchors.margins: 12
|
||||
spacing: 10
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 8
|
||||
|
||||
Text {
|
||||
font.family: "JetBrainsMono Nerd Font"
|
||||
font.pixelSize: 16
|
||||
color: "#cdd6f4"
|
||||
text: "\u{F00AF} Bluetooth"
|
||||
}
|
||||
|
||||
Item { Layout.fillWidth: true }
|
||||
|
||||
Pill {
|
||||
visible: root.enabled
|
||||
active: root.adapter?.discovering ?? false
|
||||
text: (root.adapter?.discovering ?? false) ? "Scanning…" : "Scan"
|
||||
onClicked: {
|
||||
if (root.adapter)
|
||||
root.adapter.discovering = !root.adapter.discovering;
|
||||
}
|
||||
}
|
||||
|
||||
Pill {
|
||||
active: root.enabled
|
||||
text: root.enabled ? "On" : "Off"
|
||||
onClicked: {
|
||||
if (root.adapter)
|
||||
root.adapter.enabled = !root.enabled;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
implicitHeight: 1
|
||||
color: "#313244"
|
||||
}
|
||||
|
||||
Text {
|
||||
visible: !root.enabled
|
||||
Layout.fillWidth: true
|
||||
color: "#6c7086"
|
||||
font.pixelSize: 13
|
||||
text: "Bluetooth is off"
|
||||
}
|
||||
|
||||
Text {
|
||||
visible: root.enabled && (Bluetooth.devices?.values.length ?? 0) === 0
|
||||
Layout.fillWidth: true
|
||||
color: "#6c7086"
|
||||
font.pixelSize: 13
|
||||
text: "No devices found — tap Scan"
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: root.enabled ? (Bluetooth.devices?.values ?? []) : []
|
||||
|
||||
delegate: RowLayout {
|
||||
id: drow
|
||||
|
||||
required property var modelData
|
||||
readonly property bool busy: modelData.state === BluetoothDeviceState.Connecting
|
||||
|| modelData.state === BluetoothDeviceState.Disconnecting
|
||||
|
||||
Layout.fillWidth: true
|
||||
spacing: 8
|
||||
|
||||
Text {
|
||||
font.family: "JetBrainsMono Nerd Font"
|
||||
font.pixelSize: 16
|
||||
color: drow.modelData.connected ? "#a6e3a1" : "#cdd6f4"
|
||||
text: "\u{F00AF}"
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 0
|
||||
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
elide: Text.ElideRight
|
||||
color: "#cdd6f4"
|
||||
font.pixelSize: 13
|
||||
text: drow.modelData.name || drow.modelData.address
|
||||
}
|
||||
|
||||
Text {
|
||||
visible: drow.modelData.connected
|
||||
color: "#a6e3a1"
|
||||
font.pixelSize: 11
|
||||
text: drow.modelData.batteryAvailable
|
||||
? "Connected · " + Math.round(drow.modelData.battery * 100) + "%"
|
||||
: "Connected"
|
||||
}
|
||||
}
|
||||
|
||||
Pill {
|
||||
active: drow.modelData.connected
|
||||
text: drow.busy ? "…" : (drow.modelData.connected ? "Disconnect" : "Connect")
|
||||
onClicked: drow.modelData.connected = !drow.modelData.connected
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
component Pill: Rectangle {
|
||||
id: pill
|
||||
|
||||
property string text: ""
|
||||
property bool active: false
|
||||
signal clicked
|
||||
|
||||
implicitWidth: pillLabel.implicitWidth + 18
|
||||
implicitHeight: pillLabel.implicitHeight + 8
|
||||
radius: height / 2
|
||||
color: pill.active ? "#89b4fa" : "#313244"
|
||||
|
||||
Text {
|
||||
id: pillLabel
|
||||
anchors.centerIn: parent
|
||||
text: pill.text
|
||||
color: pill.active ? "#11111b" : "#cdd6f4"
|
||||
font.pixelSize: 12
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: pill.clicked()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import QtQuick.Controls
|
||||
import Quickshell
|
||||
import Quickshell.Hyprland
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property var popups: null
|
||||
readonly property bool expanded: popups ? popups.current === "clock" : false
|
||||
|
||||
property var now: new Date()
|
||||
property int viewYear: now.getFullYear()
|
||||
property int viewMonth: now.getMonth()
|
||||
|
||||
implicitWidth: label.implicitWidth
|
||||
implicitHeight: 30
|
||||
|
||||
function shiftMonth(delta: int): void {
|
||||
let m = root.viewMonth + delta;
|
||||
let y = root.viewYear;
|
||||
if (m < 0) {
|
||||
m = 11;
|
||||
y -= 1;
|
||||
} else if (m > 11) {
|
||||
m = 0;
|
||||
y += 1;
|
||||
}
|
||||
root.viewMonth = m;
|
||||
root.viewYear = y;
|
||||
}
|
||||
|
||||
Timer {
|
||||
interval: 1000
|
||||
running: true
|
||||
repeat: true
|
||||
onTriggered: root.now = new Date()
|
||||
}
|
||||
|
||||
// Snap the calendar back to the current month each time it's opened.
|
||||
onExpandedChanged: {
|
||||
if (expanded) {
|
||||
root.viewYear = root.now.getFullYear();
|
||||
root.viewMonth = root.now.getMonth();
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
id: label
|
||||
anchors.centerIn: parent
|
||||
color: "#cdd6f4"
|
||||
text: Qt.formatDateTime(root.now, "hh:mm:ss dd MMM")
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: if (root.popups) root.popups.toggle("clock")
|
||||
}
|
||||
|
||||
LazyLoader {
|
||||
active: root.expanded
|
||||
|
||||
PanelWindow {
|
||||
id: win
|
||||
anchors.top: true
|
||||
anchors.right: true
|
||||
margins.top: 6
|
||||
margins.right: 8
|
||||
implicitWidth: 300
|
||||
implicitHeight: card.implicitHeight
|
||||
color: "transparent"
|
||||
exclusiveZone: 0
|
||||
|
||||
HyprlandFocusGrab {
|
||||
active: true
|
||||
windows: [win]
|
||||
onCleared: if (root.popups) root.popups.current = ""
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: card
|
||||
width: parent.width
|
||||
implicitHeight: col.implicitHeight + 24
|
||||
radius: 16
|
||||
color: "#f21e1e2e"
|
||||
border.color: "#45475a"
|
||||
border.width: 1
|
||||
|
||||
ColumnLayout {
|
||||
id: col
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.top: parent.top
|
||||
anchors.margins: 12
|
||||
spacing: 10
|
||||
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 0
|
||||
|
||||
Text {
|
||||
color: "#89b4fa"
|
||||
font.pixelSize: 26
|
||||
font.bold: true
|
||||
text: Qt.formatDateTime(root.now, "dddd")
|
||||
}
|
||||
|
||||
Text {
|
||||
color: "#a6adc8"
|
||||
font.pixelSize: 13
|
||||
text: Qt.formatDateTime(root.now, "dd MMMM yyyy \u00B7 hh:mm:ss")
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
implicitHeight: 1
|
||||
color: "#313244"
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 4
|
||||
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
color: "#cdd6f4"
|
||||
font.pixelSize: 14
|
||||
font.bold: true
|
||||
text: grid.title
|
||||
}
|
||||
|
||||
NavBtn {
|
||||
glyph: "\u{F0141}"
|
||||
onClicked: root.shiftMonth(-1)
|
||||
}
|
||||
|
||||
NavBtn {
|
||||
glyph: "\u{F0142}"
|
||||
onClicked: root.shiftMonth(1)
|
||||
}
|
||||
}
|
||||
|
||||
DayOfWeekRow {
|
||||
Layout.fillWidth: true
|
||||
locale: grid.locale
|
||||
|
||||
delegate: Text {
|
||||
required property string shortName
|
||||
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
color: "#6c7086"
|
||||
font.pixelSize: 11
|
||||
font.bold: true
|
||||
text: shortName
|
||||
}
|
||||
}
|
||||
|
||||
MonthGrid {
|
||||
id: grid
|
||||
|
||||
Layout.fillWidth: true
|
||||
month: root.viewMonth
|
||||
year: root.viewYear
|
||||
spacing: 2
|
||||
|
||||
delegate: Item {
|
||||
id: cell
|
||||
|
||||
required property var model
|
||||
|
||||
readonly property bool isToday: model.today
|
||||
readonly property bool inMonth: model.month === grid.month
|
||||
|
||||
implicitWidth: 36
|
||||
implicitHeight: 30
|
||||
|
||||
Rectangle {
|
||||
anchors.centerIn: parent
|
||||
width: 26
|
||||
height: 26
|
||||
radius: 13
|
||||
color: cell.isToday ? "#89b4fa" : "transparent"
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
text: cell.model.day
|
||||
font.pixelSize: 12
|
||||
color: cell.isToday ? "#11111b" : (cell.inMonth ? "#cdd6f4" : "#585b70")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
component NavBtn: Rectangle {
|
||||
id: navBtn
|
||||
|
||||
property string glyph: ""
|
||||
signal clicked
|
||||
|
||||
implicitWidth: 26
|
||||
implicitHeight: 26
|
||||
radius: 13
|
||||
color: navMouse.containsMouse ? "#313244" : "transparent"
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
font.family: "JetBrainsMono Nerd Font"
|
||||
font.pixelSize: 16
|
||||
color: "#cdd6f4"
|
||||
text: navBtn.glyph
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: navMouse
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: navBtn.clicked()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import Quickshell.Hyprland
|
||||
|
||||
RowLayout {
|
||||
id: root
|
||||
|
||||
spacing: 4
|
||||
|
||||
readonly property int maxSlots: 20
|
||||
readonly property var focusedWs: Hyprland.focusedWorkspace
|
||||
|
||||
// Show every regular workspace up to the highest occupied/focused one, plus
|
||||
// one spare empty slot (the "+1"). e.g. 1 busy -> show 1,2 ; 3 busy -> 1..4.
|
||||
readonly property int slotCount: {
|
||||
let hi = 0;
|
||||
const vals = Hyprland.workspaces.values;
|
||||
for (let i = 0; i < vals.length; i++) {
|
||||
const w = vals[i];
|
||||
if (w.id > 0 && w.id > hi)
|
||||
hi = w.id;
|
||||
}
|
||||
const f = Hyprland.focusedWorkspace;
|
||||
if (f && f.id > hi)
|
||||
hi = f.id;
|
||||
return Math.min(root.maxSlots, hi + 1);
|
||||
}
|
||||
|
||||
// Existing special workspaces (id < 0).
|
||||
readonly property var specialList: {
|
||||
const out = [];
|
||||
const vals = Hyprland.workspaces.values;
|
||||
for (let i = 0; i < vals.length; i++) {
|
||||
if (vals[i].id < 0)
|
||||
out.push(vals[i]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function wsFor(id: int): var {
|
||||
const vals = Hyprland.workspaces.values;
|
||||
for (let i = 0; i < vals.length; i++) {
|
||||
if (vals[i].id === id)
|
||||
return vals[i];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Hyprland runs in Lua dispatch mode here, so dispatchers take a Lua
|
||||
// expression. Shell out to hyprctl with the raw Lua form.
|
||||
Process {
|
||||
id: dispatcher
|
||||
}
|
||||
|
||||
function focusWorkspace(id: int): void {
|
||||
dispatcher.exec(["hyprctl", "dispatch", "hl.dsp.focus({ workspace = " + id + " })"]);
|
||||
}
|
||||
|
||||
function toggleSpecial(name: string): void {
|
||||
dispatcher.exec(["hyprctl", "dispatch", "hl.dsp.workspace.toggle_special(\"" + name + "\")"]);
|
||||
}
|
||||
|
||||
// Quickshell's workspace `active`/`focused` don't reflect whether a special
|
||||
// is currently shown on a monitor, so we track it ourselves. Map of
|
||||
// monitorName -> shown special id (0 = none). A special chip is "active"
|
||||
// when its id appears here.
|
||||
property var shownSpecials: ({})
|
||||
|
||||
function isSpecialShown(id: int): bool {
|
||||
const m = root.shownSpecials;
|
||||
for (const k in m) {
|
||||
if (m[k] === id)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: Hyprland
|
||||
|
||||
function onRawEvent(event: var): void {
|
||||
if (event.name !== "activespecialv2")
|
||||
return;
|
||||
// data: "<id>,<name>,<monitor>" (id/name empty when hidden)
|
||||
const parts = event.data.split(",");
|
||||
const mon = parts[parts.length - 1];
|
||||
const id = parts[0] === "" ? 0 : parseInt(parts[0]);
|
||||
const next = Object.assign({}, root.shownSpecials);
|
||||
next[mon] = id;
|
||||
root.shownSpecials = next;
|
||||
}
|
||||
}
|
||||
|
||||
// Seed the shown-special state at startup (no event has fired yet).
|
||||
Process {
|
||||
id: seedProc
|
||||
running: true
|
||||
command: ["hyprctl", "-j", "monitors"]
|
||||
stdout: StdioCollector {
|
||||
id: seedOut
|
||||
onStreamFinished: {
|
||||
try {
|
||||
const mons = JSON.parse(seedOut.text);
|
||||
const next = ({});
|
||||
for (const m of mons) {
|
||||
const sw = m.specialWorkspace;
|
||||
next[m.name] = sw ? sw.id : 0;
|
||||
}
|
||||
root.shownSpecials = next;
|
||||
} catch (e) {
|
||||
// ignore malformed output
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Regular workspaces ----
|
||||
Repeater {
|
||||
model: root.slotCount
|
||||
|
||||
delegate: Rectangle {
|
||||
id: chip
|
||||
|
||||
required property int index
|
||||
readonly property int wsId: index + 1
|
||||
readonly property var ws: root.wsFor(wsId)
|
||||
readonly property bool active: root.focusedWs ? root.focusedWs.id === wsId : false
|
||||
readonly property bool urgent: ws ? ws.urgent : false
|
||||
// A non-focused workspace only persists in Hyprland while occupied.
|
||||
readonly property bool occupied: ws !== null && !active
|
||||
|
||||
implicitWidth: Math.max(22, txt.implicitWidth + 12)
|
||||
implicitHeight: 22
|
||||
radius: 11
|
||||
color: chip.urgent ? "#f38ba8"
|
||||
: chip.active ? "#89b4fa"
|
||||
: (mouse.containsMouse ? "#313244" : "transparent")
|
||||
|
||||
Behavior on color {
|
||||
ColorAnimation { duration: 120 }
|
||||
}
|
||||
|
||||
Text {
|
||||
id: txt
|
||||
anchors.centerIn: parent
|
||||
font.family: "JetBrainsMono Nerd Font"
|
||||
font.pixelSize: 13
|
||||
font.bold: chip.active
|
||||
text: chip.wsId
|
||||
color: (chip.active || chip.urgent) ? "#11111b"
|
||||
: chip.occupied ? "#cdd6f4"
|
||||
: "#585b70"
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: mouse
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: root.focusWorkspace(chip.wsId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Separator (only when specials exist) ----
|
||||
Rectangle {
|
||||
visible: root.specialList.length > 0
|
||||
implicitWidth: 1
|
||||
implicitHeight: 14
|
||||
color: "#45475a"
|
||||
Layout.leftMargin: 2
|
||||
Layout.rightMargin: 2
|
||||
}
|
||||
|
||||
// ---- Special workspaces ----
|
||||
Repeater {
|
||||
model: root.specialList
|
||||
|
||||
delegate: Rectangle {
|
||||
id: spc
|
||||
|
||||
required property var modelData
|
||||
readonly property bool active: root.isSpecialShown(modelData.id)
|
||||
readonly property bool urgent: modelData.urgent
|
||||
readonly property string shortName: {
|
||||
const n = modelData.name || "";
|
||||
return n.startsWith("special:") ? n.slice(8) : n;
|
||||
}
|
||||
|
||||
implicitWidth: Math.max(22, slabel.implicitWidth + 12)
|
||||
implicitHeight: 22
|
||||
radius: 11
|
||||
color: spc.urgent ? "#f38ba8"
|
||||
: spc.active ? "#cba6f7"
|
||||
: (smouse.containsMouse ? "#313244" : "transparent")
|
||||
|
||||
Behavior on color {
|
||||
ColorAnimation { duration: 120 }
|
||||
}
|
||||
|
||||
Text {
|
||||
id: slabel
|
||||
anchors.centerIn: parent
|
||||
font.family: "JetBrainsMono Nerd Font"
|
||||
font.pixelSize: 13
|
||||
font.bold: spc.active
|
||||
text: "\u{F04CE}" + (spc.shortName ? " " + spc.shortName : "")
|
||||
color: (spc.active || spc.urgent) ? "#11111b" : "#cba6f7"
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: smouse
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: root.toggleSpecial(spc.shortName)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,442 @@
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import Quickshell.Hyprland
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property var popups: null
|
||||
readonly property bool expanded: popups ? popups.current === "network" : false
|
||||
property bool wifiEnabled: true
|
||||
property var networks: []
|
||||
property string activeSsid: ""
|
||||
property int activeSignal: 0
|
||||
property var knownSsids: ({})
|
||||
property string statusMsg: ""
|
||||
property string pendingSsid: ""
|
||||
|
||||
implicitWidth: icon.implicitWidth
|
||||
implicitHeight: 30
|
||||
|
||||
function secured(sec) {
|
||||
return !!sec && sec.trim().length > 0;
|
||||
}
|
||||
|
||||
function signalGlyph(s) {
|
||||
if (s >= 80) return "\u{F0928}";
|
||||
if (s >= 55) return "\u{F0925}";
|
||||
if (s >= 30) return "\u{F0922}";
|
||||
return "\u{F091F}";
|
||||
}
|
||||
|
||||
function wifiGlyph() {
|
||||
if (!wifiEnabled) return "\u{F05AA}";
|
||||
if (activeSsid === "") return "\u{F05A9}";
|
||||
return signalGlyph(activeSignal);
|
||||
}
|
||||
|
||||
// nmcli -t escapes ':' as '\:' and '\' as '\\'; split on unescaped colons.
|
||||
function splitFields(line) {
|
||||
const out = [];
|
||||
let cur = "";
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
const c = line[i];
|
||||
if (c === "\\" && i + 1 < line.length) {
|
||||
cur += line[i + 1];
|
||||
i++;
|
||||
} else if (c === ":") {
|
||||
out.push(cur);
|
||||
cur = "";
|
||||
} else {
|
||||
cur += c;
|
||||
}
|
||||
}
|
||||
out.push(cur);
|
||||
return out;
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
statusProc.running = true;
|
||||
listProc.running = true;
|
||||
knownProc.running = true;
|
||||
}
|
||||
|
||||
function setWifi(on) {
|
||||
radioProc.command = ["nmcli", "radio", "wifi", on ? "on" : "off"];
|
||||
radioProc.running = true;
|
||||
root.wifiEnabled = on;
|
||||
Qt.callLater(root.refresh);
|
||||
}
|
||||
|
||||
function rescan() {
|
||||
root.statusMsg = "Scanning…";
|
||||
rescanProc.running = true;
|
||||
}
|
||||
|
||||
function doConnect(ssid, password) {
|
||||
root.statusMsg = "Connecting to " + ssid + "…";
|
||||
const cmd = ["nmcli", "device", "wifi", "connect", ssid];
|
||||
if (password && password.length > 0)
|
||||
cmd.push("password", password);
|
||||
connectProc.command = cmd;
|
||||
connectProc.running = true;
|
||||
root.pendingSsid = "";
|
||||
}
|
||||
|
||||
function disconnect(ssid) {
|
||||
root.statusMsg = "Disconnecting…";
|
||||
actionProc.command = ["nmcli", "connection", "down", "id", ssid];
|
||||
actionProc.running = true;
|
||||
}
|
||||
|
||||
function onNetworkClicked(net) {
|
||||
if (net.active) {
|
||||
root.disconnect(net.ssid);
|
||||
return;
|
||||
}
|
||||
if (root.secured(net.security) && !root.knownSsids[net.ssid]) {
|
||||
root.pendingSsid = (root.pendingSsid === net.ssid) ? "" : net.ssid;
|
||||
return;
|
||||
}
|
||||
root.doConnect(net.ssid, "");
|
||||
}
|
||||
|
||||
Process {
|
||||
id: statusProc
|
||||
command: ["nmcli", "-t", "-f", "WIFI", "radio"]
|
||||
stdout: StdioCollector {
|
||||
id: statusOut
|
||||
onStreamFinished: root.wifiEnabled = statusOut.text.trim() === "enabled"
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: knownProc
|
||||
command: ["nmcli", "-t", "-f", "NAME,TYPE", "connection", "show"]
|
||||
stdout: StdioCollector {
|
||||
id: knownOut
|
||||
onStreamFinished: {
|
||||
const map = ({});
|
||||
const lines = knownOut.text.split("\n");
|
||||
for (const line of lines) {
|
||||
if (!line) continue;
|
||||
const f = root.splitFields(line);
|
||||
if (f[1] && f[1].indexOf("wireless") >= 0)
|
||||
map[f[0]] = true;
|
||||
}
|
||||
root.knownSsids = map;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: listProc
|
||||
command: ["nmcli", "-t", "-f", "ACTIVE,SSID,SIGNAL,SECURITY", "device", "wifi", "list"]
|
||||
stdout: StdioCollector {
|
||||
id: listOut
|
||||
onStreamFinished: {
|
||||
const seen = ({});
|
||||
const list = [];
|
||||
let actSsid = "";
|
||||
let actSig = 0;
|
||||
const lines = listOut.text.split("\n");
|
||||
for (const line of lines) {
|
||||
if (!line) continue;
|
||||
const f = root.splitFields(line);
|
||||
const active = f[0] === "yes";
|
||||
const ssid = f[1] || "";
|
||||
const sig = parseInt(f[2] || "0") || 0;
|
||||
const sec = f[3] || "";
|
||||
if (ssid === "") continue;
|
||||
if (active) {
|
||||
actSsid = ssid;
|
||||
actSig = sig;
|
||||
}
|
||||
if (seen[ssid] !== undefined) {
|
||||
const ex = list[seen[ssid]];
|
||||
if (sig > ex.signal) ex.signal = sig;
|
||||
if (active) ex.active = true;
|
||||
continue;
|
||||
}
|
||||
seen[ssid] = list.length;
|
||||
list.push({ ssid: ssid, signal: sig, security: sec, active: active });
|
||||
}
|
||||
list.sort((a, b) => (b.active - a.active) || (b.signal - a.signal));
|
||||
root.networks = list;
|
||||
root.activeSsid = actSsid;
|
||||
root.activeSignal = actSig;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Process { id: radioProc }
|
||||
|
||||
Process {
|
||||
id: rescanProc
|
||||
command: ["nmcli", "device", "wifi", "rescan"]
|
||||
onExited: {
|
||||
root.statusMsg = "";
|
||||
root.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: connectProc
|
||||
stdout: StdioCollector {
|
||||
id: connectOut
|
||||
onStreamFinished: {
|
||||
root.statusMsg = "";
|
||||
root.refresh();
|
||||
}
|
||||
}
|
||||
stderr: StdioCollector {
|
||||
id: connectErr
|
||||
onStreamFinished: {
|
||||
const t = connectErr.text.trim();
|
||||
if (t !== "")
|
||||
root.statusMsg = t.replace(/^Error:\s*/, "");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: actionProc
|
||||
onExited: root.refresh()
|
||||
}
|
||||
|
||||
onExpandedChanged: if (expanded) refresh()
|
||||
|
||||
Timer {
|
||||
interval: 8000
|
||||
running: root.expanded
|
||||
repeat: true
|
||||
onTriggered: root.refresh()
|
||||
}
|
||||
|
||||
Component.onCompleted: refresh()
|
||||
|
||||
Text {
|
||||
id: icon
|
||||
anchors.centerIn: parent
|
||||
font.family: "JetBrainsMono Nerd Font"
|
||||
font.pixelSize: 17
|
||||
color: root.wifiEnabled ? (root.activeSsid !== "" ? "#89b4fa" : "#cdd6f4") : "#6c7086"
|
||||
text: root.wifiGlyph()
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: if (root.popups) root.popups.toggle("network")
|
||||
}
|
||||
|
||||
LazyLoader {
|
||||
active: root.expanded
|
||||
|
||||
PanelWindow {
|
||||
id: win
|
||||
focusable: true
|
||||
anchors.top: true
|
||||
anchors.right: true
|
||||
margins.top: 6
|
||||
margins.right: 8
|
||||
implicitWidth: 360
|
||||
implicitHeight: card.implicitHeight
|
||||
color: "transparent"
|
||||
exclusiveZone: 0
|
||||
|
||||
HyprlandFocusGrab {
|
||||
active: true
|
||||
windows: [win]
|
||||
onCleared: if (root.popups) root.popups.current = ""
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: card
|
||||
width: parent.width
|
||||
implicitHeight: col.implicitHeight + 24
|
||||
radius: 16
|
||||
color: "#f21e1e2e"
|
||||
border.color: "#45475a"
|
||||
border.width: 1
|
||||
|
||||
ColumnLayout {
|
||||
id: col
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.top: parent.top
|
||||
anchors.margins: 12
|
||||
spacing: 8
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 8
|
||||
|
||||
Text {
|
||||
font.family: "JetBrainsMono Nerd Font"
|
||||
font.pixelSize: 16
|
||||
color: "#cdd6f4"
|
||||
text: "\u{F05A9} Wi-Fi"
|
||||
}
|
||||
|
||||
Item { Layout.fillWidth: true }
|
||||
|
||||
Pill {
|
||||
visible: root.wifiEnabled
|
||||
text: "Rescan"
|
||||
onClicked: root.rescan()
|
||||
}
|
||||
|
||||
Pill {
|
||||
active: root.wifiEnabled
|
||||
text: root.wifiEnabled ? "On" : "Off"
|
||||
onClicked: root.setWifi(!root.wifiEnabled)
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
implicitHeight: 1
|
||||
color: "#313244"
|
||||
}
|
||||
|
||||
Text {
|
||||
visible: !root.wifiEnabled
|
||||
color: "#6c7086"
|
||||
font.pixelSize: 13
|
||||
text: "Wi-Fi is off"
|
||||
}
|
||||
|
||||
Text {
|
||||
visible: root.statusMsg !== ""
|
||||
Layout.fillWidth: true
|
||||
wrapMode: Text.WordWrap
|
||||
color: "#f9e2af"
|
||||
font.pixelSize: 11
|
||||
text: root.statusMsg
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: root.wifiEnabled ? root.networks : []
|
||||
|
||||
delegate: ColumnLayout {
|
||||
id: nrow
|
||||
|
||||
required property var modelData
|
||||
|
||||
Layout.fillWidth: true
|
||||
spacing: 4
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 8
|
||||
|
||||
Text {
|
||||
font.family: "JetBrainsMono Nerd Font"
|
||||
font.pixelSize: 15
|
||||
color: nrow.modelData.active ? "#a6e3a1" : "#cdd6f4"
|
||||
text: root.signalGlyph(nrow.modelData.signal)
|
||||
}
|
||||
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
elide: Text.ElideRight
|
||||
color: nrow.modelData.active ? "#a6e3a1" : "#cdd6f4"
|
||||
font.pixelSize: 13
|
||||
text: nrow.modelData.ssid
|
||||
}
|
||||
|
||||
Text {
|
||||
visible: root.secured(nrow.modelData.security)
|
||||
font.family: "JetBrainsMono Nerd Font"
|
||||
font.pixelSize: 12
|
||||
color: "#6c7086"
|
||||
text: "\u{F033E}"
|
||||
}
|
||||
|
||||
Pill {
|
||||
active: nrow.modelData.active
|
||||
text: nrow.modelData.active ? "Disconnect" : "Connect"
|
||||
onClicked: root.onNetworkClicked(nrow.modelData)
|
||||
}
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
id: pwRow
|
||||
visible: root.pendingSsid === nrow.modelData.ssid
|
||||
Layout.fillWidth: true
|
||||
spacing: 6
|
||||
|
||||
onVisibleChanged: if (visible) pwField.forceActiveFocus()
|
||||
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
implicitHeight: 30
|
||||
radius: 8
|
||||
color: "#181825"
|
||||
border.color: pwField.activeFocus ? "#89b4fa" : "#45475a"
|
||||
border.width: 1
|
||||
|
||||
TextField {
|
||||
id: pwField
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: 8
|
||||
anchors.rightMargin: 8
|
||||
verticalAlignment: TextInput.AlignVCenter
|
||||
echoMode: TextInput.Password
|
||||
placeholderText: "Password"
|
||||
color: "#cdd6f4"
|
||||
placeholderTextColor: "#6c7086"
|
||||
font.pixelSize: 12
|
||||
background: null
|
||||
onAccepted: root.doConnect(nrow.modelData.ssid, text)
|
||||
}
|
||||
}
|
||||
|
||||
Pill {
|
||||
active: true
|
||||
text: "Join"
|
||||
onClicked: root.doConnect(nrow.modelData.ssid, pwField.text)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
component Pill: Rectangle {
|
||||
id: pill
|
||||
|
||||
property string text: ""
|
||||
property bool active: false
|
||||
signal clicked
|
||||
|
||||
implicitWidth: pillLabel.implicitWidth + 18
|
||||
implicitHeight: pillLabel.implicitHeight + 8
|
||||
radius: height / 2
|
||||
color: pill.active ? "#89b4fa" : "#313244"
|
||||
|
||||
Text {
|
||||
id: pillLabel
|
||||
anchors.centerIn: parent
|
||||
text: pill.text
|
||||
color: pill.active ? "#11111b" : "#cdd6f4"
|
||||
font.pixelSize: 12
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: pill.clicked()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
|
||||
Text {
|
||||
|
||||
required property var onClicked
|
||||
|
||||
function click() {
|
||||
if (mouseArea.containsMouse) {
|
||||
onClicked();
|
||||
}
|
||||
}
|
||||
|
||||
color: "#fff"
|
||||
text: "Sound"
|
||||
|
||||
MouseArea {
|
||||
id: mouseArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user