Reorganize Fedora dotfiles and remove legacy profile

This commit is contained in:
Mikhail Kilin
2026-07-25 12:38:39 +03:00
parent a601a53660
commit 6cb1d53a70
75 changed files with 2146 additions and 3974 deletions

View File

@@ -0,0 +1,517 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
import Quickshell.Io
import Quickshell.Services.Mpris
import Quickshell.Services.Pipewire
import Quickshell.Services.SystemTray
import Quickshell.Wayland
import Quickshell.Widgets
Item {
id: bar
property date currentTime: new Date()
property var controlCenter
property var settings
property var notificationCenter
property var mediaPopup
property var toggleMedia: null
property var workspaces: []
property string windowTitle: "Desktop"
property string language: "EN"
property string weather: "N/A"
property string gpu: "GPU N/A"
property string gpuTemperature: "--"
property string mail: ""
property int cpuUsage: 0
property int memoryUsage: 0
property string diskUsage: "--"
property string temperature: "--"
property var audioSink: Pipewire.defaultAudioSink
property var mediaPlayer: Mpris.players.values.find(player => player.playbackState === MprisPlaybackState.Playing)
|| Mpris.players.values[0]
function start(process) {
if (!process.running) process.running = true;
}
function focusWorkspace(index) {
Quickshell.execDetached(["niri", "msg", "action", "focus-workspace", String(index)]);
}
function parseJson(collector, fallback) {
try {
const value = JSON.parse(collector.text);
return value === null ? fallback : value;
} catch (error) {
return fallback;
}
}
PanelWindow {
id: barWindow
anchors {
top: true
left: true
right: true
}
implicitHeight: 40
exclusiveZone: 40
color: "transparent"
WlrLayershell.layer: WlrLayer.Top
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
WlrLayershell.namespace: "quickshell-bar"
Rectangle {
anchors.fill: parent
anchors.topMargin: 4
anchors.leftMargin: 4
anchors.rightMargin: 4
anchors.bottomMargin: 0
radius: 14
color: "#1e1e2e"
border.color: "#313244"
border.width: 1
RowLayout {
anchors.fill: parent
anchors.leftMargin: 6
anchors.rightMargin: 6
spacing: 6
RowLayout {
id: workspaceRow
spacing: 3
Repeater {
model: bar.workspaces
delegate: Rectangle {
required property var modelData
implicitWidth: workspaceLabel.implicitWidth + 16
implicitHeight: 26
radius: 10
color: modelData.is_focused ? "#89b4fa" : (modelData.is_active ? "#45475a" : "#313244")
Label {
id: workspaceLabel
anchors.centerIn: parent
text: modelData.name || String(modelData.idx)
color: modelData.is_focused ? "#1e1e2e" : "#cdd6f4"
font.pixelSize: 15
font.bold: true
}
MouseArea {
anchors.fill: parent
onClicked: bar.focusWorkspace(modelData.idx)
}
}
}
}
Item { Layout.fillWidth: true }
Label {
text: bar.windowTitle
color: "#cdd6f4"
font.pixelSize: 15
elide: Text.ElideRight
horizontalAlignment: Text.AlignHCenter
Layout.maximumWidth: 420
Layout.fillWidth: true
}
Item { Layout.fillWidth: true }
RowLayout {
spacing: 4
Label {
text: bar.weather
color: "#89dceb"
font.pixelSize: 15
ToolTip.visible: weatherMouse.containsMouse
ToolTip.text: "Weather"
MouseArea {
id: weatherMouse
anchors.fill: parent
}
}
Label {
visible: bar.mail.length > 0
text: bar.mail
color: "#b4befe"
font.pixelSize: 15
leftPadding: 19
BarIcon { name: "mail"; anchors.left: parent.left; anchors.verticalCenter: parent.verticalCenter }
ToolTip.visible: mailMouse.containsMouse
ToolTip.text: "Unread mail"
MouseArea {
id: mailMouse
anchors.fill: parent
onClicked: Quickshell.execDetached(["thunderbird"])
}
}
Label {
visible: bar.mediaPlayer !== null
text: bar.mediaPlayer ? (bar.mediaPlayer.trackTitle || bar.mediaPlayer.identity) : ""
color: "#f5c2e7"
leftPadding: 19
elide: Text.ElideRight
Layout.maximumWidth: 190
BarIcon { name: "audio"; anchors.left: parent.left; anchors.verticalCenter: parent.verticalCenter }
ToolTip.visible: mediaMouse.containsMouse
ToolTip.text: "Open media controls"
MouseArea {
id: mediaMouse
anchors.fill: parent
onClicked: {
if (bar.toggleMedia) bar.toggleMedia();
}
}
}
Label {
visible: !!bar.notificationCenter && bar.notificationCenter.count > 0
text: bar.notificationCenter ? String(bar.notificationCenter.count) : ""
color: "#f38ba8"
leftPadding: 19
BarIcon { name: "mail"; anchors.left: parent.left; anchors.verticalCenter: parent.verticalCenter }
ToolTip.visible: notificationMouse.containsMouse
ToolTip.text: "Open notifications"
MouseArea {
id: notificationMouse
anchors.fill: parent
onClicked: {
if (bar.notificationCenter) bar.notificationCenter.open = !bar.notificationCenter.open;
}
}
}
Label {
text: bar.gpu
color: "#a6e3a1"
font.pixelSize: 15
leftPadding: 19
BarIcon { name: "gpu"; anchors.left: parent.left; anchors.verticalCenter: parent.verticalCenter }
ToolTip.visible: gpuMouse.containsMouse
ToolTip.text: "GPU usage"
MouseArea {
id: gpuMouse
anchors.fill: parent
}
}
Label {
text: bar.cpuUsage + "%"
color: "#a6e3a1"
font.pixelSize: 15
leftPadding: 19
BarIcon { name: "cpu"; anchors.left: parent.left; anchors.verticalCenter: parent.verticalCenter }
ToolTip.visible: cpuMouse.containsMouse
ToolTip.text: "CPU usage"
MouseArea { id: cpuMouse; anchors.fill: parent }
}
Label {
text: bar.memoryUsage + "%"
color: "#cba6f7"
font.pixelSize: 15
leftPadding: 19
BarIcon { name: "memory"; anchors.left: parent.left; anchors.verticalCenter: parent.verticalCenter }
ToolTip.visible: memoryMouse.containsMouse
ToolTip.text: "Memory usage"
MouseArea { id: memoryMouse; anchors.fill: parent }
}
Label {
text: bar.diskUsage
color: "#f38ba8"
font.pixelSize: 15
leftPadding: 19
BarIcon { name: "disk"; anchors.left: parent.left; anchors.verticalCenter: parent.verticalCenter }
ToolTip.visible: diskMouse.containsMouse
ToolTip.text: "Root filesystem"
MouseArea { id: diskMouse; anchors.fill: parent }
}
Label {
text: bar.language
color: "#94e2d5"
font.pixelSize: 15
leftPadding: 19
BarIcon { name: "language"; anchors.left: parent.left; anchors.verticalCenter: parent.verticalCenter }
ToolTip.visible: languageMouse.containsMouse
ToolTip.text: "Keyboard layout"
MouseArea { id: languageMouse; anchors.fill: parent }
}
Label {
text: bar.audioSink && bar.audioSink.audio
? Math.round(bar.audioSink.audio.volume * 100) + "%"
: "--"
color: "#f9e2af"
font.pixelSize: 15
leftPadding: 19
BarIcon { name: "audio"; anchors.left: parent.left; anchors.verticalCenter: parent.verticalCenter }
ToolTip.visible: audioMouse.containsMouse
ToolTip.text: "Audio output"
MouseArea {
id: audioMouse
anchors.fill: parent
onClicked: {
if (bar.audioSink && bar.audioSink.audio) bar.audioSink.audio.muted = !bar.audioSink.audio.muted;
}
}
}
Label {
text: Qt.formatDateTime(bar.currentTime, "dd.MM HH:mm")
color: "#cdd6f4"
font.pixelSize: 15
ToolTip.visible: clockMouse.containsMouse
ToolTip.text: "Open calendar"
MouseArea {
id: clockMouse
anchors.fill: parent
onClicked: Quickshell.execDetached(["sh", "-c", "alacritty --title khal-popup -e bash -c 'vdirsyncer sync 2>/dev/null; ikhal'"])
}
}
Label {
text: ""
color: "#89b4fa"
Layout.preferredWidth: 18
Layout.minimumWidth: 18
Layout.maximumWidth: 18
BarIcon { name: "gear"; size: 18; anchors.centerIn: parent }
ToolTip.visible: settingsMouse.containsMouse
ToolTip.text: "Quick settings"
MouseArea {
id: settingsMouse
anchors.fill: parent
onClicked: {
if (bar.controlCenter) bar.controlCenter.togglePanel();
}
}
}
Repeater {
model: SystemTray.items
delegate: Item {
required property var modelData
property bool hiddenTrayItem: !modelData
|| /wayland to x11 video bridge|screen sharing|screen share/i.test(String(modelData.title || ""))
|| /video-display|screen-share|screen-sharing/i.test(String(modelData.icon || ""))
visible: !hiddenTrayItem
implicitWidth: hiddenTrayItem ? 0 : 20
implicitHeight: 22
Loader {
id: trayIcon
anchors.fill: parent
active: !hiddenTrayItem && modelData
sourceComponent: Component {
IconImage {
anchors.fill: parent
implicitSize: 18
source: modelData && modelData.icon && modelData.icon.startsWith("image://")
? modelData.icon
: (modelData ? Quickshell.iconPath(modelData.icon) : "")
}
}
}
BarIcon {
visible: !hiddenTrayItem && !trayIcon.active
anchors.fill: parent
enabled: !hiddenTrayItem
name: "video"
size: 15
anchors.margins: 2
}
MouseArea {
anchors.fill: parent
acceptedButtons: Qt.LeftButton | Qt.RightButton
onClicked: function(mouse) {
if (!modelData) return;
if (mouse.button === Qt.RightButton && modelData.hasMenu) {
modelData.display(barWindow, mouse.x, mouse.y + 20);
} else {
modelData.activate();
}
}
}
}
}
}
}
}
}
PwObjectTracker {
objects: [bar.audioSink]
}
Process {
id: workspaceReader
command: ["niri", "msg", "-j", "workspaces"]
stdout: StdioCollector {
onStreamFinished: {
const value = bar.parseJson(this, []);
if (Array.isArray(value)) {
value.sort((left, right) => left.idx - right.idx);
bar.workspaces = value;
}
}
}
}
Process {
id: windowReader
command: ["niri", "msg", "-j", "focused-window"]
stdout: StdioCollector {
onStreamFinished: {
const value = bar.parseJson(this, {});
bar.windowTitle = value.title || "Desktop";
}
}
}
Process {
id: languageReader
command: ["niri", "msg", "-j", "keyboard-layouts"]
stdout: StdioCollector {
onStreamFinished: {
const value = bar.parseJson(this, {});
const index = Number(value.current_idx);
const name = Array.isArray(value.names) && index >= 0 ? String(value.names[index] || "") : "";
const normalized = name.toLowerCase();
if (normalized.includes("russian") || normalized.includes("рус")) bar.language = "RU";
else if (normalized.includes("english") || normalized.includes("англ")) bar.language = "EN";
else if (name.length > 0) bar.language = name.slice(0, 2).toUpperCase();
}
}
}
Process {
id: cpuReader
command: ["sh", "-c", "awk '/^cpu / { idle=$5; total=0; for (i=2; i<=NF; i++) total += $i; print int((total-idle)*100/total) }' /proc/stat"]
stdout: StdioCollector { onStreamFinished: bar.cpuUsage = parseInt(text.trim()) || 0 }
}
Process {
id: memoryReader
command: ["sh", "-c", "free | awk '/^Mem:/ { print int($3*100/$2) }'"]
stdout: StdioCollector { onStreamFinished: bar.memoryUsage = parseInt(text.trim()) || 0 }
}
Process {
id: diskReader
command: ["sh", "-c", "df -P / | awk 'NR==2 { print $5 }'"]
stdout: StdioCollector { onStreamFinished: bar.diskUsage = text.trim() || "--" }
}
Process {
id: temperatureReader
command: ["sh", "-c", "max=0; for file in /sys/class/thermal/thermal_zone*/temp; do value=$(cat $file 2>/dev/null); [ $value -gt $max ] 2>/dev/null && max=$value; done; if [ $max -gt 0 ]; then echo $((max / 1000))C; else echo --; fi"]
stdout: StdioCollector { onStreamFinished: bar.temperature = text.trim() || "--" }
}
Process {
id: gpuReader
command: ["sh", "-c", "nvidia-smi --query-gpu=utilization.gpu,temperature.gpu --format=csv,noheader,nounits 2>/dev/null | head -1"]
stdout: StdioCollector {
onStreamFinished: {
const parts = text.trim().split(",");
const usage = parseInt((parts[0] || "").trim());
const temperature = parseInt((parts[1] || "").trim());
bar.gpu = isNaN(usage) ? "N/A" : usage + "%";
bar.gpuTemperature = isNaN(temperature) ? "--" : temperature + "C";
}
}
}
Process {
id: weatherReader
command: ["bash", Quickshell.shellPath("../scripts/weather.sh")]
stdout: StdioCollector {
onStreamFinished: {
const value = bar.parseJson(this, {});
bar.weather = value.text || "N/A";
}
}
}
Process {
id: mailReader
command: ["sh", "-c", "if [ -x $HOME/.local/bin/mail-counter ]; then $HOME/.local/bin/mail-counter; else printf '{}'; fi"]
stdout: StdioCollector {
onStreamFinished: {
const value = bar.parseJson(this, {});
bar.mail = value.text || "";
}
}
}
Timer {
id: refreshTimer
interval: bar.settings ? bar.settings.temperatureInterval : 2000
running: true
repeat: true
onTriggered: {
bar.currentTime = new Date();
bar.start(windowReader);
bar.start(cpuReader);
bar.start(memoryReader);
bar.start(diskReader);
bar.start(temperatureReader);
bar.start(gpuReader);
bar.start(mailReader);
}
}
Timer {
id: weatherTimer
interval: 900000
running: true
repeat: true
onTriggered: bar.start(weatherReader)
}
Timer {
id: languageTimer
interval: 200
running: true
repeat: true
onTriggered: bar.start(languageReader)
}
Timer {
id: workspaceTimer
interval: 100
running: true
repeat: true
onTriggered: bar.start(workspaceReader)
}
Component.onCompleted: {
bar.start(workspaceReader);
bar.start(windowReader);
bar.start(languageReader);
bar.start(cpuReader);
bar.start(memoryReader);
bar.start(diskReader);
bar.start(temperatureReader);
bar.start(gpuReader);
bar.start(weatherReader);
bar.start(mailReader);
}
}

