mirror of
https://github.com/Nexoniarz/nexoniarz-config-nixos.git
synced 2026-11-03 04:24:27 +00:00
Display Settings: drag-to-arrange multi-monitor canvas with edge snapping (locked when only one screen is connected), 8/10-bit depth, color management presets (sRGB/Wide/EDID/HDR+EDID), SDR brightness/saturation, ICC profile path, VRR toggle. display-set now takes a single pre-built descriptor instead of a fixed positional arg list, since Hyprland's monitor keyword grew well past that. Wallpapers: fixed the current-wallpaper highlight never loading on open, added real first-frame thumbnails for animated wallpapers (wallpaper-thumbs + ffmpeg), bigger 2-column grid, filename filter. Booru: separate downloads folder from Wallpapers so browsing doesn't clutter the wallpaper picker, bigger grid, Safe/Moderate/Explicit rating tiers replacing the old binary toggle, sort order (Newest/ Score/Random). Fixed pagination being completely broken (a Flow anchor violation was silently disabling the whole layout) and Danbooru's Random sort (order:random tag times out server-side -- switched to their random=true endpoint for that case). Wi-Fi panel: added a "Scanning.../No networks found" state and a manual rescan button -- previously showed nothing either way, making "still scanning" indistinguishable from a real failure. Bluetooth: auto-scans on open like Wi-Fi now, instead of requiring a manual click every time. New: System Monitor panel (CPU/RAM/GPU/VRAM/disk usage, NVIDIA or AMD detected automatically) and Weather panel (IP-geolocated or a typed city, current + hourly forecast, Open-Meteo, no API key) with a compact taskbar badge.
2060 lines
88 KiB
QML
2060 lines
88 KiB
QML
import QtQuick
|
||
import Quickshell
|
||
import Quickshell.Io
|
||
import Quickshell.Services.Pipewire
|
||
import Quickshell.Bluetooth
|
||
import Quickshell.Networking
|
||
|
||
Rectangle {
|
||
id: root
|
||
property string activePanel: ""
|
||
signal requestClose()
|
||
|
||
width: 320
|
||
implicitHeight: header.height + loader.item.implicitHeight + 16
|
||
color: Theme.bg
|
||
border.width: 2
|
||
border.color: Theme.border
|
||
|
||
property var titles: ({
|
||
network: "Network",
|
||
messages: "Messages",
|
||
bluetooth: "Bluetooth",
|
||
screenshot: "Screenshot",
|
||
volume: "Volume",
|
||
brightness: "Brightness",
|
||
devices: "Devices",
|
||
system: "System Monitor",
|
||
weather: "Weather",
|
||
power: "Power"
|
||
})
|
||
|
||
// Lives here, not inside messagesPanel's own Column — that Component
|
||
// gets destroyed every time the panel closes (the Loader tears it
|
||
// down), which was silently resetting this to [] on every reopen and
|
||
// making "cleared" messages reappear. `root` itself persists for the
|
||
// life of the popup, so this survives closing/reopening the panel
|
||
// (though not a full Quickshell restart — mako's own history is just
|
||
// as ephemeral, so there'd be nothing to hide-filter after one anyway).
|
||
property var hiddenMessageIds: []
|
||
function hideMessageId(id) {
|
||
var ids = root.hiddenMessageIds.slice();
|
||
if (ids.indexOf(id) === -1) ids.push(id);
|
||
root.hiddenMessageIds = ids;
|
||
}
|
||
function clearAllMessages(currentIds) {
|
||
var ids = root.hiddenMessageIds.slice();
|
||
for (var i = 0; i < currentIds.length; i++) {
|
||
if (ids.indexOf(currentIds[i]) === -1) ids.push(currentIds[i]);
|
||
}
|
||
root.hiddenMessageIds = ids;
|
||
}
|
||
|
||
Item {
|
||
id: header
|
||
width: parent.width
|
||
height: 40
|
||
|
||
Text {
|
||
anchors.left: parent.left
|
||
anchors.leftMargin: 14
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
text: root.titles[root.activePanel] || ""
|
||
color: Theme.fg
|
||
font.family: "JetBrainsMono Nerd Font"
|
||
font.pixelSize: 14
|
||
font.bold: true
|
||
}
|
||
|
||
CloseButton {
|
||
anchors.right: parent.right
|
||
anchors.rightMargin: 10
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
onClicked: root.requestClose()
|
||
}
|
||
}
|
||
Rectangle { anchors.top: header.bottom; width: parent.width; height: 1; color: Theme.border }
|
||
|
||
Loader {
|
||
id: loader
|
||
y: header.height + 8
|
||
x: 12
|
||
width: parent.width - 24
|
||
sourceComponent: {
|
||
switch (root.activePanel) {
|
||
case "network": return networkPanel;
|
||
case "messages": return messagesPanel;
|
||
case "bluetooth": return bluetoothPanel;
|
||
case "screenshot": return screenshotPanel;
|
||
case "volume": return volumePanel;
|
||
case "brightness": return brightnessPanel;
|
||
case "devices": return devicesPanel;
|
||
case "system": return systemPanel;
|
||
case "weather": return weatherPanel;
|
||
case "power": return powerPanel;
|
||
default: return emptyPanel;
|
||
}
|
||
}
|
||
}
|
||
|
||
Component {
|
||
id: emptyPanel
|
||
Item { implicitHeight: 1 }
|
||
}
|
||
|
||
// --- Network -------------------------------------------------------
|
||
Component {
|
||
id: networkPanel
|
||
Column {
|
||
id: netCol
|
||
spacing: 10
|
||
width: loader.width
|
||
property var selected: null // a Network, or a NetworkDevice (wired fallback)
|
||
property bool selectedIsDevice: netCol.selected !== null && netCol.selected.networks !== undefined
|
||
|
||
// --- List view ---------------------------------------------
|
||
Column {
|
||
visible: netCol.selected === null
|
||
width: parent.width
|
||
spacing: 10
|
||
|
||
Item {
|
||
width: parent.width
|
||
height: 22
|
||
Text {
|
||
anchors.left: parent.left
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
text: "Wi-Fi"
|
||
color: Theme.fg
|
||
font.family: "JetBrainsMono Nerd Font"
|
||
font.pixelSize: 13
|
||
font.bold: true
|
||
}
|
||
ToggleSwitch {
|
||
anchors.right: parent.right
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
checked: Networking.wifiEnabled
|
||
onToggled: Networking.wifiEnabled = !Networking.wifiEnabled
|
||
}
|
||
}
|
||
|
||
Text {
|
||
visible: Networking.devices.values.length === 0
|
||
text: "No network devices"
|
||
color: Theme.fgDim
|
||
font.family: "JetBrainsMono Nerd Font"
|
||
font.pixelSize: 11
|
||
}
|
||
|
||
// Hardware/rfkill state isn't reflected by wifiEnabled at all — without
|
||
// this, a soft- or hardware-killed adapter just looks identical to "no
|
||
// networks in range" with no way to tell the two apart.
|
||
Text {
|
||
visible: !Networking.wifiHardwareEnabled
|
||
text: "Wi-Fi is disabled in hardware (check airplane mode / rfkill switch)"
|
||
color: Theme.fgDim
|
||
font.family: "JetBrainsMono Nerd Font"
|
||
font.pixelSize: 11
|
||
wrapMode: Text.WordWrap
|
||
width: parent.width
|
||
}
|
||
|
||
Repeater {
|
||
model: Networking.devices
|
||
delegate: Column {
|
||
width: loader.width
|
||
spacing: 4
|
||
property var device: modelData
|
||
|
||
// Wired devices report a single phantom "network" entry with no
|
||
// signal strength, so detect Wi-Fi by scannerEnabled (only WifiDevice
|
||
// has that property) instead of by networks-list emptiness.
|
||
property bool isWifi: device.scannerEnabled !== undefined
|
||
|
||
// The plugin exposes no "scan in progress" signal, just the
|
||
// scannerEnabled bool — so this is a grace-period guess, not a
|
||
// real status readout. Good enough to stop "no networks yet"
|
||
// from looking identical to "scan came back empty", which is
|
||
// what made this look like a silent bug before.
|
||
property bool scanning: false
|
||
Timer {
|
||
id: scanGrace
|
||
interval: 4000
|
||
onTriggered: scanning = false
|
||
}
|
||
|
||
// Turn on active scanning while this panel is open so
|
||
// the network list shows more than just whatever we're
|
||
// already connected to.
|
||
Component.onCompleted: if (isWifi) { device.scannerEnabled = true; scanning = true; scanGrace.restart(); }
|
||
Component.onDestruction: if (isWifi) device.scannerEnabled = false;
|
||
|
||
// NetworkManager throttles RequestScan, so a bare re-toggle right
|
||
// after the last one can be silently ignored — this is still just
|
||
// a best-effort nudge, not a guaranteed fresh scan.
|
||
function rescan() {
|
||
device.scannerEnabled = false;
|
||
scanning = true;
|
||
scanGrace.restart();
|
||
rescanRestart.start();
|
||
}
|
||
Timer {
|
||
id: rescanRestart
|
||
interval: 150
|
||
onTriggered: device.scannerEnabled = true
|
||
}
|
||
|
||
Text {
|
||
text: device.name
|
||
color: Theme.fgDim
|
||
font.family: "JetBrainsMono Nerd Font"
|
||
font.pixelSize: 10
|
||
}
|
||
|
||
Rectangle {
|
||
visible: isWifi
|
||
width: loader.width
|
||
height: 26
|
||
color: rescanMa.containsMouse ? Theme.bgAlt : "transparent"
|
||
border.width: 1
|
||
border.color: Theme.borderDim
|
||
Text {
|
||
anchors.centerIn: parent
|
||
text: scanning ? "Scanning…" : "Rescan"
|
||
color: scanning ? Theme.accent : Theme.fg
|
||
font.family: "JetBrainsMono Nerd Font"
|
||
font.pixelSize: 11
|
||
}
|
||
MouseArea {
|
||
id: rescanMa
|
||
anchors.fill: parent
|
||
hoverEnabled: true
|
||
onClicked: rescan()
|
||
}
|
||
}
|
||
|
||
// Non-Wi-Fi devices — show link state directly instead of a list.
|
||
Rectangle {
|
||
visible: !isWifi
|
||
width: loader.width
|
||
height: 32
|
||
color: wiredMa.containsMouse ? Theme.bgAlt : "transparent"
|
||
border.width: 1
|
||
border.color: device.connected ? Theme.accent : Theme.borderDim
|
||
Text {
|
||
anchors.left: parent.left
|
||
anchors.leftMargin: 10
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
text: device.connected ? "Connected" : "No link"
|
||
color: device.connected ? Theme.accent : Theme.fgDim
|
||
font.family: "JetBrainsMono Nerd Font"
|
||
font.pixelSize: 11
|
||
}
|
||
MouseArea {
|
||
id: wiredMa
|
||
anchors.fill: parent
|
||
hoverEnabled: true
|
||
onClicked: netCol.selected = device
|
||
}
|
||
}
|
||
|
||
Text {
|
||
visible: isWifi && (!device.networks || device.networks.values.length === 0)
|
||
text: scanning ? "Scanning…" : "No networks found"
|
||
color: Theme.fgDim
|
||
font.family: "JetBrainsMono Nerd Font"
|
||
font.pixelSize: 11
|
||
}
|
||
|
||
Repeater {
|
||
model: (isWifi && device.networks) ? device.networks : []
|
||
delegate: Rectangle {
|
||
width: loader.width
|
||
height: 40
|
||
color: netMa.containsMouse ? Theme.bgAlt : "transparent"
|
||
border.width: 1
|
||
border.color: net.connected ? Theme.accent : Theme.borderDim
|
||
property var net: modelData
|
||
|
||
Column {
|
||
anchors.left: parent.left
|
||
anchors.leftMargin: 10
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
spacing: 1
|
||
Text {
|
||
text: net.name
|
||
color: net.connected ? Theme.accent : Theme.fg
|
||
font.family: "JetBrainsMono Nerd Font"
|
||
font.pixelSize: 12
|
||
}
|
||
Text {
|
||
text: (net.connected ? "Connected" : (net.known ? "Saved" : "Available"))
|
||
+ " · " + net.signalStrength + "%"
|
||
+ " · " + WifiSecurityType.toString(net.security)
|
||
color: Theme.fgDim
|
||
font.family: "JetBrainsMono Nerd Font"
|
||
font.pixelSize: 10
|
||
}
|
||
}
|
||
|
||
MouseArea {
|
||
id: netMa
|
||
anchors.fill: parent
|
||
hoverEnabled: true
|
||
onClicked: netCol.selected = net
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
Rectangle { width: parent.width; height: 1; color: Theme.borderDim }
|
||
|
||
Column {
|
||
id: hotspotCol
|
||
width: loader.width
|
||
spacing: 8
|
||
property bool hotspotActive: false
|
||
property bool canStart: ssidInput.text.length > 0 && pskInput.text.length >= 8
|
||
|
||
// Persists SSID/password/security/band across Quickshell
|
||
// restarts, blank/default otherwise.
|
||
PersistentProperties {
|
||
id: hotspotState
|
||
property string ssid: ""
|
||
property string password: ""
|
||
property string security: "wpa2"
|
||
property string band: "auto"
|
||
}
|
||
|
||
Text {
|
||
text: "Hotspot"
|
||
color: Theme.fg
|
||
font.family: "JetBrainsMono Nerd Font"
|
||
font.pixelSize: 13
|
||
font.bold: true
|
||
}
|
||
|
||
Process { id: hotspotProc; command: [] }
|
||
Process {
|
||
id: hotspotStatusProc
|
||
command: ["nmcli", "-t", "-f", "NAME,TYPE", "connection", "show", "--active"]
|
||
stdout: StdioCollector {
|
||
onStreamFinished: hotspotCol.hotspotActive = text.indexOf("Hotspot") !== -1
|
||
}
|
||
}
|
||
Component.onCompleted: hotspotStatusProc.running = true
|
||
|
||
Rectangle {
|
||
width: loader.width
|
||
height: 30
|
||
color: Theme.bgAlt
|
||
border.width: 1
|
||
border.color: Theme.border
|
||
TextInput {
|
||
id: ssidInput
|
||
anchors.fill: parent
|
||
anchors.margins: 8
|
||
text: hotspotState.ssid
|
||
onTextEdited: hotspotState.ssid = text
|
||
color: Theme.fg
|
||
font.family: "JetBrainsMono Nerd Font"
|
||
font.pixelSize: 12
|
||
clip: true
|
||
|
||
Text {
|
||
visible: ssidInput.text.length === 0
|
||
text: "SSID"
|
||
color: Theme.fgDim
|
||
font.family: "JetBrainsMono Nerd Font"
|
||
font.pixelSize: 12
|
||
}
|
||
}
|
||
}
|
||
Rectangle {
|
||
width: loader.width
|
||
height: 30
|
||
color: Theme.bgAlt
|
||
border.width: 1
|
||
border.color: Theme.border
|
||
TextInput {
|
||
id: pskInput
|
||
anchors.fill: parent
|
||
anchors.margins: 8
|
||
text: hotspotState.password
|
||
onTextEdited: hotspotState.password = text
|
||
echoMode: TextInput.Password
|
||
color: Theme.fg
|
||
font.family: "JetBrainsMono Nerd Font"
|
||
font.pixelSize: 12
|
||
clip: true
|
||
|
||
Text {
|
||
visible: pskInput.text.length === 0
|
||
text: "Password"
|
||
color: Theme.fgDim
|
||
font.family: "JetBrainsMono Nerd Font"
|
||
font.pixelSize: 12
|
||
}
|
||
}
|
||
}
|
||
|
||
Text {
|
||
text: "Security"
|
||
color: Theme.fgDim
|
||
font.family: "JetBrainsMono Nerd Font"
|
||
font.pixelSize: 10
|
||
}
|
||
Row {
|
||
spacing: 6
|
||
Repeater {
|
||
model: [
|
||
{ key: "wpa2", label: "WPA2" },
|
||
{ key: "wpa3", label: "WPA3" }
|
||
]
|
||
delegate: Rectangle {
|
||
width: (loader.width - 6) / 2
|
||
height: 28
|
||
color: modelData.key === hotspotState.security ? Theme.bgAlt : (secMa.containsMouse ? Theme.bgAlt : "transparent")
|
||
border.width: modelData.key === hotspotState.security ? 2 : 1
|
||
border.color: modelData.key === hotspotState.security ? Theme.accent : Theme.borderDim
|
||
Text {
|
||
anchors.centerIn: parent
|
||
text: modelData.label
|
||
color: modelData.key === hotspotState.security ? Theme.accent : Theme.fg
|
||
font.family: "JetBrainsMono Nerd Font"
|
||
font.pixelSize: 11
|
||
}
|
||
MouseArea {
|
||
id: secMa
|
||
anchors.fill: parent
|
||
hoverEnabled: true
|
||
onClicked: hotspotState.security = modelData.key
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
Text {
|
||
text: "Band"
|
||
color: Theme.fgDim
|
||
font.family: "JetBrainsMono Nerd Font"
|
||
font.pixelSize: 10
|
||
}
|
||
Row {
|
||
spacing: 6
|
||
Repeater {
|
||
model: [
|
||
{ key: "auto", label: "Auto" },
|
||
{ key: "bg", label: "2.4GHz" },
|
||
{ key: "a", label: "5GHz" }
|
||
]
|
||
delegate: Rectangle {
|
||
width: (loader.width - 12) / 3
|
||
height: 28
|
||
color: modelData.key === hotspotState.band ? Theme.bgAlt : (bandMa.containsMouse ? Theme.bgAlt : "transparent")
|
||
border.width: modelData.key === hotspotState.band ? 2 : 1
|
||
border.color: modelData.key === hotspotState.band ? Theme.accent : Theme.borderDim
|
||
Text {
|
||
anchors.centerIn: parent
|
||
text: modelData.label
|
||
color: modelData.key === hotspotState.band ? Theme.accent : Theme.fg
|
||
font.family: "JetBrainsMono Nerd Font"
|
||
font.pixelSize: 11
|
||
}
|
||
MouseArea {
|
||
id: bandMa
|
||
anchors.fill: parent
|
||
hoverEnabled: true
|
||
onClicked: hotspotState.band = modelData.key
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
Rectangle {
|
||
id: hsButton
|
||
width: loader.width
|
||
height: 32
|
||
opacity: (hotspotCol.hotspotActive || hotspotCol.canStart) ? 1 : 0.4
|
||
color: hsMa.containsMouse ? Theme.bgAlt : "transparent"
|
||
border.width: 1
|
||
border.color: hotspotCol.hotspotActive ? Theme.danger : Theme.accent
|
||
Text {
|
||
anchors.centerIn: parent
|
||
text: hotspotCol.hotspotActive ? "Stop Hotspot" : "Start Hotspot"
|
||
color: hotspotCol.hotspotActive ? Theme.danger : Theme.accent
|
||
font.family: "JetBrainsMono Nerd Font"
|
||
font.pixelSize: 12
|
||
font.bold: true
|
||
}
|
||
MouseArea {
|
||
id: hsMa
|
||
anchors.fill: parent
|
||
hoverEnabled: true
|
||
onClicked: {
|
||
if (hotspotCol.hotspotActive) {
|
||
hotspotProc.command = ["nmcli", "connection", "down", "Hotspot"];
|
||
hotspotProc.running = true;
|
||
hotspotCol.hotspotActive = false;
|
||
} else if (hotspotCol.canStart) {
|
||
hotspotProc.command = ["hotspot-start", ssidInput.text, pskInput.text, hotspotState.security, hotspotState.band];
|
||
hotspotProc.running = true;
|
||
hotspotCol.hotspotActive = true;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
Text {
|
||
text: "Password needs at least 8 characters."
|
||
color: Theme.fgDim
|
||
font.family: "JetBrainsMono Nerd Font"
|
||
font.pixelSize: 9
|
||
wrapMode: Text.WordWrap
|
||
width: loader.width
|
||
}
|
||
}
|
||
}
|
||
|
||
// --- Detail view ---------------------------------------------
|
||
Column {
|
||
id: netDetail
|
||
visible: netCol.selected !== null
|
||
width: parent.width
|
||
spacing: 10
|
||
|
||
property var sel: netCol.selected
|
||
property bool needsPassword: !!(sel && !netCol.selectedIsDevice && !sel.known && !sel.connected)
|
||
|
||
// NetworkDevice.address is actually the MAC address, not an IP —
|
||
// the real IP has to come from nmcli.
|
||
property string deviceName: sel ? (netCol.selectedIsDevice ? sel.name : (sel.device ? sel.device.name : "")) : ""
|
||
property string ip: ""
|
||
Process {
|
||
id: ipProc
|
||
command: netDetail.deviceName ? ["nmcli", "-g", "IP4.ADDRESS", "device", "show", netDetail.deviceName] : []
|
||
stdout: StdioCollector {
|
||
onStreamFinished: netDetail.ip = text.split("/")[0].split("\n")[0].trim()
|
||
}
|
||
}
|
||
onDeviceNameChanged: { ip = ""; if (deviceName) ipProc.running = true; }
|
||
Component.onCompleted: if (deviceName) ipProc.running = true
|
||
|
||
Process { id: netActionProc; command: [] }
|
||
|
||
Rectangle {
|
||
width: parent.width
|
||
height: 26
|
||
color: "transparent"
|
||
Text {
|
||
anchors.left: parent.left
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
text: "< Back"
|
||
color: Theme.accent
|
||
font.family: "JetBrainsMono Nerd Font"
|
||
font.pixelSize: 12
|
||
}
|
||
MouseArea { anchors.fill: parent; onClicked: netCol.selected = null }
|
||
}
|
||
|
||
Text {
|
||
text: netCol.selected ? netCol.selected.name : ""
|
||
color: Theme.fg
|
||
font.family: "JetBrainsMono Nerd Font"
|
||
font.pixelSize: 15
|
||
font.bold: true
|
||
}
|
||
|
||
Column {
|
||
width: parent.width
|
||
spacing: 4
|
||
|
||
property var rows: {
|
||
if (!netCol.selected) return [];
|
||
if (netCol.selectedIsDevice) {
|
||
return [
|
||
{ label: "Status", value: netCol.selected.connected ? "Connected" : "No link" },
|
||
{ label: "IP Address", value: netDetail.ip || "—" },
|
||
{ label: "MAC Address", value: netCol.selected.address || "—" }
|
||
];
|
||
}
|
||
var r = [
|
||
{ label: "Status", value: netCol.selected.connected ? "Connected" : (netCol.selected.known ? "Saved" : "Available") },
|
||
{ label: "Signal", value: netCol.selected.signalStrength + "%" },
|
||
{ label: "Security", value: WifiSecurityType.toString(netCol.selected.security) }
|
||
];
|
||
if (netCol.selected.connected)
|
||
r.push({ label: "IP Address", value: netDetail.ip || "—" });
|
||
if (netCol.selected.device && netCol.selected.device.address)
|
||
r.push({ label: "MAC Address", value: netCol.selected.device.address });
|
||
return r;
|
||
}
|
||
|
||
Repeater {
|
||
model: parent.rows
|
||
delegate: Item {
|
||
width: loader.width
|
||
height: 20
|
||
Text {
|
||
anchors.left: parent.left
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
text: modelData.label
|
||
color: Theme.fgDim
|
||
font.family: "JetBrainsMono Nerd Font"
|
||
font.pixelSize: 11
|
||
}
|
||
Text {
|
||
anchors.right: parent.right
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
text: modelData.value
|
||
color: Theme.fg
|
||
font.family: "JetBrainsMono Nerd Font"
|
||
font.pixelSize: 11
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
Rectangle { width: parent.width; height: 1; color: Theme.borderDim }
|
||
|
||
Column {
|
||
visible: !netCol.selectedIsDevice && netDetail.needsPassword
|
||
width: parent.width
|
||
spacing: 8
|
||
|
||
Rectangle {
|
||
width: parent.width
|
||
height: 30
|
||
color: Theme.bgAlt
|
||
border.width: 1
|
||
border.color: Theme.border
|
||
TextInput {
|
||
id: detailPskField
|
||
anchors.fill: parent
|
||
anchors.margins: 8
|
||
echoMode: TextInput.Password
|
||
color: Theme.fg
|
||
font.family: "JetBrainsMono Nerd Font"
|
||
font.pixelSize: 12
|
||
clip: true
|
||
|
||
Text {
|
||
visible: detailPskField.text.length === 0
|
||
text: "Password"
|
||
color: Theme.fgDim
|
||
font.family: "JetBrainsMono Nerd Font"
|
||
font.pixelSize: 12
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
Rectangle {
|
||
visible: !netCol.selectedIsDevice
|
||
width: parent.width
|
||
height: 34
|
||
property bool canSubmit: !netDetail.needsPassword || detailPskField.text.length >= 8
|
||
opacity: canSubmit ? 1 : 0.4
|
||
color: connMa.containsMouse ? Theme.bgAlt : "transparent"
|
||
border.width: 2
|
||
border.color: (netCol.selected && netCol.selected.connected) ? Theme.danger : Theme.accent
|
||
Text {
|
||
anchors.centerIn: parent
|
||
text: (netCol.selected && netCol.selected.connected) ? "Disconnect"
|
||
: (netDetail.needsPassword ? "Connect with password" : "Connect")
|
||
color: (netCol.selected && netCol.selected.connected) ? Theme.danger : Theme.accent
|
||
font.family: "JetBrainsMono Nerd Font"
|
||
font.pixelSize: 12
|
||
font.bold: true
|
||
}
|
||
MouseArea {
|
||
id: connMa
|
||
anchors.fill: parent
|
||
hoverEnabled: true
|
||
onClicked: {
|
||
if (!netCol.selected) return;
|
||
if (netCol.selected.connected) netCol.selected.disconnect();
|
||
else if (netDetail.needsPassword) {
|
||
if (detailPskField.text.length >= 8) netCol.selected.connectWithPsk(detailPskField.text);
|
||
} else netCol.selected.connect();
|
||
}
|
||
}
|
||
}
|
||
|
||
Text {
|
||
visible: !netCol.selectedIsDevice && netCol.selected && netCol.selected.known
|
||
text: "Forget this network"
|
||
color: Theme.fgDim
|
||
font.family: "JetBrainsMono Nerd Font"
|
||
font.pixelSize: 11
|
||
|
||
MouseArea {
|
||
anchors.fill: parent
|
||
onClicked: { netCol.selected.forget(); netCol.selected = null; }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// --- Bluetooth -------------------------------------------------------
|
||
Component {
|
||
id: bluetoothPanel
|
||
Column {
|
||
id: btCol
|
||
spacing: 10
|
||
width: loader.width
|
||
property var selected: null
|
||
|
||
// Same reasoning as the Wi-Fi panel's per-device scannerEnabled
|
||
// toggle — auto-start scanning while the panel is open instead
|
||
// of requiring a manual click every time, then stop when it
|
||
// closes so the adapter isn't left discovering in the background.
|
||
// Qt.callLater, not a direct write: writing `discovering` in the
|
||
// same tick this Column is constructed was silently dropped
|
||
// (verified against BlueZ directly over D-Bus — Discovering
|
||
// stayed false) even though the identical write from the
|
||
// manual "Scan for devices" button works fine; deferring one
|
||
// event-loop tick gives Quickshell's Bluetooth adapter wrapper
|
||
// time to finish attaching before the write goes out.
|
||
Component.onCompleted: Qt.callLater(function () {
|
||
if (Bluetooth.defaultAdapter && Bluetooth.defaultAdapter.enabled) Bluetooth.defaultAdapter.discovering = true;
|
||
});
|
||
Component.onDestruction: if (Bluetooth.defaultAdapter) Bluetooth.defaultAdapter.discovering = false;
|
||
|
||
// --- List view ---------------------------------------------
|
||
Column {
|
||
visible: btCol.selected === null
|
||
width: parent.width
|
||
spacing: 10
|
||
|
||
Item {
|
||
width: parent.width
|
||
height: 22
|
||
visible: Bluetooth.defaultAdapter !== null
|
||
Text {
|
||
anchors.left: parent.left
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
text: "Bluetooth"
|
||
color: Theme.fg
|
||
font.family: "JetBrainsMono Nerd Font"
|
||
font.pixelSize: 13
|
||
font.bold: true
|
||
}
|
||
ToggleSwitch {
|
||
anchors.right: parent.right
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
checked: !!(Bluetooth.defaultAdapter && Bluetooth.defaultAdapter.enabled)
|
||
onToggled: if (Bluetooth.defaultAdapter) Bluetooth.defaultAdapter.enabled = !Bluetooth.defaultAdapter.enabled
|
||
}
|
||
}
|
||
|
||
Text {
|
||
visible: Bluetooth.defaultAdapter === null
|
||
text: "No Bluetooth adapter found"
|
||
color: Theme.fgDim
|
||
font.family: "JetBrainsMono Nerd Font"
|
||
font.pixelSize: 11
|
||
}
|
||
|
||
Rectangle {
|
||
visible: !!(Bluetooth.defaultAdapter && Bluetooth.defaultAdapter.enabled)
|
||
width: loader.width
|
||
height: 30
|
||
color: scanMa.containsMouse ? Theme.bgAlt : "transparent"
|
||
border.width: 1
|
||
border.color: Theme.borderDim
|
||
Text {
|
||
anchors.centerIn: parent
|
||
text: (Bluetooth.defaultAdapter && Bluetooth.defaultAdapter.discovering) ? "Scanning…" : "Scan for devices"
|
||
color: (Bluetooth.defaultAdapter && Bluetooth.defaultAdapter.discovering) ? Theme.accent : Theme.fg
|
||
font.family: "JetBrainsMono Nerd Font"
|
||
font.pixelSize: 12
|
||
}
|
||
MouseArea {
|
||
id: scanMa
|
||
anchors.fill: parent
|
||
hoverEnabled: true
|
||
onClicked: if (Bluetooth.defaultAdapter) Bluetooth.defaultAdapter.discovering = !Bluetooth.defaultAdapter.discovering
|
||
}
|
||
}
|
||
|
||
Text {
|
||
text: "Paired & nearby devices"
|
||
color: Theme.fgDim
|
||
font.family: "JetBrainsMono Nerd Font"
|
||
font.pixelSize: 10
|
||
}
|
||
Text {
|
||
visible: Bluetooth.defaultAdapter !== null && Bluetooth.devices.values.length === 0
|
||
text: "No devices"
|
||
color: Theme.fgDim
|
||
font.family: "JetBrainsMono Nerd Font"
|
||
font.pixelSize: 11
|
||
}
|
||
|
||
Repeater {
|
||
model: Bluetooth.devices
|
||
delegate: Rectangle {
|
||
width: loader.width
|
||
height: 40
|
||
color: btMa.containsMouse ? Theme.bgAlt : "transparent"
|
||
border.width: 1
|
||
border.color: dev.connected ? Theme.accent : Theme.borderDim
|
||
property var dev: modelData
|
||
|
||
Column {
|
||
anchors.left: parent.left
|
||
anchors.leftMargin: 10
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
spacing: 1
|
||
Text {
|
||
text: dev.name
|
||
color: dev.connected ? Theme.accent : Theme.fg
|
||
font.family: "JetBrainsMono Nerd Font"
|
||
font.pixelSize: 12
|
||
}
|
||
Text {
|
||
text: (dev.connected ? "Connected" : (dev.paired ? "Paired" : "Available"))
|
||
+ (dev.batteryAvailable ? " · " + Math.round(dev.battery <= 1 ? dev.battery * 100 : dev.battery) + "%" : "")
|
||
color: Theme.fgDim
|
||
font.family: "JetBrainsMono Nerd Font"
|
||
font.pixelSize: 10
|
||
}
|
||
}
|
||
|
||
MouseArea {
|
||
id: btMa
|
||
anchors.fill: parent
|
||
hoverEnabled: true
|
||
onClicked: btCol.selected = dev
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// --- Detail view ---------------------------------------------
|
||
Column {
|
||
visible: btCol.selected !== null
|
||
width: parent.width
|
||
spacing: 10
|
||
|
||
Process { id: btActionProc; command: [] }
|
||
|
||
Rectangle {
|
||
width: parent.width
|
||
height: 26
|
||
color: "transparent"
|
||
Text {
|
||
anchors.left: parent.left
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
text: "< Back"
|
||
color: Theme.accent
|
||
font.family: "JetBrainsMono Nerd Font"
|
||
font.pixelSize: 12
|
||
}
|
||
MouseArea { anchors.fill: parent; onClicked: btCol.selected = null }
|
||
}
|
||
|
||
Text {
|
||
text: btCol.selected ? btCol.selected.name : ""
|
||
color: Theme.fg
|
||
font.family: "JetBrainsMono Nerd Font"
|
||
font.pixelSize: 15
|
||
font.bold: true
|
||
}
|
||
|
||
Column {
|
||
width: parent.width
|
||
spacing: 4
|
||
|
||
property var rows: {
|
||
if (!btCol.selected) return [];
|
||
var d = btCol.selected;
|
||
var r = [
|
||
{ label: "Status", value: d.connected ? "Connected" : (d.paired ? "Paired" : "Available") },
|
||
{ label: "MAC Address", value: d.address || "—" },
|
||
{ label: "Type", value: d.icon || "unknown" },
|
||
{ label: "Trusted", value: d.trusted ? "Yes" : "No" }
|
||
];
|
||
if (d.batteryAvailable)
|
||
r.push({ label: "Battery", value: Math.round(d.battery <= 1 ? d.battery * 100 : d.battery) + "%" });
|
||
return r;
|
||
}
|
||
|
||
Repeater {
|
||
model: parent.rows
|
||
delegate: Item {
|
||
width: loader.width
|
||
height: 20
|
||
Text {
|
||
anchors.left: parent.left
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
text: modelData.label
|
||
color: Theme.fgDim
|
||
font.family: "JetBrainsMono Nerd Font"
|
||
font.pixelSize: 11
|
||
}
|
||
Text {
|
||
anchors.right: parent.right
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
text: modelData.value
|
||
color: Theme.fg
|
||
font.family: "JetBrainsMono Nerd Font"
|
||
font.pixelSize: 11
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
Rectangle { width: parent.width; height: 1; color: Theme.borderDim }
|
||
|
||
Rectangle {
|
||
width: parent.width
|
||
height: 34
|
||
color: btConnMa.containsMouse ? Theme.bgAlt : "transparent"
|
||
border.width: 2
|
||
border.color: (btCol.selected && btCol.selected.connected) ? Theme.danger : Theme.accent
|
||
Text {
|
||
anchors.centerIn: parent
|
||
text: (btCol.selected && btCol.selected.connected) ? "Disconnect"
|
||
: (btCol.selected && btCol.selected.paired) ? "Connect" : "Pair"
|
||
color: (btCol.selected && btCol.selected.connected) ? Theme.danger : Theme.accent
|
||
font.family: "JetBrainsMono Nerd Font"
|
||
font.pixelSize: 12
|
||
font.bold: true
|
||
}
|
||
MouseArea {
|
||
id: btConnMa
|
||
anchors.fill: parent
|
||
hoverEnabled: true
|
||
onClicked: {
|
||
if (!btCol.selected) return;
|
||
if (btCol.selected.connected) btCol.selected.disconnect();
|
||
else if (btCol.selected.paired) btCol.selected.connect();
|
||
else btCol.selected.pair();
|
||
}
|
||
}
|
||
}
|
||
|
||
Text {
|
||
visible: !!(btCol.selected && btCol.selected.paired)
|
||
text: "Forget this device"
|
||
color: Theme.fgDim
|
||
font.family: "JetBrainsMono Nerd Font"
|
||
font.pixelSize: 11
|
||
|
||
MouseArea {
|
||
anchors.fill: parent
|
||
onClicked: { btCol.selected.forget(); btCol.selected = null; }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// --- Messages (notification history, via mako) -----------------------
|
||
// mako's IPC has no way to actually delete a history entry (only
|
||
// `dismiss` for currently-displayed notifications and a read-only
|
||
// `history` list) — so "clearing" is done client-side by remembering
|
||
// hidden ids across restarts and filtering them out of the list. The
|
||
// hidden ids naturally become irrelevant once mako's own history ring
|
||
// buffer rolls them out.
|
||
Component {
|
||
id: messagesPanel
|
||
Column {
|
||
id: msgCol
|
||
spacing: 6
|
||
width: loader.width
|
||
property var history: []
|
||
// The hidden-id list itself lives on `root` now (see top of
|
||
// file) — this Column gets destroyed every time the panel
|
||
// closes, so anything stored here alone would silently reset.
|
||
property var visibleHistory: {
|
||
var hidden = root.hiddenMessageIds;
|
||
var out = [];
|
||
for (var i = 0; i < msgCol.history.length; i++) {
|
||
if (hidden.indexOf(msgCol.history[i].id) === -1) out.push(msgCol.history[i]);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function refresh() { historyProc.running = true; }
|
||
function hideId(id) { root.hideMessageId(id); }
|
||
function clearAll() {
|
||
var ids = [];
|
||
for (var i = 0; i < msgCol.history.length; i++) ids.push(msgCol.history[i].id);
|
||
root.clearAllMessages(ids);
|
||
}
|
||
|
||
Process {
|
||
id: historyProc
|
||
command: ["makoctl", "history", "-j"]
|
||
stdout: StdioCollector {
|
||
onStreamFinished: {
|
||
try {
|
||
msgCol.history = JSON.parse(text);
|
||
} catch (e) { msgCol.history = []; }
|
||
}
|
||
}
|
||
}
|
||
Component.onCompleted: msgCol.refresh()
|
||
|
||
Process { id: invokeProc; command: [] }
|
||
|
||
Item {
|
||
width: parent.width
|
||
height: 22
|
||
Text {
|
||
anchors.left: parent.left
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
text: "Recent notifications"
|
||
color: Theme.fgDim
|
||
font.family: "JetBrainsMono Nerd Font"
|
||
font.pixelSize: 10
|
||
}
|
||
Row {
|
||
anchors.right: parent.right
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
spacing: 12
|
||
Text {
|
||
visible: msgCol.visibleHistory.length > 0
|
||
text: "Clear all"
|
||
color: Theme.danger
|
||
font.family: "JetBrainsMono Nerd Font"
|
||
font.pixelSize: 11
|
||
MouseArea { anchors.fill: parent; onClicked: msgCol.clearAll() }
|
||
}
|
||
Text {
|
||
text: "Refresh"
|
||
color: Theme.accent
|
||
font.family: "JetBrainsMono Nerd Font"
|
||
font.pixelSize: 11
|
||
MouseArea { anchors.fill: parent; onClicked: msgCol.refresh() }
|
||
}
|
||
}
|
||
}
|
||
|
||
Text {
|
||
visible: msgCol.visibleHistory.length === 0
|
||
text: "No notifications yet"
|
||
color: Theme.fgDim
|
||
font.family: "JetBrainsMono Nerd Font"
|
||
font.pixelSize: 12
|
||
}
|
||
|
||
Repeater {
|
||
model: msgCol.visibleHistory
|
||
delegate: Rectangle {
|
||
id: msgRow
|
||
width: loader.width
|
||
height: bodyText.implicitHeight + summaryText.implicitHeight + 20
|
||
color: rowHover.hovered ? Theme.bgAlt : "transparent"
|
||
border.width: 1
|
||
border.color: Theme.borderDim
|
||
property var entry: modelData
|
||
|
||
HoverHandler { id: rowHover }
|
||
|
||
Column {
|
||
anchors.left: parent.left
|
||
anchors.right: parent.right
|
||
anchors.leftMargin: 10
|
||
anchors.rightMargin: 30
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
spacing: 2
|
||
|
||
Row {
|
||
width: parent.width
|
||
Text {
|
||
text: entry.app_name || "unknown"
|
||
color: Theme.fgDim
|
||
font.family: "JetBrainsMono Nerd Font"
|
||
font.pixelSize: 10
|
||
}
|
||
}
|
||
Text {
|
||
id: summaryText
|
||
width: parent.width
|
||
text: entry.summary || ""
|
||
color: Theme.fg
|
||
font.family: "JetBrainsMono Nerd Font"
|
||
font.pixelSize: 12
|
||
font.bold: true
|
||
elide: Text.ElideRight
|
||
}
|
||
Text {
|
||
id: bodyText
|
||
width: parent.width
|
||
text: entry.body || ""
|
||
color: Theme.fgDim2
|
||
font.family: "JetBrainsMono Nerd Font"
|
||
font.pixelSize: 11
|
||
wrapMode: Text.WordWrap
|
||
maximumLineCount: 3
|
||
elide: Text.ElideRight
|
||
}
|
||
}
|
||
|
||
MouseArea {
|
||
id: msgMa
|
||
anchors.fill: parent
|
||
onClicked: {
|
||
invokeProc.command = ["makoctl", "invoke", "-n", String(entry.id)];
|
||
invokeProc.running = true;
|
||
}
|
||
}
|
||
|
||
Rectangle {
|
||
id: closeBtn
|
||
z: 1
|
||
visible: rowHover.hovered
|
||
width: 18
|
||
height: 18
|
||
anchors.right: parent.right
|
||
anchors.rightMargin: 6
|
||
anchors.top: parent.top
|
||
anchors.topMargin: 6
|
||
color: closeMa.containsMouse ? Theme.danger : "transparent"
|
||
border.width: 1
|
||
border.color: Theme.danger
|
||
Text {
|
||
anchors.centerIn: parent
|
||
text: "×"
|
||
color: closeMa.containsMouse ? Theme.bg : Theme.danger
|
||
font.family: "JetBrainsMono Nerd Font"
|
||
font.pixelSize: 12
|
||
font.bold: true
|
||
}
|
||
MouseArea {
|
||
id: closeMa
|
||
anchors.fill: parent
|
||
hoverEnabled: true
|
||
cursorShape: Qt.PointingHandCursor
|
||
onClicked: msgCol.hideId(entry.id)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// --- Screenshot -------------------------------------------------------
|
||
Component {
|
||
id: screenshotPanel
|
||
Column {
|
||
id: ssCol
|
||
spacing: 14
|
||
width: loader.width
|
||
property string mode: "region"
|
||
property string action: "both"
|
||
|
||
property var modes: [
|
||
{ key: "region", label: "Region (drag to select)" },
|
||
{ key: "full", label: "Full screen" },
|
||
{ key: "window", label: "Active window" }
|
||
]
|
||
property var actions: [
|
||
{ key: "both", label: "Save file + copy" },
|
||
{ key: "file", label: "Save file only" },
|
||
{ key: "clipboard", label: "Copy only" }
|
||
]
|
||
|
||
Text {
|
||
text: "Capture"
|
||
color: Theme.fgDim
|
||
font.family: "JetBrainsMono Nerd Font"
|
||
font.pixelSize: 10
|
||
}
|
||
Column {
|
||
width: parent.width
|
||
spacing: 4
|
||
Repeater {
|
||
model: ssCol.modes
|
||
delegate: Rectangle {
|
||
width: loader.width
|
||
height: 32
|
||
color: modelData.key === ssCol.mode ? Theme.bgAlt : (modeMa.containsMouse ? Theme.bgAlt : "transparent")
|
||
border.width: modelData.key === ssCol.mode ? 2 : 1
|
||
border.color: modelData.key === ssCol.mode ? Theme.accent : Theme.borderDim
|
||
Text {
|
||
anchors.left: parent.left
|
||
anchors.leftMargin: 10
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
text: modelData.label
|
||
color: modelData.key === ssCol.mode ? Theme.accent : Theme.fg
|
||
font.family: "JetBrainsMono Nerd Font"
|
||
font.pixelSize: 12
|
||
}
|
||
MouseArea {
|
||
id: modeMa
|
||
anchors.fill: parent
|
||
hoverEnabled: true
|
||
onClicked: ssCol.mode = modelData.key
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
Text {
|
||
text: "Save"
|
||
color: Theme.fgDim
|
||
font.family: "JetBrainsMono Nerd Font"
|
||
font.pixelSize: 10
|
||
}
|
||
Column {
|
||
width: parent.width
|
||
spacing: 4
|
||
Repeater {
|
||
model: ssCol.actions
|
||
delegate: Rectangle {
|
||
width: loader.width
|
||
height: 32
|
||
color: modelData.key === ssCol.action ? Theme.bgAlt : (actionMa.containsMouse ? Theme.bgAlt : "transparent")
|
||
border.width: modelData.key === ssCol.action ? 2 : 1
|
||
border.color: modelData.key === ssCol.action ? Theme.accent : Theme.borderDim
|
||
Text {
|
||
anchors.left: parent.left
|
||
anchors.leftMargin: 10
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
text: modelData.label
|
||
color: modelData.key === ssCol.action ? Theme.accent : Theme.fg
|
||
font.family: "JetBrainsMono Nerd Font"
|
||
font.pixelSize: 12
|
||
}
|
||
MouseArea {
|
||
id: actionMa
|
||
anchors.fill: parent
|
||
hoverEnabled: true
|
||
onClicked: ssCol.action = modelData.key
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
Rectangle {
|
||
width: parent.width
|
||
height: 36
|
||
color: captureMa.containsMouse ? Theme.bgAlt : "transparent"
|
||
border.width: 2
|
||
border.color: Theme.accent
|
||
Text {
|
||
anchors.centerIn: parent
|
||
text: "Capture"
|
||
color: Theme.accent
|
||
font.family: "JetBrainsMono Nerd Font"
|
||
font.pixelSize: 13
|
||
font.bold: true
|
||
}
|
||
MouseArea {
|
||
id: captureMa
|
||
anchors.fill: parent
|
||
hoverEnabled: true
|
||
onClicked: {
|
||
// execDetached, not a local Process: closing the panel
|
||
// right after this swaps the Loader's sourceComponent,
|
||
// which destroys this whole Component (and any Process
|
||
// owned by it) before it would ever get to actually run.
|
||
Quickshell.execDetached(["screenshot", ssCol.mode, ssCol.action]);
|
||
root.requestClose();
|
||
}
|
||
}
|
||
}
|
||
|
||
Text {
|
||
text: "Saves to ~/Screenshots"
|
||
color: Theme.fgDim
|
||
font.family: "JetBrainsMono Nerd Font"
|
||
font.pixelSize: 9
|
||
}
|
||
}
|
||
}
|
||
|
||
// --- Volume -------------------------------------------------------
|
||
Component {
|
||
id: volumePanel
|
||
Column {
|
||
spacing: 12
|
||
width: loader.width
|
||
|
||
Item {
|
||
width: parent.width
|
||
height: 18
|
||
Text {
|
||
anchors.left: parent.left
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
anchors.right: pct.left
|
||
anchors.rightMargin: 8
|
||
text: Pipewire.defaultAudioSink ? Pipewire.defaultAudioSink.description : "No output"
|
||
color: Theme.fg
|
||
font.family: "JetBrainsMono Nerd Font"
|
||
font.pixelSize: 12
|
||
elide: Text.ElideRight
|
||
}
|
||
Text {
|
||
id: pct
|
||
anchors.right: parent.right
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
text: (Pipewire.defaultAudioSink && Pipewire.defaultAudioSink.audio)
|
||
? Math.round(Pipewire.defaultAudioSink.audio.volume * 100) + "%" : "--"
|
||
color: Theme.accent
|
||
font.family: "JetBrainsMono Nerd Font"
|
||
font.pixelSize: 12
|
||
font.bold: true
|
||
}
|
||
}
|
||
|
||
SquareSlider {
|
||
width: parent.width
|
||
value: (Pipewire.defaultAudioSink && Pipewire.defaultAudioSink.audio) ? Pipewire.defaultAudioSink.audio.volume : 0
|
||
onMoved: (v) => {
|
||
if (Pipewire.defaultAudioSink && Pipewire.defaultAudioSink.audio)
|
||
Pipewire.defaultAudioSink.audio.volume = v;
|
||
}
|
||
}
|
||
|
||
Item {
|
||
width: parent.width
|
||
height: 22
|
||
Text {
|
||
anchors.left: parent.left
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
text: "Mute"
|
||
color: Theme.fg
|
||
font.family: "JetBrainsMono Nerd Font"
|
||
font.pixelSize: 12
|
||
}
|
||
ToggleSwitch {
|
||
anchors.right: parent.right
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
onLabel: "Muted"
|
||
offLabel: "Unmuted"
|
||
checked: !!(Pipewire.defaultAudioSink && Pipewire.defaultAudioSink.audio && Pipewire.defaultAudioSink.audio.muted)
|
||
onToggled: if (Pipewire.defaultAudioSink && Pipewire.defaultAudioSink.audio)
|
||
Pipewire.defaultAudioSink.audio.muted = !Pipewire.defaultAudioSink.audio.muted
|
||
}
|
||
}
|
||
|
||
Rectangle { width: parent.width; height: 1; color: Theme.borderDim }
|
||
|
||
Text {
|
||
text: "Output device"
|
||
color: Theme.fgDim
|
||
font.family: "JetBrainsMono Nerd Font"
|
||
font.pixelSize: 10
|
||
}
|
||
|
||
Repeater {
|
||
model: Pipewire.nodes
|
||
delegate: Rectangle {
|
||
property var node: modelData
|
||
visible: node.isSink && !node.isStream
|
||
width: loader.width
|
||
height: 32
|
||
color: outMa.containsMouse ? Theme.bgAlt : "transparent"
|
||
border.width: 1
|
||
border.color: (Pipewire.defaultAudioSink && Pipewire.defaultAudioSink.id === node.id) ? Theme.accent : Theme.borderDim
|
||
|
||
Text {
|
||
anchors.left: parent.left
|
||
anchors.leftMargin: 8
|
||
anchors.right: parent.right
|
||
anchors.rightMargin: 8
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
text: node.description
|
||
color: (Pipewire.defaultAudioSink && Pipewire.defaultAudioSink.id === node.id) ? Theme.accent : Theme.fg
|
||
font.family: "JetBrainsMono Nerd Font"
|
||
font.pixelSize: 11
|
||
elide: Text.ElideRight
|
||
}
|
||
MouseArea {
|
||
id: outMa
|
||
anchors.fill: parent
|
||
hoverEnabled: true
|
||
onClicked: Pipewire.preferredDefaultAudioSink = node
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// --- Brightness -------------------------------------------------------
|
||
Component {
|
||
id: brightnessPanel
|
||
Column {
|
||
spacing: 10
|
||
width: loader.width
|
||
|
||
property int pct: 50
|
||
|
||
Process {
|
||
id: getProc
|
||
command: ["brightness-ctl", "get"]
|
||
stdout: StdioCollector {
|
||
onStreamFinished: {
|
||
var n = parseInt(text.trim(), 10);
|
||
if (!isNaN(n)) slider.value = n / 100;
|
||
}
|
||
}
|
||
}
|
||
Process {
|
||
id: setProc
|
||
command: []
|
||
}
|
||
|
||
Component.onCompleted: getProc.running = true
|
||
|
||
Text {
|
||
text: "Monitor brightness"
|
||
color: Theme.fg
|
||
font.family: "JetBrainsMono Nerd Font"
|
||
font.pixelSize: 12
|
||
}
|
||
SquareSlider {
|
||
id: slider
|
||
width: parent.width
|
||
onMoved: (v) => {
|
||
setProc.command = ["brightness-ctl", "set", String(Math.round(v * 100))];
|
||
setProc.running = true;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// --- Devices (removable drives) -------------------------------------
|
||
Component {
|
||
id: devicesPanel
|
||
Column {
|
||
id: devCol
|
||
spacing: 6
|
||
width: loader.width
|
||
property var drives: []
|
||
|
||
function refresh() { lsblkProc.running = true; }
|
||
|
||
Process {
|
||
id: lsblkProc
|
||
command: ["lsblk", "-J", "-o", "NAME,LABEL,SIZE,RM,TYPE,MOUNTPOINT,PATH,FSTYPE"]
|
||
stdout: StdioCollector {
|
||
onStreamFinished: {
|
||
try {
|
||
var data = JSON.parse(text);
|
||
var out = [];
|
||
function walk(devs) {
|
||
for (var i = 0; i < devs.length; i++) {
|
||
var d = devs[i];
|
||
// Any data partition that isn't the running system
|
||
// itself — not just literally-removable (RM) ones,
|
||
// since internal secondary drives count too.
|
||
if (d.type === "part" && d.fstype !== "swap"
|
||
&& d.mountpoint !== "/" && d.mountpoint !== "/boot") {
|
||
out.push(d);
|
||
}
|
||
if (d.children) walk(d.children);
|
||
}
|
||
}
|
||
walk(data.blockdevices || []);
|
||
devCol.drives = out;
|
||
} catch (e) { devCol.drives = []; }
|
||
}
|
||
}
|
||
}
|
||
Component.onCompleted: devCol.refresh()
|
||
|
||
Text {
|
||
visible: devCol.drives.length === 0
|
||
text: "No extra drives found"
|
||
color: Theme.fgDim
|
||
font.family: "JetBrainsMono Nerd Font"
|
||
font.pixelSize: 12
|
||
}
|
||
|
||
Repeater {
|
||
model: devCol.drives
|
||
delegate: Rectangle {
|
||
width: loader.width
|
||
height: 44
|
||
color: "transparent"
|
||
property var drive: modelData
|
||
|
||
Process {
|
||
id: mountProc
|
||
command: []
|
||
onExited: devCol.refresh()
|
||
}
|
||
|
||
Column {
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
spacing: 2
|
||
Text {
|
||
text: (drive.label || drive.name) + " " + drive.size
|
||
color: Theme.fg
|
||
font.family: "JetBrainsMono Nerd Font"
|
||
font.pixelSize: 12
|
||
}
|
||
Row {
|
||
spacing: 10
|
||
Text {
|
||
text: drive.mountpoint ? "Unmount" : "Mount"
|
||
color: Theme.accent
|
||
font.family: "JetBrainsMono Nerd Font"
|
||
font.pixelSize: 11
|
||
MouseArea {
|
||
anchors.fill: parent
|
||
onClicked: {
|
||
mountProc.command = drive.mountpoint
|
||
? ["udisksctl", "unmount", "-b", drive.path]
|
||
: ["udisksctl", "mount", "-b", drive.path];
|
||
mountProc.running = true;
|
||
}
|
||
}
|
||
}
|
||
Text {
|
||
visible: !!drive.mountpoint
|
||
text: "Open"
|
||
color: Theme.accent
|
||
font.family: "JetBrainsMono Nerd Font"
|
||
font.pixelSize: 11
|
||
MouseArea {
|
||
anchors.fill: parent
|
||
onClicked: {
|
||
mountProc.command = ["thunar", drive.mountpoint];
|
||
mountProc.running = true;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// --- System Monitor -------------------------------------------------------
|
||
// One process invocation per poll (system-telemetry, modules/scripts/
|
||
// system-telemetry.sh) instead of separate Process objects per metric —
|
||
// it reads /proc/stat twice 200ms apart internally to compute CPU%,
|
||
// /proc/meminfo for RAM, nvidia-smi or the AMD sysfs counters for GPU/
|
||
// VRAM (whichever's actually present), and `df -h` for / and /home —
|
||
// and returns it all as one JSON line.
|
||
Component {
|
||
id: systemPanel
|
||
Column {
|
||
id: sysCol
|
||
width: loader.width
|
||
spacing: 14
|
||
property var stats: ({})
|
||
property bool haveData: Object.keys(sysCol.stats).length > 0
|
||
|
||
Timer {
|
||
interval: 2000
|
||
running: true
|
||
repeat: true
|
||
triggeredOnStart: true
|
||
onTriggered: if (!telemetryProc.running) telemetryProc.running = true
|
||
}
|
||
Process {
|
||
id: telemetryProc
|
||
command: ["system-telemetry"]
|
||
stdout: StdioCollector {
|
||
onStreamFinished: {
|
||
try { sysCol.stats = JSON.parse(text); } catch (e) { /* keep last good reading */ }
|
||
}
|
||
}
|
||
}
|
||
|
||
Text {
|
||
visible: !sysCol.haveData
|
||
text: "Reading system stats..."
|
||
color: Theme.fgDim
|
||
font.family: "JetBrainsMono Nerd Font"
|
||
font.pixelSize: 12
|
||
}
|
||
|
||
// -- CPU --
|
||
Column {
|
||
visible: sysCol.haveData
|
||
width: sysCol.width
|
||
spacing: 4
|
||
Item {
|
||
width: parent.width
|
||
height: 16
|
||
Text {
|
||
anchors.left: parent.left
|
||
text: "CPU"
|
||
color: Theme.fg
|
||
font.family: "JetBrainsMono Nerd Font"
|
||
font.pixelSize: 12
|
||
font.bold: true
|
||
}
|
||
Text {
|
||
anchors.right: parent.right
|
||
text: (sysCol.stats.cpuPct !== undefined ? sysCol.stats.cpuPct : 0) + "%"
|
||
color: Theme.accent
|
||
font.family: "JetBrainsMono Nerd Font"
|
||
font.pixelSize: 12
|
||
}
|
||
}
|
||
Meter { width: parent.width; value: (sysCol.stats.cpuPct || 0) / 100 }
|
||
}
|
||
|
||
// -- RAM --
|
||
Column {
|
||
visible: sysCol.haveData
|
||
width: sysCol.width
|
||
spacing: 4
|
||
Item {
|
||
width: parent.width
|
||
height: 16
|
||
Text {
|
||
anchors.left: parent.left
|
||
text: "Memory"
|
||
color: Theme.fg
|
||
font.family: "JetBrainsMono Nerd Font"
|
||
font.pixelSize: 12
|
||
font.bold: true
|
||
}
|
||
Text {
|
||
anchors.right: parent.right
|
||
text: ((sysCol.stats.memUsedKb || 0) / 1048576).toFixed(1) + " / "
|
||
+ ((sysCol.stats.memTotalKb || 0) / 1048576).toFixed(1) + " GB"
|
||
color: Theme.fgDim
|
||
font.family: "JetBrainsMono Nerd Font"
|
||
font.pixelSize: 11
|
||
}
|
||
}
|
||
Meter {
|
||
width: parent.width
|
||
value: sysCol.stats.memTotalKb > 0 ? sysCol.stats.memUsedKb / sysCol.stats.memTotalKb : 0
|
||
}
|
||
}
|
||
|
||
// -- GPU / VRAM --
|
||
Text {
|
||
visible: sysCol.haveData && (sysCol.stats.gpuPct === undefined || sysCol.stats.gpuPct < 0)
|
||
text: "No GPU metrics available"
|
||
color: Theme.fgDim
|
||
font.family: "JetBrainsMono Nerd Font"
|
||
font.pixelSize: 11
|
||
}
|
||
Column {
|
||
visible: sysCol.haveData && sysCol.stats.gpuPct >= 0
|
||
width: sysCol.width
|
||
spacing: 4
|
||
Item {
|
||
width: parent.width
|
||
height: 16
|
||
Text {
|
||
anchors.left: parent.left
|
||
text: "GPU"
|
||
color: Theme.fg
|
||
font.family: "JetBrainsMono Nerd Font"
|
||
font.pixelSize: 12
|
||
font.bold: true
|
||
}
|
||
Text {
|
||
anchors.right: parent.right
|
||
text: (sysCol.stats.gpuPct !== undefined ? sysCol.stats.gpuPct : 0) + "%"
|
||
color: Theme.accent
|
||
font.family: "JetBrainsMono Nerd Font"
|
||
font.pixelSize: 12
|
||
}
|
||
}
|
||
Meter { width: parent.width; value: (sysCol.stats.gpuPct || 0) / 100 }
|
||
}
|
||
Column {
|
||
visible: sysCol.haveData && sysCol.stats.vramTotalMb > 0
|
||
width: sysCol.width
|
||
spacing: 4
|
||
Item {
|
||
width: parent.width
|
||
height: 16
|
||
Text {
|
||
anchors.left: parent.left
|
||
text: "VRAM"
|
||
color: Theme.fg
|
||
font.family: "JetBrainsMono Nerd Font"
|
||
font.pixelSize: 12
|
||
font.bold: true
|
||
}
|
||
Text {
|
||
anchors.right: parent.right
|
||
text: (sysCol.stats.vramUsedMb || 0) + " / " + (sysCol.stats.vramTotalMb || 0) + " MB"
|
||
color: Theme.fgDim
|
||
font.family: "JetBrainsMono Nerd Font"
|
||
font.pixelSize: 11
|
||
}
|
||
}
|
||
Meter {
|
||
width: parent.width
|
||
value: sysCol.stats.vramTotalMb > 0 ? sysCol.stats.vramUsedMb / sysCol.stats.vramTotalMb : 0
|
||
}
|
||
}
|
||
|
||
// -- Disks --
|
||
Column {
|
||
visible: sysCol.haveData && sysCol.stats.disks && sysCol.stats.disks.length > 0
|
||
width: sysCol.width
|
||
spacing: 8
|
||
Text {
|
||
text: "STORAGE"
|
||
color: Theme.fgDim
|
||
font.family: "JetBrainsMono Nerd Font"
|
||
font.pixelSize: 9
|
||
font.bold: true
|
||
}
|
||
Repeater {
|
||
model: sysCol.stats.disks || []
|
||
delegate: Column {
|
||
width: parent.width
|
||
spacing: 4
|
||
property var disk: modelData
|
||
Item {
|
||
width: parent.width
|
||
height: 16
|
||
Text {
|
||
anchors.left: parent.left
|
||
text: disk.mount
|
||
color: Theme.fg
|
||
font.family: "JetBrainsMono Nerd Font"
|
||
font.pixelSize: 12
|
||
}
|
||
Text {
|
||
anchors.right: parent.right
|
||
text: disk.used + " / " + disk.size
|
||
color: Theme.fgDim
|
||
font.family: "JetBrainsMono Nerd Font"
|
||
font.pixelSize: 11
|
||
}
|
||
}
|
||
Meter { width: parent.width; value: (parseFloat(disk.pct) || 0) / 100 }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// --- Weather ---------------------------------------------------------
|
||
// weather-fetch (modules/scripts/weather-fetch.sh) does the actual
|
||
// network work — IP-geolocates via ip-api.com by default (or geocodes
|
||
// an explicitly typed city via Open-Meteo's own geocoding endpoint),
|
||
// then pulls current + hourly conditions from Open-Meteo. Both are
|
||
// free and keyless, matching curl everywhere else in this file rather
|
||
// than QML's own networking (Image loads already had to route around
|
||
// 403s from a CDN that didn't like Qt's request fingerprint — curl
|
||
// has been the reliable path for every other external fetch here).
|
||
Component {
|
||
id: weatherPanel
|
||
Column {
|
||
id: wxCol
|
||
width: loader.width
|
||
spacing: 12
|
||
property var forecast: ({})
|
||
property bool haveData: !!wxCol.forecast.current
|
||
property bool hasError: !!wxCol.forecast.error
|
||
|
||
Item {
|
||
id: wxSettings
|
||
property string city: ""
|
||
property string stateFile: Quickshell.stateDir + "/weather-settings.json"
|
||
function save() {
|
||
wxSaveProc.command = ["sh", "-c",
|
||
"mkdir -p \"$(dirname \"$2\")\" && printf '%s' \"$1\" > \"$2\"",
|
||
"--", JSON.stringify({ city: wxSettings.city }), wxSettings.stateFile];
|
||
wxSaveProc.running = true;
|
||
}
|
||
Process { id: wxSaveProc }
|
||
Process {
|
||
id: wxLoadProc
|
||
command: ["cat", wxSettings.stateFile]
|
||
stdout: StdioCollector {
|
||
onStreamFinished: {
|
||
try {
|
||
var d = JSON.parse(text);
|
||
if (d.city !== undefined) wxSettings.city = d.city;
|
||
} catch (e) { /* no state file yet — defaults stand */ }
|
||
wxCol.refresh();
|
||
}
|
||
}
|
||
}
|
||
Component.onCompleted: wxLoadProc.running = true
|
||
}
|
||
|
||
function refresh() {
|
||
fetchProc.command = wxSettings.city.length > 0
|
||
? ["weather-fetch", wxSettings.city] : ["weather-fetch"];
|
||
fetchProc.running = true;
|
||
}
|
||
Process {
|
||
id: fetchProc
|
||
stdout: StdioCollector {
|
||
onStreamFinished: {
|
||
try { wxCol.forecast = JSON.parse(text); } catch (e) { wxCol.forecast = { error: "bad response" }; }
|
||
}
|
||
}
|
||
}
|
||
// Open-Meteo's current reading updates roughly every 15
|
||
// minutes on their end — polling faster would just repeat the
|
||
// same numbers.
|
||
Timer { interval: 900000; running: true; repeat: true; onTriggered: wxCol.refresh() }
|
||
|
||
// WMO weather codes -> a short label + a plain Unicode symbol
|
||
// (not a Nerd Font glyph — those need exact codepoints from
|
||
// the specific icon set and guessing wrong renders as a
|
||
// tofu box; these are plain emoji, safe under any font with
|
||
// emoji fallback, which every font on this system has).
|
||
function describeCode(code) {
|
||
if (code === 0) return { label: "Clear sky", icon: "☀" };
|
||
if (code === 1) return { label: "Mostly clear", icon: "🌤" };
|
||
if (code === 2) return { label: "Partly cloudy", icon: "⛅" };
|
||
if (code === 3) return { label: "Overcast", icon: "☁" };
|
||
if (code === 45 || code === 48) return { label: "Fog", icon: "🌫" };
|
||
if (code >= 51 && code <= 57) return { label: "Drizzle", icon: "🌦" };
|
||
if (code >= 61 && code <= 67) return { label: "Rain", icon: "🌧" };
|
||
if (code >= 71 && code <= 77) return { label: "Snow", icon: "❄" };
|
||
if (code >= 80 && code <= 82) return { label: "Showers", icon: "🌧" };
|
||
if (code === 85 || code === 86) return { label: "Snow showers", icon: "🌨" };
|
||
if (code >= 95) return { label: "Thunderstorm", icon: "⛈" };
|
||
return { label: "Unknown", icon: "?" };
|
||
}
|
||
|
||
// -- City search (blank = auto IP-geolocate) --
|
||
Row {
|
||
width: wxCol.width
|
||
spacing: 6
|
||
Rectangle {
|
||
width: parent.width - 66
|
||
height: 30
|
||
color: Theme.bgAlt
|
||
border.width: 1
|
||
border.color: Theme.border
|
||
TextInput {
|
||
id: cityInput
|
||
anchors.fill: parent
|
||
anchors.margins: 8
|
||
text: wxSettings.city
|
||
onTextEdited: wxSettings.city = text
|
||
color: Theme.fg
|
||
font.family: "JetBrainsMono Nerd Font"
|
||
font.pixelSize: 12
|
||
clip: true
|
||
Keys.onReturnPressed: { wxSettings.save(); wxCol.forecast = {}; wxCol.refresh(); }
|
||
Text {
|
||
visible: cityInput.text.length === 0
|
||
text: "City (blank = auto-detect)"
|
||
color: Theme.fgDim
|
||
font.family: "JetBrainsMono Nerd Font"
|
||
font.pixelSize: 12
|
||
}
|
||
}
|
||
}
|
||
Rectangle {
|
||
width: 60
|
||
height: 30
|
||
color: wxSearchMa.containsMouse ? Theme.bgAlt : "transparent"
|
||
border.width: 1
|
||
border.color: Theme.accent
|
||
Text {
|
||
anchors.centerIn: parent
|
||
text: "Set"
|
||
color: Theme.accent
|
||
font.family: "JetBrainsMono Nerd Font"
|
||
font.pixelSize: 11
|
||
}
|
||
MouseArea {
|
||
id: wxSearchMa
|
||
anchors.fill: parent
|
||
hoverEnabled: true
|
||
cursorShape: Qt.PointingHandCursor
|
||
onClicked: { wxSettings.save(); wxCol.forecast = {}; wxCol.refresh(); }
|
||
}
|
||
}
|
||
}
|
||
|
||
Text {
|
||
visible: !wxCol.haveData && !wxCol.hasError
|
||
text: "Loading weather..."
|
||
color: Theme.fgDim
|
||
font.family: "JetBrainsMono Nerd Font"
|
||
font.pixelSize: 12
|
||
}
|
||
Text {
|
||
visible: wxCol.hasError
|
||
text: "Couldn't load weather — check the city name or try again later."
|
||
color: Theme.fgDim
|
||
font.family: "JetBrainsMono Nerd Font"
|
||
font.pixelSize: 11
|
||
wrapMode: Text.WordWrap
|
||
width: wxCol.width
|
||
}
|
||
|
||
// -- Current conditions --
|
||
Column {
|
||
visible: wxCol.haveData
|
||
width: wxCol.width
|
||
spacing: 4
|
||
|
||
Text {
|
||
text: [wxCol.forecast.place, wxCol.forecast.country].filter((s) => s && s.length > 0).join(", ")
|
||
color: Theme.fgDim
|
||
font.family: "JetBrainsMono Nerd Font"
|
||
font.pixelSize: 10
|
||
}
|
||
|
||
Row {
|
||
spacing: 12
|
||
Text {
|
||
text: wxCol.haveData ? wxCol.describeCode(wxCol.forecast.current.weather_code).icon : ""
|
||
font.pixelSize: 40
|
||
}
|
||
Column {
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
spacing: 2
|
||
Text {
|
||
text: wxCol.haveData ? Math.round(wxCol.forecast.current.temperature_2m) + "°C" : ""
|
||
color: Theme.fg
|
||
font.family: "JetBrainsMono Nerd Font"
|
||
font.pixelSize: 24
|
||
font.bold: true
|
||
}
|
||
Text {
|
||
text: wxCol.haveData ? wxCol.describeCode(wxCol.forecast.current.weather_code).label : ""
|
||
color: Theme.fgDim
|
||
font.family: "JetBrainsMono Nerd Font"
|
||
font.pixelSize: 11
|
||
}
|
||
}
|
||
}
|
||
|
||
Row {
|
||
spacing: 16
|
||
Text {
|
||
text: wxCol.haveData ? ("Wind: " + wxCol.forecast.current.wind_speed_10m + " km/h") : ""
|
||
color: Theme.fgDim
|
||
font.family: "JetBrainsMono Nerd Font"
|
||
font.pixelSize: 10
|
||
}
|
||
Text {
|
||
text: wxCol.haveData ? ("Precip: " + wxCol.forecast.current.precipitation + " mm") : ""
|
||
color: Theme.fgDim
|
||
font.family: "JetBrainsMono Nerd Font"
|
||
font.pixelSize: 10
|
||
}
|
||
}
|
||
}
|
||
|
||
Rectangle { visible: wxCol.haveData; width: wxCol.width; height: 1; color: Theme.borderDim }
|
||
|
||
// -- Hourly forecast timeline --
|
||
Column {
|
||
visible: wxCol.haveData && !!wxCol.forecast.hourly
|
||
width: wxCol.width
|
||
spacing: 8
|
||
Text {
|
||
text: "NEXT HOURS"
|
||
color: Theme.fgDim
|
||
font.family: "JetBrainsMono Nerd Font"
|
||
font.pixelSize: 9
|
||
font.bold: true
|
||
}
|
||
Flow {
|
||
width: wxCol.width
|
||
spacing: 6
|
||
Repeater {
|
||
// Only future hours from "now" — Open-Meteo's hourly
|
||
// array starts at local midnight, not the current
|
||
// hour, so without this the timeline would open
|
||
// showing most of a day that's already passed.
|
||
model: {
|
||
if (!wxCol.haveData || !wxCol.forecast.hourly) return [];
|
||
var nowIso = wxCol.forecast.current.time.substring(0, 13);
|
||
var times = wxCol.forecast.hourly.time;
|
||
var startIdx = 0;
|
||
for (var i = 0; i < times.length; i++) {
|
||
if (times[i].substring(0, 13) >= nowIso) { startIdx = i; break; }
|
||
}
|
||
var out = [];
|
||
for (var j = startIdx; j < Math.min(startIdx + 8, times.length); j++) out.push(j);
|
||
return out;
|
||
}
|
||
delegate: Rectangle {
|
||
property int idx: modelData
|
||
width: (wxCol.width - 30) / 4
|
||
height: 64
|
||
color: Theme.bgAlt
|
||
border.width: 1
|
||
border.color: Theme.borderDim
|
||
Column {
|
||
anchors.centerIn: parent
|
||
spacing: 2
|
||
Text {
|
||
anchors.horizontalCenter: parent.horizontalCenter
|
||
text: wxCol.forecast.hourly.time[idx].substring(11, 16)
|
||
color: Theme.fgDim
|
||
font.family: "JetBrainsMono Nerd Font"
|
||
font.pixelSize: 9
|
||
}
|
||
Text {
|
||
anchors.horizontalCenter: parent.horizontalCenter
|
||
text: wxCol.describeCode(wxCol.forecast.hourly.weather_code[idx]).icon
|
||
font.pixelSize: 16
|
||
}
|
||
Text {
|
||
anchors.horizontalCenter: parent.horizontalCenter
|
||
text: Math.round(wxCol.forecast.hourly.temperature_2m[idx]) + "°"
|
||
color: Theme.fg
|
||
font.family: "JetBrainsMono Nerd Font"
|
||
font.pixelSize: 11
|
||
font.bold: true
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// --- Power -------------------------------------------------------
|
||
Component {
|
||
id: powerPanel
|
||
Column {
|
||
spacing: 4
|
||
width: loader.width
|
||
|
||
Process { id: powerProc; command: [] }
|
||
|
||
property var actions: [
|
||
{ label: " Lock", cmd: ["hyprlock"] },
|
||
{ label: " Suspend", cmd: ["systemctl", "suspend"] },
|
||
{ label: " Reboot", cmd: ["systemctl", "reboot"] },
|
||
{ label: " Shutdown", cmd: ["systemctl", "poweroff"] },
|
||
{ label: " Log out", cmd: ["hyprctl", "dispatch", "exit"] }
|
||
]
|
||
|
||
Repeater {
|
||
model: parent.actions
|
||
delegate: Rectangle {
|
||
width: loader.width
|
||
height: 32
|
||
color: pma.containsMouse ? Theme.bgAlt : "transparent"
|
||
Text {
|
||
anchors.verticalCenter: parent.verticalCenter
|
||
x: 8
|
||
text: modelData.label
|
||
color: Theme.fg
|
||
font.family: "JetBrainsMono Nerd Font"
|
||
font.pixelSize: 13
|
||
}
|
||
MouseArea {
|
||
id: pma
|
||
anchors.fill: parent
|
||
hoverEnabled: true
|
||
onClicked: { powerProc.command = modelData.cmd; powerProc.running = true; }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|