nixos/settings/quickshell.nix
2026-06-11 15:01:52 +01:00

2534 lines
125 KiB
Nix
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# settings/quickshell.nix — Quickshell desktop shell (bar, notifications, QML),
# split out of settings/hyprland.nix. The hyprland-side blur layer rule for
# the "quickshell-bar" namespace still lives there.
{ config, pkgs, lib, ... }:
let
isMacbook = config.networking.hostName == "FredOS-Macbook";
in
{
config = lib.mkIf (lib.elem config.networking.hostName [ "FredOS-Gaming" "FredOS-Macbook" ]) {
environment.systemPackages = with pkgs; [
quickshell
qt6.qt5compat # Qt5Compat.GraphicalEffects in Bar.qml
];
home-manager.users.fred = { config, lib, pkgs, osConfig, ... }:
let
c = config.lib.stylix.colors;
in {
systemd.user.services.quickshell = {
Unit = {
Description = "Quickshell desktop shell";
PartOf = [ "graphical-session.target" ];
After = [ "graphical-session.target" ];
};
Service = {
ExecStart = "${pkgs.quickshell}/bin/qs";
Restart = "always";
RestartSec = 2;
};
Install.WantedBy = [ "hyprland-session.target" ];
};
xdg.configFile = let
# Soft-reload quickshell in place: the process (and its DBus services —
# tray host, notification daemon) stays alive, so Electron apps with
# tray icons (vesktop) don't crash like they do on a hard restart.
# Falls back to a unit restart if the IPC socket isn't up.
qsRestart = ''
${pkgs.quickshell}/bin/qs ipc call shell reload 2>/dev/null || ${pkgs.systemd}/bin/systemctl --user restart quickshell.service 2>/dev/null || true
'';
wifiConnectScript = pkgs.writeShellScript "wifi-connect" ''
ssid="$1"
${pkgs.networkmanager}/bin/nmcli device wifi connect "$ssid" 2>/dev/null && exit 0
pw=$(${pkgs.zenity}/bin/zenity --password --title="WiFi Password" 2>/dev/null)
[ -n "$pw" ] && ${pkgs.networkmanager}/bin/nmcli device wifi connect "$ssid" password "$pw"
'';
nmcli = "${pkgs.networkmanager}/bin/nmcli";
# Follow stylix's monospace choice so a font swap propagates to the bar
monoFont = osConfig.stylix.fonts.monospace.name;
# 7-day forecast JSON from Open-Meteo (no API key). Location is
# auto-detected by IP via ipinfo.io, falling back to London.
weatherFetchScript = pkgs.writeShellScript "weather-fetch" ''
loc=$(${pkgs.curl}/bin/curl -sf --max-time 5 https://ipinfo.io/loc 2>/dev/null || true)
case "$loc" in
*,*) lat=''${loc%,*}; lon=''${loc#*,} ;;
*) lat=51.51; lon=-0.13 ;;
esac
${pkgs.curl}/bin/curl -sf --max-time 10 "https://api.open-meteo.com/v1/forecast?latitude=$lat&longitude=$lon&daily=weather_code,temperature_2m_max,temperature_2m_min&timezone=auto&forecast_days=7"
'';
in {
"quickshell/qmldir" = {
onChange = qsRestart;
text = ''
singleton Theme 1.0 Theme.qml
singleton Commands 1.0 Commands.qml
Bar 1.0 Bar.qml
Launcher 1.0 Launcher.qml
'';
};
"quickshell/Theme.qml" = {
onChange = qsRestart;
text = ''
pragma Singleton
import QtQuick
QtObject {
readonly property color base00: "#${c.base00}"
readonly property color base01: "#${c.base01}"
readonly property color base02: "#${c.base02}"
readonly property color base03: "#${c.base03}"
readonly property color base04: "#${c.base04}"
readonly property color base05: "#${c.base05}"
readonly property color base08: "#${c.base08}"
readonly property color base0A: "#${c.base0A}"
readonly property color base0B: "#${c.base0B}"
readonly property color base0C: "#${c.base0C}"
readonly property color base0D: "#${c.base0D}"
readonly property color barBg: "#B3${c.base00}"
readonly property color toastBg: "#E6${c.base00}"
readonly property string fontFamily: "${monoFont}"
// Matches hyprland general.border_size (col.inactive_border = base03)
readonly property int borderWidth: 2
}
'';
};
"quickshell/Commands.qml" = {
onChange = qsRestart;
text = ''
pragma Singleton
import QtQuick
QtObject {
readonly property string nmcli: "${nmcli}"
readonly property string wifiConnect: "${wifiConnectScript}"
readonly property string notifSound: "${pkgs.libcanberra-gtk3}/bin/canberra-gtk-play"
readonly property string hyprlock: "${pkgs.hyprlock}/bin/hyprlock"
readonly property string systemctl: "${pkgs.systemd}/bin/systemctl"
readonly property string weatherFetch: "${weatherFetchScript}"
}
'';
};
"quickshell/shell.qml" = {
onChange = qsRestart;
text = ''
//@ pragma UseQApplication
import Quickshell
import Quickshell.Io
import Quickshell.Services.Notifications
import QtQuick
ShellRoot {
id: root
property var latestNotification: null
signal notificationReceived()
Launcher {
id: launcher
}
// Bound in hyprland.nix: Super+R toggle, Super+L powermenu
IpcHandler {
target: "launcher"
function toggle(): void { launcher.toggleMode("apps"); }
function powermenu(): void { launcher.toggleMode("power"); }
}
// Soft reload, used by the nix onChange hook keeps the
// process and its DBus services (tray host) alive.
IpcHandler {
target: "shell"
function reload(): void { Quickshell.reload(false); }
}
NotificationServer {
id: _notifServer
bodySupported: true
actionsSupported: true
imageSupported: true
persistenceSupported: true
keepOnReload: true
onNotification: (notification) => {
notification.tracked = true;
root.latestNotification = notification;
root.notificationReceived();
}
}
Variants {
model: Quickshell.screens
Bar {
notifServer: _notifServer
shellRoot: root
}
}
}
'';
};
# App launcher + power menu (replaces anyrun). Full-screen transparent
# overlay with exclusive keyboard focus while open; Esc / click-outside
# closes. Apps come from Quickshell's DesktopEntries service.
"quickshell/Launcher.qml" = {
onChange = qsRestart;
text = ''
import Quickshell
import Quickshell.Wayland
import Quickshell.Hyprland
import QtQuick
PanelWindow {
id: root
// "apps" (Super+R) or "power" (Super+L)
property string mode: "apps"
visible: false
screen: Quickshell.screens[0]
WlrLayershell.namespace: "quickshell-launcher"
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.keyboardFocus: visible ? WlrKeyboardFocus.Exclusive : WlrKeyboardFocus.None
exclusionMode: ExclusionMode.Ignore
color: "transparent"
anchors {
top: true
bottom: true
left: true
right: true
}
function toggleMode(m) {
if (visible && mode === m) { close(); return; }
mode = m;
search.text = "";
list.currentIndex = 0;
visible = true;
search.forceActiveFocus();
}
function close() {
visible = false;
}
// Power actions go through Hyprland's exec dispatcher so they
// are NOT children of quickshell a quickshell restart must
// never kill a running hyprlock.
readonly property var powerActions: [
{ name: "Lock", glyph: "", dispatch: "exec " + Commands.hyprlock },
{ name: "Logout", glyph: "", dispatch: "exit" },
{ name: "Reboot", glyph: "", dispatch: "exec " + Commands.systemctl + " reboot" },
{ name: "Shutdown", glyph: "", dispatch: "exec " + Commands.systemctl + " poweroff" }
]
function score(name, extra, q) {
let n = name.toLowerCase();
if (n.startsWith(q)) return 3;
if (n.includes(" " + q)) return 2;
if (n.includes(q)) return 1;
if (extra && extra.toLowerCase().includes(q)) return 1;
return 0;
}
property var entries: {
let q = search.text.toLowerCase().trim();
if (mode === "power") {
return powerActions.filter(a => q === "" || score(a.name, "", q) > 0);
}
let apps = DesktopEntries.applications.values.filter(a => !a.noDisplay);
if (q === "") {
apps.sort((a, b) => a.name.localeCompare(b.name));
return apps.slice(0, 8);
}
let scored = [];
for (let i = 0; i < apps.length; i++) {
let s = score(apps[i].name, apps[i].genericName + " " + apps[i].comment, q);
if (s > 0) scored.push({ app: apps[i], s: s });
}
scored.sort((a, b) => b.s - a.s || a.app.name.localeCompare(b.app.name));
return scored.slice(0, 8).map(x => x.app);
}
function activate(item) {
if (!item) return;
if (mode === "power") {
Hyprland.dispatch(item.dispatch);
} else {
item.execute();
}
close();
}
// Click outside the box closes
MouseArea {
anchors.fill: parent
onClicked: root.close()
}
Rectangle {
id: box
x: Math.round((parent.width - width) / 2)
y: Math.round(parent.height * 0.25)
width: 350
height: col.height + 16
radius: 10
color: Theme.base00
border.width: 1
border.color: Theme.base03
// Swallow clicks inside the box
MouseArea { anchors.fill: parent }
Column {
id: col
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
anchors.margins: 8
spacing: 6
Rectangle {
width: parent.width
height: 36
radius: 6
color: Theme.base01
TextInput {
id: search
anchors.fill: parent
anchors.leftMargin: 12
anchors.rightMargin: 12
verticalAlignment: TextInput.AlignVCenter
color: Theme.base05
font.family: Theme.fontFamily
font.pixelSize: 13
clip: true
onTextChanged: list.currentIndex = 0
Keys.onEscapePressed: root.close()
Keys.onUpPressed: list.currentIndex = Math.max(0, list.currentIndex - 1)
Keys.onDownPressed: list.currentIndex = Math.min(root.entries.length - 1, list.currentIndex + 1)
Keys.onReturnPressed: root.activate(root.entries[list.currentIndex])
Keys.onEnterPressed: root.activate(root.entries[list.currentIndex])
Keys.onTabPressed: list.currentIndex = (list.currentIndex + 1) % Math.max(1, root.entries.length)
}
Text {
anchors.fill: search
verticalAlignment: Text.AlignVCenter
visible: search.text === ""
text: root.mode === "power" ? "Power" : "Search"
color: Theme.base03
font.family: Theme.fontFamily
font.pixelSize: 13
}
}
ListView {
id: list
width: parent.width
height: contentHeight
interactive: false
model: root.entries
delegate: Rectangle {
required property var modelData
required property int index
width: list.width
height: 32
radius: 6
color: list.currentIndex === index ? Theme.base02 : "transparent"
Behavior on color { ColorAnimation { duration: 100 } }
Row {
anchors.verticalCenter: parent.verticalCenter
anchors.left: parent.left
anchors.leftMargin: 10
spacing: 10
Image {
visible: root.mode === "apps" && source != ""
anchors.verticalCenter: parent.verticalCenter
width: 18
height: 18
sourceSize.width: 18
sourceSize.height: 18
source: root.mode === "apps" ? Quickshell.iconPath(modelData.icon, true) : ""
}
Text {
visible: root.mode === "power"
anchors.verticalCenter: parent.verticalCenter
text: root.mode === "power" ? modelData.glyph : ""
color: Theme.base0D
font.family: Theme.fontFamily
font.pixelSize: 14
}
Text {
anchors.verticalCenter: parent.verticalCenter
text: modelData.name
color: Theme.base05
font.family: Theme.fontFamily
font.pixelSize: 13
elide: Text.ElideRight
width: 270
}
}
MouseArea {
anchors.fill: parent
hoverEnabled: true
onEntered: list.currentIndex = index
onClicked: root.activate(modelData)
}
}
}
}
}
}
'';
};
"quickshell/Bar.qml" = {
onChange = qsRestart;
text = ''
import Quickshell
import Quickshell.Hyprland
import Quickshell.Wayland
import Quickshell.Services.SystemTray
import Quickshell.Services.Notifications
import Quickshell.Services.Pipewire
import Quickshell.Services.UPower
import Quickshell.Services.Mpris
import Quickshell.Widgets
import Quickshell.Io
import QtQuick
import QtQuick.Layouts
import Qt5Compat.GraphicalEffects
PanelWindow {
id: bar
required property var modelData
required property NotificationServer notifServer
required property var shellRoot
screen: modelData
WlrLayershell.namespace: "quickshell-bar"
anchors {
top: true
left: true
right: true
}
implicitHeight: bar.screen.height
exclusiveZone: 30
color: "transparent"
mask: Region {
item: barBgRect
Region {
x: activeDropdown ? activeDropdown.x : 0
y: activeDropdown ? activeDropdown.y : 0
width: activeDropdown && activeDropdown.visible ? activeDropdown.width : 0
height: activeDropdown && activeDropdown.visible ? activeDropdown.height : 0
}
Region {
x: toastItem.visible ? toastItem.x : 0
y: toastItem.visible ? toastItem.y : 0
width: toastItem.visible ? toastItem.width : 0
height: toastItem.visible ? toastItem.height : 0
}
}
Rectangle {
id: barBgRect
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
height: 30
color: Theme.barBg
}
// The "gap source" for the bar border dropdown takes priority, then toast
property bool hasGap: (activeDropdown && activeDropdown.dropdownHeight > 0)
|| (toastItem.visible && _toastRect.height > 0)
property real gapLeft: activeDropdown && activeDropdown.dropdownHeight > 0
? activeDropdown.x
: toastItem.visible && _toastRect.height > 0
? toastItem.x : 0
property real gapRight: activeDropdown && activeDropdown.dropdownHeight > 0
? activeDropdown.x + activeDropdown.width
: toastItem.visible && _toastRect.height > 0
? toastItem.x + toastItem.width : 0
property bool gapAlignRight: activeDropdown ? activeDropdown.alignRight : false
// Bar bottom border left segment (up to gap)
Rectangle {
id: barBorderLeft
x: 0; y: 30
width: bar.hasGap ? bar.gapLeft : bar.width
height: Theme.borderWidth
color: Theme.base03
}
// Bar bottom border right segment (after gap)
Rectangle {
id: barBorderRight
visible: bar.hasGap && !bar.gapAlignRight
x: bar.gapRight
y: 30
width: bar.width - x
height: Theme.borderWidth
color: Theme.base03
}
property var activeDropdown: null
function closeAllDropdowns() {
if (activeDropdown && activeDropdown.visible) {
activeDropdown.animateClose();
}
}
function toggleDropdown(dd, setupFn) {
if (dd.visible && !dd.closing) {
dd.animateClose();
} else {
if (activeDropdown && activeDropdown !== dd && activeDropdown.visible) {
activeDropdown.animateClose();
}
if (setupFn) setupFn();
if (dd.closing) {
dd.closing = false;
dd.open = true;
} else {
dd.visible = true;
}
activeDropdown = dd;
}
}
// Left workspaces
Row {
anchors.left: parent.left
anchors.leftMargin: 6
anchors.verticalCenter: barBgRect.verticalCenter
spacing: 0
Repeater {
model: Hyprland.workspaces
Item {
required property var modelData
visible: modelData.id > 0
width: visible ? 28 : 0
height: 30
Text {
anchors.centerIn: parent
text: modelData.name
color: modelData.focused ? Theme.base05 : Theme.base03
font.family: Theme.fontFamily
font.pixelSize: 13
}
Rectangle {
anchors.bottom: parent.bottom
anchors.horizontalCenter: parent.horizontalCenter
width: parent.width - 8
height: 2
color: Theme.base05
visible: modelData.focused
}
MouseArea {
anchors.fill: parent
onClicked: modelData.activate()
}
}
}
}
// Center clock. SystemClock ticks on the minute boundary
// instead of a 1 Hz Timer; the calendar reads clockText.now too.
SystemClock {
id: sysClock
precision: SystemClock.Minutes
}
Text {
id: clockText
anchors.horizontalCenter: parent.horizontalCenter
anchors.verticalCenter: barBgRect.verticalCenter
property date now: sysClock.date
text: now.toLocaleTimeString(Qt.locale(), "HH:mm")
color: Theme.base05
font.family: Theme.fontFamily
font.pixelSize: 13
font.weight: Font.Medium
MouseArea {
anchors.fill: parent
hoverEnabled: true
onClicked: bar.toggleDropdown(calPopup, function() { calPopup.resetView(); })
onEntered: {
if (bar.activeDropdown) {
if (bar.activeDropdown !== calPopup) bar.toggleDropdown(calPopup, function() { calPopup.resetView(); });
else bar.activeDropdown.resetAutoClose();
}
}
}
}
// Right network, battery, tray
Row {
anchors.right: parent.right
anchors.rightMargin: 8
anchors.verticalCenter: barBgRect.verticalCenter
spacing: 10
// Volume
Item {
id: volWidget
width: volText.width
height: 30
property PwNode sink: Pipewire.defaultAudioSink
PwObjectTracker {
objects: [volWidget.sink]
}
property int vol: sink && sink.audio ? Math.round(sink.audio.volume * 100) : 0
property bool muted: sink && sink.audio ? sink.audio.muted : false
property string volIcon: muted ? "\u{f0581}"
: vol > 66 ? "\u{f057e}"
: vol > 33 ? "\u{f0580}"
: vol > 0 ? "\u{f057f}"
: "\u{f0581}"
function openVolDropdown() {
bar.toggleDropdown(volDropdown, function() {
let pos = volWidget.mapToItem(bar.contentItem, volWidget.width / 2, 0);
volDropdown.dropdownX = pos.x;
});
}
Text {
id: volText
anchors.verticalCenter: parent.verticalCenter
text: volWidget.volIcon + " " + volWidget.vol + "%"
color: volWidget.muted ? Theme.base03 : Theme.base05
font.family: Theme.fontFamily
font.pixelSize: 13
}
MouseArea {
anchors.fill: parent
hoverEnabled: true
acceptedButtons: Qt.LeftButton | Qt.MiddleButton
onClicked: (event) => {
if (event.button === Qt.MiddleButton) {
if (volWidget.sink && volWidget.sink.audio)
volWidget.sink.audio.muted = !volWidget.sink.audio.muted;
} else {
volWidget.openVolDropdown();
}
}
onEntered: {
if (bar.activeDropdown) {
if (bar.activeDropdown !== volDropdown) volWidget.openVolDropdown();
else bar.activeDropdown.resetAutoClose();
}
}
}
}
// Network status
Item {
id: netWidget
width: 16
height: 30
property string netState: "disconnected"
property string netConn: ""
property string netType: ""
property string netIcon: "\u{f0b0}"
property var wifiNetworks: []
property string netDevice: ""
property string _pendingState: "disconnected"
property string _pendingConn: ""
property string _pendingType: ""
property string _pendingDevice: ""
property var _pendingNets: []
// Event-driven: `nmcli monitor` prints a line on every
// NetworkManager event; debounce bursts into one refresh.
Process {
id: netMonitor
command: [Commands.nmcli, "monitor"]
running: true
stdout: SplitParser {
onRead: data => netRefreshDebounce.restart()
}
onRunningChanged: if (!running) netMonitorRestart.start()
}
Timer {
id: netRefreshDebounce
interval: 500
onTriggered: netWidget.refreshNet()
}
Timer {
id: netMonitorRestart
interval: 5000
onTriggered: netMonitor.running = true
}
// Slow fallback poll in case the monitor dies quietly
Timer {
interval: 60000
running: true
repeat: true
triggeredOnStart: true
onTriggered: netWidget.refreshNet()
}
function refreshNet() {
netWidget._pendingState = "disconnected";
netWidget._pendingConn = "";
netWidget._pendingType = "";
netProc.running = true;
}
Process {
id: netProc
command: [Commands.nmcli, "-t", "-f", "DEVICE,TYPE,STATE,CONNECTION", "device"]
stdout: SplitParser {
onRead: data => {
let fields = data.split(":");
if (fields.length < 4) return;
let type = fields[1];
let state = fields[2];
let conn = fields[3];
if (type !== "ethernet" && type !== "wifi") return;
if (type === "wifi") {
netWidget._pendingDevice = fields[0];
}
if (state === "connected") {
netWidget._pendingState = "connected";
netWidget._pendingConn = conn;
netWidget._pendingType = type;
}
}
}
onRunningChanged: {
if (!running) {
netWidget.netState = netWidget._pendingState;
netWidget.netConn = netWidget._pendingConn;
netWidget.netType = netWidget._pendingType.length > 0 ? netWidget._pendingType : netWidget.netType;
netWidget.netDevice = netWidget._pendingDevice.length > 0 ? netWidget._pendingDevice : netWidget.netDevice;
if (netWidget.netState === "connected") {
netWidget.netIcon = netWidget.netType === "wifi" ? "\u{f05a9}" : "\u{f0200}";
} else {
netWidget.netIcon = netWidget.netType === "wifi" ? "\u{f05aa}" : "\u{f0201}";
}
}
}
}
Text {
anchors.centerIn: parent
text: netWidget.netIcon
color: Theme.base05
font.family: Theme.fontFamily
font.pixelSize: 14
}
Timer {
id: netRefreshDelay
interval: 2000
onTriggered: netWidget.refreshNet()
}
Process {
id: wifiScanProc
command: [Commands.nmcli, "-t", "-f", "SSID,SIGNAL,SECURITY,IN-USE", "device", "wifi", "list", "--rescan", "auto"]
stdout: SplitParser {
onRead: data => {
let fields = data.split(":");
if (fields.length < 4 || fields[0] === "") return;
for (let i = 0; i < netWidget._pendingNets.length; i++) {
if (netWidget._pendingNets[i].ssid === fields[0]) return;
}
netWidget._pendingNets.push({
ssid: fields[0],
signal: parseInt(fields[1]) || 0,
security: fields[2],
active: fields[3] === "*"
});
}
}
onRunningChanged: {
if (!running) {
netWidget.wifiNetworks = netWidget._pendingNets;
netWidget._pendingNets = [];
}
}
}
Process {
id: wifiConnectProc
property string targetSsid: ""
command: [Commands.wifiConnect, targetSsid]
}
Process {
id: netDisconnectProc
property string targetDevice: ""
command: [Commands.nmcli, "device", "disconnect", targetDevice]
}
function openNetDropdown() {
bar.toggleDropdown(netDropdown, function() {
wifiScanProc.running = true;
let pos = netWidget.mapToItem(bar.contentItem, netWidget.width / 2, 0);
netDropdown.dropdownX = pos.x;
});
}
MouseArea {
anchors.fill: parent
hoverEnabled: true
onClicked: netWidget.openNetDropdown()
onEntered: {
if (bar.activeDropdown) {
if (bar.activeDropdown !== netDropdown) netWidget.openNetDropdown();
else bar.activeDropdown.resetAutoClose();
}
}
}
}
${lib.optionalString isMacbook ''
// Battery
Item {
id: batteryWidget
width: batteryText.width + 4 + batteryIconText.width
height: 30
// Live DBus-driven properties from the UPower service
// no polling, no /sys parsing, no subprocess spawns.
property var dev: UPower.displayDevice
property int batteryLevel: dev && dev.ready ? Math.round(dev.percentage * 100) : 0
property bool charging: dev ? dev.state === UPowerDeviceState.Charging : false
property real powerDraw: dev ? Math.abs(dev.changeRate) : 0.0
property string timeRemaining: {
if (!dev) return "";
let secs = charging ? dev.timeToFull : dev.timeToEmpty;
if (!secs || secs <= 0) return "";
let h = Math.floor(secs / 3600);
let m = Math.round((secs % 3600) / 60);
return h + "h " + m + "m";
}
property string powerProfile:
PowerProfiles.profile === PowerProfile.PowerSaver ? "power-saver"
: PowerProfiles.profile === PowerProfile.Performance ? "performance"
: "balanced"
property string batteryIcon: charging ? "\u{f0084}"
: batteryLevel >= 90 ? "\u{f0079}"
: batteryLevel >= 70 ? "\u{f0082}"
: batteryLevel >= 50 ? "\u{f007f}"
: batteryLevel >= 30 ? "\u{f007c}"
: batteryLevel >= 15 ? "\u{f007a}"
: "\u{f008e}"
Row {
anchors.verticalCenter: parent.verticalCenter
spacing: 4
Text {
id: batteryText
text: batteryWidget.batteryLevel + "%"
color: batteryWidget.batteryLevel <= 15 ? Theme.base08
: batteryWidget.batteryLevel <= 30 ? Theme.base0A
: Theme.base05
font.family: Theme.fontFamily
font.pixelSize: 13
}
Text {
id: batteryIconText
text: batteryWidget.batteryIcon
color: batteryWidget.batteryLevel <= 15 ? Theme.base08
: batteryWidget.batteryLevel <= 30 ? Theme.base0A
: Theme.base05
font.family: Theme.fontFamily
font.pixelSize: 14
}
}
function openBatteryDropdown() {
bar.toggleDropdown(batteryDropdown, function() {
let pos = batteryWidget.mapToItem(bar.contentItem, batteryWidget.width / 2, 0);
batteryDropdown.dropdownX = pos.x;
});
}
MouseArea {
anchors.fill: parent
hoverEnabled: true
onClicked: batteryWidget.openBatteryDropdown()
onEntered: {
if (bar.activeDropdown) {
if (bar.activeDropdown !== batteryDropdown) batteryWidget.openBatteryDropdown();
else bar.activeDropdown.resetAutoClose();
}
}
}
}
''}
// Tray icons
Row {
id: trayArea
spacing: 8
height: 30
anchors.verticalCenter: parent.verticalCenter
HoverHandler {
onHoveredChanged: {
if (hovered && bar.activeDropdown) bar.activeDropdown.resetAutoClose();
}
}
Repeater {
model: SystemTray.items
Item {
required property var modelData
width: 24
height: 30
Image {
id: trayIcon
anchors.centerIn: parent
width: 16
height: 16
source: modelData.icon
sourceSize.width: 16
sourceSize.height: 16
smooth: true
mipmap: true
visible: false
}
ColorOverlay {
anchors.fill: trayIcon
source: trayIcon
color: Theme.base05
}
MouseArea {
anchors.fill: parent
hoverEnabled: true
acceptedButtons: Qt.NoButton
onEntered: {
if (bar.activeDropdown) {
bar.activeDropdown.resetAutoClose();
if (modelData.hasMenu && !(bar.activeDropdown === contextMenu && contextMenu.trayItem === modelData)) {
if (bar.activeDropdown === contextMenu) {
// Same dropdown, just switch content
let pos = parent.mapToItem(bar.contentItem, parent.width / 2, 0);
contextMenu.dropdownX = pos.x;
contextMenu.trayItem = modelData;
menuOpener.menu = modelData.menu;
contextMenu.resetAutoClose();
} else {
bar.toggleDropdown(contextMenu, function() {
let pos = parent.mapToItem(bar.contentItem, parent.width / 2, 0);
contextMenu.dropdownX = pos.x;
contextMenu.trayItem = modelData;
menuOpener.menu = modelData.menu;
});
}
}
}
}
}
MouseArea {
anchors.fill: parent
acceptedButtons: Qt.LeftButton | Qt.RightButton
onClicked: (event) => {
if (modelData.hasMenu) {
bar.toggleDropdown(contextMenu, function() {
let pos = parent.mapToItem(bar.contentItem, parent.width / 2, 0);
contextMenu.dropdownX = pos.x;
contextMenu.trayItem = modelData;
menuOpener.menu = modelData.menu;
});
} else {
modelData.activate();
}
}
}
}
}
}
}
// Reusable dropdown component
component BarDropdown: Item {
id: dropdown
property bool open: false
property bool closing: false
property real dropdownX: 0
property real fullWidth: 200
property real fullHeight: 200
property int autoCloseMs: 1500
property bool alignRight: false
property real dropdownHeight: _dropdownRect.height
default property alias content: dropdownContent.data
function animateClose() {
if (!visible || closing) return;
closing = true;
open = false;
_autoClose.stop();
_closeDelay.start();
}
function resetAutoClose() {
if (visible && !closing) _autoClose.restart();
}
// Whole-pixel x fractional positions (odd widths centered
// on the bar) antialias the ears/borders into fuzzy seams.
x: Math.round(alignRight ? bar.width - width : Math.min(
bar.width - width,
Math.max(0, dropdownX - (fullWidth + 16) / 2)
))
y: 30
visible: false
width: fullWidth + (alignRight ? 8 : 16)
height: fullHeight + 4 + (alignRight ? 8 : 0)
onVisibleChanged: {
if (visible) {
closing = false;
open = true;
_autoClose.restart();
} else {
open = false;
closing = false;
_autoClose.stop();
}
}
Timer {
id: _autoClose
interval: dropdown.autoCloseMs
onTriggered: bar.closeAllDropdowns()
}
Timer {
id: _closeDelay
interval: 230
onTriggered: { dropdown.visible = false; dropdown.closing = false; if (bar.activeDropdown === dropdown) bar.activeDropdown = null; }
}
HoverHandler {
onHoveredChanged: {
if (hovered) _autoClose.stop();
else _autoClose.restart();
}
}
// Left ear
Item {
anchors.right: _dropdownRect.left
anchors.top: parent.top
width: 8
height: Math.min(8, _dropdownRect.height)
clip: true
visible: _dropdownRect.height >= 8
Canvas {
anchors.top: parent.top
width: 8; height: 8
onPaint: {
var ctx = getContext("2d");
ctx.clearRect(0, 0, 8, 8);
ctx.fillStyle = Theme.barBg;
ctx.beginPath();
ctx.moveTo(0, 0); ctx.lineTo(8, 0); ctx.lineTo(8, 8);
ctx.arc(0, 8, 8, 0, -Math.PI / 2, true);
ctx.closePath(); ctx.fill();
// Border stroke along the curve
ctx.strokeStyle = Theme.base03;
ctx.lineWidth = Theme.borderWidth;
ctx.beginPath();
ctx.arc(0, 8, 8, 0, -Math.PI / 2, true);
ctx.stroke();
}
}
}
// Right ear (for centered dropdowns)
Item {
anchors.left: _dropdownRect.right
anchors.top: parent.top
width: 8
height: Math.min(8, _dropdownRect.height)
clip: true
visible: _dropdownRect.height >= 8 && !dropdown.alignRight
Canvas {
anchors.top: parent.top
width: 8; height: 8
onPaint: {
var ctx = getContext("2d");
ctx.clearRect(0, 0, 8, 8);
ctx.fillStyle = Theme.barBg;
ctx.beginPath();
ctx.moveTo(0, 0); ctx.lineTo(8, 0);
ctx.arc(8, 8, 8, -Math.PI / 2, Math.PI, true);
ctx.closePath(); ctx.fill();
// Border stroke along the curve
ctx.strokeStyle = Theme.base03;
ctx.lineWidth = Theme.borderWidth;
ctx.beginPath();
ctx.arc(8, 8, 8, -Math.PI / 2, Math.PI, true);
ctx.stroke();
}
}
}
Rectangle {
id: _dropdownRect
anchors.right: dropdown.alignRight ? parent.right : undefined
anchors.horizontalCenter: dropdown.alignRight ? undefined : parent.horizontalCenter
anchors.top: parent.top
width: dropdown.fullWidth
height: dropdown.open ? dropdown.fullHeight : 0
color: Theme.barBg
radius: 8
topLeftRadius: 0
topRightRadius: 0
bottomRightRadius: dropdown.alignRight ? 0 : 8
clip: true
// Border outline (sides + bottom with rounded corners)
Canvas {
id: _dropdownBorder
anchors.fill: parent
onPaint: {
var ctx = getContext("2d");
var w = width, h = height, r = 8;
// o centers the stroke so its outer edge lands on the rect edge
var b = Theme.borderWidth, o = b / 2;
ctx.clearRect(0, 0, w, h);
if (h < 1) return;
ctx.strokeStyle = Theme.base03;
ctx.lineWidth = b;
ctx.beginPath();
// Start below the top ear, go down left side
ctx.moveTo(o, r);
ctx.lineTo(o, h - r);
// Bottom-left curve
ctx.arc(r + o, h - r - o, r, Math.PI, Math.PI / 2, true);
// Bottom edge
if (dropdown.alignRight) {
// Stop 8px before right edge bottom-right ear continues
ctx.lineTo(w - r, h - o);
} else {
ctx.lineTo(w - r - o, h - o);
// Bottom-right curve
ctx.arc(w - r - o, h - r - o, r, Math.PI / 2, 0, true);
// Right side up (stop at ear height)
ctx.lineTo(w - o, r);
}
ctx.stroke();
}
// Repaint when size changes
onWidthChanged: requestPaint()
onHeightChanged: requestPaint()
}
Behavior on height {
NumberAnimation { duration: 280; easing.type: Easing.OutExpo }
}
Item {
id: dropdownContent
anchors.fill: parent
}
}
// Bottom-right concave ear connects dropdown bottom to right screen edge
Item {
visible: dropdown.alignRight && _dropdownRect.height >= 8
anchors.right: _dropdownRect.right
anchors.top: _dropdownRect.bottom
width: 8
height: Math.min(8, _dropdownRect.height)
clip: true
Canvas {
width: 8; height: 8
onPaint: {
var ctx = getContext("2d");
ctx.clearRect(0, 0, 8, 8);
ctx.fillStyle = Theme.barBg;
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.lineTo(8, 0);
ctx.lineTo(8, 8);
ctx.arc(0, 8, 8, 0, -Math.PI / 2, true);
ctx.fill();
// Border stroke along the curve
ctx.strokeStyle = Theme.base03;
ctx.lineWidth = Theme.borderWidth;
ctx.beginPath();
ctx.arc(0, 8, 8, 0, -Math.PI / 2, true);
ctx.stroke();
}
}
}
}
// Context menu
BarDropdown {
id: contextMenu
alignRight: true
property var trayItem: null
fullWidth: menuItems.width + 24
fullHeight: menuItems.height + 16
onVisibleChanged: {
if (!visible) menuOpener.menu = null;
}
QsMenuOpener {
id: menuOpener
}
Column {
id: menuItems
anchors.centerIn: parent
width: 200
Repeater {
model: menuOpener.children
Rectangle {
required property var modelData
width: 200
height: modelData.isSeparator ? 9 : 28
color: !modelData.isSeparator && itemMouse.containsMouse && modelData.enabled
? Theme.base02 : "transparent"
Behavior on color { ColorAnimation { duration: 120 } }
radius: modelData.isSeparator ? 0 : 4
Rectangle {
visible: modelData.isSeparator
anchors.centerIn: parent
width: parent.width - 20
height: 1
color: Theme.base03
}
RowLayout {
visible: !modelData.isSeparator
anchors.fill: parent
anchors.leftMargin: 10
anchors.rightMargin: 10
spacing: 8
Text {
Layout.fillWidth: true
text: modelData.text ?? ""
color: modelData.enabled ? Theme.base05 : Theme.base03
font.family: Theme.fontFamily
font.pixelSize: 12
elide: Text.ElideRight
}
Text {
visible: modelData.buttonType !== QsMenuButtonType.None
text: modelData.checkState === Qt.Checked ? "\u2713" : ""
color: Theme.base0D
font.family: Theme.fontFamily
font.pixelSize: 12
}
}
MouseArea {
id: itemMouse
anchors.fill: parent
hoverEnabled: true
enabled: !modelData.isSeparator && modelData.enabled
onClicked: {
modelData.triggered();
bar.closeAllDropdowns();
}
}
}
}
}
}
// Volume dropdown
BarDropdown {
id: volDropdown
alignRight: true
fullWidth: volDropdownCol.width + 28
fullHeight: volDropdownCol.height + 20
autoCloseMs: 3000
Column {
id: volDropdownCol
anchors.centerIn: parent
width: 260
spacing: 8
// Master volume
Text {
text: "\u{f057e} Master"
color: Theme.base05
font.family: Theme.fontFamily
font.pixelSize: 13
font.weight: Font.Medium
}
Row {
width: parent.width
spacing: 8
Rectangle {
id: masterSliderBg
width: parent.width - masterVolLabel.width - 8
height: 20
radius: 4
color: Theme.base01
anchors.verticalCenter: parent.verticalCenter
Rectangle {
width: volWidget.sink && volWidget.sink.audio
? Math.min(1, volWidget.sink.audio.volume) * parent.width : 0
height: parent.height
radius: 4
color: volWidget.muted ? Theme.base03 : Theme.base0D
Behavior on width { NumberAnimation { duration: 80 } }
}
MouseArea {
anchors.fill: parent
onPressed: (mouse) => setVolume(mouse)
onPositionChanged: (mouse) => { if (pressed) setVolume(mouse); }
function setVolume(mouse) {
if (!volWidget.sink || !volWidget.sink.audio) return;
let v = Math.max(0, Math.min(1, mouse.x / width));
volWidget.sink.audio.volume = v;
}
}
}
Text {
id: masterVolLabel
width: 36
text: volWidget.vol + "%"
color: Theme.base05
font.family: Theme.fontFamily
font.pixelSize: 11
horizontalAlignment: Text.AlignRight
anchors.verticalCenter: parent.verticalCenter
}
}
// Mute button
Rectangle {
width: parent.width
height: 28
color: masterMuteMa.containsMouse ? Theme.base02 : "transparent"
Behavior on color { ColorAnimation { duration: 120 } }
radius: 4
Text {
anchors.centerIn: parent
text: volWidget.muted ? "\u{f0581} Unmute" : "\u{f057e} Mute"
color: Theme.base05
font.family: Theme.fontFamily
font.pixelSize: 12
}
MouseArea {
id: masterMuteMa
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
if (volWidget.sink && volWidget.sink.audio)
volWidget.sink.audio.muted = !volWidget.sink.audio.muted;
}
}
}
// Separator
Rectangle {
width: parent.width - 20
anchors.horizontalCenter: parent.horizontalCenter
height: 1
color: Theme.base02
visible: appStreamsCol.childrenRect.height > 0
}
// App streams header
Text {
visible: appStreamsCol.childrenRect.height > 0
text: "\u{f0641} Applications"
color: Theme.base05
font.family: Theme.fontFamily
font.pixelSize: 13
font.weight: Font.Medium
}
// Per-app streams
Column {
id: appStreamsCol
width: parent.width
spacing: 6
Repeater {
id: appStreamsRepeater
model: Pipewire.nodes
Column {
required property var modelData
width: parent.width
spacing: 2
visible: modelData.isStream && modelData.audio !== null
PwObjectTracker {
objects: [modelData]
}
Text {
text: modelData.properties["application.name"] || modelData.name || "Unknown"
color: Theme.base04
font.family: Theme.fontFamily
font.pixelSize: 11
elide: Text.ElideRight
width: parent.width
}
Row {
width: parent.width
spacing: 8
Rectangle {
width: parent.width - appVolLabel.width - 8
height: 16
radius: 3
color: Theme.base01
anchors.verticalCenter: parent.verticalCenter
Rectangle {
width: modelData.audio
? Math.min(1, modelData.audio.volume) * parent.width : 0
height: parent.height
radius: 3
color: modelData.audio && modelData.audio.muted
? Theme.base03 : Theme.base0C
Behavior on width { NumberAnimation { duration: 80 } }
}
MouseArea {
anchors.fill: parent
onPressed: (mouse) => setVol(mouse)
onPositionChanged: (mouse) => { if (pressed) setVol(mouse); }
function setVol(mouse) {
if (!modelData.audio) return;
let v = Math.max(0, Math.min(1, mouse.x / width));
modelData.audio.volume = v;
}
}
}
Text {
id: appVolLabel
width: 36
text: modelData.audio ? Math.round(modelData.audio.volume * 100) + "%" : "0%"
color: Theme.base04
font.family: Theme.fontFamily
font.pixelSize: 10
horizontalAlignment: Text.AlignRight
anchors.verticalCenter: parent.verticalCenter
}
}
}
}
}
}
}
// Network dropdown
BarDropdown {
id: netDropdown
alignRight: true
fullWidth: netDropdownCol.width + 28
fullHeight: netDropdownCol.height + 20
Column {
id: netDropdownCol
anchors.centerIn: parent
width: 220
spacing: 4
Text {
width: parent.width
text: netWidget.netState === "connected"
? "\u{f05a9} " + netWidget.netConn
: "\u{f05aa} Not connected"
color: Theme.base05
font.family: Theme.fontFamily
font.pixelSize: 13
font.weight: Font.Medium
elide: Text.ElideRight
}
Rectangle {
visible: netWidget.netState === "connected"
width: parent.width
height: 28
color: disconnectMouse.containsMouse ? Theme.base02 : "transparent"
Behavior on color { ColorAnimation { duration: 120 } }
radius: 4
Text {
anchors.centerIn: parent
text: "Disconnect"
color: Theme.base08
font.family: Theme.fontFamily
font.pixelSize: 12
}
MouseArea {
id: disconnectMouse
anchors.fill: parent
hoverEnabled: true
onClicked: {
netDisconnectProc.targetDevice = netWidget.netDevice;
netDisconnectProc.running = true;
netWidget.netState = "disconnected";
netWidget.netConn = "";
netWidget.netIcon = "\u{f05aa}";
bar.closeAllDropdowns();
netRefreshDelay.start();
}
}
}
Rectangle {
width: parent.width - 20
anchors.horizontalCenter: parent.horizontalCenter
height: 1
color: Theme.base03
}
Text {
text: "Available networks"
color: Theme.base03
font.family: Theme.fontFamily
font.pixelSize: 11
topPadding: 2
}
Repeater {
model: netWidget.wifiNetworks
Rectangle {
required property var modelData
width: 220
height: 32
color: netItemMouse.containsMouse ? Theme.base02 : "transparent"
Behavior on color { ColorAnimation { duration: 120 } }
radius: 4
Row {
anchors.verticalCenter: parent.verticalCenter
anchors.left: parent.left
anchors.leftMargin: 8
anchors.right: parent.right
anchors.rightMargin: 8
spacing: 8
Text {
text: {
let s = modelData.signal;
if (s >= 75) return "\u{f0928}"; // strength 4
if (s >= 50) return "\u{f0925}"; // strength 3
if (s >= 25) return "\u{f0922}"; // strength 2
return "\u{f091f}"; // strength 1
}
color: modelData.active ? Theme.base0B : Theme.base04
font.family: Theme.fontFamily
font.pixelSize: 13
anchors.verticalCenter: parent.verticalCenter
}
Text {
text: modelData.ssid
color: modelData.active ? Theme.base0B : Theme.base05
font.family: Theme.fontFamily
font.pixelSize: 12
elide: Text.ElideRight
width: 140
anchors.verticalCenter: parent.verticalCenter
}
Text {
visible: modelData.security !== "" && modelData.security !== "--"
text: "\u{f0341}"
color: Theme.base03
font.family: Theme.fontFamily
font.pixelSize: 10
anchors.verticalCenter: parent.verticalCenter
}
}
MouseArea {
id: netItemMouse
anchors.fill: parent
hoverEnabled: true
onClicked: {
if (!modelData.active) {
wifiConnectProc.targetSsid = modelData.ssid;
wifiConnectProc.running = true;
netRefreshDelay.start();
}
bar.closeAllDropdowns();
}
}
}
}
}
}
${lib.optionalString isMacbook ''
// Battery dropdown
BarDropdown {
id: batteryDropdown
alignRight: true
fullWidth: batteryDropdownCol.width + 28
fullHeight: batteryDropdownCol.height + 20
Column {
id: batteryDropdownCol
anchors.centerIn: parent
width: 200
spacing: 8
Row {
width: parent.width
spacing: 8
Text {
text: batteryWidget.batteryIcon
color: Theme.base05
font.family: Theme.fontFamily
font.pixelSize: 18
anchors.verticalCenter: parent.verticalCenter
}
Column {
anchors.verticalCenter: parent.verticalCenter
Text {
text: batteryWidget.batteryLevel + "%" + (batteryWidget.charging ? " Charging" : "")
color: Theme.base05
font.family: Theme.fontFamily
font.pixelSize: 13
font.weight: Font.Medium
}
Text {
text: batteryWidget.powerDraw.toFixed(1) + " W"
+ (batteryWidget.timeRemaining !== "" ? " \u2022 " + batteryWidget.timeRemaining + (batteryWidget.charging ? " to full" : " left") : "")
color: Theme.base04
font.family: Theme.fontFamily
font.pixelSize: 11
}
}
}
Rectangle {
width: parent.width - 10
anchors.horizontalCenter: parent.horizontalCenter
height: 1
color: Theme.base03
}
Text {
text: "Power Profile"
color: Theme.base03
font.family: Theme.fontFamily
font.pixelSize: 11
}
Row {
width: parent.width
spacing: 4
Repeater {
model: [
{ name: "power-saver", profile: PowerProfile.PowerSaver, label: "\u{f0425}", tip: "Saver" },
{ name: "balanced", profile: PowerProfile.Balanced, label: "\u{f0376}", tip: "Balanced" },
{ name: "performance", profile: PowerProfile.Performance, label: "\u{f0e0e}", tip: "Performance" }
]
Rectangle {
required property var modelData
width: (parent.width - 8) / 3
height: 36
radius: 6
color: batteryWidget.powerProfile === modelData.name
? Theme.base02 : profMouse.containsMouse
? Theme.base01 : "transparent"
Behavior on color { ColorAnimation { duration: 120 } }
border.width: batteryWidget.powerProfile === modelData.name ? 1 : 0
border.color: Theme.base03
Column {
anchors.centerIn: parent
spacing: 1
Text {
anchors.horizontalCenter: parent.horizontalCenter
text: modelData.label
color: batteryWidget.powerProfile === modelData.name
? Theme.base0D : Theme.base05
font.family: Theme.fontFamily
font.pixelSize: 14
}
Text {
anchors.horizontalCenter: parent.horizontalCenter
text: modelData.tip
color: Theme.base04
font.family: Theme.fontFamily
font.pixelSize: 9
}
}
MouseArea {
id: profMouse
anchors.fill: parent
hoverEnabled: true
onClicked: PowerProfiles.profile = modelData.profile
}
}
}
}
}
}
''}
// Calendar popup GNOME-style two-pane panel.
// Left: navigable month calendar + 7-day weather strip.
// Right: MPRIS media controls + notification list.
BarDropdown {
id: calPopup
dropdownX: bar.width / 2
// ceil: Text metrics give fractional sizes; fractional rect
// edges render as soft 2px lines
fullWidth: Math.ceil(calRow.width) + 24
fullHeight: Math.ceil(calRow.height) + 24
autoCloseMs: 3000
// Month being viewed; reset to today when the popup opens
// (via the setup function passed to bar.toggleDropdown).
property int viewYear: clockText.now.getFullYear()
property int viewMonth: clockText.now.getMonth()
function resetView() {
viewYear = clockText.now.getFullYear();
viewMonth = clockText.now.getMonth();
// Runs on every open: an on-screen toast is redundant
// once the notification list is visible.
if (toastItem.visible) toastItem.dismiss();
}
function shiftMonth(d) {
let m = viewMonth + d;
if (m < 0) { viewMonth = 11; viewYear--; }
else if (m > 11) { viewMonth = 0; viewYear++; }
else viewMonth = m;
}
// --- Weather: 7-day forecast, refreshed every 30 min ---
property var weatherDays: []
Process {
id: weatherProc
command: [Commands.weatherFetch]
stdout: StdioCollector {
onStreamFinished: {
try {
let j = JSON.parse(text);
let out = [];
for (let i = 0; i < j.daily.time.length && i < 7; i++) {
out.push({
day: new Date(j.daily.time[i] + "T12:00:00").toLocaleDateString(Qt.locale(), "ddd").slice(0, 2),
code: j.daily.weather_code[i],
max: Math.round(j.daily.temperature_2m_max[i]),
min: Math.round(j.daily.temperature_2m_min[i])
});
}
if (out.length > 0) calPopup.weatherDays = out;
} catch (e) { /* keep previous forecast */ }
}
}
}
Timer {
interval: 1800000
running: true
repeat: true
triggeredOnStart: true
onTriggered: weatherProc.running = true
}
function weatherGlyph(code) {
if (code === 0) return "\u{f0599}"; // sunny
if (code <= 2) return "\u{f0595}"; // partly cloudy
if (code === 3) return "\u{f0590}"; // overcast
if (code <= 48) return "\u{f0591}"; // fog
if (code <= 57) return "\u{f0597}"; // drizzle
if (code <= 67) return "\u{f0596}"; // rain
if (code <= 77) return "\u{f0598}"; // snow
if (code <= 82) return "\u{f0597}"; // showers
if (code <= 86) return "\u{f0598}"; // snow showers
return "\u{f0593}"; // thunder
}
// --- Media: prefer the actively playing MPRIS player ---
property var player: {
let ps = Mpris.players.values;
for (let i = 0; i < ps.length; i++) {
if (ps[i].playbackState === MprisPlaybackState.Playing) return ps[i];
}
return ps.length > 0 ? ps[0] : null;
}
Row {
id: calRow
anchors.horizontalCenter: parent.horizontalCenter
anchors.top: parent.top
anchors.topMargin: 12
spacing: 16
opacity: calPopup.open ? 1.0 : 0.0
Behavior on opacity {
NumberAnimation { duration: 150; easing.type: Easing.OutCubic }
}
// Left pane: calendar card + weather card
Column {
id: calLeftCol
width: 7 * 32 + 16
spacing: 8
// Calendar card
Rectangle {
width: parent.width
height: calCardCol.height + 16
radius: 8
color: Theme.base01
Column {
id: calCardCol
anchors.top: parent.top
anchors.topMargin: 8
anchors.horizontalCenter: parent.horizontalCenter
width: 7 * 32
spacing: 8
// Month header: [Month Year] label click jumps to today
Item {
width: parent.width
height: 28
Rectangle {
width: 28; height: 28; radius: 6
anchors.left: parent.left
color: calPrevMa.containsMouse ? Theme.base02 : "transparent"
Behavior on color { ColorAnimation { duration: 120 } }
Text {
anchors.centerIn: parent
text: "\u{f0141}"
color: Theme.base05
font.family: Theme.fontFamily
font.pixelSize: 16
}
MouseArea {
id: calPrevMa
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: calPopup.shiftMonth(-1)
}
}
Text {
anchors.centerIn: parent
text: new Date(calPopup.viewYear, calPopup.viewMonth, 1).toLocaleDateString(Qt.locale(), "MMMM yyyy")
color: Theme.base05
font.family: Theme.fontFamily
font.pixelSize: 14
font.weight: Font.Medium
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: calPopup.resetView()
}
}
Rectangle {
width: 28; height: 28; radius: 6
anchors.right: parent.right
color: calNextMa.containsMouse ? Theme.base02 : "transparent"
Behavior on color { ColorAnimation { duration: 120 } }
Text {
anchors.centerIn: parent
text: "\u{f0142}"
color: Theme.base05
font.family: Theme.fontFamily
font.pixelSize: 16
}
MouseArea {
id: calNextMa
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: calPopup.shiftMonth(1)
}
}
}
Row {
spacing: 0
Repeater {
model: ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"]
Text {
required property var modelData
width: 32
horizontalAlignment: Text.AlignHCenter
text: modelData
color: Theme.base04
font.family: Theme.fontFamily
font.pixelSize: 13
}
}
}
Grid {
columns: 7
spacing: 0
Repeater {
model: 42
Rectangle {
required property int index
property int dayNum: {
let first = new Date(calPopup.viewYear, calPopup.viewMonth, 1);
let startDay = (first.getDay() + 6) % 7;
return index - startDay + 1;
}
property int daysInMonth: new Date(calPopup.viewYear, calPopup.viewMonth + 1, 0).getDate()
property bool isToday: dayNum === clockText.now.getDate()
&& calPopup.viewMonth === clockText.now.getMonth()
&& calPopup.viewYear === clockText.now.getFullYear()
width: 32
height: 26
radius: 4
color: isToday ? Theme.base03 : "transparent"
Text {
anchors.centerIn: parent
text: parent.dayNum >= 1 && parent.dayNum <= parent.daysInMonth ? parent.dayNum.toString() : ""
color: parent.isToday ? Theme.base05 : Theme.base04
font.family: Theme.fontFamily
font.pixelSize: 13
}
}
}
}
}
}
// Weather card
Rectangle {
width: parent.width
height: weatherRow.height + 16
radius: 8
color: Theme.base01
visible: calPopup.weatherDays.length > 0
Row {
id: weatherRow
anchors.top: parent.top
anchors.topMargin: 8
anchors.horizontalCenter: parent.horizontalCenter
Repeater {
model: calPopup.weatherDays
Column {
required property var modelData
width: 32
spacing: 2
Text {
anchors.horizontalCenter: parent.horizontalCenter
text: modelData.day
color: Theme.base04
font.family: Theme.fontFamily
font.pixelSize: 10
}
Text {
anchors.horizontalCenter: parent.horizontalCenter
text: calPopup.weatherGlyph(modelData.code)
color: Theme.base0C
font.family: Theme.fontFamily
font.pixelSize: 14
}
Text {
anchors.horizontalCenter: parent.horizontalCenter
text: modelData.max + "°"
color: Theme.base05
font.family: Theme.fontFamily
font.pixelSize: 10
}
Text {
anchors.horizontalCenter: parent.horizontalCenter
text: modelData.min + "°"
color: Theme.base03
font.family: Theme.fontFamily
font.pixelSize: 10
}
}
}
}
}
}
// Right pane: media + notifications
Column {
id: calRightCol
width: 300
spacing: 8
// Media player card
Rectangle {
width: parent.width
height: 64
radius: 8
color: Theme.base01
visible: calPopup.player !== null
Row {
anchors.fill: parent
anchors.margins: 8
spacing: 10
Rectangle {
width: 48; height: 48
radius: 6
anchors.verticalCenter: parent.verticalCenter
color: Theme.base02
clip: true
Text {
anchors.centerIn: parent
visible: albumArt.status !== Image.Ready
text: "\u{f0387}"
color: Theme.base04
font.family: Theme.fontFamily
font.pixelSize: 20
}
Image {
id: albumArt
anchors.fill: parent
fillMode: Image.PreserveAspectCrop
source: calPopup.player ? calPopup.player.trackArtUrl : ""
}
}
Column {
width: parent.width - 48 - 10 - 88 - 10
anchors.verticalCenter: parent.verticalCenter
spacing: 2
Text {
width: parent.width
text: calPopup.player ? calPopup.player.trackTitle : ""
color: Theme.base05
font.family: Theme.fontFamily
font.pixelSize: 12
font.weight: Font.Medium
elide: Text.ElideRight
}
Text {
width: parent.width
text: calPopup.player ? calPopup.player.trackArtist : ""
color: Theme.base04
font.family: Theme.fontFamily
font.pixelSize: 11
elide: Text.ElideRight
}
}
Row {
anchors.verticalCenter: parent.verticalCenter
spacing: 2
Repeater {
model: [
{ glyph: "\u{f04ae}", act: "prev" },
{ glyph: calPopup.player && calPopup.player.playbackState === MprisPlaybackState.Playing ? "\u{f03e4}" : "\u{f040a}", act: "toggle" },
{ glyph: "\u{f04ad}", act: "next" }
]
Rectangle {
id: mediaBtn
required property var modelData
width: 28; height: 28; radius: 14
color: mediaBtnMa.containsMouse ? Theme.base02 : "transparent"
Behavior on color { ColorAnimation { duration: 120 } }
Text {
anchors.centerIn: parent
text: mediaBtn.modelData.glyph
color: Theme.base05
font.family: Theme.fontFamily
font.pixelSize: 16
}
MouseArea {
id: mediaBtnMa
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
let p = calPopup.player;
if (!p) return;
if (mediaBtn.modelData.act === "prev") p.previous();
else if (mediaBtn.modelData.act === "next") p.next();
else p.togglePlaying();
}
}
}
}
}
}
}
// Notifications card
Rectangle {
width: parent.width
height: notifCardCol.height + 16
radius: 8
color: Theme.base01
Column {
id: notifCardCol
anchors.top: parent.top
anchors.topMargin: 8
anchors.horizontalCenter: parent.horizontalCenter
width: parent.width - 16
spacing: 6
Item {
width: parent.width
height: 20
Text {
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
text: "Notifications"
color: Theme.base05
font.family: Theme.fontFamily
font.pixelSize: 13
font.weight: Font.Medium
}
Text {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
text: bar.notifServer.trackedNotifications.values.length > 0 ? "Clear all" : ""
color: Theme.base04
font.family: Theme.fontFamily
font.pixelSize: 11
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: {
let notifs = bar.notifServer.trackedNotifications.values;
for (let i = notifs.length - 1; i >= 0; i--) {
notifs[i].dismiss();
}
}
}
}
}
Text {
visible: bar.notifServer.trackedNotifications.values.length === 0
text: "No notifications"
color: Theme.base03
font.family: Theme.fontFamily
font.pixelSize: 11
anchors.horizontalCenter: parent.horizontalCenter
}
Repeater {
model: bar.notifServer.trackedNotifications
Rectangle {
id: notifItem
required property var modelData
width: notifCardCol.width
height: notifCol.height + 12
radius: 6
color: Theme.base02
Column {
id: notifCol
anchors.left: parent.left
anchors.right: dismissBtn.left
anchors.top: parent.top
anchors.margins: 6
spacing: 2
Text {
width: parent.width
text: notifItem.modelData.summary || notifItem.modelData.appName
color: Theme.base05
font.family: Theme.fontFamily
font.pixelSize: 11
font.weight: Font.Medium
elide: Text.ElideRight
}
Text {
width: parent.width
text: notifItem.modelData.body || ""
color: Theme.base04
font.family: Theme.fontFamily
font.pixelSize: 10
elide: Text.ElideRight
maximumLineCount: 2
wrapMode: Text.Wrap
visible: text !== ""
}
Row {
spacing: 4
visible: notifItem.modelData.actions.length > 0
Repeater {
model: notifItem.modelData.actions
Rectangle {
required property var modelData
width: actionText.width + 12
height: actionText.height + 4
radius: 4
color: actionMa.containsMouse ? Theme.base03 : Theme.base02
Behavior on color { ColorAnimation { duration: 120 } }
border.width: 1
border.color: Theme.base03
Text {
id: actionText
anchors.centerIn: parent
text: modelData.text
color: Theme.base05
font.family: Theme.fontFamily
font.pixelSize: 10
}
MouseArea {
id: actionMa
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: modelData.invoke()
}
}
}
}
}
Text {
id: dismissBtn
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: 6
text: "\u{f0156}"
color: dismissMa.containsMouse ? Theme.base05 : Theme.base03
font.family: Theme.fontFamily
font.pixelSize: 12
MouseArea {
id: dismissMa
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: notifItem.modelData.dismiss()
}
}
}
}
}
}
}
}
}
// Notification Toast (only on primary screen)
Item {
id: toastItem
visible: false
property var currentNotif: null
property bool toastOpen: false
readonly property var mutedApps: ["discord", "Discord", "Vesktop", "vesktop", "Spotify", "spotify", "vlc", "mpv"]
readonly property bool isPrimary: bar.screen === Quickshell.screens[0]
x: Math.round(bar.width / 2 - width / 2)
y: 30
width: _toastLeftEar.width + _toastRect.width + _toastRightEar.width
height: _toastRect.height + 4
Process {
id: notifSoundProc
command: [Commands.notifSound, "-i", "message"]
}
Connections {
target: bar.shellRoot
function onNotificationReceived() {
if (!toastItem.isPrimary) return;
let n = bar.shellRoot.latestNotification;
// Popup open: the notification list is already on
// screen play the sound but skip the toast.
if (calPopup.visible) {
if (!toastItem.mutedApps.includes(n.appName)) {
notifSoundProc.running = true;
}
return;
}
toastItem.showToast(n);
}
}
function showToast(notification) {
currentNotif = notification;
visible = true;
toastOpen = true;
_toastTimer.restart();
if (!mutedApps.includes(notification.appName)) {
notifSoundProc.running = true;
}
}
function dismiss() {
toastOpen = false;
_toastCloseDelay.start();
}
Timer {
id: _toastTimer
interval: 5000
onTriggered: toastItem.dismiss()
}
Timer {
id: _toastCloseDelay
interval: 230
onTriggered: { toastItem.visible = false; toastItem.toastOpen = false; }
}
HoverHandler {
onHoveredChanged: {
if (hovered) _toastTimer.stop();
else _toastTimer.restart();
}
}
// Left inverse corner ear
Item {
id: _toastLeftEar
anchors.right: _toastRect.left
anchors.top: parent.top
width: 8
height: Math.min(8, _toastRect.height)
clip: true
visible: _toastRect.height >= 8
Canvas {
anchors.top: parent.top
width: 8; height: 8
onPaint: {
var ctx = getContext("2d");
ctx.clearRect(0, 0, 8, 8);
ctx.fillStyle = Theme.barBg;
ctx.beginPath();
ctx.moveTo(0, 0); ctx.lineTo(8, 0); ctx.lineTo(8, 8);
ctx.arc(0, 8, 8, 0, -Math.PI / 2, true);
ctx.closePath(); ctx.fill();
ctx.strokeStyle = Theme.base03;
ctx.lineWidth = Theme.borderWidth;
ctx.beginPath();
ctx.arc(0, 8, 8, 0, -Math.PI / 2, true);
ctx.stroke();
}
}
}
// Right inverse corner ear
Item {
id: _toastRightEar
anchors.left: _toastRect.right
anchors.top: parent.top
width: 8
height: Math.min(8, _toastRect.height)
clip: true
visible: _toastRect.height >= 8
Canvas {
anchors.top: parent.top
width: 8; height: 8
onPaint: {
var ctx = getContext("2d");
ctx.clearRect(0, 0, 8, 8);
ctx.fillStyle = Theme.barBg;
ctx.beginPath();
ctx.moveTo(0, 0); ctx.lineTo(8, 0);
ctx.arc(8, 8, 8, -Math.PI / 2, Math.PI, true);
ctx.closePath(); ctx.fill();
ctx.strokeStyle = Theme.base03;
ctx.lineWidth = Theme.borderWidth;
ctx.beginPath();
ctx.arc(8, 8, 8, -Math.PI / 2, Math.PI, true);
ctx.stroke();
}
}
}
Rectangle {
id: _toastRect
anchors.horizontalCenter: parent.horizontalCenter
anchors.top: parent.top
width: 320
height: toastItem.toastOpen ? toastCol.height + 16 : 0
color: Theme.barBg
radius: 8
topLeftRadius: 0
topRightRadius: 0
clip: true
// Border outline (sides + bottom with rounded corners)
Canvas {
anchors.fill: parent
onPaint: {
var ctx = getContext("2d");
var w = width, h = height, r = 8;
var b = Theme.borderWidth, o = b / 2;
ctx.clearRect(0, 0, w, h);
if (h < 1) return;
ctx.strokeStyle = Theme.base03;
ctx.lineWidth = b;
ctx.beginPath();
ctx.moveTo(o, r);
ctx.lineTo(o, h - r);
ctx.arc(r + o, h - r - o, r, Math.PI, Math.PI / 2, true);
ctx.lineTo(w - r - o, h - o);
ctx.arc(w - r - o, h - r - o, r, Math.PI / 2, 0, true);
ctx.lineTo(w - o, r);
ctx.stroke();
}
onWidthChanged: requestPaint()
onHeightChanged: requestPaint()
}
Behavior on height {
NumberAnimation { duration: 280; easing.type: Easing.OutExpo }
}
Column {
id: toastCol
anchors.left: parent.left
anchors.right: toastDismiss.left
anchors.top: parent.top
anchors.margins: 8
spacing: 2
Text {
width: parent.width
text: toastItem.currentNotif ? (toastItem.currentNotif.summary || toastItem.currentNotif.appName) : ""
color: Theme.base05
font.family: Theme.fontFamily
font.pixelSize: 12
font.weight: Font.Medium
elide: Text.ElideRight
}
Text {
width: parent.width
text: toastItem.currentNotif ? (toastItem.currentNotif.body || "") : ""
color: Theme.base04
font.family: Theme.fontFamily
font.pixelSize: 11
elide: Text.ElideRight
maximumLineCount: 3
wrapMode: Text.Wrap
visible: text !== ""
}
Row {
spacing: 4
visible: toastItem.currentNotif && toastItem.currentNotif.actions.length > 0
Repeater {
model: toastItem.currentNotif ? toastItem.currentNotif.actions : []
Rectangle {
required property var modelData
width: toastActionText.width + 12
height: toastActionText.height + 6
radius: 4
color: toastActionMa.containsMouse ? Theme.base02 : Theme.base01
Behavior on color { ColorAnimation { duration: 120 } }
border.width: 1
border.color: Theme.base02
Text {
id: toastActionText
anchors.centerIn: parent
text: modelData.text
color: Theme.base05
font.family: Theme.fontFamily
font.pixelSize: 10
}
MouseArea {
id: toastActionMa
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: { modelData.invoke(); toastItem.dismiss(); }
}
}
}
}
}
Text {
id: toastDismiss
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: 8
text: "\u{f0156}"
color: toastDismissMa.containsMouse ? Theme.base05 : Theme.base03
font.family: Theme.fontFamily
font.pixelSize: 13
MouseArea {
id: toastDismissMa
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: { toastItem.currentNotif.dismiss(); toastItem.dismiss(); }
}
}
}
}
}
'';
};
};
};
};
}