View File

@@ -0,0 +1,16 @@
import QtQuick
Image {
id: root
property string name
property real size: 15
width: size
height: size
source: Qt.resolvedUrl("icons/" + name + ".svg")
sourceSize.width: size * 2
sourceSize.height: size * 2
fillMode: Image.PreserveAspectFit
smooth: true
}

View File

@@ -0,0 +1,209 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
import Quickshell.Services.Mpris
import Quickshell.Wayland
Item {
id: root
property bool open: false
property var selectedPlayer: null
property var player: selectedPlayer
|| Mpris.players.values.find(candidate => candidate.playbackState === MprisPlaybackState.Playing)
|| Mpris.players.values[0]
|| null
signal dismissed()
function formatTime(value) {
if (!value || value < 0) return "0:00";
const seconds = Math.floor(value);
return Math.floor(seconds / 60) + ":" + String(seconds % 60).padStart(2, "0");
}
PanelWindow {
visible: root.open
color: "transparent"
anchors {
top: true
bottom: true
left: true
right: true
}
exclusionMode: ExclusionMode.Ignore
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.keyboardFocus: root.open ? WlrKeyboardFocus.Exclusive : WlrKeyboardFocus.None
WlrLayershell.namespace: "quickshell-media-popup"
MouseArea {
anchors.fill: parent
onClicked: root.dismissed()
}
Rectangle {
id: card
width: Math.min(410, parent.width - 32)
height: root.player ? 330 : 150
anchors.top: parent.top
anchors.horizontalCenter: parent.horizontalCenter
anchors.topMargin: 52
radius: 20
color: "#f5f2ea"
border.color: "#d8d0c2"
border.width: 1
z: 1
MouseArea {
anchors.fill: parent
onClicked: function(mouse) { mouse.accepted = true; }
}
ColumnLayout {
anchors.fill: parent
anchors.margins: 16
spacing: 10
RowLayout {
Layout.fillWidth: true
Label {
text: "Media"
color: "#252321"
font.pixelSize: 20
font.bold: true
Layout.fillWidth: true
}
Label {
text: root.player ? root.player.identity : "No player"
color: "#7c756c"
elide: Text.ElideRight
}
Rectangle {
width: 26
height: 26
radius: 13
color: "#e5e0d7"
Label {
anchors.centerIn: parent
text: "x"
color: "#6e675f"
}
MouseArea {
anchors.fill: parent
onClicked: root.dismissed()
}
}
}
RowLayout {
visible: root.player !== null
Layout.fillWidth: true
Layout.fillHeight: true
spacing: 14
Rectangle {
Layout.preferredWidth: 132
Layout.preferredHeight: 132
radius: 14
color: "#e5e0d7"
clip: true
Image {
id: cover
anchors.fill: parent
source: root.player ? root.player.trackArtUrl : ""
fillMode: Image.PreserveAspectCrop
asynchronous: true
}
Label {
anchors.centerIn: parent
visible: cover.status !== Image.Ready
text: "♪"
color: "#8b8176"
font.pixelSize: 42
}
}
ColumnLayout {
Layout.fillWidth: true
spacing: 4
Label {
text: root.player ? (root.player.trackTitle || "Unknown track") : ""
color: "#252321"
font.pixelSize: 17
font.bold: true
elide: Text.ElideRight
Layout.fillWidth: true
}
Label {
text: root.player ? (root.player.trackArtist || root.player.trackAlbum || "Unknown artist") : ""
color: "#7c756c"
elide: Text.ElideRight
Layout.fillWidth: true
}
Item { Layout.fillHeight: true }
Label {
text: root.player ? root.formatTime(root.player.position) + " / " + root.formatTime(root.player.length) : ""
color: "#7c756c"
font.pixelSize: 11
}
}
}
Slider {
visible: root.player && root.player.length > 0
Layout.fillWidth: true
from: 0
to: root.player ? root.player.length : 1
value: root.player ? root.player.position : 0
onMoved: {
if (root.player && root.player.canSeek) root.player.position = value;
}
}
RowLayout {
visible: root.player !== null
Layout.alignment: Qt.AlignHCenter
spacing: 14
Button {
text: "Prev"
enabled: !!(root.player && root.player.canGoPrevious)
onClicked: root.player.previous()
}
Button {
text: root.player && root.player.playbackState === MprisPlaybackState.Playing ? "Pause" : "Play"
enabled: !!(root.player && root.player.canTogglePlaying)
onClicked: root.player.togglePlaying()
}
Button {
text: "Next"
enabled: !!(root.player && root.player.canGoNext)
onClicked: root.player.next()
}
}
RowLayout {
visible: Mpris.players.values.length > 1
Layout.fillWidth: true
Label {
text: "Players"
color: "#7c756c"
Layout.fillWidth: true
}
Repeater {
model: Mpris.players
delegate: Button {
required property var modelData
text: modelData.identity
onClicked: root.selectedPlayer = modelData
}
}
}
}
}
}
}

View File

@@ -0,0 +1,220 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
import Quickshell.Services.Notifications
import Quickshell.Wayland
Item {
id: root
property bool open: false
property bool dnd: false
property bool toastOpen: false
property var latest: null
readonly property int count: server.trackedNotifications ? server.trackedNotifications.values.length : 0
signal dismissed()
NotificationServer {
id: server
keepOnReload: true
onNotification: function(notification) {
notification.tracked = true;
root.latest = notification;
if (!root.dnd) {
root.toastOpen = true;
toastTimer.restart();
}
}
}
Timer {
id: toastTimer
interval: 5000
repeat: false
onTriggered: root.toastOpen = false
}
PanelWindow {
visible: root.toastOpen && !root.open && root.latest !== null
color: "transparent"
anchors { top: true; bottom: true; left: true; right: true }
exclusionMode: ExclusionMode.Ignore
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
WlrLayershell.namespace: "quickshell-notification-toast"
Rectangle {
width: Math.min(390, parent.width - 32)
height: 100
anchors.top: parent.top
anchors.right: parent.right
anchors.margins: 16
radius: 16
color: "#f5f2ea"
border.color: "#d8d0c2"
border.width: 1
MouseArea {
anchors.fill: parent
onClicked: {
root.toastOpen = false;
root.open = true;
}
}
ColumnLayout {
anchors.fill: parent
anchors.margins: 14
spacing: 3
Label {
text: root.latest ? root.latest.appName : ""
color: "#7c756c"
font.pixelSize: 11
Layout.fillWidth: true
elide: Text.ElideRight
}
Label {
text: root.latest ? root.latest.summary : ""
color: "#252321"
font.pixelSize: 16
font.bold: true
Layout.fillWidth: true
elide: Text.ElideRight
}
Label {
text: root.latest ? root.latest.body : ""
color: "#5f5952"
Layout.fillWidth: true
elide: Text.ElideRight
}
}
}
}
PanelWindow {
visible: root.open
color: "transparent"
anchors { top: true; bottom: true; left: true; right: true }
exclusionMode: ExclusionMode.Ignore
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.keyboardFocus: root.open ? WlrKeyboardFocus.Exclusive : WlrKeyboardFocus.None
WlrLayershell.namespace: "quickshell-notification-center"
MouseArea {
anchors.fill: parent
onClicked: root.dismissed()
}
Rectangle {
width: Math.min(430, parent.width - 32)
height: Math.min(parent.height - 32, 700)
anchors.top: parent.top
anchors.right: parent.right
anchors.margins: 16
radius: 20
color: "#f5f2ea"
border.color: "#d8d0c2"
border.width: 1
z: 1
MouseArea {
anchors.fill: parent
onClicked: function(mouse) { mouse.accepted = true }
}
ColumnLayout {
anchors.fill: parent
anchors.margins: 18
spacing: 10
RowLayout {
Layout.fillWidth: true
Label {
text: "Notifications"
color: "#252321"
font.pixelSize: 22
font.bold: true
Layout.fillWidth: true
}
Button {
text: root.dnd ? "DND on" : "DND off"
onClicked: root.dnd = !root.dnd
}
Button {
text: "Close"
onClicked: root.dismissed()
}
}
Label {
visible: root.count === 0
text: "No notifications"
color: "#7c756c"
Layout.alignment: Qt.AlignHCenter
Layout.topMargin: 18
}
ScrollView {
Layout.fillWidth: true
Layout.fillHeight: true
clip: true
ColumnLayout {
width: parent.width
spacing: 8
Repeater {
model: server.trackedNotifications
delegate: Rectangle {
required property var modelData
Layout.fillWidth: true
implicitHeight: notificationColumn.implicitHeight + 22
radius: 14
color: "#e8e3da"
ColumnLayout {
id: notificationColumn
anchors.fill: parent
anchors.margins: 11
spacing: 3
RowLayout {
Layout.fillWidth: true
Label {
text: modelData.appName
color: "#7c756c"
font.pixelSize: 11
Layout.fillWidth: true
elide: Text.ElideRight
}
Button {
text: "x"
onClicked: modelData.dismiss()
}
}
Label {
text: modelData.summary
color: "#252321"
font.pixelSize: 15
font.bold: true
Layout.fillWidth: true
wrapMode: Text.Wrap
}
Label {
text: modelData.body
color: "#5f5952"
Layout.fillWidth: true
wrapMode: Text.Wrap
}
}
}
}
}
}
}
}
}
}

View File

@@ -0,0 +1,76 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
import Quickshell.Wayland
Item {
id: root
property bool open: false
property string kind: "volume"
property real value: 0
property bool muted: false
PanelWindow {
visible: root.open
color: "transparent"
anchors {
top: true
bottom: true
left: true
right: true
}
exclusionMode: ExclusionMode.Ignore
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
WlrLayershell.namespace: "quickshell-osd"
Rectangle {
width: 300
height: 78
anchors.horizontalCenter: parent.horizontalCenter
anchors.bottom: parent.bottom
anchors.bottomMargin: 110
radius: 20
color: "#1e1e2e"
border.color: "#45475a"
border.width: 1
RowLayout {
anchors.fill: parent
anchors.margins: 16
spacing: 12
Label {
text: root.kind === "brightness" ? "Display" : (root.muted ? "Muted" : "Volume")
color: "#cdd6f4"
font.pixelSize: 14
font.bold: true
}
Rectangle {
Layout.fillWidth: true
height: 8
radius: 4
color: "#313244"
Rectangle {
width: parent.width * Math.max(0, Math.min(1, root.value))
height: parent.height
radius: 4
color: root.kind === "brightness" ? "#f9e2af" : "#89b4fa"
}
}
Label {
text: Math.round(root.value * 100) + "%"
color: "#cdd6f4"
font.pixelSize: 15
font.bold: true
}
}
}
}
}

View File

@@ -0,0 +1,36 @@
import QtQuick
import Quickshell
import Quickshell.Io
QtObject {
id: root
property alias temperatureInterval: values.temperatureInterval
property alias temperatureWarning: values.temperatureWarning
property alias temperatureCritical: values.temperatureCritical
function setTemperatureWarning(value) {
root.temperatureWarning = Math.min(value, root.temperatureCritical - 1);
}
function setTemperatureCritical(value) {
root.temperatureCritical = Math.max(value, root.temperatureWarning + 1);
}
property FileView settingsFile: FileView {
path: Quickshell.statePath("control-center.json")
printErrors: false
watchChanges: true
onFileChanged: reload()
onAdapterUpdated: writeAdapter()
JsonAdapter {
id: values
property int temperatureInterval: 2000
property int temperatureWarning: 75
property int temperatureCritical: 90
}
}
}

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="#f9e2af" d="M3 9v6h4l5 4V5L7 9H3Zm13.5 3a4.5 4.5 0 0 0-2.5-4.03v8.06A4.5 4.5 0 0 0 16.5 12Zm0-8v2.12A6.98 6.98 0 0 1 20 12a6.98 6.98 0 0 1-3.5 5.88V20a9 9 0 0 0 0-16Z"/></svg>

After

Width:  |  Height:  |  Size: 248 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="#a6e3a1" d="M4 4h16v16H4V4Zm3 3v10h10V7H7Zm2 2h6v6H9V9Zm-7 2H0v2h2v-2Zm20 0h2v2h-2v-2ZM11 0h2v2h-2V0Zm0 22h2v2h-2v-2Z"/></svg>

After

Width:  |  Height:  |  Size: 199 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="#f38ba8" d="M3 5h18v14H3V5Zm3 3v8h12V8H6Zm2 2h8v4H8v-4Z"/></svg>

After

Width:  |  Height:  |  Size: 137 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="#89b4fa" d="m19.43 12.98.04-.98-.04-.98 2.11-1.65-2-3.46-2.49 1a7.7 7.7 0 0 0-1.7-.98L15 3h-4l-.35 2.93c-.6.25-1.17.58-1.7.98l-2.49-1-2 3.46 2.11 1.65-.04.98.04.98-2.11 1.65 2 3.46 2.49-1c.53.4 1.1.73 1.7.98L11 21h4l.35-2.93a7.7 7.7 0 0 0 1.7-.98l2.49 1 2-3.46-2.11-1.65ZM13 15.5A3.5 3.5 0 1 1 13 8a3.5 3.5 0 0 1 0 7.5Z"/></svg>

After

Width:  |  Height:  |  Size: 401 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="#a6e3a1" d="M2 5h20v14H2V5Zm2 2v10h16V7H4Zm2 2h6v6H6V9Zm2 2v2h2v-2H8Zm8-2h2v2h-2V9Zm0 4h2v2h-2v-2ZM0 9h2v2H0V9Zm0 4h2v2H0v-2Zm22-4h2v2h-2V9Zm0 4h2v2h-2v-2Z"/></svg>

After

Width:  |  Height:  |  Size: 237 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="#94e2d5" d="M4 20 9 4h2l5 16h-2l-1.25-4h-5.5L6 20H4Zm4.88-6h3.24L10.5 8.8 8.88 14ZM15 6h7v2h-2.5c-.38 3-1.42 5.47-3.12 7.4.9.8 1.94 1.5 3.12 2.1v2.2a16.2 16.2 0 0 1-4.45-2.86A16 16 0 0 1 12 19v-2.2c.98-.58 1.88-1.3 2.7-2.18A14.3 14.3 0 0 1 12.8 11h2.1c.27.7.65 1.4 1.15 2.08A12.7 12.7 0 0 0 17.4 8H15V6Z"/></svg>

After

Width:  |  Height:  |  Size: 385 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="#b4befe" d="M3 5h18a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2Zm0 3v1l9 5 9-5V8l-9 5-9-5Z"/></svg>

After

Width:  |  Height:  |  Size: 191 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="#cba6f7" d="M3 3h18v4H3V3Zm0 7h18v4H3v-4Zm0 7h18v4H3v-4Z"/></svg>

After

Width:  |  Height:  |  Size: 138 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="#fab387" d="M14 14.76V5a2 2 0 0 0-4 0v9.76a5 5 0 1 0 4 0ZM12 20a3 3 0 0 1-1-5.83V5a1 1 0 0 1 2 0v9.17A3 3 0 0 1 12 20Z"/></svg>

After

Width:  |  Height:  |  Size: 200 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="#cdd6f4" d="M3 5h13a2 2 0 0 1 2 2v2l4-2v10l-4-2v2a2 2 0 0 1-2 2H3V5Zm2 3v8h11V8H5Z"/></svg>

After

Width:  |  Height:  |  Size: 164 B

View File

@@ -0,0 +1,748 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
import Quickshell.Bluetooth
import Quickshell.Io
import Quickshell.Networking
import Quickshell.Services.Pipewire
import Quickshell.Services.UPower
import Quickshell.Wayland
Scope {
id: root
property bool open: false
property bool panelVisible: false
property bool mediaOpen: false
property bool notificationsOpen: false
property bool osdOpen: false
property string osdKind: "volume"
property int brightness: 0
property var pendingNetwork: null
property string wifiPassword: ""
property var wifiDevice: Networking.devices.values.find(device => device.type === DeviceType.Wifi)
property var bluetoothAdapter: Bluetooth.defaultAdapter
property var audioSink: Pipewire.defaultAudioSink
property var settings: statusSettings
property alias cpuTemperature: statusBar.temperature
property alias gpuTemperature: statusBar.gpuTemperature
Settings {
id: statusSettings
}
Bar {
id: statusBar
controlCenter: root
settings: root.settings
mediaPopup: mediaPopup
toggleMedia: function() { root.mediaOpen = !root.mediaOpen }
notificationCenter: notificationCenter
}
MediaPopup {
id: mediaPopup
open: root.mediaOpen
onDismissed: root.mediaOpen = false
}
Notifications {
id: notificationCenter
open: root.notificationsOpen
onDismissed: root.notificationsOpen = false
}
Osd {
open: root.osdOpen
kind: root.osdKind
value: root.osdKind === "brightness"
? root.brightness / 100
: (root.audioSink && root.audioSink.audio ? root.audioSink.audio.volume : 0)
muted: root.audioSink && root.audioSink.audio ? root.audioSink.audio.muted : false
}
function setBrightness(value) {
const level = Math.max(0, Math.min(100, Math.round(value)));
Quickshell.execDetached(["ddcutil", "setvcp", "10", String(level)]);
root.brightness = level;
}
function connectWifi(network) {
if (network.security === WifiSecurityType.Open) {
network.connect();
return;
}
root.pendingNetwork = network;
root.wifiPassword = "";
}
function submitWifiPassword() {
if (root.pendingNetwork && root.wifiPassword.length > 0) {
root.pendingNetwork.connectWithPsk(root.wifiPassword);
root.pendingNetwork = null;
root.wifiPassword = "";
}
}
function showOsd(kind) {
root.osdKind = kind;
root.osdOpen = true;
osdTimer.restart();
}
function adjustBrightness(delta) {
const current = root.brightness > 0 ? root.brightness : 50;
root.setBrightness(current + delta);
root.showOsd("brightness");
}
function temperatureColor(value) {
const match = String(value).match(/(\d+)C\s*$/);
const temperature = match ? Number(match[1]) : -1;
if (temperature < 0) return "#806d95";
if (temperature >= root.settings.temperatureCritical) return "#c94f6d";
if (temperature >= root.settings.temperatureWarning) return "#996315";
return "#4d7c38";
}
function openPanel() {
closePanelTimer.stop();
root.panelVisible = true;
openPanelTimer.restart();
}
function closePanel() {
openPanelTimer.stop();
root.open = false;
closePanelTimer.restart();
}
function togglePanel() {
if (root.open || root.panelVisible) root.closePanel();
else root.openPanel();
}
Timer {
id: openPanelTimer
interval: 1
repeat: false
onTriggered: root.open = true
}
Timer {
id: closePanelTimer
interval: 240
repeat: false
onTriggered: root.panelVisible = false
}
IpcHandler {
target: "controlcenter"
function toggle() {
root.togglePanel();
}
function open() {
root.openPanel();
}
function close() {
root.closePanel();
}
}
IpcHandler {
target: "osd"
function volume_up() {
Quickshell.execDetached(["wpctl", "set-volume", "@DEFAULT_AUDIO_SINK@", "5%+"]);
root.showOsd("volume");
}
function volume_down() {
Quickshell.execDetached(["wpctl", "set-volume", "@DEFAULT_AUDIO_SINK@", "5%-"]);
root.showOsd("volume");
}
function volume_mute() {
Quickshell.execDetached(["wpctl", "set-mute", "@DEFAULT_AUDIO_SINK@", "toggle"]);
root.showOsd("volume");
}
function mic_mute() {
Quickshell.execDetached(["wpctl", "set-mute", "@DEFAULT_AUDIO_SOURCE@", "toggle"]);
root.showOsd("mic");
}
function brightness_up() {
root.adjustBrightness(5);
}
function brightness_down() {
root.adjustBrightness(-5);
}
}
Timer {
id: osdTimer
interval: 1400
repeat: false
onTriggered: root.osdOpen = false
}
PwObjectTracker {
objects: [root.audioSink]
}
Process {
id: brightnessReader
command: ["sh", "-c", "ddcutil getvcp 10 2>/dev/null | sed -n 's/.*current value = *\\([0-9]*\\).*/\\1/p'"]
stdout: StdioCollector {
onStreamFinished: {
const value = parseInt(text.trim());
if (!isNaN(value)) root.brightness = value;
}
}
}
Timer {
id: brightnessPoll
interval: 3000
repeat: true
running: root.open
onTriggered: {
if (!brightnessReader.running) brightnessReader.running = true;
}
}
PanelWindow {
visible: root.panelVisible
color: "transparent"
anchors {
top: true
bottom: true
left: true
right: true
}
exclusionMode: ExclusionMode.Ignore
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.keyboardFocus: root.panelVisible ? WlrKeyboardFocus.Exclusive : WlrKeyboardFocus.None
WlrLayershell.namespace: "quickshell-control-center"
MouseArea {
anchors.fill: parent
onClicked: root.closePanel()
}
Rectangle {
id: card
width: Math.min(470, parent.width - 32)
height: parent.height
anchors.top: parent.top
anchors.topMargin: 0
x: root.open ? parent.width - width - 16 : parent.width + 24
radius: 20
color: "#f5f2ea"
border.color: "#d8d0c2"
border.width: 1
z: 1
Behavior on x {
NumberAnimation {
duration: 220
easing.type: Easing.OutCubic
}
}
MouseArea {
anchors.fill: parent
onClicked: mouse.accepted = true
}
ColumnLayout {
id: content
anchors.fill: parent
anchors.margins: 18
spacing: 12
RowLayout {
Layout.fillWidth: true
ColumnLayout {
Layout.fillWidth: true
spacing: 0
Label {
text: "Control center"
color: "#252321"
font.pixelSize: 22
font.bold: true
}
Label {
text: "Niri / QuickShell"
color: "#7c756c"
font.pixelSize: 12
}
}
Button {
text: "✕"
ToolTip.visible: hovered
ToolTip.text: "Close"
onClicked: root.closePanel()
}
}
ScrollView {
Layout.fillWidth: true
Layout.fillHeight: true
clip: true
ColumnLayout {
width: parent.width
spacing: 10
Rectangle {
Layout.fillWidth: true
implicitHeight: wifiColumn.implicitHeight + 24
radius: 14
color: "#e5f0ef"
ColumnLayout {
id: wifiColumn
anchors.fill: parent
anchors.margins: 12
spacing: 8
RowLayout {
Layout.fillWidth: true
Label {
text: "Wi-Fi"
color: "#1c666d"
font.pixelSize: 16
font.bold: true
Layout.fillWidth: true
}
Label {
text: root.wifiDevice ? (root.wifiDevice.name || "Available") : "Unavailable"
color: "#52777a"
elide: Text.ElideRight
}
Switch {
checked: Networking.wifiEnabled
enabled: Networking.wifiHardwareEnabled
onToggled: Networking.wifiEnabled = checked
}
}
RowLayout {
visible: root.wifiDevice !== null
Layout.fillWidth: true
Label {
text: root.wifiDevice && root.wifiDevice.networks.values.find(network => network.connected)
? root.wifiDevice.networks.values.find(network => network.connected).name
: "Not connected"
color: "#252321"
elide: Text.ElideRight
Layout.fillWidth: true
}
Button {
text: "Scan"
onClicked: {
if (root.wifiDevice) root.wifiDevice.scannerEnabled = true;
}
}
}
Repeater {
model: root.wifiDevice ? root.wifiDevice.networks : null
delegate: RowLayout {
Layout.fillWidth: true
spacing: 8
Label {
text: modelData.name
color: "#252321"
elide: Text.ElideRight
Layout.fillWidth: true
}
Label {
text: modelData.connected ? "Connected" : Math.round(modelData.signalStrength) + "%"
color: modelData.connected ? "#1c666d" : "#7c756c"
}
Button {
text: modelData.connected ? "Disconnect" : "Connect"
enabled: !modelData.stateChanging
onClicked: modelData.connected ? modelData.disconnect() : root.connectWifi(modelData)
}
}
}
RowLayout {
visible: root.pendingNetwork !== null
Layout.fillWidth: true
TextField {
Layout.fillWidth: true
placeholderText: "Password for " + (root.pendingNetwork ? root.pendingNetwork.name : "network")
echoMode: TextInput.Password
text: root.wifiPassword
onTextChanged: root.wifiPassword = text
onAccepted: root.submitWifiPassword()
}
Button {
text: "Join"
enabled: root.wifiPassword.length > 0
onClicked: root.submitWifiPassword()
}
}
}
}
Rectangle {
Layout.fillWidth: true
implicitHeight: bluetoothColumn.implicitHeight + 24
radius: 14
color: "#eceafd"
ColumnLayout {
id: bluetoothColumn
anchors.fill: parent
anchors.margins: 12
spacing: 8
RowLayout {
Layout.fillWidth: true
Label {
text: "Bluetooth"
color: "#55499c"
font.pixelSize: 16
font.bold: true
Layout.fillWidth: true
}
Switch {
checked: root.bluetoothAdapter ? root.bluetoothAdapter.enabled : false
enabled: root.bluetoothAdapter !== null
onToggled: {
if (root.bluetoothAdapter) root.bluetoothAdapter.enabled = checked;
}
}
Button {
text: root.bluetoothAdapter && root.bluetoothAdapter.discovering ? "Stop scan" : "Scan"
enabled: root.bluetoothAdapter && root.bluetoothAdapter.enabled
onClicked: {
if (root.bluetoothAdapter) root.bluetoothAdapter.discovering = !root.bluetoothAdapter.discovering;
}
}
}
Repeater {
model: root.bluetoothAdapter ? root.bluetoothAdapter.devices : null
delegate: RowLayout {
Layout.fillWidth: true
spacing: 8
ColumnLayout {
Layout.fillWidth: true
spacing: 0
Label {
text: modelData.name || modelData.deviceName || "Unknown device"
color: "#252321"
elide: Text.ElideRight
Layout.fillWidth: true
}
Label {
text: modelData.connected ? "Connected" : (modelData.paired ? "Paired" : "Not paired")
color: "#7c756c"
font.pixelSize: 11
}
}
Label {
visible: modelData.batteryAvailable
text: Math.round(modelData.battery * 100) + "%"
color: "#7c756c"
}
Button {
text: modelData.connected ? "Disconnect" : (modelData.paired ? "Connect" : "Pair")
onClicked: {
if (modelData.connected) modelData.disconnect();
else if (modelData.paired) modelData.connect();
else modelData.pair();
}
}
}
}
}
}
Rectangle {
Layout.fillWidth: true
implicitHeight: audioColumn.implicitHeight + 24
radius: 14
color: "#fff1d7"
ColumnLayout {
id: audioColumn
anchors.fill: parent
anchors.margins: 12
spacing: 8
RowLayout {
Layout.fillWidth: true
Label {
text: "Audio"
color: "#996315"
font.pixelSize: 16
font.bold: true
Layout.fillWidth: true
}
Label {
text: root.audioSink ? (root.audioSink.description || "Default output") : "Unavailable"
color: "#806a48"
elide: Text.ElideRight
Layout.maximumWidth: 230
}
}
RowLayout {
Layout.fillWidth: true
Button {
text: root.audioSink && root.audioSink.audio && root.audioSink.audio.muted ? "Unmute" : "Mute"
enabled: root.audioSink && root.audioSink.audio
onClicked: {
if (root.audioSink && root.audioSink.audio) root.audioSink.audio.muted = !root.audioSink.audio.muted;
}
}
Slider {
Layout.fillWidth: true
from: 0
to: 1
value: root.audioSink && root.audioSink.audio ? root.audioSink.audio.volume : 0
enabled: root.audioSink && root.audioSink.audio
onMoved: {
if (root.audioSink && root.audioSink.audio) root.audioSink.audio.volume = value;
}
}
Label {
text: root.audioSink && root.audioSink.audio ? Math.round(root.audioSink.audio.volume * 100) + "%" : "--"
color: "#806a48"
}
}
Repeater {
model: Pipewire.nodes
delegate: RowLayout {
visible: modelData.isSink && !modelData.isStream
Layout.fillWidth: true
Label {
text: modelData.description || modelData.name
color: "#252321"
elide: Text.ElideRight
Layout.fillWidth: true
}
Button {
text: modelData === root.audioSink ? "Default" : "Use"
enabled: modelData !== root.audioSink
onClicked: Pipewire.preferredDefaultAudioSink = modelData
}
}
}
}
}
Rectangle {
Layout.fillWidth: true
implicitHeight: displayColumn.implicitHeight + 24
radius: 14
color: "#f8e4dc"
ColumnLayout {
id: displayColumn
anchors.fill: parent
anchors.margins: 12
spacing: 8
RowLayout {
Layout.fillWidth: true
Label {
text: "Display brightness"
color: "#9e4d39"
font.pixelSize: 16
font.bold: true
Layout.fillWidth: true
}
Label {
text: root.brightness > 0 ? root.brightness + "%" : "Unavailable"
color: "#8b665b"
}
}
Slider {
Layout.fillWidth: true
from: 1
to: 100
value: root.brightness
enabled: root.brightness > 0
onPressedChanged: {
if (!pressed) root.setBrightness(value);
}
}
}
}
Rectangle {
Layout.fillWidth: true
implicitHeight: statusColumn.implicitHeight + 24
radius: 14
color: "#eee8f7"
ColumnLayout {
id: statusColumn
anchors.fill: parent
anchors.margins: 12
spacing: 8
RowLayout {
Layout.fillWidth: true
Label {
text: "Status bar"
color: "#684b8f"
font.pixelSize: 16
font.bold: true
Layout.fillWidth: true
}
Label {
text: "Temperature"
color: "#806d95"
}
}
RowLayout {
Layout.fillWidth: true
Label {
text: "CPU temperature"
color: "#252321"
Layout.fillWidth: true
}
Label {
text: root.cpuTemperature
color: root.temperatureColor(root.cpuTemperature)
}
}
RowLayout {
Layout.fillWidth: true
Label {
text: "GPU temperature"
color: "#252321"
Layout.fillWidth: true
}
Label {
text: root.gpuTemperature
color: root.temperatureColor(root.gpuTemperature)
}
}
RowLayout {
Layout.fillWidth: true
Label {
text: "Refresh interval"
color: "#252321"
Layout.fillWidth: true
}
ComboBox {
model: ["1 second", "2 seconds", "5 seconds"]
currentIndex: [1000, 2000, 5000].indexOf(root.settings.temperatureInterval)
onActivated: root.settings.temperatureInterval = [1000, 2000, 5000][currentIndex]
}
}
RowLayout {
Layout.fillWidth: true
Label {
text: "Warning / critical"
color: "#252321"
Layout.fillWidth: true
}
SpinBox {
from: 40
to: 99
value: root.settings.temperatureWarning
editable: true
onValueModified: root.settings.setTemperatureWarning(value)
}
Label {
text: "/"
color: "#7c756c"
}
SpinBox {
from: 41
to: 110
value: root.settings.temperatureCritical
editable: true
onValueModified: root.settings.setTemperatureCritical(value)
}
Label {
text: "°C"
color: "#7c756c"
}
}
}
}
Rectangle {
Layout.fillWidth: true
implicitHeight: powerColumn.implicitHeight + 24
radius: 14
color: "#e8f1df"
ColumnLayout {
id: powerColumn
anchors.fill: parent
anchors.margins: 12
spacing: 8
RowLayout {
Layout.fillWidth: true
Label {
text: "Power profile"
color: "#4d7c38"
font.pixelSize: 16
font.bold: true
Layout.fillWidth: true
}
Label {
text: PowerProfile.toString(PowerProfiles.profile)
color: "#66825a"
}
}
RowLayout {
Layout.fillWidth: true
Button {
text: "Saver"
Layout.fillWidth: true
onClicked: PowerProfiles.profile = PowerProfile.PowerSaver
}
Button {
text: "Balanced"
Layout.fillWidth: true
onClicked: PowerProfiles.profile = PowerProfile.Balanced
}
Button {
text: "Performance"
enabled: PowerProfiles.hasPerformanceProfile
Layout.fillWidth: true
onClicked: PowerProfiles.profile = PowerProfile.Performance
}
}
}
}
}
}
}
}
}
}

View File

@@ -0,0 +1,11 @@
#!/bin/bash
data=$(nvidia-smi --query-gpu=utilization.gpu,temperature.gpu --format=csv,noheader,nounits 2>/dev/null)
if [ -z "$data" ]; then
printf '%s\n' '{"text":"N/A"}'
exit 0
fi
usage=$(printf '%s\n' "$data" | cut -d',' -f1 | tr -d ' ')
temp=$(printf '%s\n' "$data" | cut -d',' -f2 | tr -d ' ')
printf '{"text":"%s%% %sC"}\n' "$usage" "$temp"

View File

@@ -0,0 +1,9 @@
#!/bin/bash
weather=$(curl -4 -sS --connect-timeout 3 --max-time 8 "https://wttr.in/?format=%c%t" 2>/dev/null)
if [ -z "$weather" ] || printf '%s' "$weather" | grep -q "Unknown"; then
printf '%s\n' '{"text":"N/A"}'
exit 0
fi
printf '{"text":"%s"}\n' "$weather"